Merge manager tools into main cabinet
This commit is contained in:
289
app/pages/client-orders.vue
Normal file
289
app/pages/client-orders.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<script setup lang="ts">
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
|
||||
import {
|
||||
BlockOrderDocument,
|
||||
CompleteOrderDocument,
|
||||
ManagerFinalizeOrderDocument,
|
||||
ManagerOrdersDocument,
|
||||
ManagerSetOrderOfferDocument,
|
||||
StartOrderWorkDocument,
|
||||
type ManagerOrdersQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['manager-only'],
|
||||
});
|
||||
|
||||
type ManagerOrderItem = ManagerOrdersQuery['managerOrders'][number];
|
||||
|
||||
const ACTIVE_STATUSES = new Set(['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS']);
|
||||
const CLOSED_STATUSES = new Set(['COMPLETED', 'CLIENT_REJECTED', 'MANAGER_REJECTED', 'MANAGER_BLOCKED']);
|
||||
|
||||
const ordersQuery = useQuery(ManagerOrdersDocument, { status: null });
|
||||
|
||||
const setOfferMutation = useMutation(ManagerSetOrderOfferDocument);
|
||||
const finalizeMutation = useMutation(ManagerFinalizeOrderDocument);
|
||||
const blockMutation = useMutation(BlockOrderDocument);
|
||||
const startWorkMutation = useMutation(StartOrderWorkDocument);
|
||||
const completeWorkMutation = useMutation(CompleteOrderDocument);
|
||||
|
||||
const search = ref('');
|
||||
const statusFilter = ref<'ALL' | 'WAITING' | 'ACTIVE' | 'CLOSED'>('ALL');
|
||||
|
||||
const offerForm = reactive<Record<string, { deliveryTerms: string; deliveryFee: number; totalPrice: number }>>({});
|
||||
|
||||
watchEffect(() => {
|
||||
for (const order of ordersQuery.result.value?.managerOrders ?? []) {
|
||||
if (!offerForm[order.id]) {
|
||||
offerForm[order.id] = {
|
||||
deliveryTerms: order.deliveryTerms || 'Доставка 3-5 дней',
|
||||
deliveryFee: Number(order.deliveryFee ?? 1000),
|
||||
totalPrice: Number(order.totalPrice ?? 12500),
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function matchesFilter(order: ManagerOrderItem) {
|
||||
if (statusFilter.value === 'ALL') {
|
||||
return true;
|
||||
}
|
||||
if (statusFilter.value === 'WAITING') {
|
||||
return order.status === 'WAITING_DOUBLE_CONFIRM';
|
||||
}
|
||||
if (statusFilter.value === 'ACTIVE') {
|
||||
return ACTIVE_STATUSES.has(order.status);
|
||||
}
|
||||
return CLOSED_STATUSES.has(order.status);
|
||||
}
|
||||
|
||||
function getOffer(orderId: string) {
|
||||
if (!offerForm[orderId]) {
|
||||
offerForm[orderId] = {
|
||||
deliveryTerms: 'Доставка 3-5 дней',
|
||||
deliveryFee: 1000,
|
||||
totalPrice: 12500,
|
||||
};
|
||||
}
|
||||
|
||||
return offerForm[orderId];
|
||||
}
|
||||
|
||||
function updateOfferField(orderId: string, field: 'deliveryTerms' | 'deliveryFee' | 'totalPrice', value: string) {
|
||||
const offer = getOffer(orderId);
|
||||
if (field === 'deliveryTerms') {
|
||||
offer.deliveryTerms = value;
|
||||
return;
|
||||
}
|
||||
|
||||
const numericValue = Number(value);
|
||||
offer[field] = Number.isFinite(numericValue) ? numericValue : 0;
|
||||
}
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
const orders = ordersQuery.result.value?.managerOrders ?? [];
|
||||
const query = search.value.trim().toLowerCase();
|
||||
|
||||
return orders.filter((order) => {
|
||||
const haystack = [
|
||||
order.code,
|
||||
order.customerId || '',
|
||||
order.deliveryAddress || '',
|
||||
...order.items.map((item) => item.productName),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
const matchSearch = !query || haystack.includes(query);
|
||||
return matchSearch && matchesFilter(order);
|
||||
});
|
||||
});
|
||||
|
||||
async function refetchOrders() {
|
||||
await ordersQuery.refetch({
|
||||
status: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function publishOffer(orderId: string) {
|
||||
const form = getOffer(orderId);
|
||||
|
||||
await setOfferMutation.mutate({
|
||||
input: {
|
||||
orderId,
|
||||
deliveryTerms: form.deliveryTerms,
|
||||
deliveryFee: Number(form.deliveryFee),
|
||||
totalPrice: Number(form.totalPrice),
|
||||
},
|
||||
});
|
||||
|
||||
await refetchOrders();
|
||||
}
|
||||
|
||||
async function approve(orderId: string) {
|
||||
await finalizeMutation.mutate({
|
||||
orderId,
|
||||
decision: 'APPROVE',
|
||||
});
|
||||
await refetchOrders();
|
||||
}
|
||||
|
||||
async function reject(orderId: string) {
|
||||
await finalizeMutation.mutate({
|
||||
orderId,
|
||||
decision: 'REJECT',
|
||||
});
|
||||
await refetchOrders();
|
||||
}
|
||||
|
||||
async function blockOrder(orderId: string) {
|
||||
await blockMutation.mutate({
|
||||
input: {
|
||||
orderId,
|
||||
reason: 'Нужно уточнение параметров заказа.',
|
||||
},
|
||||
});
|
||||
await refetchOrders();
|
||||
}
|
||||
|
||||
async function start(orderId: string) {
|
||||
await startWorkMutation.mutate({
|
||||
orderId,
|
||||
});
|
||||
await refetchOrders();
|
||||
}
|
||||
|
||||
async function complete(orderId: string) {
|
||||
await completeWorkMutation.mutate({
|
||||
orderId,
|
||||
});
|
||||
await refetchOrders();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<div class="manager-hero">
|
||||
<p class="manager-eyebrow">Заказы клиентов</p>
|
||||
<h1 class="manager-title">Разбор заявок, офферов и статусов без отдельной менеджерки</h1>
|
||||
<p class="manager-copy">
|
||||
Фильтруйте заявки, публикуйте условия доставки, подтверждайте работу и доводите заказ до завершения прямо в основном кабинете.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-4 md:p-5">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<label class="form-control">
|
||||
<span class="label-text">Поиск</span>
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="input manager-field w-full"
|
||||
placeholder="Номер, пользователь, адрес или товар"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="form-control md:min-w-60">
|
||||
<span class="label-text">Фильтр</span>
|
||||
<select v-model="statusFilter" class="select manager-field w-full">
|
||||
<option value="ALL">Все заказы</option>
|
||||
<option value="WAITING">Ожидают подтверждения</option>
|
||||
<option value="ACTIVE">Активные</option>
|
||||
<option value="CLOSED">Закрытые</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="ordersQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем очередь заказов...
|
||||
</div>
|
||||
<div v-else-if="filteredOrders.length === 0" class="manager-empty-state">
|
||||
По текущим условиям заказов не найдено.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<article
|
||||
v-for="order in filteredOrders"
|
||||
:key="order.id"
|
||||
class="surface-card rounded-3xl p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-xl font-bold text-[#123824]">{{ order.code }}</h2>
|
||||
<div class="flex flex-wrap gap-3 text-sm text-[#5c7b69]">
|
||||
<span>Клиент: {{ order.customerId || 'не указан' }}</span>
|
||||
<span>{{ new Date(order.createdAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<OrderStatusBadge :status="order.status" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<div class="space-y-3">
|
||||
<div class="surface-subcard p-4">
|
||||
<p class="text-sm font-semibold text-[#123824]">Состав заказа</p>
|
||||
<ul class="mt-3 space-y-2 text-sm text-[#214735]">
|
||||
<li
|
||||
v-for="item in order.items"
|
||||
:key="item.id"
|
||||
class="manager-mini-card"
|
||||
>
|
||||
{{ item.productName }} × {{ item.quantity }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="surface-subcard p-4">
|
||||
<p class="text-sm font-semibold text-[#123824]">Доставка</p>
|
||||
<div class="mt-3 grid gap-3 text-sm text-[#214735] md:grid-cols-2">
|
||||
<div class="manager-mini-card">
|
||||
Адрес: {{ order.deliveryAddress || 'клиент еще не выбрал адрес' }}
|
||||
</div>
|
||||
<div class="manager-mini-card">
|
||||
Условия: {{ order.deliveryTerms || 'ожидает оффера менеджера' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-subcard p-4">
|
||||
<p class="text-sm font-semibold text-[#123824]">Оффер и действия</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<input
|
||||
:value="getOffer(order.id).deliveryTerms"
|
||||
class="input manager-field w-full"
|
||||
placeholder="Условия доставки"
|
||||
@input="updateOfferField(order.id, 'deliveryTerms', ($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
<input
|
||||
:value="getOffer(order.id).deliveryFee"
|
||||
type="number"
|
||||
class="input manager-field w-full"
|
||||
placeholder="Стоимость доставки"
|
||||
@input="updateOfferField(order.id, 'deliveryFee', ($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
<input
|
||||
:value="getOffer(order.id).totalPrice"
|
||||
type="number"
|
||||
class="input manager-field w-full"
|
||||
placeholder="Итоговая стоимость"
|
||||
@input="updateOfferField(order.id, 'totalPrice', ($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button class="btn btn-primary btn-sm border-0" @click="publishOffer(order.id)">Публиковать оффер</button>
|
||||
<button class="btn btn-success btn-sm border-0" @click="approve(order.id)">Подтвердить</button>
|
||||
<button class="btn btn-error btn-sm border-0" @click="reject(order.id)">Отклонить</button>
|
||||
<button class="btn btn-warning btn-sm border-0" @click="blockOrder(order.id)">Заблокировать</button>
|
||||
<button class="btn btn-accent btn-sm border-0" :disabled="order.status !== 'CONFIRMED'" @click="start(order.id)">В работу</button>
|
||||
<button class="btn btn-neutral btn-sm border-0" :disabled="order.status !== 'IN_PROGRESS'" @click="complete(order.id)">Завершить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
391
app/pages/clients.vue
Normal file
391
app/pages/clients.vue
Normal file
@@ -0,0 +1,391 @@
|
||||
<script setup lang="ts">
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import {
|
||||
AddBonusTransactionDocument,
|
||||
CreateInvitationDocument,
|
||||
CreateReferralDocument,
|
||||
ManagerNotificationHistoryDocument,
|
||||
MyMessengerConnectionsDocument,
|
||||
RegistrationRequestsDocument,
|
||||
ReviewRegistrationRequestDocument,
|
||||
ReviewRewardWithdrawalDocument,
|
||||
type ManagerNotificationHistoryQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
import {
|
||||
messengerConnectionAvatarSrc,
|
||||
messengerConnectionHandle,
|
||||
messengerConnectionInitials,
|
||||
messengerConnectionName,
|
||||
} from '~/composables/useMessengerConnectionPresentation';
|
||||
import { useGqlClient } from '~/composables/useGqlClient';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['manager-only'],
|
||||
});
|
||||
|
||||
type HistoryItem = ManagerNotificationHistoryQuery['managerNotificationHistory'][number];
|
||||
|
||||
const gql = useGqlClient();
|
||||
|
||||
const registrationQuery = useQuery(RegistrationRequestsDocument, {
|
||||
status: 'PENDING',
|
||||
});
|
||||
const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
|
||||
|
||||
const reviewRequestMutation = useMutation(ReviewRegistrationRequestDocument);
|
||||
const createInvitationMutation = useMutation(CreateInvitationDocument);
|
||||
const createReferralMutation = useMutation(CreateReferralDocument);
|
||||
const addBonusMutation = useMutation(AddBonusTransactionDocument);
|
||||
const reviewWithdrawalMutation = useMutation(ReviewRewardWithdrawalDocument);
|
||||
|
||||
const selectedHistoryChannel = ref<'TELEGRAM' | 'MAX'>('TELEGRAM');
|
||||
const targetUserId = ref('');
|
||||
const historyItems = ref<HistoryItem[]>([]);
|
||||
const historyError = ref('');
|
||||
const historyLoading = ref(false);
|
||||
|
||||
const invitationEmail = ref('');
|
||||
const invitationCompanyName = ref('');
|
||||
const invitationToken = ref('');
|
||||
|
||||
const referralUserId = ref('');
|
||||
const referralCreatedId = ref('');
|
||||
|
||||
const bonusUserId = ref('');
|
||||
const bonusAmount = ref(100);
|
||||
const bonusReason = ref('Реферальный бонус');
|
||||
const bonusCreatedId = ref('');
|
||||
|
||||
const withdrawalId = ref('');
|
||||
const withdrawalDecision = ref<'APPROVE' | 'REJECT'>('APPROVE');
|
||||
const withdrawalComment = ref('');
|
||||
const withdrawalResult = ref('');
|
||||
|
||||
const pendingRequests = computed(() => registrationQuery.result.value?.registrationRequests ?? []);
|
||||
|
||||
const telegramConnection = computed(() =>
|
||||
connectionsQuery.result.value?.myMessengerConnections?.find(
|
||||
(item) => item.type === 'TELEGRAM' && item.isActive,
|
||||
),
|
||||
);
|
||||
|
||||
const maxConnection = computed(() =>
|
||||
connectionsQuery.result.value?.myMessengerConnections?.find(
|
||||
(item) => item.type === 'MAX' && item.isActive,
|
||||
),
|
||||
);
|
||||
|
||||
async function approveRequest(requestId: string) {
|
||||
await reviewRequestMutation.mutate({
|
||||
input: {
|
||||
requestId,
|
||||
decision: 'APPROVE',
|
||||
},
|
||||
});
|
||||
await registrationQuery.refetch({
|
||||
status: 'PENDING',
|
||||
});
|
||||
}
|
||||
|
||||
async function rejectRequest(requestId: string) {
|
||||
await reviewRequestMutation.mutate({
|
||||
input: {
|
||||
requestId,
|
||||
decision: 'REJECT',
|
||||
rejectionReason: 'Не хватает данных для регистрации.',
|
||||
},
|
||||
});
|
||||
await registrationQuery.refetch({
|
||||
status: 'PENDING',
|
||||
});
|
||||
}
|
||||
|
||||
async function createInvitation() {
|
||||
invitationToken.value = '';
|
||||
const response = await createInvitationMutation.mutate({
|
||||
input: {
|
||||
email: invitationEmail.value,
|
||||
companyName: invitationCompanyName.value,
|
||||
expiresInDays: 7,
|
||||
},
|
||||
});
|
||||
|
||||
invitationToken.value = response?.data?.createInvitation.token ?? '';
|
||||
}
|
||||
|
||||
async function createReferral() {
|
||||
referralCreatedId.value = '';
|
||||
const response = await createReferralMutation.mutate({
|
||||
input: {
|
||||
refereeUserId: referralUserId.value,
|
||||
},
|
||||
});
|
||||
|
||||
referralCreatedId.value = response?.data?.createReferral.id ?? '';
|
||||
}
|
||||
|
||||
async function addBonus() {
|
||||
bonusCreatedId.value = '';
|
||||
const response = await addBonusMutation.mutate({
|
||||
input: {
|
||||
userId: bonusUserId.value,
|
||||
amount: Number(bonusAmount.value),
|
||||
reason: bonusReason.value,
|
||||
},
|
||||
});
|
||||
|
||||
bonusCreatedId.value = response?.data?.addBonusTransaction.id ?? '';
|
||||
}
|
||||
|
||||
async function reviewWithdrawal() {
|
||||
withdrawalResult.value = '';
|
||||
const response = await reviewWithdrawalMutation.mutate({
|
||||
input: {
|
||||
withdrawalId: withdrawalId.value,
|
||||
decision: withdrawalDecision.value,
|
||||
reviewComment: withdrawalComment.value || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
withdrawalResult.value = response?.data?.reviewRewardWithdrawal.status ?? '';
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
historyError.value = '';
|
||||
historyItems.value = [];
|
||||
|
||||
if (!targetUserId.value.trim()) {
|
||||
historyError.value = 'Укажите user ID клиента, чтобы загрузить историю уведомлений.';
|
||||
return;
|
||||
}
|
||||
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
const response = await gql.query({
|
||||
query: ManagerNotificationHistoryDocument,
|
||||
variables: {
|
||||
userId: targetUserId.value.trim(),
|
||||
channel: selectedHistoryChannel.value,
|
||||
limit: 50,
|
||||
},
|
||||
fetchPolicy: 'no-cache',
|
||||
});
|
||||
|
||||
historyItems.value = response.data.managerNotificationHistory;
|
||||
} catch (error) {
|
||||
historyError.value = error instanceof Error ? error.message : 'Не удалось загрузить историю уведомлений.';
|
||||
} finally {
|
||||
historyLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<div class="manager-hero">
|
||||
<p class="manager-eyebrow">Клиенты</p>
|
||||
<h1 class="manager-title">Все менеджерские действия по клиентам в одном экране</h1>
|
||||
<p class="manager-copy">
|
||||
Здесь можно разбирать заявки на регистрацию, выдавать инвайты, смотреть историю уведомлений клиента и вести бонусные операции.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="manager-stat-card">
|
||||
<p class="manager-stat-label">Новые заявки</p>
|
||||
<p class="manager-stat-value">{{ pendingRequests.length }}</p>
|
||||
<p class="manager-helper">Очередь на проверку новых клиентов.</p>
|
||||
</div>
|
||||
<div class="manager-stat-card">
|
||||
<p class="manager-stat-label">Telegram менеджера</p>
|
||||
<p class="manager-stat-value text-lg">
|
||||
{{ telegramConnection ? messengerConnectionName(telegramConnection) : 'Не подключен' }}
|
||||
</p>
|
||||
<p class="manager-helper">Личные каналы менеджера настраиваются в профиле.</p>
|
||||
</div>
|
||||
<div class="manager-stat-card">
|
||||
<p class="manager-stat-label">Max менеджера</p>
|
||||
<p class="manager-stat-value text-lg">
|
||||
{{ maxConnection ? messengerConnectionName(maxConnection) : 'Не подключен' }}
|
||||
</p>
|
||||
<p class="manager-helper">Используется для тестов и рабочих уведомлений.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-[#123824]">Заявки на регистрацию</h2>
|
||||
<p class="manager-helper">Проверьте компанию и примите решение без перехода в отдельный кабинет.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="registrationQuery.loading.value" class="manager-empty-state mt-4">
|
||||
Загружаем очередь заявок...
|
||||
</div>
|
||||
<div v-else-if="pendingRequests.length === 0" class="manager-empty-state mt-4">
|
||||
Новых заявок сейчас нет.
|
||||
</div>
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<article
|
||||
v-for="request in pendingRequests"
|
||||
:key="request.id"
|
||||
class="surface-subcard p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-lg font-bold text-[#123824]">{{ request.companyName }}</h3>
|
||||
<p class="text-sm text-[#466653]">{{ request.contactName }} • {{ request.email }}</p>
|
||||
<p class="text-xs text-[#5c7b69]">{{ new Date(request.createdAt).toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="btn btn-success btn-sm border-0" @click="approveRequest(request.id)">Одобрить</button>
|
||||
<button class="btn btn-error btn-sm border-0" @click="rejectRequest(request.id)">Отклонить</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Инвайт клиенту</h2>
|
||||
<p class="manager-helper mt-1">Быстро выдайте ссылку на вход новой компании.</p>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<input v-model="invitationEmail" type="email" class="input manager-field w-full" placeholder="Email клиента">
|
||||
<input v-model="invitationCompanyName" type="text" class="input manager-field w-full" placeholder="Компания">
|
||||
<button class="btn btn-primary border-0" @click="createInvitation">Создать инвайт</button>
|
||||
<div v-if="invitationToken" class="manager-mini-card text-sm text-[#123824]">
|
||||
Токен приглашения: <span class="font-semibold">{{ invitationToken }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Рабочие каналы менеджера</h2>
|
||||
<p class="manager-helper mt-1">
|
||||
Подключение Telegram и Max выполняется в <NuxtLink to="/profile/notifications" class="font-semibold text-[#0d854a]">профиле</NuxtLink>.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<div class="manager-channel-chip">
|
||||
<div v-if="telegramConnection && messengerConnectionAvatarSrc(telegramConnection)" class="avatar">
|
||||
<div class="manager-channel-dot h-8 w-8 rounded-full bg-[#123824]">
|
||||
<img :src="messengerConnectionAvatarSrc(telegramConnection)" :alt="messengerConnectionName(telegramConnection)">
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="manager-channel-dot bg-[#123824]">
|
||||
{{ messengerConnectionInitials(telegramConnection, 'TG') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-[#123824]">
|
||||
{{ telegramConnection ? messengerConnectionHandle(telegramConnection) || messengerConnectionName(telegramConnection) : 'Telegram не подключен' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="manager-channel-chip">
|
||||
<span class="manager-channel-dot bg-[#2b7fff]">
|
||||
{{ messengerConnectionInitials(maxConnection, 'MX') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-[#123824]">
|
||||
{{ maxConnection ? messengerConnectionHandle(maxConnection) || messengerConnectionName(maxConnection) : 'Max не подключен' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-[#123824]">История уведомлений клиента</h2>
|
||||
<p class="manager-helper mt-1">Проверяйте, что именно ушло клиенту в Telegram или Max.</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs tabs-boxed">
|
||||
<button class="tab" :class="{ 'tab-active': selectedHistoryChannel === 'TELEGRAM' }" @click="selectedHistoryChannel = 'TELEGRAM'">
|
||||
Telegram
|
||||
</button>
|
||||
<button class="tab" :class="{ 'tab-active': selectedHistoryChannel === 'MAX' }" @click="selectedHistoryChannel = 'MAX'">
|
||||
Max
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-3 md:flex-row">
|
||||
<input v-model="targetUserId" class="input manager-field w-full" placeholder="User ID клиента">
|
||||
<button class="btn btn-secondary border-0 md:w-auto" @click="loadHistory">Загрузить</button>
|
||||
</div>
|
||||
|
||||
<div v-if="historyLoading" class="manager-empty-state mt-4">
|
||||
Загружаем историю...
|
||||
</div>
|
||||
<div v-else-if="historyError" class="manager-empty-state mt-4 text-[#a34a34]">
|
||||
{{ historyError }}
|
||||
</div>
|
||||
<div v-else-if="historyItems.length === 0" class="manager-empty-state mt-4">
|
||||
История пока пустая.
|
||||
</div>
|
||||
<ul v-else class="mt-4 space-y-3">
|
||||
<li
|
||||
v-for="item in historyItems"
|
||||
:key="item.id"
|
||||
class="surface-subcard p-4"
|
||||
>
|
||||
<p class="font-semibold text-[#123824]">{{ item.title }}</p>
|
||||
<p class="mt-1 text-sm text-[#355947]">{{ item.message }}</p>
|
||||
<div class="mt-3 flex flex-wrap gap-3 text-xs text-[#5c7b69]">
|
||||
<span>{{ new Date(item.createdAt).toLocaleString() }}</span>
|
||||
<span v-if="item.orderId">Заказ: {{ item.orderId }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Реферальная связь</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
<input v-model="referralUserId" class="input manager-field w-full" placeholder="ID приглашенного пользователя">
|
||||
<button class="btn btn-primary border-0" @click="createReferral">Создать связь</button>
|
||||
<div v-if="referralCreatedId" class="manager-mini-card text-sm text-[#123824]">
|
||||
Создана связь: <span class="font-semibold">{{ referralCreatedId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Начислить бонус</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
<input v-model="bonusUserId" class="input manager-field w-full" placeholder="ID пользователя">
|
||||
<input v-model="bonusAmount" type="number" class="input manager-field w-full" placeholder="Сумма">
|
||||
<input v-model="bonusReason" class="input manager-field w-full" placeholder="Причина">
|
||||
<button class="btn btn-secondary border-0" @click="addBonus">Начислить</button>
|
||||
<div v-if="bonusCreatedId" class="manager-mini-card text-sm text-[#123824]">
|
||||
Создана транзакция: <span class="font-semibold">{{ bonusCreatedId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Заявка на вывод</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
<input v-model="withdrawalId" class="input manager-field w-full" placeholder="ID заявки на вывод">
|
||||
<select v-model="withdrawalDecision" class="select manager-field w-full">
|
||||
<option value="APPROVE">Одобрить</option>
|
||||
<option value="REJECT">Отклонить</option>
|
||||
</select>
|
||||
<textarea v-model="withdrawalComment" class="textarea manager-field min-h-28 w-full" placeholder="Комментарий для клиента" />
|
||||
<button class="btn btn-accent border-0" @click="reviewWithdrawal">Подтвердить решение</button>
|
||||
<div v-if="withdrawalResult" class="manager-mini-card text-sm text-[#123824]">
|
||||
Новый статус: <span class="font-semibold">{{ withdrawalResult }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
|
||||
import {
|
||||
ClientReviewOrderDocument,
|
||||
MyOrdersDocument,
|
||||
type MyOrdersQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
@@ -10,30 +9,12 @@ import {
|
||||
type OrderItem = MyOrdersQuery['myOrders'][number];
|
||||
|
||||
const allOrders = useQuery(MyOrdersDocument);
|
||||
const reviewOrder = useMutation(ClientReviewOrderDocument);
|
||||
const actionError = ref('');
|
||||
const search = ref('');
|
||||
const statusFilter = ref<'ALL' | 'WAITING' | 'ACTIVE' | 'CLOSED'>('ALL');
|
||||
|
||||
const ACTIVE_STATUSES = new Set(['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS']);
|
||||
const CLOSED_STATUSES = new Set(['COMPLETED', 'CLIENT_REJECTED', 'MANAGER_REJECTED', 'MANAGER_BLOCKED']);
|
||||
|
||||
reviewOrder.onError((error) => {
|
||||
actionError.value = error.message;
|
||||
});
|
||||
|
||||
async function approve(orderId: string) {
|
||||
actionError.value = '';
|
||||
await reviewOrder.mutate({ orderId, decision: 'APPROVE' });
|
||||
await allOrders.refetch();
|
||||
}
|
||||
|
||||
async function reject(orderId: string) {
|
||||
actionError.value = '';
|
||||
await reviewOrder.mutate({ orderId, decision: 'REJECT' });
|
||||
await allOrders.refetch();
|
||||
}
|
||||
|
||||
function matchesFilter(order: OrderItem) {
|
||||
if (statusFilter.value === 'ALL') {
|
||||
return true;
|
||||
@@ -69,8 +50,6 @@ const filteredOrders = computed(() => {
|
||||
<section class="space-y-6">
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Заказы</h1>
|
||||
|
||||
<div v-if="actionError" class="alert alert-error">{{ actionError }}</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-4 md:p-5">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<label class="form-control">
|
||||
@@ -136,15 +115,6 @@ const filteredOrders = computed(() => {
|
||||
<div class="mt-3 rounded-xl border border-[#d6ebde] bg-white/75 px-3 py-2 text-sm text-[#214735]">
|
||||
Адрес доставки: {{ order.deliveryAddress || 'адрес не был выбран' }}
|
||||
</div>
|
||||
|
||||
<div v-if="order.status === 'WAITING_DOUBLE_CONFIRM'" class="mt-4 flex flex-wrap gap-2">
|
||||
<button class="btn btn-sm border-0 bg-[#139957] text-white hover:bg-[#0d854a]" @click="approve(order.id)">
|
||||
Подтвердить
|
||||
</button>
|
||||
<button class="btn btn-sm border-0 bg-[#d32422] text-white hover:bg-[#b31f1d]" @click="reject(order.id)">
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user