Files
web-frontend/app/pages/bonus-system/[userId].vue
2026-04-07 10:48:07 +07:00

256 lines
9.9 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 {
CreateBonusProgramLinkDocument,
ManagerBonusAccountDocument,
type ManagerBonusAccountQuery,
} from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
path: '/admin/bonuses/balances/:userId',
alias: ['/bonus-system/:userId'],
});
type TransactionItem = ManagerBonusAccountQuery['managerBonusAccount']['transactions'][number];
type PendingWithdrawalItem = ManagerBonusAccountQuery['managerBonusAccount']['pendingWithdrawals'][number];
const route = useRoute();
const userId = computed(() => String(route.params.userId || ''));
const createBonusProgramLinkMutation = useMutation(CreateBonusProgramLinkDocument, { throws: 'never' });
const bonusProgramLink = ref('');
const bonusProgramLinkExpiresAt = ref('');
const bonusProgramLinkFeedback = ref('');
const bonusAccountQuery = useQuery(ManagerBonusAccountDocument, () => ({
userId: userId.value,
}));
const bonusAccount = computed(() => bonusAccountQuery.result.value?.managerBonusAccount ?? null);
const transactions = computed<TransactionItem[]>(() => bonusAccount.value?.transactions ?? []);
const pendingWithdrawals = computed<PendingWithdrawalItem[]>(() => bonusAccount.value?.pendingWithdrawals ?? []);
function formatAmount(value: number) {
return new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(value);
}
function formatDateTime(value: string) {
return new Date(value).toLocaleString('ru-RU');
}
function withdrawalStatusLabel(status: string) {
if (status === 'APPROVED') {
return 'Проведена';
}
if (status === 'REJECTED') {
return 'Отклонена';
}
return 'На проверке';
}
function withdrawalStatusClass(status: string) {
if (status === 'APPROVED') {
return 'bg-[#def7e8] text-[#0d854a]';
}
if (status === 'REJECTED') {
return 'bg-[#fde8ea] text-[#b73742]';
}
return 'bg-[#fff3d8] text-[#9a6100]';
}
async function generateBonusProgramLink() {
bonusProgramLinkFeedback.value = '';
const response = await createBonusProgramLinkMutation.mutate({
userId: userId.value,
});
const payload = response?.data?.createBonusProgramLink;
if (!payload?.url) {
bonusProgramLinkFeedback.value = createBonusProgramLinkMutation.error.value?.message || 'Не удалось сгенерировать ссылку.';
return;
}
bonusProgramLink.value = payload.url;
bonusProgramLinkExpiresAt.value = payload.expiresAt;
bonusProgramLinkFeedback.value = 'Ссылка готова. Её можно переслать клиенту.';
}
async function copyBonusProgramLink() {
if (!bonusProgramLink.value) {
return;
}
await navigator.clipboard.writeText(bonusProgramLink.value);
bonusProgramLinkFeedback.value = 'Ссылка скопирована.';
}
</script>
<template>
<section class="space-y-6">
<div v-if="bonusAccountQuery.loading.value" class="manager-empty-state">
Загружаем бонусный счёт...
</div>
<div v-else-if="!bonusAccount" class="manager-empty-state">
Бонусный счёт не найден.
</div>
<template v-else>
<div class="space-y-4">
<UiBackHeader
to="/admin/bonuses/balances"
back-label="Назад к бонусам"
:title="`Бонусный счёт ${bonusAccount.fullName}`"
:subtitle="bonusAccount.companyName || bonusAccount.email || undefined"
>
<template #actions>
<div class="flex flex-col gap-3 md:items-end">
<div class="text-left md:text-right">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Доступный бонус</p>
<p class="mt-2 text-3xl font-black leading-none text-[#123824]">{{ formatAmount(bonusAccount.balance) }}</p>
</div>
<button
class="btn rounded-full border-0 bg-[#123824] px-5 text-white hover:bg-[#0f2f20]"
:disabled="createBonusProgramLinkMutation.loading.value"
@click="generateBonusProgramLink"
>
{{ createBonusProgramLinkMutation.loading.value ? 'Генерируем...' : 'Сгенерировать ссылку' }}
</button>
</div>
</template>
</UiBackHeader>
<article v-if="bonusProgramLink" class="surface-card rounded-[28px] px-5 py-4">
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="min-w-0 flex-1 space-y-2">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Ссылка в бонусный кабинет</p>
<p class="text-sm text-[#355947]">
Эту ссылку менеджер может отправить клиенту. Дальше клиент авторизуется через отдельный Telegram-бот бонусной программы.
</p>
<div class="rounded-[20px] bg-[#f8fbf9] px-4 py-3 text-sm font-semibold text-[#123824] break-all">
{{ bonusProgramLink }}
</div>
<p v-if="bonusProgramLinkExpiresAt" class="text-xs text-[#5c7b69]">
Действует до {{ formatDateTime(bonusProgramLinkExpiresAt) }}
</p>
</div>
<div class="flex gap-3">
<a
:href="bonusProgramLink"
target="_blank"
rel="noreferrer"
class="btn rounded-full border border-[#d7e9de] bg-white px-5 text-[#123824] hover:bg-[#f3f8f5]"
>
Открыть
</a>
<button
class="btn rounded-full border-0 bg-[#139957] px-5 text-white hover:bg-[#0d854a]"
@click="copyBonusProgramLink"
>
Скопировать
</button>
</div>
</div>
<p v-if="bonusProgramLinkFeedback" class="mt-4 text-sm font-semibold text-[#0d854a]">
{{ bonusProgramLinkFeedback }}
</p>
</article>
<p
v-else-if="bonusProgramLinkFeedback"
class="text-sm font-semibold text-[#0d854a]"
>
{{ bonusProgramLinkFeedback }}
</p>
<div v-if="pendingWithdrawals.length" class="space-y-3">
<div class="space-y-1">
<p class="text-lg font-bold text-[#123824]">Заявки на выплату</p>
<p class="text-sm text-[#5c7b69]">Все активные выплаты по этому бонусному счёту.</p>
</div>
<NuxtLink
v-for="withdrawal in pendingWithdrawals"
:key="withdrawal.id"
:to="`/admin/bonuses/requests/${withdrawal.id}`"
class="surface-card surface-card-interactive block rounded-[28px] px-5 py-4"
>
<div class="grid gap-4 md:grid-cols-[180px_minmax(0,1fr)_170px_140px] md:items-center md:gap-6">
<div class="space-y-1">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-[#6a8a76]">Заявка</p>
<p class="text-base font-bold text-[#123824]">WD-{{ withdrawal.id.slice(-6).toUpperCase() }}</p>
</div>
<div class="min-w-0 space-y-1">
<p class="text-sm font-semibold text-[#123824]">Создано {{ formatDateTime(withdrawal.createdAt) }}</p>
<p v-if="withdrawal.reviewComment" class="truncate text-sm text-[#5c7b69]">{{ withdrawal.reviewComment }}</p>
</div>
<div>
<span class="rounded-full px-3 py-1 text-sm font-semibold" :class="withdrawalStatusClass(withdrawal.status)">
{{ withdrawalStatusLabel(withdrawal.status) }}
</span>
</div>
<div class="text-left md:text-right">
<p class="text-base font-bold text-[#123824]">{{ formatAmount(withdrawal.amount) }} </p>
</div>
</div>
</NuxtLink>
</div>
<div class="space-y-3">
<div class="space-y-1">
<p class="text-lg font-bold text-[#123824]">Транзакции</p>
<p class="text-sm text-[#5c7b69]">История начислений и операций по бонусному счёту.</p>
</div>
<div v-if="transactions.length === 0" class="manager-empty-state mt-4">
Начислений пока нет.
</div>
<div v-else class="space-y-3">
<article
v-for="transaction in transactions"
:key="transaction.id"
class="surface-card rounded-[28px] px-5 py-4"
>
<div class="grid gap-4 md:grid-cols-[140px_minmax(0,1fr)_180px_140px] md:items-center md:gap-6">
<div>
<p class="text-base font-bold text-[#123824]">+{{ formatAmount(transaction.amount) }} </p>
</div>
<div class="min-w-0 space-y-1">
<p class="text-sm font-semibold text-[#123824]">Начисление</p>
<p class="text-sm text-[#355947]">{{ transaction.reason }}</p>
</div>
<div class="text-sm text-[#5c7b69]">
{{ formatDateTime(transaction.createdAt) }}
</div>
<div class="text-left md:text-right">
<NuxtLink
v-if="transaction.orderId"
:to="`/admin/orders/${transaction.orderId}`"
class="text-sm font-semibold text-[#0d854a]"
>
Открыть заказ
</NuxtLink>
</div>
</div>
</article>
</div>
</div>
</div>
</template>
</section>
</template>