458 lines
14 KiB
Vue
458 lines
14 KiB
Vue
<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('');
|
||
}
|
||
|
||
function compactProductTitle(product: ProductCard) {
|
||
return `Подарочная карта ${product.store}`;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="space-y-6">
|
||
<UiSectionSearchHero
|
||
v-model="search"
|
||
title="Бонусы"
|
||
:search-placeholder="activeTab === 'balances'
|
||
? 'Клиент, связанный клиент или email'
|
||
: activeTab === 'withdrawals'
|
||
? 'Номер выплаты, клиент или сумма'
|
||
: 'Название или номинал'"
|
||
>
|
||
<template #controls>
|
||
<NuxtLink
|
||
v-if="activeTab === 'balances'"
|
||
to="/admin/bonuses/referrals/new"
|
||
class="btn btn-primary border-0"
|
||
>
|
||
Добавить
|
||
</NuxtLink>
|
||
</template>
|
||
</UiSectionSearchHero>
|
||
|
||
<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">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<span class="rounded-full bg-[#eef5f0] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#4e7060]">
|
||
{{ product.store }}
|
||
</span>
|
||
<span class="rounded-full bg-[#fff8dc] px-3 py-1 text-xs font-bold text-[#7a5b00]">
|
||
{{ formatAmount(product.amount) }} ₽
|
||
</span>
|
||
</div>
|
||
<h2 class="mt-3 text-lg font-bold leading-tight text-[#123824]">{{ compactProductTitle(product) }}</h2>
|
||
</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>
|