Polish manager bonus and message views
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user