Files
web-frontend/app/pages/clients.vue
2026-04-03 19:01:22 +07:00

392 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>