Files
web-frontend/app/pages/bonus-system/index.vue
2026-04-06 15:07:59 +07:00

402 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'],
});
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;
subtitle: string;
gradient: string;
tags: 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' | 'products'>(() => {
if (route.query.tab === 'withdrawals') {
return 'withdrawals';
}
if (route.query.tab === 'products' || route.query.tab === 'manager') {
return 'products';
}
return 'balances';
});
const productCards: ProductCard[] = [
{
id: 'ozon-3000',
store: 'Ozon',
title: 'Подарочная карта Ozon',
amount: 3000,
subtitle: 'Универсальная карта для маркетплейса: техника, дом и повседневные покупки.',
gradient: 'linear-gradient(135deg, #38b6ff 0%, #1369ff 55%, #0b2f72 100%)',
tags: ['Маркетплейс', 'Электронная карта', '3 000 ₽'],
},
{
id: 'ozon-5000',
store: 'Ozon',
title: 'Подарочная карта Ozon',
amount: 5000,
subtitle: 'Крупный номинал для заметных подарков и сезонных закупок.',
gradient: 'linear-gradient(135deg, #65d0ff 0%, #247bff 52%, #12315e 100%)',
tags: ['Маркетплейс', 'Топ-номинал', '5 000 ₽'],
},
{
id: 'wildberries-3000',
store: 'Wildberries',
title: 'Подарочная карта Wildberries',
amount: 3000,
subtitle: 'Подходит для одежды, дома и повседневных мелочей в одном каталоге.',
gradient: 'linear-gradient(135deg, #d84dff 0%, #8b27ff 52%, #39006a 100%)',
tags: ['Fashion', 'Маркетплейс', '3 000 ₽'],
},
{
id: 'wildberries-4000',
store: 'Wildberries',
title: 'Подарочная карта Wildberries',
amount: 4000,
subtitle: 'Средний номинал для fashion-покупок и товаров для дома.',
gradient: 'linear-gradient(135deg, #ef7cff 0%, #a12dff 50%, #4c0b7d 100%)',
tags: ['Одежда', 'Дом', '4 000 ₽'],
},
{
id: 'mvideo-4000',
store: 'М.Видео',
title: 'Подарочная карта М.Видео',
amount: 4000,
subtitle: 'Для техники, аксессуаров и бытовой электроники.',
gradient: 'linear-gradient(135deg, #ff9461 0%, #ff5630 48%, #821414 100%)',
tags: ['Техника', 'Электроника', '4 000 ₽'],
},
{
id: 'mvideo-5000',
store: 'М.Видео',
title: 'Подарочная карта М.Видео',
amount: 5000,
subtitle: 'Максимальный номинал для заметных подарков и апгрейдов рабочего места.',
gradient: 'linear-gradient(135deg, #ffb17e 0%, #ff6842 50%, #8f1818 100%)',
tags: ['Электроника', 'Подарок', '5 000 ₽'],
},
];
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,
item.subtitle,
String(item.amount),
...item.tags,
]
.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],
});
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);
}
</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="`/bonus-system/${item.userId}`"
:full-name="item.fullName"
:avatar-src="messengerConnectionAvatarSrc(usersById.get(item.userId)?.telegramConnection)"
:initials="userInitials(item.fullName)"
meta-label="Доступный бонус"
: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 === 'products'">
<div class="surface-card rounded-[32px] p-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div class="space-y-2">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Витрина магазина</p>
<h2 class="text-2xl font-black tracking-[-0.03em] text-[#123824]">Подарочные карты для бонусного каталога</h2>
<p class="max-w-3xl text-sm leading-6 text-[#557562]">
Вынес товары в отдельную вкладку без лишнего вложения. Пока это стартовый сет из популярных магазинов с номиналами от 3 000 до 5 000 рублей.
</p>
</div>
<div class="flex flex-wrap gap-2 text-sm text-[#355947]">
<span class="rounded-full bg-[#eef7f1] px-3 py-2 font-semibold">3 магазина</span>
<span class="rounded-full bg-[#eef7f1] px-3 py-2 font-semibold">6 карточек</span>
<span class="rounded-full bg-[#eef7f1] px-3 py-2 font-semibold">3 000-5 000 </span>
</div>
</div>
</div>
<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 overflow-hidden rounded-[32px]"
>
<div class="p-5 text-white" :style="{ background: product.gradient }">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/72">{{ product.store }}</p>
<h3 class="mt-3 text-2xl font-black tracking-[-0.03em]">{{ product.title }}</h3>
</div>
<span class="rounded-full bg-white/14 px-3 py-1 text-sm font-semibold backdrop-blur-sm">Витрина</span>
</div>
<div class="mt-10">
<p class="text-sm text-white/72">Номинал</p>
<p class="mt-2 text-4xl font-black leading-none">{{ formatAmount(product.amount) }} </p>
</div>
</div>
<div class="space-y-4 p-5">
<p class="text-sm leading-6 text-[#557562]">
{{ product.subtitle }}
</p>
<div class="flex flex-wrap gap-2">
<span
v-for="tag in product.tags"
:key="tag"
class="rounded-full bg-[#eef7f1] px-3 py-1.5 text-xs font-semibold text-[#0d854a]"
>
{{ tag }}
</span>
</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-4">
<article
v-for="withdrawal in visibleWithdrawals"
:key="withdrawal.id"
class="surface-card rounded-3xl px-5 py-5"
>
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="space-y-1">
<p class="text-sm font-semibold text-[#123824]">{{ withdrawal.requesterFullName }}</p>
<p class="text-sm text-[#355947]">{{ withdrawal.requesterEmail }}</p>
<p v-if="withdrawal.companyName" class="text-sm text-[#355947]">{{ withdrawal.companyName }}</p>
<p class="text-sm text-[#355947]">Сумма: {{ withdrawal.amount }}</p>
<p class="text-xs text-[#5c7b69]">{{ new Date(withdrawal.createdAt).toLocaleString() }}</p>
</div>
<NuxtLink :to="`/bonus-system/withdrawals/${withdrawal.id}`" class="btn btn-accent btn-sm border-0">
Проверить выплату
</NuxtLink>
</div>
</article>
<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>