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 = {
to: string;
label: string;
icon: 'orders' | 'bonus';
icon: 'orders' | 'bonus' | 'settings';
};
const route = useRoute();
@@ -10,6 +10,7 @@ const route = useRoute();
const dockItems: DockItem[] = [
{ to: '/client-orders', label: 'Заказы', icon: 'orders' },
{ to: '/bonus-system', label: 'Бонусы', icon: 'bonus' },
{ to: '/messages', label: 'Настройки', icon: 'settings' },
];
function isActive(path: string) {
@@ -22,6 +23,9 @@ function isActive(path: string) {
if (path === '/bonus-system') {
return route.path === '/bonus-system' || route.path.startsWith('/bonus-system/');
}
if (path === '/messages') {
return route.path === '/messages';
}
return route.path === path;
}
</script>
@@ -43,9 +47,13 @@ function isActive(path: string) {
<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" />
</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" />
</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 class="manager-dock__label">{{ item.label }}</span>
</NuxtLink>

View File

@@ -35,10 +35,10 @@ withDefaults(defineProps<{
<h2 class="mt-8 text-lg font-bold leading-tight text-[#123824]">{{ fullName }}</h2>
<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]"
>
<span class="font-semibold">{{ metaLabel }}</span>
<span v-if="metaLabel" class="font-semibold">{{ metaLabel }}</span>
<span class="font-bold text-[#123824]">{{ metaValue }}</span>
</div>
</NuxtLink>

View File

@@ -25,12 +25,11 @@ type ProductCard = {
store: string;
title: string;
amount: number;
subtitle: string;
gradient: string;
tags: string[];
};
const route = useRoute();
const router = useRouter();
const search = ref('');
const balancesQuery = useQuery(ManagerBonusBalancesDocument);
const referralLinksQuery = useQuery(ManagerReferralLinksDocument);
@@ -39,12 +38,12 @@ const withdrawalsQuery = useQuery(ManagerWithdrawalRequestsDocument, {
status: 'PENDING',
});
const activeTab = computed<'balances' | 'withdrawals' | 'products'>(() => {
const activeTab = computed<'balances' | 'withdrawals' | 'rewards'>(() => {
if (route.query.tab === 'withdrawals') {
return 'withdrawals';
}
if (route.query.tab === 'products' || route.query.tab === 'manager') {
return 'products';
if (route.query.tab === 'rewards' || route.query.tab === 'products' || route.query.tab === 'manager') {
return 'rewards';
}
return 'balances';
});
@@ -55,54 +54,42 @@ const productCards: ProductCard[] = [
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 ₽'],
},
];
@@ -196,9 +183,7 @@ const filteredProducts = computed(() => {
return [
item.store,
item.title,
item.subtitle,
String(item.amount),
...item.tags,
]
.join(' ')
.toLowerCase()
@@ -230,6 +215,31 @@ const {
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) {
const parts = fullName
.trim()
@@ -250,6 +260,55 @@ function formatAmount(value: number) {
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>
@@ -258,12 +317,29 @@ function formatAmount(value: number) {
v-model="search"
title="Бонусы"
:search-placeholder="activeTab === 'balances'
? 'Клиент, связанный клиент, email или процент'
? 'Клиент, связанный клиент или email'
: 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'">
<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"
:avatar-src="messengerConnectionAvatarSrc(usersById.get(item.userId)?.telegramConnection)"
:initials="userInitials(item.fullName)"
meta-label="Доступный бонус"
:meta-value="formatAmount(item.balance)"
:meta-value="`${formatAmount(item.balance)} `"
/>
</div>
@@ -297,25 +372,7 @@ function formatAmount(value: number) {
</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>
<template v-else-if="activeTab === 'rewards'">
<div v-if="filteredProducts.length === 0" class="manager-empty-state">
По текущему запросу товары не найдены.
</div>
@@ -323,36 +380,24 @@ function formatAmount(value: number) {
<article
v-for="product in filteredProducts"
: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-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 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="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 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="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 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>
@@ -366,25 +411,55 @@ function formatAmount(value: number) {
<div v-else-if="filteredWithdrawals.length === 0" class="manager-empty-state">
Активных заявок на выплату сейчас нет.
</div>
<div v-else class="space-y-4">
<article
<div v-else class="space-y-3">
<NuxtLink
v-for="withdrawal in visibleWithdrawals"
: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="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 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>
<NuxtLink :to="`/bonus-system/withdrawals/${withdrawal.id}`" class="btn btn-accent btn-sm border-0">
Проверить выплату
</NuxtLink>
</div>
</article>
</NuxtLink>
<div
v-if="canLoadMoreWithdrawals"

View File

@@ -30,13 +30,21 @@ function channelLabel(channel: TemplateChannel['channel']) {
<template>
<section class="space-y-6">
<div class="manager-hero">
<h1 class="manager-title">Реестр шаблонов уведомлений</h1>
<p class="manager-eyebrow">Настройки</p>
<h1 class="manager-title">Сообщения</h1>
<p class="manager-copy">
Экран собирается из backend-кода. Здесь только реальные шаблоны и реальные типы взаимодействия с клиентом,
которые сейчас описаны в системе.
Здесь менеджер видит реальные шаблоны из backend-кода и может быстро проверить, что именно получает клиент в каждом канале.
</p>
</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>