Files
web-frontend/app/pages/bonus-system/index.vue
2026-04-06 16:12:54 +07:00

443 lines
14 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 { useQuery } from '@vue/apollo-composable';
import {
ManagerBonusBalancesDocument,
ManagerReferralLinksDocument,
ManagerUsersDocument,
ManagerWithdrawalRequestsDocument,
type ManagerBonusBalancesQuery,
type ManagerReferralLinksQuery,
type ManagerUsersQuery,
type ManagerWithdrawalRequestsQuery,
} from '~/composables/graphql/generated';
import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnectionPresentation';
definePageMeta({
middleware: ['manager-only'],
path: '/admin/bonuses/:section(balances|requests|rewards)?',
alias: ['/bonus-system'],
});
type BalanceItem = ManagerBonusBalancesQuery['managerBonusBalances'][number];
type ReferralLinkItem = ManagerReferralLinksQuery['managerReferralLinks'][number];
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
type WithdrawalItem = ManagerWithdrawalRequestsQuery['managerWithdrawalRequests'][number];
type ProductCard = {
id: string;
store: string;
title: string;
amount: number;
gradient: string;
};
const route = useRoute();
const search = ref('');
const balancesQuery = useQuery(ManagerBonusBalancesDocument);
const referralLinksQuery = useQuery(ManagerReferralLinksDocument);
const usersQuery = useQuery(ManagerUsersDocument);
const withdrawalsQuery = useQuery(ManagerWithdrawalRequestsDocument, {
status: 'PENDING',
});
const activeTab = computed<'balances' | 'withdrawals' | 'rewards'>(() => {
if (route.path === '/admin/bonuses/requests') {
return 'withdrawals';
}
if (route.path === '/admin/bonuses/rewards') {
return 'rewards';
}
return 'balances';
});
const productCards: ProductCard[] = [
{
id: 'ozon-3000',
store: 'Ozon',
title: 'Подарочная карта Ozon',
amount: 3000,
gradient: 'linear-gradient(135deg, #38b6ff 0%, #1369ff 55%, #0b2f72 100%)',
},
{
id: 'ozon-5000',
store: 'Ozon',
title: 'Подарочная карта Ozon',
amount: 5000,
gradient: 'linear-gradient(135deg, #65d0ff 0%, #247bff 52%, #12315e 100%)',
},
{
id: 'wildberries-3000',
store: 'Wildberries',
title: 'Подарочная карта Wildberries',
amount: 3000,
gradient: 'linear-gradient(135deg, #d84dff 0%, #8b27ff 52%, #39006a 100%)',
},
{
id: 'wildberries-4000',
store: 'Wildberries',
title: 'Подарочная карта Wildberries',
amount: 4000,
gradient: 'linear-gradient(135deg, #ef7cff 0%, #a12dff 50%, #4c0b7d 100%)',
},
{
id: 'mvideo-4000',
store: 'М.Видео',
title: 'Подарочная карта М.Видео',
amount: 4000,
gradient: 'linear-gradient(135deg, #ff9461 0%, #ff5630 48%, #821414 100%)',
},
{
id: 'mvideo-5000',
store: 'М.Видео',
title: 'Подарочная карта М.Видео',
amount: 5000,
gradient: 'linear-gradient(135deg, #ffb17e 0%, #ff6842 50%, #8f1818 100%)',
},
];
const balances = computed<BalanceItem[]>(() => balancesQuery.result.value?.managerBonusBalances ?? []);
const referralLinks = computed<ReferralLinkItem[]>(() => referralLinksQuery.result.value?.managerReferralLinks ?? []);
const users = computed<ManagerUserItem[]>(() => usersQuery.result.value?.managerUsers ?? []);
const withdrawals = computed<WithdrawalItem[]>(() => withdrawalsQuery.result.value?.managerWithdrawalRequests ?? []);
const usersById = computed(() => new Map(users.value.map((user) => [user.id, user])));
const referralLinksByReferrer = computed(() => {
const grouped = new Map<string, ReferralLinkItem[]>();
for (const link of referralLinks.value) {
const existing = grouped.get(link.referrerId) ?? [];
existing.push(link);
grouped.set(link.referrerId, existing);
}
return grouped;
});
const filteredBalances = computed(() => {
const query = search.value.trim().toLowerCase();
return balances.value
.filter((item) => {
const links = referralLinksByReferrer.value.get(item.userId);
if (!links?.length) {
return false;
}
if (!query) {
return true;
}
return [
item.fullName,
item.email,
item.companyName || '',
String(item.balance),
...links.flatMap((link) => [
link.refereeName,
link.refereeEmail,
link.refereeCompanyName || '',
String(link.bonusPercent),
]),
]
.join(' ')
.toLowerCase()
.includes(query);
})
.slice()
.sort((left, right) => {
const leftLatest = referralLinksByReferrer.value.get(left.userId)?.[0]?.createdAt ?? '';
const rightLatest = referralLinksByReferrer.value.get(right.userId)?.[0]?.createdAt ?? '';
return rightLatest.localeCompare(leftLatest);
});
});
const filteredWithdrawals = computed(() => {
const query = search.value.trim().toLowerCase();
return withdrawals.value.filter((item) => {
if (!query) {
return true;
}
return [
item.requesterFullName,
item.requesterEmail,
item.companyName || '',
String(item.amount),
item.status,
item.reviewComment || '',
]
.join(' ')
.toLowerCase()
.includes(query);
});
});
const filteredProducts = computed(() => {
const query = search.value.trim().toLowerCase();
return productCards.filter((item) => {
if (!query) {
return true;
}
return [
item.store,
item.title,
String(item.amount),
]
.join(' ')
.toLowerCase()
.includes(query);
});
});
const {
canLoadMore: canLoadMoreBalances,
loadMore: loadMoreBalances,
loadMoreSentinel: loadMoreBalancesSentinel,
remainingCount: remainingBalancesCount,
visibleItems: visibleBalances,
} = useIncrementalList(filteredBalances, {
pageSize: 24,
enabled: computed(() => activeTab.value === 'balances'),
resetKeys: [search, activeTab],
});
const {
canLoadMore: canLoadMoreWithdrawals,
loadMore: loadMoreWithdrawals,
loadMoreSentinel: loadMoreWithdrawalsSentinel,
remainingCount: remainingWithdrawalsCount,
visibleItems: visibleWithdrawals,
} = useIncrementalList(filteredWithdrawals, {
pageSize: 24,
enabled: computed(() => activeTab.value === 'withdrawals'),
resetKeys: [search, activeTab],
});
const WITHDRAWAL_DATE_FORMATTER = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
function userInitials(fullName: string) {
const parts = fullName
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2);
if (!parts.length) {
return 'FR';
}
return parts.map((part) => part[0]?.toUpperCase() ?? '').join('');
}
function formatAmount(value: number) {
return new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(value);
}
function formatWithdrawalCode(id: string) {
return `WD-${id.slice(-6).toUpperCase()}`;
}
function formatDateTime(value: string) {
return WITHDRAWAL_DATE_FORMATTER.format(new Date(value));
}
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]';
}
function requesterMeta(withdrawal: WithdrawalItem) {
const requester = usersById.value.get(withdrawal.requesterId);
const fullName = withdrawal.requesterFullName;
return {
avatarSrc: messengerConnectionAvatarSrc(requester?.telegramConnection),
initials: userInitials(fullName),
companyName: withdrawal.companyName || requester?.companyName || '',
};
}
function productVisualLabel(product: ProductCard) {
return product.store
.replace(/[^A-Za-zА-Яа-яЁё0-9]+/g, ' ')
.trim()
.split(/\s+/)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('');
}
</script>
<template>
<section class="space-y-6">
<UiSectionSearchHero
v-model="search"
title="Бонусы"
:search-placeholder="activeTab === 'balances'
? 'Клиент, связанный клиент или email'
: activeTab === 'withdrawals'
? 'Номер выплаты, клиент или сумма'
: 'Название или номинал'"
/>
<template v-if="activeTab === 'balances'">
<div v-if="balancesQuery.loading.value || referralLinksQuery.loading.value || usersQuery.loading.value" class="manager-empty-state">
Загружаем балансы...
</div>
<div v-else-if="filteredBalances.length === 0" class="manager-empty-state">
Бонусных связок пока нет.
</div>
<div v-else class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
<UsersGridCard
v-for="item in visibleBalances"
:key="item.userId"
:to="`/admin/bonuses/balances/${item.userId}`"
:full-name="item.fullName"
:avatar-src="messengerConnectionAvatarSrc(usersById.get(item.userId)?.telegramConnection)"
:initials="userInitials(item.fullName)"
:meta-value="`${formatAmount(item.balance)} `"
/>
</div>
<div
v-if="canLoadMoreBalances"
ref="loadMoreBalancesSentinel"
class="flex justify-center"
>
<button class="btn btn-outline border-[#d7e9de] bg-white" @click="loadMoreBalances">
Показать ещё {{ Math.min(remainingBalancesCount, 24) }}
</button>
</div>
</div>
</template>
<template v-else-if="activeTab === 'rewards'">
<div v-if="filteredProducts.length === 0" class="manager-empty-state">
По текущему запросу товары не найдены.
</div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<article
v-for="product in filteredProducts"
:key="product.id"
class="surface-card rounded-[28px] p-5"
>
<div class="flex items-center gap-4">
<div
class="flex h-16 w-16 shrink-0 items-center justify-center rounded-[20px] text-lg font-black text-white"
:style="{ background: product.gradient }"
>
{{ productVisualLabel(product) }}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-[#6a8a76]">{{ product.store }}</p>
<h2 class="mt-1 text-lg font-bold leading-tight text-[#123824]">{{ product.title }}</h2>
</div>
</div>
<div class="mt-5 flex items-center justify-between gap-3">
<p class="text-sm text-[#557562]">Номинал</p>
<div class="rounded-full bg-[#fff8dc] px-4 py-2 text-sm font-bold text-[#123824]">
{{ formatAmount(product.amount) }}
</div>
</div>
</article>
</div>
</template>
<template v-else>
<div v-if="withdrawalsQuery.loading.value" class="manager-empty-state">
Загружаем заявки...
</div>
<div v-else-if="filteredWithdrawals.length === 0" class="manager-empty-state">
Активных заявок на выплату сейчас нет.
</div>
<div v-else class="space-y-3">
<NuxtLink
v-for="withdrawal in visibleWithdrawals"
:key="withdrawal.id"
:to="`/admin/bonuses/requests/${withdrawal.id}`"
class="surface-card surface-card-interactive block rounded-[30px] bg-white px-4 py-4 md:px-5"
>
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_180px_140px] md:items-center md:gap-6">
<div class="min-w-0">
<h2 class="truncate text-lg font-bold text-[#123824]">{{ formatWithdrawalCode(withdrawal.id) }}</h2>
<p class="mt-1 text-sm text-[#688676]">{{ formatDateTime(withdrawal.createdAt) }}</p>
</div>
<div class="flex min-w-0 items-center gap-3">
<img
v-if="requesterMeta(withdrawal).avatarSrc"
:src="requesterMeta(withdrawal).avatarSrc"
:alt="withdrawal.requesterFullName"
class="h-12 w-12 rounded-[16px] object-cover"
>
<div
v-else
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-[16px] bg-[linear-gradient(135deg,#dff7e9_0%,#c2ead3_100%)] text-sm font-black text-[#123824]"
>
{{ requesterMeta(withdrawal).initials }}
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-[#123824]">{{ withdrawal.requesterFullName }}</p>
<p class="truncate text-sm text-[#557562]">
{{ requesterMeta(withdrawal).companyName || withdrawal.requesterEmail }}
</p>
</div>
</div>
<div class="flex items-center justify-start">
<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
v-if="canLoadMoreWithdrawals"
ref="loadMoreWithdrawalsSentinel"
class="flex justify-center"
>
<button class="btn btn-outline border-[#d7e9de] bg-white" @click="loadMoreWithdrawals">
Показать ещё {{ Math.min(remainingWithdrawalsCount, 24) }}
</button>
</div>
</div>
</template>
</section>
</template>