Polish manager bonus and message views

This commit is contained in:
Ruslan Bakiev
2026-04-06 15:45:15 +07:00
parent bed4c2f467
commit 824065f852
4 changed files with 181 additions and 90 deletions

View File

@@ -2,7 +2,7 @@
type DockItem = { type DockItem = {
to: string; to: string;
label: string; label: string;
icon: 'orders' | 'bonus'; icon: 'orders' | 'bonus' | 'settings';
}; };
const route = useRoute(); const route = useRoute();
@@ -10,6 +10,7 @@ const route = useRoute();
const dockItems: DockItem[] = [ const dockItems: DockItem[] = [
{ to: '/client-orders', label: 'Заказы', icon: 'orders' }, { to: '/client-orders', label: 'Заказы', icon: 'orders' },
{ to: '/bonus-system', label: 'Бонусы', icon: 'bonus' }, { to: '/bonus-system', label: 'Бонусы', icon: 'bonus' },
{ to: '/messages', label: 'Настройки', icon: 'settings' },
]; ];
function isActive(path: string) { function isActive(path: string) {
@@ -22,6 +23,9 @@ function isActive(path: string) {
if (path === '/bonus-system') { if (path === '/bonus-system') {
return route.path === '/bonus-system' || route.path.startsWith('/bonus-system/'); return route.path === '/bonus-system' || route.path.startsWith('/bonus-system/');
} }
if (path === '/messages') {
return route.path === '/messages';
}
return route.path === path; return route.path === path;
} }
</script> </script>
@@ -43,9 +47,13 @@ function isActive(path: string) {
<path d="M7 17.25H13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /> <path d="M7 17.25H13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
<rect x="4" y="4" width="16" height="16" rx="4" stroke="currentColor" stroke-width="1.8" /> <rect x="4" y="4" width="16" height="16" rx="4" stroke="currentColor" stroke-width="1.8" />
</svg> </svg>
<svg v-else viewBox="0 0 24 24" fill="none" class="h-5 w-5"> <svg v-else-if="item.icon === 'bonus'" viewBox="0 0 24 24" fill="none" class="h-5 w-5">
<path d="M12 4.75L14.2401 9.28984L19.25 10.0172L15.625 13.5504L16.4802 18.5398L12 16.1848L7.51983 18.5398L8.375 13.5504L4.75 10.0172L9.75987 9.28984L12 4.75Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /> <path d="M12 4.75L14.2401 9.28984L19.25 10.0172L15.625 13.5504L16.4802 18.5398L12 16.1848L7.51983 18.5398L8.375 13.5504L4.75 10.0172L9.75987 9.28984L12 4.75Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
</svg> </svg>
<svg v-else viewBox="0 0 24 24" fill="none" class="h-5 w-5">
<path d="M12 8.25C9.92893 8.25 8.25 9.92893 8.25 12C8.25 14.0711 9.92893 15.75 12 15.75C14.0711 15.75 15.75 14.0711 15.75 12C15.75 9.92893 14.0711 8.25 12 8.25Z" stroke="currentColor" stroke-width="1.8" />
<path d="M19.25 13.25V10.75L17.4539 10.2018C17.3255 9.82617 17.1745 9.46304 17.0023 9.11134L17.875 7.45833L16.0417 5.625L14.3887 6.4977C14.037 6.32552 13.6738 6.17449 13.2982 6.04607L12.75 4.25H10.25L9.70183 6.04607C9.32617 6.17449 8.96304 6.32552 8.61134 6.4977L6.95833 5.625L5.125 7.45833L5.9977 9.11134C5.82552 9.46304 5.67449 9.82617 5.54607 10.2018L3.75 10.75V13.25L5.54607 13.7982C5.67449 14.1738 5.82552 14.537 5.9977 14.8887L5.125 16.5417L6.95833 18.375L8.61134 17.5023C8.96304 17.6745 9.32617 17.8255 9.70183 17.9539L10.25 19.75H12.75L13.2982 17.9539C13.6738 17.8255 14.037 17.6745 14.3887 17.5023L16.0417 18.375L17.875 16.5417L17.0023 14.8887C17.1745 14.537 17.3255 14.1738 17.4539 13.7982L19.25 13.25Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
</svg>
</span> </span>
<span class="manager-dock__label">{{ item.label }}</span> <span class="manager-dock__label">{{ item.label }}</span>
</NuxtLink> </NuxtLink>

View File

@@ -35,10 +35,10 @@ withDefaults(defineProps<{
<h2 class="mt-8 text-lg font-bold leading-tight text-[#123824]">{{ fullName }}</h2> <h2 class="mt-8 text-lg font-bold leading-tight text-[#123824]">{{ fullName }}</h2>
<div <div
v-if="metaLabel && metaValue" v-if="metaValue"
class="mt-4 inline-flex flex-wrap items-center justify-center gap-2 rounded-full border border-[#e1c15a] bg-[#fff8dc] px-4 py-2 text-sm text-[#7a5b00]" class="mt-4 inline-flex flex-wrap items-center justify-center gap-2 rounded-full border border-[#e1c15a] bg-[#fff8dc] px-4 py-2 text-sm text-[#7a5b00]"
> >
<span class="font-semibold">{{ metaLabel }}</span> <span v-if="metaLabel" class="font-semibold">{{ metaLabel }}</span>
<span class="font-bold text-[#123824]">{{ metaValue }}</span> <span class="font-bold text-[#123824]">{{ metaValue }}</span>
</div> </div>
</NuxtLink> </NuxtLink>

View File

@@ -25,12 +25,11 @@ type ProductCard = {
store: string; store: string;
title: string; title: string;
amount: number; amount: number;
subtitle: string;
gradient: string; gradient: string;
tags: string[];
}; };
const route = useRoute(); const route = useRoute();
const router = useRouter();
const search = ref(''); const search = ref('');
const balancesQuery = useQuery(ManagerBonusBalancesDocument); const balancesQuery = useQuery(ManagerBonusBalancesDocument);
const referralLinksQuery = useQuery(ManagerReferralLinksDocument); const referralLinksQuery = useQuery(ManagerReferralLinksDocument);
@@ -39,12 +38,12 @@ const withdrawalsQuery = useQuery(ManagerWithdrawalRequestsDocument, {
status: 'PENDING', status: 'PENDING',
}); });
const activeTab = computed<'balances' | 'withdrawals' | 'products'>(() => { const activeTab = computed<'balances' | 'withdrawals' | 'rewards'>(() => {
if (route.query.tab === 'withdrawals') { if (route.query.tab === 'withdrawals') {
return 'withdrawals'; return 'withdrawals';
} }
if (route.query.tab === 'products' || route.query.tab === 'manager') { if (route.query.tab === 'rewards' || route.query.tab === 'products' || route.query.tab === 'manager') {
return 'products'; return 'rewards';
} }
return 'balances'; return 'balances';
}); });
@@ -55,54 +54,42 @@ const productCards: ProductCard[] = [
store: 'Ozon', store: 'Ozon',
title: 'Подарочная карта Ozon', title: 'Подарочная карта Ozon',
amount: 3000, amount: 3000,
subtitle: 'Универсальная карта для маркетплейса: техника, дом и повседневные покупки.',
gradient: 'linear-gradient(135deg, #38b6ff 0%, #1369ff 55%, #0b2f72 100%)', gradient: 'linear-gradient(135deg, #38b6ff 0%, #1369ff 55%, #0b2f72 100%)',
tags: ['Маркетплейс', 'Электронная карта', '3 000 ₽'],
}, },
{ {
id: 'ozon-5000', id: 'ozon-5000',
store: 'Ozon', store: 'Ozon',
title: 'Подарочная карта Ozon', title: 'Подарочная карта Ozon',
amount: 5000, amount: 5000,
subtitle: 'Крупный номинал для заметных подарков и сезонных закупок.',
gradient: 'linear-gradient(135deg, #65d0ff 0%, #247bff 52%, #12315e 100%)', gradient: 'linear-gradient(135deg, #65d0ff 0%, #247bff 52%, #12315e 100%)',
tags: ['Маркетплейс', 'Топ-номинал', '5 000 ₽'],
}, },
{ {
id: 'wildberries-3000', id: 'wildberries-3000',
store: 'Wildberries', store: 'Wildberries',
title: 'Подарочная карта Wildberries', title: 'Подарочная карта Wildberries',
amount: 3000, amount: 3000,
subtitle: 'Подходит для одежды, дома и повседневных мелочей в одном каталоге.',
gradient: 'linear-gradient(135deg, #d84dff 0%, #8b27ff 52%, #39006a 100%)', gradient: 'linear-gradient(135deg, #d84dff 0%, #8b27ff 52%, #39006a 100%)',
tags: ['Fashion', 'Маркетплейс', '3 000 ₽'],
}, },
{ {
id: 'wildberries-4000', id: 'wildberries-4000',
store: 'Wildberries', store: 'Wildberries',
title: 'Подарочная карта Wildberries', title: 'Подарочная карта Wildberries',
amount: 4000, amount: 4000,
subtitle: 'Средний номинал для fashion-покупок и товаров для дома.',
gradient: 'linear-gradient(135deg, #ef7cff 0%, #a12dff 50%, #4c0b7d 100%)', gradient: 'linear-gradient(135deg, #ef7cff 0%, #a12dff 50%, #4c0b7d 100%)',
tags: ['Одежда', 'Дом', '4 000 ₽'],
}, },
{ {
id: 'mvideo-4000', id: 'mvideo-4000',
store: 'М.Видео', store: 'М.Видео',
title: 'Подарочная карта М.Видео', title: 'Подарочная карта М.Видео',
amount: 4000, amount: 4000,
subtitle: 'Для техники, аксессуаров и бытовой электроники.',
gradient: 'linear-gradient(135deg, #ff9461 0%, #ff5630 48%, #821414 100%)', gradient: 'linear-gradient(135deg, #ff9461 0%, #ff5630 48%, #821414 100%)',
tags: ['Техника', 'Электроника', '4 000 ₽'],
}, },
{ {
id: 'mvideo-5000', id: 'mvideo-5000',
store: 'М.Видео', store: 'М.Видео',
title: 'Подарочная карта М.Видео', title: 'Подарочная карта М.Видео',
amount: 5000, amount: 5000,
subtitle: 'Максимальный номинал для заметных подарков и апгрейдов рабочего места.',
gradient: 'linear-gradient(135deg, #ffb17e 0%, #ff6842 50%, #8f1818 100%)', gradient: 'linear-gradient(135deg, #ffb17e 0%, #ff6842 50%, #8f1818 100%)',
tags: ['Электроника', 'Подарок', '5 000 ₽'],
}, },
]; ];
@@ -196,9 +183,7 @@ const filteredProducts = computed(() => {
return [ return [
item.store, item.store,
item.title, item.title,
item.subtitle,
String(item.amount), String(item.amount),
...item.tags,
] ]
.join(' ') .join(' ')
.toLowerCase() .toLowerCase()
@@ -230,6 +215,31 @@ const {
resetKeys: [search, activeTab], resetKeys: [search, activeTab],
}); });
const WITHDRAWAL_DATE_FORMATTER = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
const bonusTabs = computed<Array<{ id: 'balances' | 'withdrawals' | 'rewards'; label: string }>>(() => [
{ id: 'balances', label: 'Балансы' },
{ id: 'withdrawals', label: 'Выплаты' },
{ id: 'rewards', label: 'Вознаграждения' },
]);
function setActiveTab(tab: 'balances' | 'withdrawals' | 'rewards') {
const query = { ...route.query };
if (tab === 'balances') {
delete query.tab;
} else {
query.tab = tab;
}
void router.replace({ query });
}
function userInitials(fullName: string) { function userInitials(fullName: string) {
const parts = fullName const parts = fullName
.trim() .trim()
@@ -250,6 +260,55 @@ function formatAmount(value: number) {
maximumFractionDigits: 2, maximumFractionDigits: 2,
}).format(value); }).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> </script>
<template> <template>
@@ -258,12 +317,29 @@ function formatAmount(value: number) {
v-model="search" v-model="search"
title="Бонусы" title="Бонусы"
:search-placeholder="activeTab === 'balances' :search-placeholder="activeTab === 'balances'
? 'Клиент, связанный клиент, email или процент' ? 'Клиент, связанный клиент или email'
: activeTab === 'withdrawals' : activeTab === 'withdrawals'
? 'Пользователь, сумма или статус' ? 'Номер выплаты, клиент или сумма'
: 'Магазин, номинал или тип карты'" : 'Название или номинал'"
/> />
<div class="surface-card rounded-3xl p-2">
<div class="flex flex-wrap gap-2">
<button
v-for="tab in bonusTabs"
:key="tab.id"
type="button"
class="rounded-full px-4 py-2 text-sm font-semibold transition"
:class="activeTab === tab.id
? 'bg-[#123824] text-white'
: 'bg-[#eef7f1] text-[#355947] hover:bg-[#e3f1e8]'"
@click="setActiveTab(tab.id)"
>
{{ tab.label }}
</button>
</div>
</div>
<template v-if="activeTab === 'balances'"> <template v-if="activeTab === 'balances'">
<div v-if="balancesQuery.loading.value || referralLinksQuery.loading.value || usersQuery.loading.value" class="manager-empty-state"> <div v-if="balancesQuery.loading.value || referralLinksQuery.loading.value || usersQuery.loading.value" class="manager-empty-state">
Загружаем балансы... Загружаем балансы...
@@ -280,8 +356,7 @@ function formatAmount(value: number) {
:full-name="item.fullName" :full-name="item.fullName"
:avatar-src="messengerConnectionAvatarSrc(usersById.get(item.userId)?.telegramConnection)" :avatar-src="messengerConnectionAvatarSrc(usersById.get(item.userId)?.telegramConnection)"
:initials="userInitials(item.fullName)" :initials="userInitials(item.fullName)"
meta-label="Доступный бонус" :meta-value="`${formatAmount(item.balance)} `"
:meta-value="formatAmount(item.balance)"
/> />
</div> </div>
@@ -297,25 +372,7 @@ function formatAmount(value: number) {
</div> </div>
</template> </template>
<template v-else-if="activeTab === 'products'"> <template v-else-if="activeTab === 'rewards'">
<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 v-if="filteredProducts.length === 0" class="manager-empty-state">
По текущему запросу товары не найдены. По текущему запросу товары не найдены.
</div> </div>
@@ -323,36 +380,24 @@ function formatAmount(value: number) {
<article <article
v-for="product in filteredProducts" v-for="product in filteredProducts"
:key="product.id" :key="product.id"
class="surface-card overflow-hidden rounded-[32px]" class="surface-card rounded-[28px] p-5"
> >
<div class="p-5 text-white" :style="{ background: product.gradient }"> <div class="flex items-center gap-4">
<div class="flex items-start justify-between gap-4"> <div
<div> class="flex h-16 w-16 shrink-0 items-center justify-center rounded-[20px] text-lg font-black text-white"
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/72">{{ product.store }}</p> :style="{ background: product.gradient }"
<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 }} {{ productVisualLabel(product) }}
</span> </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>
</div> </div>
</article> </article>
@@ -366,25 +411,55 @@ function formatAmount(value: number) {
<div v-else-if="filteredWithdrawals.length === 0" class="manager-empty-state"> <div v-else-if="filteredWithdrawals.length === 0" class="manager-empty-state">
Активных заявок на выплату сейчас нет. Активных заявок на выплату сейчас нет.
</div> </div>
<div v-else class="space-y-4"> <div v-else class="space-y-3">
<article <NuxtLink
v-for="withdrawal in visibleWithdrawals" v-for="withdrawal in visibleWithdrawals"
:key="withdrawal.id" :key="withdrawal.id"
class="surface-card rounded-3xl px-5 py-5" :to="`/bonus-system/withdrawals/${withdrawal.id}`"
class="surface-card surface-card-interactive block rounded-[30px] bg-white px-4 py-4 md:px-5"
> >
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> <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="space-y-1"> <div class="min-w-0">
<p class="text-sm font-semibold text-[#123824]">{{ withdrawal.requesterFullName }}</p> <h2 class="truncate text-lg font-bold text-[#123824]">{{ formatWithdrawalCode(withdrawal.id) }}</h2>
<p class="text-sm text-[#355947]">{{ withdrawal.requesterEmail }}</p> <p class="mt-1 text-sm text-[#688676]">{{ formatDateTime(withdrawal.createdAt) }}</p>
<p v-if="withdrawal.companyName" class="text-sm text-[#355947]">{{ withdrawal.companyName }}</p> </div>
<p class="text-sm text-[#355947]">Сумма: {{ withdrawal.amount }}</p>
<p class="text-xs text-[#5c7b69]">{{ new Date(withdrawal.createdAt).toLocaleString() }}</p> <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> </div>
<NuxtLink :to="`/bonus-system/withdrawals/${withdrawal.id}`" class="btn btn-accent btn-sm border-0">
Проверить выплату
</NuxtLink> </NuxtLink>
</div>
</article>
<div <div
v-if="canLoadMoreWithdrawals" v-if="canLoadMoreWithdrawals"

View File

@@ -30,13 +30,21 @@ function channelLabel(channel: TemplateChannel['channel']) {
<template> <template>
<section class="space-y-6"> <section class="space-y-6">
<div class="manager-hero"> <div class="manager-hero">
<h1 class="manager-title">Реестр шаблонов уведомлений</h1> <p class="manager-eyebrow">Настройки</p>
<h1 class="manager-title">Сообщения</h1>
<p class="manager-copy"> <p class="manager-copy">
Экран собирается из backend-кода. Здесь только реальные шаблоны и реальные типы взаимодействия с клиентом, Здесь менеджер видит реальные шаблоны из backend-кода и может быстро проверить, что именно получает клиент в каждом канале.
которые сейчас описаны в системе.
</p> </p>
</div> </div>
<div class="surface-card rounded-3xl p-2">
<div class="flex flex-wrap gap-2">
<span class="rounded-full bg-[#123824] px-4 py-2 text-sm font-semibold text-white">
Сообщения
</span>
</div>
</div>
<div v-if="templatesQuery.loading.value" class="manager-empty-state"> <div v-if="templatesQuery.loading.value" class="manager-empty-state">
Загружаем шаблоны... Загружаем шаблоны...
</div> </div>