Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
3489e0b816
319 files changed
+45483
-121
No files matched your search
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#4a1028" />
|
||||
<title>Barev Club · Команда</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='15' fill='%23b8235a'/%3E%3Cpath d='M10 16.5l4 4 8-8.5' stroke='%23fff' stroke-width='3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,700;0,800;1,400;1,500&family=Onest:wght@400;500;600&family=Noto+Sans+Armenian:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@barev/backoffice",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5176 --strictPort",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5176",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@barev/i18n": "workspace:*",
|
||||
"@barev/ui": "workspace:*",
|
||||
"@barev/web": "workspace:*",
|
||||
"@tanstack/react-query": "^5.104.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@barev/api": "workspace:*",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Logo } from '@barev/ui';
|
||||
import { match, useLocation, useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { BO_LOCALE_LABELS, BO_LOCALES, errText, useT, type BoKey } from './i18n.tsx';
|
||||
import { Avatar, I, OLD_ADMIN_URL, type IconName } from './components/kit.tsx';
|
||||
import { Login } from './pages/Login.tsx';
|
||||
import { Dashboard } from './pages/Dashboard.tsx';
|
||||
import { Clients } from './pages/Clients.tsx';
|
||||
import { ClientCard } from './pages/ClientCard.tsx';
|
||||
import { Team } from './pages/Team.tsx';
|
||||
import { Soon } from './pages/Soon.tsx';
|
||||
|
||||
export type Role = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
|
||||
export interface Me {
|
||||
id: string;
|
||||
name: string;
|
||||
roles: Role[];
|
||||
telegramLinked: boolean;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const trpc = useTRPC();
|
||||
const me = useQuery({ ...trpc.bo.auth.me.queryOptions(), retry: false });
|
||||
if (me.isPending) return null;
|
||||
if (!me.data || !me.data.verified) return <Login />;
|
||||
return <Shell me={{ ...me.data, roles: me.data.roles as Role[] }} />;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
key: BoKey;
|
||||
icon: IconName;
|
||||
/** этап, на котором раздел появится; пусто — уже работает */
|
||||
stage?: number;
|
||||
/** кому виден; суперадмину — всё */
|
||||
roles: Role[] | 'all';
|
||||
group?: 'work' | 'partners' | 'setup';
|
||||
count?: 'chats' | 'orders';
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ path: '/', key: 'nav.dashboard', icon: 'dashboard', roles: 'all' },
|
||||
{ path: '/clients', key: 'nav.clients', icon: 'clients', roles: ['SUPPORT', 'CONCIERGE'] },
|
||||
{ path: '/chats', key: 'nav.chats', icon: 'chats', stage: 2, roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' },
|
||||
{ path: '/orders', key: 'nav.orders', icon: 'orders', stage: 3, roles: ['SUPPORT'], count: 'orders' },
|
||||
{ path: '/providers', key: 'nav.providers', icon: 'providers', stage: 3, roles: ['SUPPORT'], group: 'work' },
|
||||
{ path: '/agencies', key: 'nav.agencies', icon: 'agencies', stage: 4, roles: [], group: 'partners' },
|
||||
{ path: '/venues', key: 'nav.venues', icon: 'venues', stage: 4, roles: [], group: 'partners' },
|
||||
{ path: '/crm', key: 'nav.crm', icon: 'crm', stage: 4, roles: ['SALES'], group: 'partners' },
|
||||
{ path: '/products', key: 'nav.products', icon: 'products', stage: 5, roles: [], group: 'setup' },
|
||||
{ path: '/journal', key: 'nav.journal', icon: 'journal', stage: 5, roles: [], group: 'setup' },
|
||||
{ path: '/team', key: 'nav.team', icon: 'team', roles: [], group: 'setup' },
|
||||
];
|
||||
const GROUP_KEY = { work: 'nav.groupWork', partners: 'nav.groupPartners', setup: 'nav.groupSetup' } as const;
|
||||
|
||||
function Shell({ me }: { me: Me }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const { path } = useLocation();
|
||||
const isSuper = me.roles.includes('SUPERADMIN');
|
||||
const items = NAV.filter((n) => isSuper || n.roles === 'all' || n.roles.some((r) => me.roles.includes(r)));
|
||||
const attention = useQuery({ ...trpc.bo.dashboard.attention.queryOptions(), refetchInterval: 30_000 });
|
||||
const counts = { chats: attention.data?.unreadChats, orders: attention.data?.unassigned };
|
||||
const [sheet, setSheet] = useState(false);
|
||||
useEffect(() => {
|
||||
setSheet(false);
|
||||
window.scrollTo(0, 0);
|
||||
}, [path]);
|
||||
|
||||
const current = items.find((n) => n.path === path || (n.path !== '/' && path.startsWith(`${n.path}/`)));
|
||||
let page: ReactNode;
|
||||
const clientId = match('/clients/:id', path)?.id;
|
||||
if (path === '/') page = <Dashboard me={me} />;
|
||||
else if (path === '/clients' && current) page = <Clients me={me} />;
|
||||
else if (clientId && items.some((n) => n.path === '/clients')) page = <ClientCard id={clientId} me={me} />;
|
||||
else if (path === '/team' && isSuper) page = <Team me={me} />;
|
||||
else if (current?.stage) page = <Soon title={t(current.key)} stage={current.stage} />;
|
||||
else page = <Dashboard me={me} />;
|
||||
|
||||
const isActive = (n: NavItem) => (n.path === '/' ? path === '/' : path === n.path || path.startsWith(`${n.path}/`));
|
||||
const tabItems = items.slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="bo-layout">
|
||||
<aside className="bo-side">
|
||||
<div className="bo-side__logo">
|
||||
<Logo size={22} color="#f6e9dc" />
|
||||
</div>
|
||||
<NavList items={items} isActive={isActive} counts={counts} />
|
||||
<SideFoot me={me} />
|
||||
</aside>
|
||||
|
||||
<main className="bo-main">
|
||||
{!me.telegramLinked && <LinkTelegramBanner />}
|
||||
{page}
|
||||
</main>
|
||||
|
||||
<nav className="bo-tabbar" aria-label="Menu">
|
||||
{tabItems.map((n) => (
|
||||
<a key={n.path} href={`#${n.path}`} className="bo-tab" aria-current={isActive(n) ? 'page' : undefined}>
|
||||
<I name={n.icon} size={22} />
|
||||
{t(n.key)}
|
||||
{!!(n.count && counts[n.count]) && <span className="bo-nav__count">{counts[n.count!]}</span>}
|
||||
</a>
|
||||
))}
|
||||
<button type="button" className="bo-tab" onClick={() => setSheet(true)} aria-expanded={sheet}>
|
||||
<I name="more" size={22} />
|
||||
{t('nav.more')}
|
||||
</button>
|
||||
</nav>
|
||||
{sheet && (
|
||||
<>
|
||||
<div className="bo-sheet-backdrop" onClick={() => setSheet(false)} />
|
||||
<div className="bo-sheet" role="dialog" aria-label={t('nav.more')}>
|
||||
<NavList items={items.slice(4)} isActive={isActive} counts={counts} />
|
||||
<SideFoot me={me} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavList({ items, isActive, counts }: { items: NavItem[]; isActive: (n: NavItem) => boolean; counts: Record<'chats' | 'orders', number | undefined> }) {
|
||||
const { t } = useT();
|
||||
let lastGroup: string | undefined;
|
||||
return (
|
||||
<>
|
||||
{items.map((n) => {
|
||||
const header = n.group && n.group !== lastGroup ? <div className="bo-side__group">{t(GROUP_KEY[n.group])}</div> : null;
|
||||
lastGroup = n.group ?? lastGroup;
|
||||
const count = n.count ? counts[n.count] : undefined;
|
||||
return (
|
||||
<div key={n.path}>
|
||||
{header}
|
||||
<a href={`#${n.path}`} className="bo-nav" aria-current={isActive(n) ? 'page' : undefined}>
|
||||
<I name={n.icon} />
|
||||
{t(n.key)}
|
||||
{count ? <span className="bo-nav__count">{count}</span> : n.stage ? <span className="bo-nav__soon">{t('nav.soon', { n: n.stage })}</span> : null}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SideFoot({ me }: { me: Me }) {
|
||||
const { t, locale, setLocale } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const logout = useMutation(trpc.bo.auth.logout.mutationOptions({ onSuccess: () => qc.resetQueries() }));
|
||||
return (
|
||||
<div className="bo-side__foot">
|
||||
<div className="bo-langs" role="group" aria-label="Language">
|
||||
{BO_LOCALES.map((l) => (
|
||||
<button key={l} type="button" className="bo-lang" aria-pressed={l === locale} onClick={() => setLocale(l)}>
|
||||
{BO_LOCALE_LABELS[l]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="bo-me">
|
||||
<Avatar name={me.name} id={me.id} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600 }}>{me.name}</div>
|
||||
<div className="bo-me__roles">{me.roles.map((r) => t(`role.${r}`)).join(', ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<a className="bo-side__link" href={OLD_ADMIN_URL} target="_blank" rel="noreferrer">
|
||||
{t('shell.oldAdmin')}
|
||||
</a>
|
||||
<button type="button" className="bo-side__link" onClick={() => logout.mutate()}>
|
||||
{t('shell.logout')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkTelegramBanner() {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const link = useMutation(
|
||||
trpc.bo.auth.telegramLink.mutationOptions({
|
||||
onSuccess: (r) => window.open(r.url, '_blank', 'noopener'),
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<div className="bo-banner">
|
||||
<span>{t('shell.linkTg')}</span>
|
||||
<button type="button" className="bo-btn bo-btn--sm" onClick={() => link.mutate()} disabled={link.isPending}>
|
||||
<I name="telegram" size={16} />
|
||||
{t('shell.linkTgBtn')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import type { T } from '../i18n.tsx';
|
||||
|
||||
const PATHS = {
|
||||
dashboard: 'M4 13h6V4H4zM14 20h6v-9h-6zM4 20h6v-4H4zM14 4v4h6V4z',
|
||||
clients: 'M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM2 21a7 7 0 0 1 14 0M16 3.5a4 4 0 0 1 0 7.5M18 14a6 6 0 0 1 4 7',
|
||||
chats: 'M4 5h12v9H8l-4 3.5zM8 17.5V18h8l4 3v-9h-2',
|
||||
orders: 'M6 3h12v18l-3-2-3 2-3-2-3 2zM9 8h6M9 12h6',
|
||||
providers: 'M14.7 6.3a4 4 0 0 0-5.4 5.3L3 18l3 3 6.4-6.3a4 4 0 0 0 5.3-5.4l-2.5 2.5-2.8-.7-.7-2.8z',
|
||||
agencies: 'M3 21V8l9-5 9 5v13M9 21v-6h6v6M3 21h18',
|
||||
venues: 'M5 11v10h14V11M3 7l2-4h14l2 4v1a3 3 0 0 1-6 0 3 3 0 0 1-6 0 3 3 0 0 1-6 0zM10 21v-5h4v5',
|
||||
crm: 'M3 4h18l-7 8.5V19l-4 2v-8.5z',
|
||||
products: 'M12 3l8 4.5v9L12 21l-8-4.5v-9zM4 7.5l8 4.5 8-4.5M12 12v9',
|
||||
journal: 'M6 3h11a2 2 0 0 1 2 2v16H8a2 2 0 0 1-2-2zM6 19a2 2 0 0 1 2-2h11M10 8h5M10 12h5',
|
||||
team: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM5 21a7 7 0 0 1 14 0',
|
||||
more: 'M5 12h.01M12 12h.01M19 12h.01',
|
||||
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-4-4',
|
||||
back: 'M19 12H5M11 18l-6-6 6-6',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
gift: 'M4 8h16v4H4zM5 12h14v8H5zM12 8v12M12 8c-2-3-6-3-6 0M12 8c2-3 6-3 6 0',
|
||||
download: 'M12 4v11M7 10l5 5 5-5M5 20h14',
|
||||
send: 'M4 12l16-8-6 16-2.5-6.5z',
|
||||
check: 'M5 12l4 4 10-10',
|
||||
close: 'M6 6l12 12M18 6L6 18',
|
||||
filter: 'M4 6h16M7 12h10M10 18h4',
|
||||
telegram: 'M21 4L3 11l6 2 2 6 3-4 5 4z M9 13l8-6',
|
||||
arrowUp: 'M12 19V5M6 11l6-6 6 6',
|
||||
arrowDown: 'M12 5v14M6 13l6 6 6-6',
|
||||
external: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6',
|
||||
} as const;
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
export function I({ name, size = 20, stroke = 1.8 }: { name: IconName; size?: number; stroke?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={name === 'more' ? 3.2 : stroke} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d={PATHS[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function initials(name: string) {
|
||||
const clean = name.replace(/^@/, '').trim();
|
||||
const parts = clean.split(/\s+/).filter(Boolean);
|
||||
return ((parts[0]?.[0] ?? '') + (parts[1]?.[0] ?? '')).toUpperCase() || '·';
|
||||
}
|
||||
|
||||
/** Пастельный фон аватара по имени — чтобы в списке глаз цеплялся за людей */
|
||||
const AVATAR = [
|
||||
['var(--bc-pink)', 'var(--bc-plum)'],
|
||||
['var(--bc-blue)', 'var(--bc-blue-ink)'],
|
||||
['var(--bc-home)', 'var(--bc-home-ink)'],
|
||||
['var(--bc-learn)', 'var(--bc-learn-ink)'],
|
||||
['var(--bc-docs)', 'var(--bc-docs-ink)'],
|
||||
['var(--bc-perks)', 'var(--bc-perks-ink)'],
|
||||
] as const;
|
||||
export function Avatar({ name, id, size }: { name: string; id: string; size?: number }) {
|
||||
let h = 0;
|
||||
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
const [bg, fg] = AVATAR[h % AVATAR.length]!;
|
||||
return (
|
||||
<span className="bo-avatar" style={{ background: bg, color: fg, ...(size ? { width: size, height: size, fontSize: size * 0.4 } : {}) }} aria-hidden>
|
||||
{initials(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanBadge({ plan, t }: { plan: 'BAREV_PLUS' | 'ELITE' | null; t: T }) {
|
||||
return <span className={`bo-plan bo-plan--${plan ?? 'none'}`}>{plan ? t(`plan.${plan}`) : t('plan.none')}</span>;
|
||||
}
|
||||
|
||||
/** Источник клиента простыми словами */
|
||||
export function sourceLabel(t: T, c: { source: string; agency?: { name: string } | null; utmSource?: string | null }) {
|
||||
if (c.agency) return t('source.agency', { name: c.agency.name });
|
||||
if (c.utmSource) return t('source.utm', { name: c.utmSource });
|
||||
const key = `source.${c.source}`;
|
||||
const tr = t(key);
|
||||
return tr === key ? c.source : tr;
|
||||
}
|
||||
|
||||
/** Ключ источника из дашборда: agency:Имя | utm:имя | organic */
|
||||
export function sourceKeyLabel(t: T, key: string) {
|
||||
if (key.startsWith('agency:')) return t('source.agency', { name: key.slice(7) });
|
||||
if (key.startsWith('utm:')) return t('source.utm', { name: key.slice(4) });
|
||||
return sourceLabel(t, { source: key });
|
||||
}
|
||||
|
||||
/** Модальное окно на <dialog>: фокус, Esc и затемнение — силами браузера */
|
||||
export function Dialog({ open, onClose, title, children, footer }: { open: boolean; onClose: () => void; title: string; children: ReactNode; footer: ReactNode }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
const d = ref.current;
|
||||
if (!d) return;
|
||||
if (open && !d.open) d.showModal();
|
||||
if (!open && d.open) d.close();
|
||||
}, [open]);
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
className="bo-dialog"
|
||||
onClose={onClose}
|
||||
onClick={(e) => {
|
||||
if (e.target === ref.current) onClose();
|
||||
}}
|
||||
aria-labelledby="bo-dialog-title"
|
||||
>
|
||||
{open && (
|
||||
<div className="bo-dialog__body">
|
||||
<h2 id="bo-dialog-title" className="bo-h2">
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
<div className="bo-dialog__foot">{footer}</div>
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: ReactNode }) {
|
||||
return (
|
||||
<label className="bo-field">
|
||||
<span className="bo-label">{label}</span>
|
||||
{children}
|
||||
{hint && <span className="bo-hint">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Skeleton({ h }: { h: number }) {
|
||||
return <div className="bo-skeleton" style={{ height: h }} aria-hidden />;
|
||||
}
|
||||
|
||||
export function download(filename: string, content: string, type = 'text/csv;charset=utf-8') {
|
||||
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
export const OLD_ADMIN_URL = import.meta.env.VITE_OLD_ADMIN_URL ?? (location.hostname === 'localhost' ? 'http://localhost:5173' : '/');
|
||||
@@ -0,0 +1,976 @@
|
||||
import { createT as createSharedT, formatAmd, formatDateTime, type Locale as SharedLocale } from '@barev/i18n';
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
/** Языки новой админки */
|
||||
export const BO_LOCALES = ['ru', 'en', 'hy'] as const;
|
||||
export type BoLocale = (typeof BO_LOCALES)[number];
|
||||
export const BO_LOCALE_LABELS: Record<BoLocale, string> = { ru: 'Рус', en: 'Eng', hy: 'Հայ' };
|
||||
|
||||
const ru = {
|
||||
'ui.save': 'Сохранить',
|
||||
'ui.close': 'Закрыть',
|
||||
'ui.loading': 'Загружаем…',
|
||||
'ui.error': 'Не получилось: {msg}',
|
||||
'ui.retry': 'Повторить',
|
||||
'ui.copy': 'Скопировать',
|
||||
'ui.copied': 'Скопировано',
|
||||
'ui.days': '{n} дн.',
|
||||
'ui.yes': 'Да',
|
||||
'ui.no': 'Нет',
|
||||
'ui.none': '—',
|
||||
|
||||
'err.PAYMENTS_NOT_CONFIGURED': 'Платёжная система ещё не подключена — отметьте оплату вручную.',
|
||||
'err.REFUND_TOO_BIG': 'Сумма возврата больше, чем клиент заплатил.',
|
||||
'err.ENDS_BEFORE_START': 'Подписка не может закончиться раньше, чем началась.',
|
||||
'err.CANNOT_DEMOTE_SELF': 'Нельзя отключить себя или снять с себя роль суперадмина.',
|
||||
'err.ROLE_FORBIDDEN': 'У вашей роли нет доступа к этому действию.',
|
||||
'err.NO_ROLE': 'У вашего аккаунта нет ролей в новой админке. Попросите суперадмина их выдать.',
|
||||
|
||||
'login.coverWord': 'команда',
|
||||
'login.title': 'Вход для команды',
|
||||
'login.lead': 'Подтвердите вход в рабочем боте Barev Club. Пароль не нужен.',
|
||||
'login.open': 'Подтвердить в Telegram',
|
||||
'login.qr': 'С компьютера удобнее отсканировать код телефоном.',
|
||||
'login.waiting': 'Ждём подтверждения в Telegram…',
|
||||
'login.expired': 'Ссылка устарела.',
|
||||
'login.retry': 'Получить новую ссылку',
|
||||
'login.dev': 'Войти локально',
|
||||
'login.devHint': 'Только на этом компьютере — на сервере этого блока нет.',
|
||||
'login.noTelegram': 'Нет доступа? Попросите суперадмина добавить вас в команду и прислать ссылку для привязки Telegram.',
|
||||
|
||||
'nav.dashboard': 'Дашборд',
|
||||
'nav.clients': 'Клиенты',
|
||||
'nav.chats': 'Чаты',
|
||||
'nav.orders': 'Заказы',
|
||||
'nav.providers': 'Исполнители',
|
||||
'nav.agencies': 'Агентства',
|
||||
'nav.venues': 'Заведения',
|
||||
'nav.crm': 'CRM',
|
||||
'nav.products': 'Продукты',
|
||||
'nav.journal': 'Журнал',
|
||||
'nav.team': 'Команда',
|
||||
'nav.more': 'Ещё',
|
||||
'nav.soon': 'этап {n}',
|
||||
'nav.groupWork': 'Работа',
|
||||
'nav.groupPartners': 'Партнёры',
|
||||
'nav.groupSetup': 'Настройка',
|
||||
|
||||
'role.SUPERADMIN': 'Суперадмин',
|
||||
'role.SUPPORT': 'Поддержка',
|
||||
'role.CONCIERGE': 'Консьерж',
|
||||
'role.SALES': 'Продажи',
|
||||
'roleHint.SUPERADMIN': 'Всё, включая деньги и команду',
|
||||
'roleHint.SUPPORT': 'Чат поддержки, заказы, подписки',
|
||||
'roleHint.CONCIERGE': 'Чат консьержа для подписчиков',
|
||||
'roleHint.SALES': 'CRM: заведения и агентства',
|
||||
|
||||
'shell.logout': 'Выйти',
|
||||
'shell.oldAdmin': 'Старая админка',
|
||||
'shell.linkTg': 'Привяжите Telegram — через него вы входите и получаете уведомления.',
|
||||
'shell.linkTgBtn': 'Привязать Telegram',
|
||||
|
||||
'soon.title': '{section} переезжает сюда',
|
||||
'soon.text': 'Раздел появится на этапе {n}. Пока работайте в старой админке — данные общие.',
|
||||
'soon.open': 'Открыть старую админку',
|
||||
|
||||
'dash.morning': 'Доброе утро, {name}',
|
||||
'dash.day': 'Добрый день, {name}',
|
||||
'dash.evening': 'Добрый вечер, {name}',
|
||||
'dash.attention': 'Требует внимания',
|
||||
'dash.allClear': 'Срочного нет — всё разобрано.',
|
||||
'att.unassigned': 'Заказы без исполнителя',
|
||||
'att.unreadChats': 'Непрочитанные сообщения',
|
||||
'att.webRequests': 'Заявки с сайта',
|
||||
'att.leads': 'Заявки на переезд',
|
||||
'att.appRequests': 'Заявки из приложения',
|
||||
'att.lowRatings': 'Оценки 3 и ниже за неделю',
|
||||
'att.expiring': 'Подписки истекают за 7 дней',
|
||||
'dash.money': 'Деньги',
|
||||
'dash.today': 'Сегодня',
|
||||
'dash.week': '7 дней',
|
||||
'dash.month': '30 дней',
|
||||
'dash.vsYesterday': 'вчера {sum}',
|
||||
'dash.vsPrev': 'к прошлым {period}',
|
||||
'dash.orders': 'заказы',
|
||||
'dash.subs': 'подписки',
|
||||
'dash.chart': 'Выручка по дням за 30 дней',
|
||||
'dash.subsTitle': 'Подписки',
|
||||
'dash.gifts': 'Из них подарочных: {n}',
|
||||
'dash.expiring': 'Истекают в ближайшие 7 дней',
|
||||
'dash.noExpiring': 'В ближайшую неделю ничего не истекает.',
|
||||
'dash.conversion': 'Подарок → покупка',
|
||||
'dash.conversionHint': '{paid} из {total} клиентов с подарком потом купили подписку',
|
||||
'dash.growth': 'Рост',
|
||||
'dash.newClients': 'Новые клиенты',
|
||||
'dash.prevMonth': 'за прошлые 30 дней — {n}',
|
||||
'dash.sources': 'Откуда пришли за 30 дней',
|
||||
'dash.funnel': 'Воронка за 30 дней',
|
||||
'funnel.leads': 'Заявки с сайта',
|
||||
'funnel.clients': 'Клиенты в приложении',
|
||||
'funnel.paying': 'Заплатили',
|
||||
'dash.team': 'Команда и партнёры',
|
||||
'dash.topProviders': 'Лучшие исполнители',
|
||||
'dash.bottomProviders': 'Стоит поговорить',
|
||||
'dash.topPartners': 'Заведения: сканы карты за 30 дней',
|
||||
'dash.salesSoon': 'Результаты продажников появятся вместе с CRM.',
|
||||
'dash.empty': 'Пока пусто',
|
||||
'dash.feed': 'Лента',
|
||||
'feed.subscription': '{plan} · {source}',
|
||||
'feed.payment': 'Оплачен заказ №{number}',
|
||||
'dash.ratings': '{n} оценок',
|
||||
'dash.scans': '{n} сканов',
|
||||
|
||||
'source.organic': 'Сами нашли',
|
||||
'source.deeplink': 'Ссылка агентства',
|
||||
'source.manual_code': 'Код агентства',
|
||||
'source.agency': 'Агентство «{name}»',
|
||||
'source.utm': 'Реклама: {name}',
|
||||
|
||||
'plan.BAREV_PLUS': 'Barev+',
|
||||
'plan.ELITE': 'Elite',
|
||||
'plan.none': 'Без подписки',
|
||||
'subSource.PAID': 'Покупка',
|
||||
'subSource.MANUAL': 'Подарок команды',
|
||||
'subSource.AGENCY_GIFT': 'Подарок агентства',
|
||||
'subStatus.ACTIVE': 'Активна',
|
||||
'subStatus.EXPIRED': 'Истекла',
|
||||
'subStatus.CANCELLED': 'Отменена',
|
||||
'pay.cash': 'Наличные',
|
||||
'pay.transfer': 'Перевод',
|
||||
'pay.card': 'Карта через терминал',
|
||||
'pay.link': 'Ссылка на оплату',
|
||||
'segment.MOVING': 'Переезжает',
|
||||
'segment.LIVING': 'Живёт в Армении',
|
||||
|
||||
'clients.title': 'Клиенты',
|
||||
'clients.search': 'Имя, @username, телефон или номер карты',
|
||||
'clients.found': 'Найдено: {n}',
|
||||
'clients.revenueTotal': 'принесли {sum}',
|
||||
'col.client': 'Клиент',
|
||||
'col.sub': 'Подписка',
|
||||
'col.source': 'Откуда',
|
||||
'col.revenue': 'Принёс',
|
||||
'col.since': 'С нами с',
|
||||
'filter.plan': 'Подписка',
|
||||
'filter.all': 'Все',
|
||||
'filter.any': 'С подпиской',
|
||||
'filter.none': 'Без подписки',
|
||||
'filter.subSource': 'Как получена',
|
||||
'filter.source': 'Откуда',
|
||||
'filter.agency': 'Агентство',
|
||||
'filter.tag': 'Тег',
|
||||
'filter.segment': 'Сценарий',
|
||||
'filter.paying': 'Платили',
|
||||
'filter.payingYes': 'Платили',
|
||||
'filter.payingNo': 'Ни разу не платили',
|
||||
'filter.expiring': 'Истекает за 7 дней',
|
||||
'filter.reset': 'Сбросить фильтры',
|
||||
'filter.more': 'Фильтры',
|
||||
'filter.anyValue': 'Любое',
|
||||
'sort.label': 'Порядок',
|
||||
'sort.new': 'Сначала новые',
|
||||
'sort.revenue': 'Больше принесли',
|
||||
'sort.ends': 'Скоро истекает',
|
||||
'segments.title': 'Мои выборки',
|
||||
'segments.save': 'Сохранить выборку',
|
||||
'segments.name': 'Название выборки',
|
||||
'segments.saved': 'Выборка сохранена',
|
||||
'segments.remove': 'Удалить выборку «{name}»',
|
||||
'clients.export': 'Скачать для Excel',
|
||||
'clients.exported': 'Файл скачан',
|
||||
'clients.broadcast': 'Рассылка',
|
||||
'clients.broadcastSoon': 'Рассылки по выборке появятся на этапе 5.',
|
||||
'clients.empty': 'Под эти фильтры никто не подходит.',
|
||||
'clients.emptyAll': 'Клиентов пока нет — они появятся, когда напишут боту.',
|
||||
'clients.prev': 'Назад',
|
||||
'clients.next': 'Дальше',
|
||||
'clients.page': '{from}–{to} из {total}',
|
||||
|
||||
'card.back': 'Все клиенты',
|
||||
'card.since': 'С нами с {date}',
|
||||
'card.telegram': 'Открыть в Telegram',
|
||||
'card.cardNo': 'Карта {code}',
|
||||
'card.noSub': 'Нет активной подписки',
|
||||
'card.noSubHint': 'Подарите или продайте подписку — клиент сразу получит скидки.',
|
||||
'card.until': 'до {date}',
|
||||
'card.daysLeft': 'осталось {n} дн.',
|
||||
'card.history': 'Все подписки',
|
||||
'card.grantedBy': 'начислил(а) {name}',
|
||||
'card.giftFrom': 'подарок от «{name}»',
|
||||
'card.paidVia': '{method}',
|
||||
'card.refunded': 'возврат {sum}',
|
||||
'card.cancelledAt': 'отменена {date}',
|
||||
'card.upcoming': 'начнётся {date}',
|
||||
'card.money': 'Принёс',
|
||||
'card.fromOrders': 'заказы',
|
||||
'card.fromSubs': 'подписки',
|
||||
'card.orders': 'Заказы',
|
||||
'card.noOrders': 'Заказов пока нет.',
|
||||
'card.newOrder': 'Создать заказ',
|
||||
'card.newOrderSoon': 'Заказ за клиента появится на этапе 3, вместе с разделом заказов.',
|
||||
'card.profile': 'Профиль',
|
||||
'card.source': 'Откуда пришёл',
|
||||
'card.agency': 'Агентство',
|
||||
'card.language': 'Язык',
|
||||
'card.phone': 'Телефон',
|
||||
'card.segment': 'Сценарий',
|
||||
'card.consent': 'Согласие на обработку данных',
|
||||
'card.tags': 'Теги',
|
||||
'card.addTag': 'Новый тег',
|
||||
'card.note': 'Заметка команды',
|
||||
'card.notePh': 'Клиент не видит эту заметку',
|
||||
'card.saved': 'Сохранено',
|
||||
'card.activity': 'Журнал по клиенту',
|
||||
'card.noActivity': 'Команда пока ничего не меняла.',
|
||||
'card.stats': 'Активность',
|
||||
'stat.cardUsages': 'сканов карты',
|
||||
'stat.leads': 'заявок на переезд',
|
||||
'stat.requests': 'заявок из приложения',
|
||||
'stat.concierge': 'сообщений в чате',
|
||||
|
||||
'act.gift': 'Подарить',
|
||||
'act.sell': 'Продать',
|
||||
'act.extend': 'Изменить срок',
|
||||
'act.cancel': 'Отменить',
|
||||
'act.changePlan': 'Сменить тариф',
|
||||
'act.refund': 'Вернуть деньги',
|
||||
|
||||
'audit.bo.clients.gift': 'подарил(а) подписку',
|
||||
'audit.bo.clients.sell': 'продал(а) подписку',
|
||||
'audit.bo.clients.extend': 'изменил(а) срок подписки',
|
||||
'audit.bo.clients.cancel': 'отменил(а) подписку',
|
||||
'audit.bo.clients.changePlan': 'сменил(а) тариф',
|
||||
'audit.bo.clients.refund': 'оформил(а) возврат',
|
||||
'audit.bo.clients.setTags': 'изменил(а) теги',
|
||||
'audit.bo.clients.setNote': 'изменил(а) заметку',
|
||||
|
||||
'dlg.giftTitle': 'Подарить подписку',
|
||||
'dlg.giftHint': 'Подписка будет помечена как подарок от вас.',
|
||||
'dlg.sellTitle': 'Продать подписку',
|
||||
'dlg.plan': 'Тариф',
|
||||
'dlg.period': 'Срок',
|
||||
'dlg.period1': '1 месяц',
|
||||
'dlg.period3': '3 месяца',
|
||||
'dlg.period6': '6 месяцев',
|
||||
'dlg.period12': '1 год',
|
||||
'dlg.periodCustom': 'Своё',
|
||||
'dlg.days': 'Дней',
|
||||
'dlg.amount': 'Сумма, ֏',
|
||||
'dlg.method': 'Как оплатил',
|
||||
'dlg.note': 'Комментарий для команды',
|
||||
'dlg.linkSoon': 'Ссылку на оплату включим, когда подключим платёжную систему.',
|
||||
'dlg.giftConfirm': 'Подарить',
|
||||
'dlg.sellConfirm': 'Отметить оплату',
|
||||
'dlg.extendTitle': 'Изменить срок',
|
||||
'dlg.extendHint': 'Отрицательное число сокращает срок.',
|
||||
'dlg.newEnd': 'Новая дата окончания: {date}',
|
||||
'dlg.extendConfirm': 'Сохранить срок',
|
||||
'dlg.cancelTitle': 'Отменить подписку',
|
||||
'dlg.cancelHint': 'Клиент сразу потеряет скидки и привилегии подписки.',
|
||||
'dlg.reason': 'Причина',
|
||||
'dlg.cancelConfirm': 'Отменить подписку',
|
||||
'dlg.planTitle': 'Сменить тариф',
|
||||
'dlg.planConfirm': 'Сменить тариф',
|
||||
'dlg.refundTitle': 'Вернуть деньги',
|
||||
'dlg.refundPaid': 'Оплачено {sum}, уже возвращено {back}',
|
||||
'dlg.refundCancel': 'Сразу отменить подписку',
|
||||
'dlg.refundConfirm': 'Оформить возврат',
|
||||
'done.gift': 'Подписка подарена',
|
||||
'done.sell': 'Оплата отмечена',
|
||||
'done.extend': 'Срок изменён',
|
||||
'done.cancel': 'Подписка отменена',
|
||||
'done.changePlan': 'Тариф изменён',
|
||||
'done.refund': 'Возврат оформлен',
|
||||
|
||||
'team.title': 'Команда',
|
||||
'team.lead': 'Кто работает в админке и что ему доступно. У одного человека может быть несколько ролей.',
|
||||
'team.add': 'Добавить человека',
|
||||
'team.name': 'Имя',
|
||||
'team.roles': 'Роли',
|
||||
'team.create': 'Добавить',
|
||||
'team.linkTitle': 'Ссылка для {name}',
|
||||
'team.linkHint': 'Отправьте её человеку. Он откроет рабочий бот, Telegram привяжется — и можно входить.',
|
||||
'team.tgLinked': 'Telegram привязан',
|
||||
'team.tgNotLinked': 'Telegram не привязан',
|
||||
'team.newLink': 'Ссылка для привязки',
|
||||
'team.disable': 'Отключить доступ',
|
||||
'team.enable': 'Вернуть доступ',
|
||||
'team.disabled': 'Доступ отключён',
|
||||
'team.you': 'это вы',
|
||||
'team.saved': 'Роли сохранены',
|
||||
};
|
||||
|
||||
export type BoKey = keyof typeof ru;
|
||||
type Dict = Record<BoKey, string>;
|
||||
|
||||
const en: Dict = {
|
||||
'ui.save': 'Save',
|
||||
'ui.close': 'Close',
|
||||
'ui.loading': 'Loading…',
|
||||
'ui.error': 'That didn’t work: {msg}',
|
||||
'ui.retry': 'Try again',
|
||||
'ui.copy': 'Copy',
|
||||
'ui.copied': 'Copied',
|
||||
'ui.days': '{n} d',
|
||||
'ui.yes': 'Yes',
|
||||
'ui.no': 'No',
|
||||
'ui.none': '—',
|
||||
|
||||
'err.PAYMENTS_NOT_CONFIGURED': 'Online payments aren’t connected yet — mark the payment manually.',
|
||||
'err.REFUND_TOO_BIG': 'The refund is larger than what the client paid.',
|
||||
'err.ENDS_BEFORE_START': 'A subscription can’t end before it starts.',
|
||||
'err.CANNOT_DEMOTE_SELF': 'You can’t disable yourself or remove your own super admin role.',
|
||||
'err.ROLE_FORBIDDEN': 'Your role doesn’t have access to this action.',
|
||||
'err.NO_ROLE': 'Your account has no roles in the new admin. Ask a super admin to grant them.',
|
||||
|
||||
'login.coverWord': 'team',
|
||||
'login.title': 'Team sign-in',
|
||||
'login.lead': 'Confirm sign-in in the Barev Club work bot. No password needed.',
|
||||
'login.open': 'Confirm in Telegram',
|
||||
'login.qr': 'On a computer, scan the code with your phone.',
|
||||
'login.waiting': 'Waiting for confirmation in Telegram…',
|
||||
'login.expired': 'The link has expired.',
|
||||
'login.retry': 'Get a new link',
|
||||
'login.dev': 'Local sign-in',
|
||||
'login.devHint': 'Only on this computer — the server doesn’t show this block.',
|
||||
'login.noTelegram': 'No access? Ask a super admin to add you to the team and send a Telegram link.',
|
||||
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.clients': 'Clients',
|
||||
'nav.chats': 'Chats',
|
||||
'nav.orders': 'Orders',
|
||||
'nav.providers': 'Providers',
|
||||
'nav.agencies': 'Agencies',
|
||||
'nav.venues': 'Venues',
|
||||
'nav.crm': 'CRM',
|
||||
'nav.products': 'Products',
|
||||
'nav.journal': 'Activity log',
|
||||
'nav.team': 'Team',
|
||||
'nav.more': 'More',
|
||||
'nav.soon': 'stage {n}',
|
||||
'nav.groupWork': 'Work',
|
||||
'nav.groupPartners': 'Partners',
|
||||
'nav.groupSetup': 'Setup',
|
||||
|
||||
'role.SUPERADMIN': 'Super admin',
|
||||
'role.SUPPORT': 'Support',
|
||||
'role.CONCIERGE': 'Concierge',
|
||||
'role.SALES': 'Sales',
|
||||
'roleHint.SUPERADMIN': 'Everything, including money and the team',
|
||||
'roleHint.SUPPORT': 'Support chat, orders, subscriptions',
|
||||
'roleHint.CONCIERGE': 'Concierge chat for subscribers',
|
||||
'roleHint.SALES': 'CRM: venues and agencies',
|
||||
|
||||
'shell.logout': 'Sign out',
|
||||
'shell.oldAdmin': 'Old admin',
|
||||
'shell.linkTg': 'Link Telegram — you sign in and get notifications through it.',
|
||||
'shell.linkTgBtn': 'Link Telegram',
|
||||
|
||||
'soon.title': '{section} is moving here',
|
||||
'soon.text': 'This section arrives in stage {n}. Until then, use the old admin — the data is shared.',
|
||||
'soon.open': 'Open the old admin',
|
||||
|
||||
'dash.morning': 'Good morning, {name}',
|
||||
'dash.day': 'Good afternoon, {name}',
|
||||
'dash.evening': 'Good evening, {name}',
|
||||
'dash.attention': 'Needs attention',
|
||||
'dash.allClear': 'Nothing urgent — all caught up.',
|
||||
'att.unassigned': 'Orders without a provider',
|
||||
'att.unreadChats': 'Unread messages',
|
||||
'att.webRequests': 'Website requests',
|
||||
'att.leads': 'Relocation requests',
|
||||
'att.appRequests': 'In-app requests',
|
||||
'att.lowRatings': 'Ratings of 3 or lower this week',
|
||||
'att.expiring': 'Subscriptions ending within 7 days',
|
||||
'dash.money': 'Money',
|
||||
'dash.today': 'Today',
|
||||
'dash.week': '7 days',
|
||||
'dash.month': '30 days',
|
||||
'dash.vsYesterday': 'yesterday {sum}',
|
||||
'dash.vsPrev': 'vs previous {period}',
|
||||
'dash.orders': 'orders',
|
||||
'dash.subs': 'subscriptions',
|
||||
'dash.chart': 'Daily revenue, last 30 days',
|
||||
'dash.subsTitle': 'Subscriptions',
|
||||
'dash.gifts': 'Gifts among them: {n}',
|
||||
'dash.expiring': 'Ending in the next 7 days',
|
||||
'dash.noExpiring': 'Nothing ends in the coming week.',
|
||||
'dash.conversion': 'Gift → purchase',
|
||||
'dash.conversionHint': '{paid} of {total} gifted clients later bought a subscription',
|
||||
'dash.growth': 'Growth',
|
||||
'dash.newClients': 'New clients',
|
||||
'dash.prevMonth': 'previous 30 days — {n}',
|
||||
'dash.sources': 'Where they came from, 30 days',
|
||||
'dash.funnel': 'Funnel, 30 days',
|
||||
'funnel.leads': 'Website requests',
|
||||
'funnel.clients': 'Clients in the app',
|
||||
'funnel.paying': 'Paid',
|
||||
'dash.team': 'Team and partners',
|
||||
'dash.topProviders': 'Top providers',
|
||||
'dash.bottomProviders': 'Worth a talk',
|
||||
'dash.topPartners': 'Venues: card scans, 30 days',
|
||||
'dash.salesSoon': 'Sales results will appear with the CRM.',
|
||||
'dash.empty': 'Nothing yet',
|
||||
'dash.feed': 'Feed',
|
||||
'feed.subscription': '{plan} · {source}',
|
||||
'feed.payment': 'Order #{number} paid',
|
||||
'dash.ratings': '{n} ratings',
|
||||
'dash.scans': '{n} scans',
|
||||
|
||||
'source.organic': 'Found us',
|
||||
'source.deeplink': 'Agency link',
|
||||
'source.manual_code': 'Agency code',
|
||||
'source.agency': 'Agency “{name}”',
|
||||
'source.utm': 'Ads: {name}',
|
||||
|
||||
'plan.BAREV_PLUS': 'Barev+',
|
||||
'plan.ELITE': 'Elite',
|
||||
'plan.none': 'No subscription',
|
||||
'subSource.PAID': 'Purchase',
|
||||
'subSource.MANUAL': 'Team gift',
|
||||
'subSource.AGENCY_GIFT': 'Agency gift',
|
||||
'subStatus.ACTIVE': 'Active',
|
||||
'subStatus.EXPIRED': 'Expired',
|
||||
'subStatus.CANCELLED': 'Cancelled',
|
||||
'pay.cash': 'Cash',
|
||||
'pay.transfer': 'Bank transfer',
|
||||
'pay.card': 'Card via terminal',
|
||||
'pay.link': 'Payment link',
|
||||
'segment.MOVING': 'Relocating',
|
||||
'segment.LIVING': 'Lives in Armenia',
|
||||
|
||||
'clients.title': 'Clients',
|
||||
'clients.search': 'Name, @username, phone or card number',
|
||||
'clients.found': 'Found: {n}',
|
||||
'clients.revenueTotal': 'brought {sum}',
|
||||
'col.client': 'Client',
|
||||
'col.sub': 'Subscription',
|
||||
'col.source': 'Source',
|
||||
'col.revenue': 'Brought',
|
||||
'col.since': 'Joined',
|
||||
'filter.plan': 'Subscription',
|
||||
'filter.all': 'All',
|
||||
'filter.any': 'Subscribed',
|
||||
'filter.none': 'Not subscribed',
|
||||
'filter.subSource': 'How received',
|
||||
'filter.source': 'Source',
|
||||
'filter.agency': 'Agency',
|
||||
'filter.tag': 'Tag',
|
||||
'filter.segment': 'Scenario',
|
||||
'filter.paying': 'Paid',
|
||||
'filter.payingYes': 'Have paid',
|
||||
'filter.payingNo': 'Never paid',
|
||||
'filter.expiring': 'Ends within 7 days',
|
||||
'filter.reset': 'Reset filters',
|
||||
'filter.more': 'Filters',
|
||||
'filter.anyValue': 'Any',
|
||||
'sort.label': 'Order',
|
||||
'sort.new': 'Newest first',
|
||||
'sort.revenue': 'Brought the most',
|
||||
'sort.ends': 'Ending soon',
|
||||
'segments.title': 'My segments',
|
||||
'segments.save': 'Save segment',
|
||||
'segments.name': 'Segment name',
|
||||
'segments.saved': 'Segment saved',
|
||||
'segments.remove': 'Delete segment “{name}”',
|
||||
'clients.export': 'Download for Excel',
|
||||
'clients.exported': 'File downloaded',
|
||||
'clients.broadcast': 'Broadcast',
|
||||
'clients.broadcastSoon': 'Segment broadcasts arrive in stage 5.',
|
||||
'clients.empty': 'No one matches these filters.',
|
||||
'clients.emptyAll': 'No clients yet — they appear once they message the bot.',
|
||||
'clients.prev': 'Back',
|
||||
'clients.next': 'Next',
|
||||
'clients.page': '{from}–{to} of {total}',
|
||||
|
||||
'card.back': 'All clients',
|
||||
'card.since': 'With us since {date}',
|
||||
'card.telegram': 'Open in Telegram',
|
||||
'card.cardNo': 'Card {code}',
|
||||
'card.noSub': 'No active subscription',
|
||||
'card.noSubHint': 'Gift or sell a subscription — the client gets discounts right away.',
|
||||
'card.until': 'until {date}',
|
||||
'card.daysLeft': '{n} d left',
|
||||
'card.history': 'All subscriptions',
|
||||
'card.grantedBy': 'granted by {name}',
|
||||
'card.giftFrom': 'gift from “{name}”',
|
||||
'card.paidVia': '{method}',
|
||||
'card.refunded': 'refund {sum}',
|
||||
'card.cancelledAt': 'cancelled {date}',
|
||||
'card.upcoming': 'starts {date}',
|
||||
'card.money': 'Brought',
|
||||
'card.fromOrders': 'orders',
|
||||
'card.fromSubs': 'subscriptions',
|
||||
'card.orders': 'Orders',
|
||||
'card.noOrders': 'No orders yet.',
|
||||
'card.newOrder': 'Create order',
|
||||
'card.newOrderSoon': 'Ordering on a client’s behalf arrives in stage 3, together with the orders section.',
|
||||
'card.profile': 'Profile',
|
||||
'card.source': 'Source',
|
||||
'card.agency': 'Agency',
|
||||
'card.language': 'Language',
|
||||
'card.phone': 'Phone',
|
||||
'card.segment': 'Scenario',
|
||||
'card.consent': 'Data processing consent',
|
||||
'card.tags': 'Tags',
|
||||
'card.addTag': 'New tag',
|
||||
'card.note': 'Team note',
|
||||
'card.notePh': 'The client doesn’t see this note',
|
||||
'card.saved': 'Saved',
|
||||
'card.activity': 'Client log',
|
||||
'card.noActivity': 'The team hasn’t changed anything yet.',
|
||||
'card.stats': 'Activity',
|
||||
'stat.cardUsages': 'card scans',
|
||||
'stat.leads': 'relocation requests',
|
||||
'stat.requests': 'in-app requests',
|
||||
'stat.concierge': 'chat messages',
|
||||
|
||||
'act.gift': 'Gift',
|
||||
'act.sell': 'Sell',
|
||||
'act.extend': 'Change term',
|
||||
'act.cancel': 'Cancel',
|
||||
'act.changePlan': 'Change plan',
|
||||
'act.refund': 'Refund',
|
||||
|
||||
'audit.bo.clients.gift': 'gifted a subscription',
|
||||
'audit.bo.clients.sell': 'sold a subscription',
|
||||
'audit.bo.clients.extend': 'changed the subscription term',
|
||||
'audit.bo.clients.cancel': 'cancelled a subscription',
|
||||
'audit.bo.clients.changePlan': 'changed the plan',
|
||||
'audit.bo.clients.refund': 'issued a refund',
|
||||
'audit.bo.clients.setTags': 'changed tags',
|
||||
'audit.bo.clients.setNote': 'changed the note',
|
||||
|
||||
'dlg.giftTitle': 'Gift a subscription',
|
||||
'dlg.giftHint': 'It will be marked as a gift from you.',
|
||||
'dlg.sellTitle': 'Sell a subscription',
|
||||
'dlg.plan': 'Plan',
|
||||
'dlg.period': 'Term',
|
||||
'dlg.period1': '1 month',
|
||||
'dlg.period3': '3 months',
|
||||
'dlg.period6': '6 months',
|
||||
'dlg.period12': '1 year',
|
||||
'dlg.periodCustom': 'Custom',
|
||||
'dlg.days': 'Days',
|
||||
'dlg.amount': 'Amount, ֏',
|
||||
'dlg.method': 'Paid by',
|
||||
'dlg.note': 'Note for the team',
|
||||
'dlg.linkSoon': 'Payment links turn on once a payment provider is connected.',
|
||||
'dlg.giftConfirm': 'Gift',
|
||||
'dlg.sellConfirm': 'Mark as paid',
|
||||
'dlg.extendTitle': 'Change term',
|
||||
'dlg.extendHint': 'A negative number shortens the term.',
|
||||
'dlg.newEnd': 'New end date: {date}',
|
||||
'dlg.extendConfirm': 'Save term',
|
||||
'dlg.cancelTitle': 'Cancel subscription',
|
||||
'dlg.cancelHint': 'The client loses subscription discounts and perks immediately.',
|
||||
'dlg.reason': 'Reason',
|
||||
'dlg.cancelConfirm': 'Cancel subscription',
|
||||
'dlg.planTitle': 'Change plan',
|
||||
'dlg.planConfirm': 'Change plan',
|
||||
'dlg.refundTitle': 'Refund',
|
||||
'dlg.refundPaid': 'Paid {sum}, already refunded {back}',
|
||||
'dlg.refundCancel': 'Cancel the subscription too',
|
||||
'dlg.refundConfirm': 'Issue refund',
|
||||
'done.gift': 'Subscription gifted',
|
||||
'done.sell': 'Payment recorded',
|
||||
'done.extend': 'Term changed',
|
||||
'done.cancel': 'Subscription cancelled',
|
||||
'done.changePlan': 'Plan changed',
|
||||
'done.refund': 'Refund issued',
|
||||
|
||||
'team.title': 'Team',
|
||||
'team.lead': 'Who works in the admin and what they can access. One person can have several roles.',
|
||||
'team.add': 'Add a person',
|
||||
'team.name': 'Name',
|
||||
'team.roles': 'Roles',
|
||||
'team.create': 'Add',
|
||||
'team.linkTitle': 'Link for {name}',
|
||||
'team.linkHint': 'Send it to them. They open the work bot, Telegram gets linked, and they can sign in.',
|
||||
'team.tgLinked': 'Telegram linked',
|
||||
'team.tgNotLinked': 'Telegram not linked',
|
||||
'team.newLink': 'Linking link',
|
||||
'team.disable': 'Disable access',
|
||||
'team.enable': 'Restore access',
|
||||
'team.disabled': 'Access disabled',
|
||||
'team.you': 'you',
|
||||
'team.saved': 'Roles saved',
|
||||
};
|
||||
|
||||
const hy: Dict = {
|
||||
'ui.save': 'Պահպանել',
|
||||
'ui.close': 'Փակել',
|
||||
'ui.loading': 'Բեռնվում է…',
|
||||
'ui.error': 'Չստացվեց՝ {msg}',
|
||||
'ui.retry': 'Կրկնել',
|
||||
'ui.copy': 'Պատճենել',
|
||||
'ui.copied': 'Պատճենված է',
|
||||
'ui.days': '{n} օր',
|
||||
'ui.yes': 'Այո',
|
||||
'ui.no': 'Ոչ',
|
||||
'ui.none': '—',
|
||||
|
||||
'err.PAYMENTS_NOT_CONFIGURED': 'Առցանց վճարումները դեռ միացված չեն․ նշեք վճարումը ձեռքով։',
|
||||
'err.REFUND_TOO_BIG': 'Վերադարձի գումարը մեծ է հաճախորդի վճարածից։',
|
||||
'err.ENDS_BEFORE_START': 'Բաժանորդագրությունը չի կարող ավարտվել սկսվելուց առաջ։',
|
||||
'err.CANNOT_DEMOTE_SELF': 'Չեք կարող անջատել ինքներդ ձեզ կամ հանել ձեր սուպերադմինի դերը։',
|
||||
'err.ROLE_FORBIDDEN': 'Ձեր դերը մուտք չունի այս գործողությանը։',
|
||||
'err.NO_ROLE': 'Ձեր հաշիվը նոր ադմինում դերեր չունի։ Խնդրեք սուպերադմինին տրամադրել։',
|
||||
|
||||
'login.coverWord': 'թիմ',
|
||||
'login.title': 'Մուտք թիմի համար',
|
||||
'login.lead': 'Հաստատեք մուտքը Barev Club-ի աշխատանքային բոտում։ Գաղտնաբառ պետք չէ։',
|
||||
'login.open': 'Հաստատել Telegram-ում',
|
||||
'login.qr': 'Համակարգչից հարմար է կոդը սկանավորել հեռախոսով։',
|
||||
'login.waiting': 'Սպասում ենք հաստատմանը Telegram-ում…',
|
||||
'login.expired': 'Հղումը հնացել է։',
|
||||
'login.retry': 'Ստանալ նոր հղում',
|
||||
'login.dev': 'Տեղային մուտք',
|
||||
'login.devHint': 'Միայն այս համակարգչում․ սերվերում այս բլոկը չկա։',
|
||||
'login.noTelegram': 'Մուտք չունե՞ք։ Խնդրեք սուպերադմինին ավելացնել ձեզ թիմում և ուղարկել Telegram-ի կապման հղում։',
|
||||
|
||||
'nav.dashboard': 'Վահանակ',
|
||||
'nav.clients': 'Հաճախորդներ',
|
||||
'nav.chats': 'Չատեր',
|
||||
'nav.orders': 'Պատվերներ',
|
||||
'nav.providers': 'Կատարողներ',
|
||||
'nav.agencies': 'Գործակալություններ',
|
||||
'nav.venues': 'Հաստատություններ',
|
||||
'nav.crm': 'CRM',
|
||||
'nav.products': 'Ապրանքներ',
|
||||
'nav.journal': 'Մատյան',
|
||||
'nav.team': 'Թիմ',
|
||||
'nav.more': 'Ավելին',
|
||||
'nav.soon': 'փուլ {n}',
|
||||
'nav.groupWork': 'Աշխատանք',
|
||||
'nav.groupPartners': 'Գործընկերներ',
|
||||
'nav.groupSetup': 'Կարգավորում',
|
||||
|
||||
'role.SUPERADMIN': 'Սուպերադմին',
|
||||
'role.SUPPORT': 'Աջակցություն',
|
||||
'role.CONCIERGE': 'Կոնսիերժ',
|
||||
'role.SALES': 'Վաճառք',
|
||||
'roleHint.SUPERADMIN': 'Ամեն ինչ, ներառյալ գումարներն ու թիմը',
|
||||
'roleHint.SUPPORT': 'Աջակցության չատ, պատվերներ, բաժանորդագրություններ',
|
||||
'roleHint.CONCIERGE': 'Կոնսիերժի չատ բաժանորդների համար',
|
||||
'roleHint.SALES': 'CRM՝ հաստատություններ և գործակալություններ',
|
||||
|
||||
'shell.logout': 'Ելք',
|
||||
'shell.oldAdmin': 'Հին ադմին',
|
||||
'shell.linkTg': 'Կապեք Telegram-ը․ դրանով եք մուտք գործում և ստանում ծանուցումներ։',
|
||||
'shell.linkTgBtn': 'Կապել Telegram-ը',
|
||||
|
||||
'soon.title': '{section} բաժինը տեղափոխվում է այստեղ',
|
||||
'soon.text': 'Բաժինը կհայտնվի {n}-րդ փուլում։ Մինչ այդ աշխատեք հին ադմինում․ տվյալները ընդհանուր են։',
|
||||
'soon.open': 'Բացել հին ադմինը',
|
||||
|
||||
'dash.morning': 'Բարի լույս, {name}',
|
||||
'dash.day': 'Բարի օր, {name}',
|
||||
'dash.evening': 'Բարի երեկո, {name}',
|
||||
'dash.attention': 'Պահանջում է ուշադրություն',
|
||||
'dash.allClear': 'Շտապ ոչինչ չկա․ ամեն ինչ արված է։',
|
||||
'att.unassigned': 'Պատվերներ առանց կատարողի',
|
||||
'att.unreadChats': 'Չկարդացված հաղորդագրություններ',
|
||||
'att.webRequests': 'Հայտեր կայքից',
|
||||
'att.leads': 'Տեղափոխման հայտեր',
|
||||
'att.appRequests': 'Հայտեր հավելվածից',
|
||||
'att.lowRatings': '3 և ցածր գնահատականներ այս շաբաթ',
|
||||
'att.expiring': 'Ավարտվում են 7 օրում',
|
||||
'dash.money': 'Գումար',
|
||||
'dash.today': 'Այսօր',
|
||||
'dash.week': '7 օր',
|
||||
'dash.month': '30 օր',
|
||||
'dash.vsYesterday': 'երեկ՝ {sum}',
|
||||
'dash.vsPrev': 'նախորդ {period}-ի համեմատ',
|
||||
'dash.orders': 'պատվերներ',
|
||||
'dash.subs': 'բաժանորդագրություններ',
|
||||
'dash.chart': 'Եկամուտն ըստ օրերի, 30 օր',
|
||||
'dash.subsTitle': 'Բաժանորդագրություններ',
|
||||
'dash.gifts': 'Որից նվեր՝ {n}',
|
||||
'dash.expiring': 'Ավարտվում են առաջիկա 7 օրում',
|
||||
'dash.noExpiring': 'Առաջիկա շաբաթ ոչինչ չի ավարտվում։',
|
||||
'dash.conversion': 'Նվեր → գնում',
|
||||
'dash.conversionHint': 'Նվեր ստացած {total} հաճախորդից {paid}-ը հետո գնել են',
|
||||
'dash.growth': 'Աճ',
|
||||
'dash.newClients': 'Նոր հաճախորդներ',
|
||||
'dash.prevMonth': 'նախորդ 30 օրում՝ {n}',
|
||||
'dash.sources': 'Որտեղից են եկել, 30 օր',
|
||||
'dash.funnel': 'Ձագար, 30 օր',
|
||||
'funnel.leads': 'Հայտեր կայքից',
|
||||
'funnel.clients': 'Հաճախորդներ հավելվածում',
|
||||
'funnel.paying': 'Վճարել են',
|
||||
'dash.team': 'Թիմ և գործընկերներ',
|
||||
'dash.topProviders': 'Լավագույն կատարողներ',
|
||||
'dash.bottomProviders': 'Արժե խոսել',
|
||||
'dash.topPartners': 'Հաստատություններ՝ քարտի սկանավորումներ, 30 օր',
|
||||
'dash.salesSoon': 'Վաճառողների արդյունքները կհայտնվեն CRM-ի հետ։',
|
||||
'dash.empty': 'Դեռ դատարկ է',
|
||||
'dash.feed': 'Լրահոս',
|
||||
'feed.subscription': '{plan} · {source}',
|
||||
'feed.payment': '№{number} պատվերը վճարված է',
|
||||
'dash.ratings': '{n} գնահատական',
|
||||
'dash.scans': '{n} սկան',
|
||||
|
||||
'source.organic': 'Ինքն են գտել',
|
||||
'source.deeplink': 'Գործակալության հղում',
|
||||
'source.manual_code': 'Գործակալության կոդ',
|
||||
'source.agency': '«{name}» գործակալություն',
|
||||
'source.utm': 'Գովազդ՝ {name}',
|
||||
|
||||
'plan.BAREV_PLUS': 'Barev+',
|
||||
'plan.ELITE': 'Elite',
|
||||
'plan.none': 'Առանց բաժանորդագրության',
|
||||
'subSource.PAID': 'Գնում',
|
||||
'subSource.MANUAL': 'Թիմի նվեր',
|
||||
'subSource.AGENCY_GIFT': 'Գործակալության նվեր',
|
||||
'subStatus.ACTIVE': 'Ակտիվ',
|
||||
'subStatus.EXPIRED': 'Ավարտված',
|
||||
'subStatus.CANCELLED': 'Չեղարկված',
|
||||
'pay.cash': 'Կանխիկ',
|
||||
'pay.transfer': 'Փոխանցում',
|
||||
'pay.card': 'Քարտ տերմինալով',
|
||||
'pay.link': 'Վճարման հղում',
|
||||
'segment.MOVING': 'Տեղափոխվում է',
|
||||
'segment.LIVING': 'Ապրում է Հայաստանում',
|
||||
|
||||
'clients.title': 'Հաճախորդներ',
|
||||
'clients.search': 'Անուն, @username, հեռախոս կամ քարտի համար',
|
||||
'clients.found': 'Գտնվել է՝ {n}',
|
||||
'clients.revenueTotal': 'բերել են {sum}',
|
||||
'col.client': 'Հաճախորդ',
|
||||
'col.sub': 'Բաժանորդագրություն',
|
||||
'col.source': 'Աղբյուր',
|
||||
'col.revenue': 'Բերել է',
|
||||
'col.since': 'Մեզ հետ է',
|
||||
'filter.plan': 'Բաժանորդագրություն',
|
||||
'filter.all': 'Բոլորը',
|
||||
'filter.any': 'Բաժանորդագրությամբ',
|
||||
'filter.none': 'Առանց բաժանորդագրության',
|
||||
'filter.subSource': 'Ինչպես է ստացել',
|
||||
'filter.source': 'Աղբյուր',
|
||||
'filter.agency': 'Գործակալություն',
|
||||
'filter.tag': 'Պիտակ',
|
||||
'filter.segment': 'Սցենար',
|
||||
'filter.paying': 'Վճարել են',
|
||||
'filter.payingYes': 'Վճարել են',
|
||||
'filter.payingNo': 'Երբեք չեն վճարել',
|
||||
'filter.expiring': 'Ավարտվում է 7 օրում',
|
||||
'filter.reset': 'Զրոյացնել ֆիլտրերը',
|
||||
'filter.more': 'Ֆիլտրեր',
|
||||
'filter.anyValue': 'Ցանկացած',
|
||||
'sort.label': 'Հերթականություն',
|
||||
'sort.new': 'Նախ նորերը',
|
||||
'sort.revenue': 'Ամենաշատը բերածները',
|
||||
'sort.ends': 'Շուտով ավարտվող',
|
||||
'segments.title': 'Իմ ընտրանիները',
|
||||
'segments.save': 'Պահպանել ընտրանին',
|
||||
'segments.name': 'Ընտրանու անունը',
|
||||
'segments.saved': 'Ընտրանին պահպանված է',
|
||||
'segments.remove': 'Ջնջել «{name}» ընտրանին',
|
||||
'clients.export': 'Ներբեռնել Excel-ի համար',
|
||||
'clients.exported': 'Ֆայլը ներբեռնված է',
|
||||
'clients.broadcast': 'Առաքում',
|
||||
'clients.broadcastSoon': 'Ըստ ընտրանու առաքումները կհայտնվեն 5-րդ փուլում։',
|
||||
'clients.empty': 'Այս ֆիլտրերին ոչ ոք չի համապատասխանում։',
|
||||
'clients.emptyAll': 'Հաճախորդներ դեռ չկան․ կհայտնվեն, երբ գրեն բոտին։',
|
||||
'clients.prev': 'Հետ',
|
||||
'clients.next': 'Առաջ',
|
||||
'clients.page': '{from}–{to}՝ {total}-ից',
|
||||
|
||||
'card.back': 'Բոլոր հաճախորդները',
|
||||
'card.since': 'Մեզ հետ է {date}-ից',
|
||||
'card.telegram': 'Բացել Telegram-ում',
|
||||
'card.cardNo': 'Քարտ {code}',
|
||||
'card.noSub': 'Ակտիվ բաժանորդագրություն չկա',
|
||||
'card.noSubHint': 'Նվիրեք կամ վաճառեք բաժանորդագրություն․ հաճախորդը միանգամից կստանա զեղչեր։',
|
||||
'card.until': 'մինչև {date}',
|
||||
'card.daysLeft': 'մնաց {n} օր',
|
||||
'card.history': 'Բոլոր բաժանորդագրությունները',
|
||||
'card.grantedBy': 'տրամադրել է {name}',
|
||||
'card.giftFrom': 'նվեր «{name}»-ից',
|
||||
'card.paidVia': '{method}',
|
||||
'card.refunded': 'վերադարձ {sum}',
|
||||
'card.cancelledAt': 'չեղարկված {date}',
|
||||
'card.upcoming': 'կսկսվի {date}',
|
||||
'card.money': 'Բերել է',
|
||||
'card.fromOrders': 'պատվերներ',
|
||||
'card.fromSubs': 'բաժանորդագրություններ',
|
||||
'card.orders': 'Պատվերներ',
|
||||
'card.noOrders': 'Պատվերներ դեռ չկան։',
|
||||
'card.newOrder': 'Ստեղծել պատվեր',
|
||||
'card.newOrderSoon': 'Պատվեր հաճախորդի անունից՝ 3-րդ փուլում, պատվերների բաժնի հետ։',
|
||||
'card.profile': 'Պրոֆիլ',
|
||||
'card.source': 'Որտեղից է եկել',
|
||||
'card.agency': 'Գործակալություն',
|
||||
'card.language': 'Լեզու',
|
||||
'card.phone': 'Հեռախոս',
|
||||
'card.segment': 'Սցենար',
|
||||
'card.consent': 'Տվյալների մշակման համաձայնություն',
|
||||
'card.tags': 'Պիտակներ',
|
||||
'card.addTag': 'Նոր պիտակ',
|
||||
'card.note': 'Թիմի նշում',
|
||||
'card.notePh': 'Հաճախորդը չի տեսնում այս նշումը',
|
||||
'card.saved': 'Պահպանված է',
|
||||
'card.activity': 'Հաճախորդի մատյան',
|
||||
'card.noActivity': 'Թիմը դեռ ոչինչ չի փոխել։',
|
||||
'card.stats': 'Ակտիվություն',
|
||||
'stat.cardUsages': 'քարտի սկան',
|
||||
'stat.leads': 'տեղափոխման հայտ',
|
||||
'stat.requests': 'հայտ հավելվածից',
|
||||
'stat.concierge': 'հաղորդագրություն չատում',
|
||||
|
||||
'act.gift': 'Նվիրել',
|
||||
'act.sell': 'Վաճառել',
|
||||
'act.extend': 'Փոխել ժամկետը',
|
||||
'act.cancel': 'Չեղարկել',
|
||||
'act.changePlan': 'Փոխել սակագինը',
|
||||
'act.refund': 'Վերադարձնել գումարը',
|
||||
|
||||
'audit.bo.clients.gift': 'նվիրել է բաժանորդագրություն',
|
||||
'audit.bo.clients.sell': 'վաճառել է բաժանորդագրություն',
|
||||
'audit.bo.clients.extend': 'փոխել է ժամկետը',
|
||||
'audit.bo.clients.cancel': 'չեղարկել է բաժանորդագրությունը',
|
||||
'audit.bo.clients.changePlan': 'փոխել է սակագինը',
|
||||
'audit.bo.clients.refund': 'ձևակերպել է վերադարձ',
|
||||
'audit.bo.clients.setTags': 'փոխել է պիտակները',
|
||||
'audit.bo.clients.setNote': 'փոխել է նշումը',
|
||||
|
||||
'dlg.giftTitle': 'Նվիրել բաժանորդագրություն',
|
||||
'dlg.giftHint': 'Այն կնշվի որպես ձեր նվերը։',
|
||||
'dlg.sellTitle': 'Վաճառել բաժանորդագրություն',
|
||||
'dlg.plan': 'Սակագին',
|
||||
'dlg.period': 'Ժամկետ',
|
||||
'dlg.period1': '1 ամիս',
|
||||
'dlg.period3': '3 ամիս',
|
||||
'dlg.period6': '6 ամիս',
|
||||
'dlg.period12': '1 տարի',
|
||||
'dlg.periodCustom': 'Այլ',
|
||||
'dlg.days': 'Օր',
|
||||
'dlg.amount': 'Գումար, ֏',
|
||||
'dlg.method': 'Ինչպես է վճարել',
|
||||
'dlg.note': 'Մեկնաբանություն թիմի համար',
|
||||
'dlg.linkSoon': 'Վճարման հղումը կմիացնենք վճարային համակարգը միացնելուց հետո։',
|
||||
'dlg.giftConfirm': 'Նվիրել',
|
||||
'dlg.sellConfirm': 'Նշել վճարումը',
|
||||
'dlg.extendTitle': 'Փոխել ժամկետը',
|
||||
'dlg.extendHint': 'Բացասական թիվը կրճատում է ժամկետը։',
|
||||
'dlg.newEnd': 'Նոր ավարտի ամսաթիվ՝ {date}',
|
||||
'dlg.extendConfirm': 'Պահպանել ժամկետը',
|
||||
'dlg.cancelTitle': 'Չեղարկել բաժանորդագրությունը',
|
||||
'dlg.cancelHint': 'Հաճախորդը միանգամից կկորցնի զեղչերն ու արտոնությունները։',
|
||||
'dlg.reason': 'Պատճառ',
|
||||
'dlg.cancelConfirm': 'Չեղարկել բաժանորդագրությունը',
|
||||
'dlg.planTitle': 'Փոխել սակագինը',
|
||||
'dlg.planConfirm': 'Փոխել սակագինը',
|
||||
'dlg.refundTitle': 'Վերադարձնել գումարը',
|
||||
'dlg.refundPaid': 'Վճարված է {sum}, արդեն վերադարձված՝ {back}',
|
||||
'dlg.refundCancel': 'Միանգամից չեղարկել բաժանորդագրությունը',
|
||||
'dlg.refundConfirm': 'Ձևակերպել վերադարձ',
|
||||
'done.gift': 'Բաժանորդագրությունը նվիրված է',
|
||||
'done.sell': 'Վճարումը նշված է',
|
||||
'done.extend': 'Ժամկետը փոխված է',
|
||||
'done.cancel': 'Բաժանորդագրությունը չեղարկված է',
|
||||
'done.changePlan': 'Սակագինը փոխված է',
|
||||
'done.refund': 'Վերադարձը ձևակերպված է',
|
||||
|
||||
'team.title': 'Թիմ',
|
||||
'team.lead': 'Ով է աշխատում ադմինում և ինչ հասանելիություն ունի։ Մեկ մարդը կարող է ունենալ մի քանի դեր։',
|
||||
'team.add': 'Ավելացնել մարդու',
|
||||
'team.name': 'Անուն',
|
||||
'team.roles': 'Դերեր',
|
||||
'team.create': 'Ավելացնել',
|
||||
'team.linkTitle': 'Հղում {name}-ի համար',
|
||||
'team.linkHint': 'Ուղարկեք նրան։ Կբացի աշխատանքային բոտը, Telegram-ը կկապվի, և կարող է մուտք գործել։',
|
||||
'team.tgLinked': 'Telegram-ը կապված է',
|
||||
'team.tgNotLinked': 'Telegram-ը կապված չէ',
|
||||
'team.newLink': 'Կապման հղում',
|
||||
'team.disable': 'Անջատել մուտքը',
|
||||
'team.enable': 'Վերադարձնել մուտքը',
|
||||
'team.disabled': 'Մուտքն անջատված է',
|
||||
'team.you': 'դուք',
|
||||
'team.saved': 'Դերերը պահպանված են',
|
||||
};
|
||||
|
||||
const DICTS: Record<BoLocale, Dict> = { ru, en, hy };
|
||||
|
||||
export type T = (key: BoKey | (string & {}), params?: Record<string, string | number | null | undefined>) => string;
|
||||
|
||||
function makeT(locale: BoLocale): T {
|
||||
const dict = DICTS[locale];
|
||||
return (key, params) => {
|
||||
let s: string = (dict as Record<string, string>)[key] ?? (ru as Record<string, string>)[key] ?? key;
|
||||
if (params) for (const [k, v] of Object.entries(params)) s = s.replaceAll(`{${k}}`, String(v ?? ''));
|
||||
return s;
|
||||
};
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
locale: BoLocale;
|
||||
setLocale: (l: BoLocale) => void;
|
||||
t: T;
|
||||
/** общие переводы проекта: services.<code>.name и т.п. */
|
||||
shared: (key: string) => string;
|
||||
amd: (n: number) => string;
|
||||
date: (d: Date) => string;
|
||||
dateTime: (d: Date) => string;
|
||||
/** сокращённая сумма для плотных мест: 1,2 млн */
|
||||
amdShort: (n: number) => string;
|
||||
}
|
||||
|
||||
const I18nCtx = createContext<Ctx | null>(null);
|
||||
const KEY = 'barev.bo.locale';
|
||||
|
||||
function initialLocale(): BoLocale {
|
||||
try {
|
||||
const saved = localStorage.getItem(KEY);
|
||||
if (saved && (BO_LOCALES as readonly string[]).includes(saved)) return saved as BoLocale;
|
||||
} catch {
|
||||
/* приватный режим */
|
||||
}
|
||||
const nav = navigator.language.slice(0, 2);
|
||||
return (BO_LOCALES as readonly string[]).includes(nav) ? (nav as BoLocale) : 'ru';
|
||||
}
|
||||
|
||||
export function BoI18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<BoLocale>(initialLocale);
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale;
|
||||
}, [locale]);
|
||||
const value = useMemo<Ctx>(() => {
|
||||
const sharedT = createSharedT(locale as SharedLocale);
|
||||
const intl = locale === 'en' ? 'en-GB' : locale === 'hy' ? 'hy-AM' : 'ru-RU';
|
||||
const compact = new Intl.NumberFormat(intl, { notation: 'compact', maximumFractionDigits: 1 });
|
||||
return {
|
||||
locale,
|
||||
setLocale: (l) => {
|
||||
setLocaleState(l);
|
||||
try {
|
||||
localStorage.setItem(KEY, l);
|
||||
} catch {
|
||||
/* приватный режим */
|
||||
}
|
||||
},
|
||||
t: makeT(locale),
|
||||
shared: sharedT,
|
||||
amd: (n) => formatAmd(locale as SharedLocale, n),
|
||||
amdShort: (n) => `${compact.format(n)} ֏`,
|
||||
date: (d) => formatDateTime(locale as SharedLocale, d, { day: 'numeric', month: 'short', year: 'numeric' }),
|
||||
dateTime: (d) => formatDateTime(locale as SharedLocale, d, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }),
|
||||
};
|
||||
}, [locale]);
|
||||
return <I18nCtx.Provider value={value}>{children}</I18nCtx.Provider>;
|
||||
}
|
||||
|
||||
export function useT() {
|
||||
const v = useContext(I18nCtx);
|
||||
if (!v) throw new Error('BoI18nProvider missing');
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Текст ошибки API: код бизнес-ошибки → перевод, иначе сообщение */
|
||||
export function errText(t: T, err: unknown): string {
|
||||
const msg = (err as { message?: string })?.message ?? '';
|
||||
const known = `err.${msg}`;
|
||||
const tr = t(known);
|
||||
return tr !== known ? tr : t('ui.error', { msg: msg || '—' });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import '@barev/ui/styles.css';
|
||||
import './styles.css';
|
||||
import { ApiProvider, ToastProvider } from '@barev/web';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.tsx';
|
||||
import { BoI18nProvider } from './i18n.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ApiProvider withCredentials>
|
||||
<BoI18nProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</BoI18nProvider>
|
||||
</ApiProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,403 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Me } from '../App.tsx';
|
||||
import { errText, useT, type BoKey } from '../i18n.tsx';
|
||||
import { Avatar, I, PlanBadge, Skeleton, sourceLabel } from '../components/kit.tsx';
|
||||
import { actionLabel } from './Dashboard.tsx';
|
||||
import { SubDialog, type SubMode, type SubTarget } from './SubDialog.tsx';
|
||||
|
||||
const FLOWS = ['CLEANING', 'REPAIR', 'TRANSFER', 'LANGUAGE', 'CAREER', 'DOCUMENTS'];
|
||||
const DAY = 86_400_000;
|
||||
|
||||
export function ClientCard({ id, me }: { id: string; me: Me }) {
|
||||
const { t, amd, date, dateTime, shared } = useT();
|
||||
const trpc = useTRPC();
|
||||
const c = useQuery(trpc.bo.clients.get.queryOptions({ id }));
|
||||
const [dlg, setDlg] = useState<{ mode: SubMode; sub?: SubTarget } | null>(null);
|
||||
const isSuper = me.roles.includes('SUPERADMIN');
|
||||
const canManage = isSuper || me.roles.includes('SUPPORT');
|
||||
|
||||
if (c.isError)
|
||||
return (
|
||||
<>
|
||||
<a className="bo-back" href="#/clients">
|
||||
<I name="back" size={16} />
|
||||
{t('card.back')}
|
||||
</a>
|
||||
<div className="bo-panel bo-empty">{errText(t, c.error)}</div>
|
||||
</>
|
||||
);
|
||||
if (!c.data)
|
||||
return (
|
||||
<div className="bo-col">
|
||||
<Skeleton h={80} />
|
||||
<Skeleton h={220} />
|
||||
<Skeleton h={300} />
|
||||
</div>
|
||||
);
|
||||
const d = c.data;
|
||||
const current = d.subscriptions.find((s) => s.active && s.plan === 'ELITE') ?? d.subscriptions.find((s) => s.active);
|
||||
const statusText = (status: string) => {
|
||||
for (const f of FLOWS) {
|
||||
const k = `status.${f}.${status}`;
|
||||
const v = shared(k);
|
||||
if (v !== k) return v;
|
||||
}
|
||||
return status;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<a className="bo-back" href="#/clients">
|
||||
<I name="back" size={16} />
|
||||
{t('card.back')}
|
||||
</a>
|
||||
|
||||
<header className="bo-profile">
|
||||
<Avatar name={d.name} id={d.id} size={64} />
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<h1 className="bo-title" style={{ fontSize: 'clamp(26px, 3.4vw, 38px)' }}>
|
||||
{d.name}
|
||||
</h1>
|
||||
<div className="bo-muted" style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: '4px 14px' }}>
|
||||
{d.username && <span>@{d.username}</span>}
|
||||
{d.phone && <span>{d.phone}</span>}
|
||||
<span>{t('card.since', { date: date(d.createdAt) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
{d.username && (
|
||||
<a className="bo-btn bo-btn--ghost" href={`https://t.me/${d.username}`} target="_blank" rel="noreferrer">
|
||||
<I name="telegram" size={16} />
|
||||
{t('card.telegram')}
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="bo-client-grid">
|
||||
<div className="bo-col">
|
||||
{/* посадочный талон текущей подписки */}
|
||||
<section>
|
||||
<div className={`bo-pass bo-pass--${current?.plan ?? 'none'}`}>
|
||||
<div className="bo-pass__main">
|
||||
<div className="bo-pass__plan">{current ? t(`plan.${current.plan}`) : t('card.noSub')}</div>
|
||||
<div className="bo-pass__meta">
|
||||
{current ? (
|
||||
<>
|
||||
<span>{t(`subSource.${current.source}`)}</span>
|
||||
{current.grantedBy && <span>{t('card.grantedBy', { name: current.grantedBy })}</span>}
|
||||
{current.agency && <span>{t('card.giftFrom', { name: current.agency })}</span>}
|
||||
{current.payMethod && <span>{t(`pay.${current.payMethod}`)}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span>{t('card.noSubHint')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bo-pass__stub">
|
||||
{current ? (
|
||||
<div>
|
||||
<div className="bo-pass__date">{date(current.endsAt)}</div>
|
||||
<div className="bo-pass__left">{t('card.daysLeft', { n: Math.max(0, Math.ceil((current.endsAt.getTime() - Date.now()) / DAY)) })}</div>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="bo-pass__card">{t('card.cardNo', { code: d.cardCode })}</div>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="bo-actions">
|
||||
<button type="button" className="bo-btn bo-btn--accent bo-btn--sm" onClick={() => setDlg({ mode: 'gift' })}>
|
||||
<I name="gift" size={16} />
|
||||
{t('act.gift')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn bo-btn--sm" onClick={() => setDlg({ mode: 'sell' })}>
|
||||
{t('act.sell')}
|
||||
</button>
|
||||
{current && (
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg({ mode: 'extend', sub: current })}>
|
||||
{t('act.extend')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg({ mode: 'plan', sub: current })}>
|
||||
{t('act.changePlan')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn bo-btn--danger bo-btn--sm" onClick={() => setDlg({ mode: 'cancel', sub: current })}>
|
||||
{t('act.cancel')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{d.subscriptions.length > 0 && (
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('card.history')}</h2>
|
||||
</div>
|
||||
{d.subscriptions.map((s) => {
|
||||
const upcoming = s.status === 'ACTIVE' && s.startsAt.getTime() > Date.now();
|
||||
const off = !s.active && !upcoming;
|
||||
return (
|
||||
<div key={s.id} className={`bo-subrow${off ? ' bo-subrow--off' : ''}`}>
|
||||
<PlanBadge plan={s.plan} t={t} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{date(s.startsAt)} — {date(s.endsAt)}
|
||||
{s.amount != null && <span className="bo-num" style={{ marginLeft: 10 }}>{amd(s.amount)}</span>}
|
||||
</div>
|
||||
<div className="bo-subrow__meta">
|
||||
{[
|
||||
upcoming ? t('card.upcoming', { date: date(s.startsAt) }) : t(`subStatus.${s.status}`),
|
||||
t(`subSource.${s.source}`),
|
||||
s.grantedBy && t('card.grantedBy', { name: s.grantedBy }),
|
||||
s.agency && t('card.giftFrom', { name: s.agency }),
|
||||
s.payMethod && t(`pay.${s.payMethod}`),
|
||||
s.refundAmount ? t('card.refunded', { sum: amd(s.refundAmount) }) : null,
|
||||
s.cancelledAt && t('card.cancelledAt', { date: date(s.cancelledAt) }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</div>
|
||||
{s.note && <div className="bo-subrow__meta" style={{ whiteSpace: 'pre-line', marginTop: 2 }}>{s.note}</div>}
|
||||
</div>
|
||||
<div className="bo-subrow__menu">
|
||||
{canManage && s.status === 'ACTIVE' && !s.active && upcoming && (
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg({ mode: 'cancel', sub: s })}>
|
||||
{t('act.cancel')}
|
||||
</button>
|
||||
)}
|
||||
{isSuper && s.amount != null && s.amount > (s.refundAmount ?? 0) && (
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg({ mode: 'refund', sub: s })}>
|
||||
{t('act.refund')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('card.orders')}</h2>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled title={t('card.newOrderSoon')}>
|
||||
<I name="plus" size={16} />
|
||||
{t('card.newOrder')}
|
||||
</button>
|
||||
</div>
|
||||
{d.orders.length === 0 ? (
|
||||
<p className="bo-hint">{t('card.noOrders')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{d.orders.map((o) => (
|
||||
<li key={o.id} style={{ flexWrap: 'wrap' }}>
|
||||
<span className="bo-num bo-faint" style={{ width: 52 }}>
|
||||
№{o.number}
|
||||
</span>
|
||||
<span className="bo-grow" style={{ minWidth: 140 }}>
|
||||
<b style={{ fontWeight: 600 }}>{shared(`services.${o.service}.name`)}</b>
|
||||
<span className="bo-muted bo-small" style={{ display: 'block' }}>
|
||||
{dateTime(o.scheduledAt)}
|
||||
{o.provider && `, ${o.provider}`}
|
||||
{o.rating && ` ★ ${o.rating}`}
|
||||
</span>
|
||||
</span>
|
||||
<span className="bo-status" style={{ ['--dot' as string]: o.status === 'CANCELLED' ? 'var(--bc-danger)' : o.status === 'DONE' || o.status === 'COMPLETED' ? 'var(--bc-ok)' : 'var(--bc-accent)' }}>
|
||||
{statusText(o.status)}
|
||||
</span>
|
||||
{o.total != null && (
|
||||
<span style={{ textAlign: 'right', minWidth: 96 }}>
|
||||
<b className="bo-num">{amd(o.total)}</b>
|
||||
<span className="bo-small bo-muted" style={{ display: 'block' }}>
|
||||
{shared(`payment.${o.paymentStatus}`)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="bo-col">
|
||||
{d.money && (
|
||||
<section className="bo-panel bo-money-card">
|
||||
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
|
||||
{t('card.money')}
|
||||
</span>
|
||||
<div className="bo-bignum" style={{ margin: '6px 0 8px' }}>
|
||||
{amd(d.money.total)}
|
||||
</div>
|
||||
<div className="bo-small" style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<span>
|
||||
{t('card.fromSubs')} <b className="bo-num">{amd(d.money.subs)}</b>
|
||||
</span>
|
||||
<span>
|
||||
{t('card.fromOrders')} <b className="bo-num">{amd(d.money.orders)}</b>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<TagsAndNote id={d.id} tags={d.tags} note={d.adminNote ?? ''} />
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('card.profile')}</h2>
|
||||
</div>
|
||||
<dl className="bo-kv">
|
||||
<dt>{t('card.source')}</dt>
|
||||
<dd>{sourceLabel(t, d)}</dd>
|
||||
{d.agency && (
|
||||
<>
|
||||
<dt>{t('card.agency')}</dt>
|
||||
<dd>
|
||||
{d.agency.name}
|
||||
{d.agencyAttachedAt && <span className="bo-faint"> ({date(d.agencyAttachedAt)})</span>}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t('card.language')}</dt>
|
||||
<dd>{d.locale.toUpperCase()}</dd>
|
||||
{d.segment && (
|
||||
<>
|
||||
<dt>{t('card.segment')}</dt>
|
||||
<dd>{t(`segment.${d.segment}`)}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t('card.consent')}</dt>
|
||||
<dd>{d.consentAt ? date(d.consentAt) : t('ui.no')}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('card.stats')}</h2>
|
||||
</div>
|
||||
<div className="bo-stats">
|
||||
{(
|
||||
[
|
||||
['cardUsages', d.counts.cardUsages],
|
||||
['concierge', d.counts.concierge],
|
||||
['requests', d.counts.requests],
|
||||
['leads', d.counts.leads],
|
||||
] as [string, number][]
|
||||
).map(([k, n]) => (
|
||||
<div key={k}>
|
||||
<b>{n}</b>
|
||||
<span className="bo-small bo-muted">{t(`stat.${k}` as BoKey)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('card.activity')}</h2>
|
||||
</div>
|
||||
{d.history.length === 0 ? (
|
||||
<p className="bo-hint">{t('card.noActivity')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{d.history.map((h) => (
|
||||
<li key={h.id} style={{ alignItems: 'flex-start' }}>
|
||||
<span className="bo-feed__time">{dateTime(h.at)}</span>
|
||||
<span className="bo-grow" style={{ whiteSpace: 'normal' }}>
|
||||
<b>{h.by ?? '—'}</b> {actionLabel(t, h.action)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dlg && <SubDialog clientId={d.id} mode={dlg.mode} sub={dlg.sub} current={current} onClose={() => setDlg(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TagsAndNote({ id, tags, note }: { id: string; tags: string[]; note: string }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [draftTag, setDraftTag] = useState('');
|
||||
const [text, setText] = useState(note);
|
||||
useEffect(() => {
|
||||
setText(note);
|
||||
}, [note]);
|
||||
const facets = useQuery(trpc.bo.clients.facets.queryOptions());
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.clients.get.queryKey({ id }) });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.clients.facets.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.clients.list.queryKey() });
|
||||
};
|
||||
const setTags = useMutation(trpc.bo.clients.setTags.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') }));
|
||||
const setNote = useMutation(
|
||||
trpc.bo.clients.setNote.mutationOptions({
|
||||
onSuccess: () => {
|
||||
refresh();
|
||||
toast(t('card.saved'));
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const add = () => {
|
||||
const v = draftTag.trim();
|
||||
if (v && !tags.includes(v)) setTags.mutate({ id, tags: [...tags, v] });
|
||||
setDraftTag('');
|
||||
};
|
||||
const suggestions = (facets.data?.tags ?? []).map((x) => x.tag).filter((x) => !tags.includes(x));
|
||||
|
||||
return (
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('card.tags')}</h2>
|
||||
</div>
|
||||
<div className="bo-tags" style={{ marginBottom: 10 }}>
|
||||
{tags.map((tg) => (
|
||||
<span key={tg} className="bo-tag" style={{ background: 'var(--bc-learn)', color: 'var(--bc-learn-ink)' }}>
|
||||
{tg}
|
||||
<button type="button" aria-label={`× ${tg}`} onClick={() => setTags.mutate({ id, tags: tags.filter((x) => x !== tg) })}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8 }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
add();
|
||||
}}
|
||||
>
|
||||
<input className="bo-input" list={`tags-${id}`} value={draftTag} onChange={(e) => setDraftTag(e.target.value)} placeholder={t('card.addTag')} maxLength={40} style={{ minHeight: 36, padding: '6px 12px' }} />
|
||||
<datalist id={`tags-${id}`}>
|
||||
{suggestions.map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
<button type="submit" className="bo-btn bo-btn--sm" disabled={!draftTag.trim()} aria-label={t('card.addTag')}>
|
||||
<I name="plus" size={16} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<h3 className="bo-h3" style={{ margin: '20px 0 8px' }}>
|
||||
{t('card.note')}
|
||||
</h3>
|
||||
<textarea className="bo-textarea" value={text} placeholder={t('card.notePh')} onChange={(e) => setText(e.target.value)} maxLength={4000} />
|
||||
{text !== note && (
|
||||
<button type="button" className="bo-btn bo-btn--sm" style={{ marginTop: 8 }} onClick={() => setNote.mutate({ id, note: text })} disabled={setNote.isPending}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { navigate, useLocation, useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { Me } from '../App.tsx';
|
||||
import { errText, useT } from '../i18n.tsx';
|
||||
import { Avatar, Dialog, download, Field, I, PlanBadge, Skeleton, sourceLabel } from '../components/kit.tsx';
|
||||
|
||||
type Plan = 'any' | 'none' | 'BAREV_PLUS' | 'ELITE';
|
||||
interface Filters {
|
||||
search?: string;
|
||||
plan?: Plan;
|
||||
subSource?: 'PAID' | 'MANUAL' | 'AGENCY_GIFT';
|
||||
source?: string;
|
||||
agencyId?: string;
|
||||
tag?: string;
|
||||
expiringDays?: number;
|
||||
segment?: 'MOVING' | 'LIVING';
|
||||
paying?: 'yes' | 'no';
|
||||
}
|
||||
type Sort = 'new' | 'revenue' | 'ends';
|
||||
|
||||
const FILTER_KEYS = ['search', 'plan', 'subSource', 'source', 'agencyId', 'tag', 'segment', 'paying'] as const;
|
||||
|
||||
function readQuery(q: URLSearchParams): { f: Filters; sort: Sort; page: number } {
|
||||
const f: Filters = {};
|
||||
for (const k of FILTER_KEYS) {
|
||||
const v = q.get(k);
|
||||
if (v) (f as Record<string, string>)[k] = v;
|
||||
}
|
||||
const exp = Number(q.get('expiring'));
|
||||
if (exp > 0) f.expiringDays = exp;
|
||||
return { f, sort: (q.get('sort') as Sort) || 'new', page: Number(q.get('page')) || 0 };
|
||||
}
|
||||
|
||||
function writeQuery(f: Filters, sort: Sort, page: number) {
|
||||
const q = new URLSearchParams();
|
||||
for (const k of FILTER_KEYS) if (f[k]) q.set(k, String(f[k]));
|
||||
if (f.expiringDays) q.set('expiring', String(f.expiringDays));
|
||||
if (sort !== 'new') q.set('sort', sort);
|
||||
if (page) q.set('page', String(page));
|
||||
const s = q.toString();
|
||||
navigate(`/clients${s ? `?${s}` : ''}`, { replace: true });
|
||||
}
|
||||
|
||||
export function Clients({ me }: { me: Me }) {
|
||||
const { t, amd, date } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const { query } = useLocation();
|
||||
const { f, sort, page } = useMemo(() => readQuery(query), [query]);
|
||||
const isSuper = me.roles.includes('SUPERADMIN');
|
||||
|
||||
const [search, setSearch] = useState(f.search ?? '');
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => {
|
||||
if ((f.search ?? '') !== search) writeQuery({ ...f, search: search || undefined }, sort, 0);
|
||||
}, 300);
|
||||
return () => clearTimeout(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search]);
|
||||
|
||||
const set = (patch: Partial<Filters>) => writeQuery({ ...f, ...patch }, sort, 0);
|
||||
const activeCount = Object.entries(f).filter(([k, v]) => k !== 'search' && v !== undefined).length;
|
||||
const [showFilters, setShowFilters] = useState(activeCount > 1 || !!(f.source || f.agencyId || f.tag || f.segment || f.subSource));
|
||||
|
||||
const list = useQuery({ ...trpc.bo.clients.list.queryOptions({ ...f, sort, page }), placeholderData: keepPreviousData });
|
||||
const facets = useQuery(trpc.bo.clients.facets.queryOptions());
|
||||
const segments = useQuery(trpc.bo.segments.list.queryOptions());
|
||||
const removeSeg = useMutation(trpc.bo.segments.remove.mutationOptions({ onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.segments.list.queryKey() }) }));
|
||||
const exportCsv = useMutation(
|
||||
trpc.bo.clients.exportCsv.mutationOptions({
|
||||
onSuccess: (r) => {
|
||||
download(r.filename, r.content);
|
||||
toast(t('clients.exported'));
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
|
||||
const data = list.data;
|
||||
const from = data ? page * data.pageSize + 1 : 0;
|
||||
const to = data ? Math.min(data.total, (page + 1) * data.pageSize) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<div>
|
||||
<h1 className="bo-title">{t('clients.title')}</h1>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" title={t('clients.broadcastSoon')} disabled>
|
||||
<I name="send" size={16} />
|
||||
{t('clients.broadcast')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => exportCsv.mutate(f)} disabled={exportCsv.isPending}>
|
||||
<I name="download" size={16} />
|
||||
{t('clients.export')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bo-toolbar">
|
||||
<label className="bo-search">
|
||||
<I name="search" size={18} />
|
||||
<span className="bo-visually-hidden">{t('clients.search')}</span>
|
||||
<input className="bo-input" type="search" value={search} placeholder={t('clients.search')} onChange={(e) => setSearch(e.target.value)} />
|
||||
</label>
|
||||
<div className="bo-pills" role="group" aria-label={t('filter.plan')}>
|
||||
{(
|
||||
[
|
||||
[undefined, t('filter.all')],
|
||||
['any', t('filter.any')],
|
||||
['BAREV_PLUS', t('plan.BAREV_PLUS')],
|
||||
['ELITE', t('plan.ELITE')],
|
||||
['none', t('filter.none')],
|
||||
] as [Plan | undefined, string][]
|
||||
).map(([v, label]) => (
|
||||
<button key={label} type="button" className="bo-pill" aria-pressed={f.plan === v} onClick={() => set({ plan: v })}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="bo-pill" aria-pressed={!!f.expiringDays} onClick={() => set({ expiringDays: f.expiringDays ? undefined : 7 })}>
|
||||
{t('filter.expiring')}
|
||||
</button>
|
||||
<button type="button" className="bo-pill" aria-pressed={showFilters} onClick={() => setShowFilters((s) => !s)} aria-expanded={showFilters}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<I name="filter" size={15} />
|
||||
{t('filter.more')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="bo-filters">
|
||||
<Field label={t('filter.subSource')}>
|
||||
<select className="bo-select" value={f.subSource ?? ''} onChange={(e) => set({ subSource: (e.target.value || undefined) as Filters['subSource'] })}>
|
||||
<option value="">{t('filter.anyValue')}</option>
|
||||
{(['PAID', 'MANUAL', 'AGENCY_GIFT'] as const).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`subSource.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('filter.source')}>
|
||||
<select className="bo-select" value={f.source ?? ''} onChange={(e) => set({ source: e.target.value || undefined })}>
|
||||
<option value="">{t('filter.anyValue')}</option>
|
||||
{(facets.data?.sources ?? []).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{sourceLabel(t, { source: s })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('filter.agency')}>
|
||||
<select className="bo-select" value={f.agencyId ?? ''} onChange={(e) => set({ agencyId: e.target.value || undefined })}>
|
||||
<option value="">{t('filter.anyValue')}</option>
|
||||
{(facets.data?.agencies ?? []).map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('filter.tag')}>
|
||||
<select className="bo-select" value={f.tag ?? ''} onChange={(e) => set({ tag: e.target.value || undefined })}>
|
||||
<option value="">{t('filter.anyValue')}</option>
|
||||
{(facets.data?.tags ?? []).map((tg) => (
|
||||
<option key={tg.tag} value={tg.tag}>
|
||||
{tg.tag} ({tg.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('filter.segment')}>
|
||||
<select className="bo-select" value={f.segment ?? ''} onChange={(e) => set({ segment: (e.target.value || undefined) as Filters['segment'] })}>
|
||||
<option value="">{t('filter.anyValue')}</option>
|
||||
<option value="MOVING">{t('segment.MOVING')}</option>
|
||||
<option value="LIVING">{t('segment.LIVING')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('filter.paying')}>
|
||||
<select className="bo-select" value={f.paying ?? ''} onChange={(e) => set({ paying: (e.target.value || undefined) as Filters['paying'] })}>
|
||||
<option value="">{t('filter.anyValue')}</option>
|
||||
<option value="yes">{t('filter.payingYes')}</option>
|
||||
<option value="no">{t('filter.payingNo')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('sort.label')}>
|
||||
<select className="bo-select" value={sort} onChange={(e) => writeQuery(f, e.target.value as Sort, 0)}>
|
||||
<option value="new">{t('sort.new')}</option>
|
||||
{isSuper && <option value="revenue">{t('sort.revenue')}</option>}
|
||||
<option value="ends">{t('sort.ends')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="bo-btn bo-btn--soft bo-btn--sm" style={{ background: '#fff' }} onClick={() => setSaveOpen(true)} disabled={activeCount === 0 && !f.search}>
|
||||
{t('segments.save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn bo-btn--ghost bo-btn--sm"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
writeQuery({}, 'new', 0);
|
||||
}}
|
||||
>
|
||||
{t('filter.reset')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!segments.data?.length && (
|
||||
<div className="bo-segments" aria-label={t('segments.title')}>
|
||||
<span className="bo-small bo-muted" style={{ marginRight: 4 }}>
|
||||
{t('segments.title')}
|
||||
</span>
|
||||
{segments.data.map((s) => (
|
||||
<span key={s.id} className="bo-seg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const sf = s.filters as Filters;
|
||||
setSearch(sf.search ?? '');
|
||||
writeQuery(sf, sort, 0);
|
||||
}}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
<button type="button" aria-label={t('segments.remove', { name: s.name })} onClick={() => removeSeg.mutate({ id: s.id })}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<div className="bo-summary">
|
||||
<span>{t('clients.found', { n: data.total })}</span>
|
||||
{data.totalRevenue != null && <span>{t('clients.revenueTotal', { sum: amd(data.totalRevenue) })}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data ? (
|
||||
<Skeleton h={420} />
|
||||
) : data.items.length === 0 ? (
|
||||
<div className="bo-panel bo-empty">{activeCount || f.search ? t('clients.empty') : t('clients.emptyAll')}</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="bo-table" style={{ opacity: list.isPlaceholderData ? 0.6 : 1 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('col.client')}</th>
|
||||
<th>{t('col.sub')}</th>
|
||||
<th>{t('col.source')}</th>
|
||||
<th>{t('col.since')}</th>
|
||||
{isSuper && <th className="bo-r">{t('col.revenue')}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((c) => (
|
||||
<tr key={c.id} onClick={() => navigate(`/clients/${c.id}`)}>
|
||||
<td>
|
||||
<a href={`#/clients/${c.id}`} className="bo-who" style={{ textDecoration: 'none', color: 'inherit' }} onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar name={c.name} id={c.id} />
|
||||
<span style={{ minWidth: 0 }}>
|
||||
<span className="bo-who__name">{c.name}</span>
|
||||
<span className="bo-who__meta" style={{ display: 'block' }}>
|
||||
{[c.username && `@${c.username}`, c.phone].filter(Boolean).join(', ') || '—'}
|
||||
</span>
|
||||
{c.tags.length > 0 && (
|
||||
<span className="bo-tags" style={{ marginTop: 4 }}>
|
||||
{c.tags.map((tg) => (
|
||||
<span key={tg} className="bo-tag">
|
||||
{tg}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<PlanBadge plan={c.sub?.plan ?? null} t={t} />
|
||||
{c.sub && c.sub.source !== 'PAID' && <I name="gift" size={15} />}
|
||||
</div>
|
||||
{c.sub && <div className="bo-who__meta">{t('card.until', { date: date(c.sub.endsAt) })}</div>}
|
||||
</td>
|
||||
<td className="bo-small">{sourceLabel(t, c)}</td>
|
||||
<td className="bo-small bo-muted">{date(c.createdAt)}</td>
|
||||
{isSuper && <td className="bo-r bo-num">{c.revenue ? amd(c.revenue) : <span className="bo-faint">0</span>}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="bo-cards">
|
||||
{data.items.map((c) => (
|
||||
<a key={c.id} className="bo-ccard" href={`#/clients/${c.id}`}>
|
||||
<div className="bo-who">
|
||||
<Avatar name={c.name} id={c.id} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="bo-who__name">{c.name}</div>
|
||||
<div className="bo-who__meta">{sourceLabel(t, c)}</div>
|
||||
</div>
|
||||
{isSuper && c.revenue != null && <b className="bo-num">{amd(c.revenue)}</b>}
|
||||
</div>
|
||||
<div className="bo-ccard__row">
|
||||
<PlanBadge plan={c.sub?.plan ?? null} t={t} />
|
||||
{c.sub && <span>{t('card.until', { date: date(c.sub.endsAt) })}</span>}
|
||||
{c.tags.map((tg) => (
|
||||
<span key={tg} className="bo-tag">
|
||||
{tg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.total > data.pageSize && (
|
||||
<div className="bo-pager">
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled={page === 0} onClick={() => writeQuery(f, sort, page - 1)}>
|
||||
{t('clients.prev')}
|
||||
</button>
|
||||
<span className="bo-small bo-muted">{t('clients.page', { from, to, total: data.total })}</span>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled={to >= data.total} onClick={() => writeQuery(f, sort, page + 1)}>
|
||||
{t('clients.next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SaveSegment open={saveOpen} onClose={() => setSaveOpen(false)} filters={f} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveSegment({ open, onClose, filters }: { open: boolean; onClose: () => void; filters: Filters }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [name, setName] = useState('');
|
||||
const save = useMutation(
|
||||
trpc.bo.segments.save.mutationOptions({
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.segments.list.queryKey() });
|
||||
toast(t('segments.saved'));
|
||||
setName('');
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('segments.save')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" disabled={!name.trim() || save.isPending} onClick={() => save.mutate({ name: name.trim(), filters })}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label={t('segments.name')}>
|
||||
<input className="bo-input" value={name} onChange={(e) => setName(e.target.value)} autoFocus maxLength={60} />
|
||||
</Field>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTRPC } from '@barev/web';
|
||||
import type { Me } from '../App.tsx';
|
||||
import { useT, type BoKey } from '../i18n.tsx';
|
||||
import { I, OLD_ADMIN_URL, PlanBadge, Skeleton, sourceKeyLabel } from '../components/kit.tsx';
|
||||
|
||||
type Pair = { orders: number; subs: number };
|
||||
const total = (p: Pair) => p.orders + p.subs;
|
||||
|
||||
function greetingKey(): BoKey {
|
||||
const h = Number(new Intl.DateTimeFormat('en-GB', { hour: 'numeric', hour12: false, timeZone: 'Asia/Yerevan' }).format(new Date()));
|
||||
return h < 12 ? 'dash.morning' : h < 18 ? 'dash.day' : 'dash.evening';
|
||||
}
|
||||
|
||||
export function Dashboard({ me }: { me: Me }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const attention = useQuery({ ...trpc.bo.dashboard.attention.queryOptions(), refetchInterval: 30_000 });
|
||||
const ov = useQuery({ ...trpc.bo.dashboard.overview.queryOptions(), refetchInterval: 60_000 });
|
||||
const firstName = me.name.split(' ')[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="bo-greet">{t(greetingKey(), { name: firstName })}</h1>
|
||||
<Attention data={attention.data} />
|
||||
|
||||
{!ov.data ? (
|
||||
<div className="bo-grid">
|
||||
<div className="bo-span-12">
|
||||
<Skeleton h={260} />
|
||||
</div>
|
||||
<div className="bo-span-6">
|
||||
<Skeleton h={220} />
|
||||
</div>
|
||||
<div className="bo-span-6">
|
||||
<Skeleton h={220} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{ov.data.money && <Money m={ov.data.money} />}
|
||||
<div className="bo-grid">
|
||||
<Subs s={ov.data.subs} />
|
||||
<Growth g={ov.data.growth} />
|
||||
{ov.data.team && <TeamBlock team={ov.data.team} />}
|
||||
<Feed items={ov.data.feed} wide={!ov.data.team} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── требует внимания ─────
|
||||
|
||||
type Att = { unassigned: number; unreadChats: number; webRequests: number; leads: number; appRequests: number; lowRatings: number; expiring: number };
|
||||
const ATT: { key: keyof Att; href: string; urgent?: boolean; fill: string; ink: string }[] = [
|
||||
{ key: 'unassigned', href: '#/orders', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
|
||||
{ key: 'unreadChats', href: '#/chats', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
|
||||
{ key: 'webRequests', href: `${OLD_ADMIN_URL}#/requests`, fill: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
|
||||
{ key: 'leads', href: `${OLD_ADMIN_URL}#/leads`, fill: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)' },
|
||||
{ key: 'appRequests', href: `${OLD_ADMIN_URL}#/app-requests`, fill: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
|
||||
{ key: 'lowRatings', href: '#/orders', fill: 'var(--bc-pink)', ink: 'var(--bc-plum)' },
|
||||
{ key: 'expiring', href: '#/clients?expiring=7', fill: 'var(--bc-perks)', ink: 'var(--bc-perks-ink)' },
|
||||
];
|
||||
|
||||
function Attention({ data }: { data?: Att }) {
|
||||
const { t } = useT();
|
||||
if (!data) return <div className="bo-attn" style={{ minHeight: 58 }} />;
|
||||
const items = ATT.filter((a) => data[a.key] > 0);
|
||||
return (
|
||||
<section className="bo-attn" aria-label={t('dash.attention')}>
|
||||
{items.length === 0 ? (
|
||||
<span className="bo-attn__ok">
|
||||
<I name="check" size={18} />
|
||||
{t('dash.allClear')}
|
||||
</span>
|
||||
) : (
|
||||
items.map((a) => {
|
||||
const external = a.href.startsWith('http');
|
||||
return (
|
||||
<a key={a.key} className="bo-attn__item" href={a.href} {...(external ? { target: '_blank', rel: 'noreferrer' } : {})}>
|
||||
<span className="bo-attn__n" style={{ background: a.fill, color: a.ink }}>
|
||||
{data[a.key]}
|
||||
</span>
|
||||
{t(`att.${a.key}`)}
|
||||
</a>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── деньги ─────
|
||||
|
||||
function Delta({ cur, prev, label }: { cur: number; prev: number; label: string }) {
|
||||
if (prev <= 0) return null;
|
||||
const pct = Math.round(((cur - prev) / prev) * 100);
|
||||
const up = pct >= 0;
|
||||
return (
|
||||
<span className={`bo-delta bo-delta--${up ? 'up' : 'down'}`} title={label}>
|
||||
<I name={up ? 'arrowUp' : 'arrowDown'} size={13} stroke={2.4} />
|
||||
{Math.abs(pct)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Money({ m }: { m: { today: Pair; yesterday: Pair; week: Pair; prevWeek: Pair; month: Pair; prevMonth: Pair; series: ({ day: string } & Pair)[] } }) {
|
||||
const { t, amd, amdShort, locale } = useT();
|
||||
const periods = [
|
||||
{ label: t('dash.today'), cur: m.today, prev: m.yesterday, extra: t('dash.vsYesterday', { sum: amd(total(m.yesterday)) }) },
|
||||
{ label: t('dash.week'), cur: m.week, prev: m.prevWeek, extra: t('dash.vsPrev', { period: t('dash.week') }) },
|
||||
{ label: t('dash.month'), cur: m.month, prev: m.prevMonth, extra: t('dash.vsPrev', { period: t('dash.month') }) },
|
||||
];
|
||||
const max = Math.max(1, ...m.series.map(total));
|
||||
const dayFmt = new Intl.DateTimeFormat(locale === 'en' ? 'en-GB' : locale === 'hy' ? 'hy-AM' : 'ru-RU', { day: 'numeric', month: 'short' });
|
||||
const ORDERS = 'var(--bc-pink)';
|
||||
const SUBS = 'var(--bc-perks)';
|
||||
return (
|
||||
<section className="bo-money" aria-label={t('dash.money')}>
|
||||
<div className="bo-money__periods">
|
||||
{periods.map((p) => (
|
||||
<div key={p.label}>
|
||||
<div className="bo-money__label">{p.label}</div>
|
||||
<div className="bo-money__sum">{amd(total(p.cur))}</div>
|
||||
<div className="bo-money__split">
|
||||
<Delta cur={total(p.cur)} prev={total(p.prev)} label={p.extra} />
|
||||
<span>
|
||||
{t('dash.orders')} {amdShort(p.cur.orders)}
|
||||
</span>
|
||||
<span>
|
||||
{t('dash.subs')} {amdShort(p.cur.subs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bo-chart">
|
||||
<div className="bo-chart__bars" role="img" aria-label={t('dash.chart')}>
|
||||
{m.series.map((d) => {
|
||||
const date = dayFmt.format(new Date(`${d.day}T12:00:00+04:00`));
|
||||
return (
|
||||
<div key={d.day} className="bo-chart__day" data-tip={`${date}: ${amd(total(d))}`}>
|
||||
<span className="bo-chart__base" />
|
||||
{d.orders > 0 && <span style={{ height: `${(d.orders / max) * 100}%`, background: ORDERS }} />}
|
||||
{d.subs > 0 && <span style={{ height: `${(d.subs / max) * 100}%`, background: SUBS }} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="bo-chart__legend">
|
||||
<span>{t('dash.chart')}</span>
|
||||
<span style={{ display: 'flex', gap: 14 }}>
|
||||
<span className="bo-key" style={{ ['--key' as string]: ORDERS }}>
|
||||
{t('dash.orders')}
|
||||
</span>
|
||||
<span className="bo-key" style={{ ['--key' as string]: SUBS }}>
|
||||
{t('dash.subs')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── подписки ─────
|
||||
|
||||
interface SubsData {
|
||||
plus: number;
|
||||
elite: number;
|
||||
gifts: number;
|
||||
expiring: { clientId: string; name: string; plan: 'BAREV_PLUS' | 'ELITE'; endsAt: Date; source: string }[];
|
||||
giftClients: number;
|
||||
giftConverted: number;
|
||||
}
|
||||
|
||||
function Subs({ s }: { s: SubsData }) {
|
||||
const { t, date } = useT();
|
||||
const conv = s.giftClients ? Math.round((s.giftConverted / s.giftClients) * 100) : 0;
|
||||
return (
|
||||
<section className="bo-panel bo-span-7">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('dash.subsTitle')}</h2>
|
||||
<a className="bo-small bo-muted" href="#/clients?plan=any">
|
||||
{t('nav.clients')}
|
||||
</a>
|
||||
</div>
|
||||
<div className="bo-pair" style={{ marginBottom: 6 }}>
|
||||
<div>
|
||||
<PlanBadge plan="BAREV_PLUS" t={t} />
|
||||
<div className="bo-bignum" style={{ marginTop: 8 }}>
|
||||
{s.plus}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<PlanBadge plan="ELITE" t={t} />
|
||||
<div className="bo-bignum" style={{ marginTop: 8 }}>
|
||||
{s.elite}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="bo-label">{t('dash.conversion')}</span>
|
||||
<div className="bo-bignum" style={{ marginTop: 8 }}>
|
||||
{conv}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="bo-hint" style={{ marginBottom: 18 }}>
|
||||
{t('dash.gifts', { n: s.gifts })}. {t('dash.conversionHint', { paid: s.giftConverted, total: s.giftClients })}.
|
||||
</p>
|
||||
<h3 className="bo-h3" style={{ marginBottom: 4 }}>
|
||||
{t('dash.expiring')}
|
||||
</h3>
|
||||
{s.expiring.length === 0 ? (
|
||||
<p className="bo-hint">{t('dash.noExpiring')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{s.expiring.map((e) => (
|
||||
<li key={e.clientId + e.plan}>
|
||||
<PlanBadge plan={e.plan} t={t} />
|
||||
<a className="bo-grow" href={`#/clients/${e.clientId}`}>
|
||||
{e.name}
|
||||
</a>
|
||||
<span className="bo-small bo-muted">{date(e.endsAt)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── рост ─────
|
||||
|
||||
interface GrowthData {
|
||||
today: number;
|
||||
week: number;
|
||||
month: number;
|
||||
prevMonth: number;
|
||||
sources: { key: string; count: number }[];
|
||||
funnel: { leads: number; clients: number; paying: number };
|
||||
}
|
||||
|
||||
function Growth({ g }: { g: GrowthData }) {
|
||||
const { t } = useT();
|
||||
const maxSrc = Math.max(1, ...g.sources.map((s) => s.count));
|
||||
const steps = [
|
||||
{ key: 'funnel.leads' as const, n: g.funnel.leads, fill: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
|
||||
{ key: 'funnel.clients' as const, n: g.funnel.clients, fill: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
|
||||
{ key: 'funnel.paying' as const, n: g.funnel.paying, fill: 'var(--bc-home)', ink: 'var(--bc-home-ink)' },
|
||||
];
|
||||
const maxStep = Math.max(1, ...steps.map((s) => s.n));
|
||||
return (
|
||||
<section className="bo-panel bo-span-5">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('dash.growth')}</h2>
|
||||
</div>
|
||||
<span className="bo-label">{t('dash.newClients')}</span>
|
||||
<div className="bo-pair" style={{ margin: '6px 0 4px', alignItems: 'baseline' }}>
|
||||
<span>
|
||||
<span className="bo-bignum">{g.month}</span> <span className="bo-muted bo-small">{t('dash.month')}</span>
|
||||
</span>
|
||||
<span>
|
||||
<b className="bo-num" style={{ fontSize: 22 }}>
|
||||
{g.week}
|
||||
</b>{' '}
|
||||
<span className="bo-muted bo-small">{t('dash.week')}</span>
|
||||
</span>
|
||||
<span>
|
||||
<b className="bo-num" style={{ fontSize: 22 }}>
|
||||
{g.today}
|
||||
</b>{' '}
|
||||
<span className="bo-muted bo-small">{t('dash.today')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="bo-hint" style={{ marginBottom: 16 }}>
|
||||
{t('dash.prevMonth', { n: g.prevMonth })}
|
||||
</p>
|
||||
|
||||
<h3 className="bo-h3" style={{ marginBottom: 6 }}>
|
||||
{t('dash.sources')}
|
||||
</h3>
|
||||
{g.sources.length === 0 ? (
|
||||
<p className="bo-hint">{t('dash.empty')}</p>
|
||||
) : (
|
||||
g.sources.slice(0, 5).map((s) => (
|
||||
<div key={s.key} className="bo-bar">
|
||||
<span className="bo-grow bo-small">{sourceKeyLabel(t, s.key)}</span>
|
||||
<b className="bo-num bo-small">{s.count}</b>
|
||||
<div className="bo-bar__track">
|
||||
<div className="bo-bar__fill" style={{ width: `${(s.count / maxSrc) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<h3 className="bo-h3" style={{ margin: '18px 0 8px' }}>
|
||||
{t('dash.funnel')}
|
||||
</h3>
|
||||
<div className="bo-funnel">
|
||||
{steps.map((s) => (
|
||||
<div key={s.key} className="bo-funnel__step" style={{ ['--fill' as string]: s.fill, ['--ink' as string]: s.ink, width: `${Math.max(48, (s.n / maxStep) * 100)}%` }}>
|
||||
<span>{t(s.key)}</span>
|
||||
<b className="bo-num">{s.n}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── команда ─────
|
||||
|
||||
interface TeamData {
|
||||
topProviders: { id: string; name: string; avg: number; count: number }[];
|
||||
bottomProviders: { id: string; name: string; avg: number; count: number }[];
|
||||
topPartners: { id: string; name: string; scans: number }[];
|
||||
}
|
||||
|
||||
function TeamBlock({ team }: { team: TeamData }) {
|
||||
const { t } = useT();
|
||||
const prov = (p: TeamData['topProviders'][number]) => (
|
||||
<li key={p.id}>
|
||||
<span className="bo-grow">{p.name}</span>
|
||||
<span className="bo-small bo-muted">{t('dash.ratings', { n: p.count })}</span>
|
||||
<b className="bo-num">★ {p.avg.toFixed(1)}</b>
|
||||
</li>
|
||||
);
|
||||
return (
|
||||
<section className="bo-panel bo-span-5">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('dash.team')}</h2>
|
||||
</div>
|
||||
<h3 className="bo-h3">{t('dash.topProviders')}</h3>
|
||||
{team.topProviders.length ? <ul className="bo-list">{team.topProviders.map(prov)}</ul> : <p className="bo-hint">{t('dash.empty')}</p>}
|
||||
{team.bottomProviders.length > 0 && (
|
||||
<>
|
||||
<h3 className="bo-h3" style={{ marginTop: 14 }}>
|
||||
{t('dash.bottomProviders')}
|
||||
</h3>
|
||||
<ul className="bo-list">{team.bottomProviders.map(prov)}</ul>
|
||||
</>
|
||||
)}
|
||||
<h3 className="bo-h3" style={{ marginTop: 14 }}>
|
||||
{t('dash.topPartners')}
|
||||
</h3>
|
||||
{team.topPartners.length ? (
|
||||
<ul className="bo-list">
|
||||
{team.topPartners.map((p) => (
|
||||
<li key={p.id}>
|
||||
<span className="bo-grow">{p.name}</span>
|
||||
<span className="bo-small bo-muted">{t('dash.scans', { n: p.scans })}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="bo-hint">{t('dash.empty')}</p>
|
||||
)}
|
||||
<p className="bo-hint" style={{ marginTop: 14 }}>
|
||||
{t('dash.salesSoon')}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── лента ─────
|
||||
|
||||
type FeedItem =
|
||||
| { kind: 'subscription'; at: Date; clientId: string; who: string; plan: 'BAREV_PLUS' | 'ELITE'; source: string; amount: number | null }
|
||||
| { kind: 'payment'; at: Date; clientId: string; who: string; service: string; number: number; amount: number | null }
|
||||
| { kind: 'action'; at: Date; by: string | null; action: string; entityId: string | null };
|
||||
|
||||
function Feed({ items, wide }: { items: FeedItem[]; wide: boolean }) {
|
||||
const { t, dateTime, amd, shared } = useT();
|
||||
return (
|
||||
<section className={`bo-panel ${wide ? 'bo-span-12' : 'bo-span-7'}`}>
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('dash.feed')}</h2>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="bo-hint">{t('dash.empty')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{items.map((f, i) => (
|
||||
<li key={i}>
|
||||
<span className="bo-feed__time">{dateTime(f.at)}</span>
|
||||
{f.kind === 'subscription' && (
|
||||
<>
|
||||
<PlanBadge plan={f.plan} t={t} />
|
||||
<span className="bo-grow">
|
||||
<a href={`#/clients/${f.clientId}`}>{f.who}</a> <span className="bo-muted">· {t(`subSource.${f.source}`)}</span>
|
||||
</span>
|
||||
{f.amount != null && <b className="bo-num">{amd(f.amount)}</b>}
|
||||
</>
|
||||
)}
|
||||
{f.kind === 'payment' && (
|
||||
<>
|
||||
<span className="bo-grow">
|
||||
<a href={`#/clients/${f.clientId}`}>{f.who}</a> <span className="bo-muted">· {shared(`services.${f.service}.name`)} · {t('feed.payment', { number: f.number })}</span>
|
||||
</span>
|
||||
{f.amount != null && <b className="bo-num">{amd(f.amount)}</b>}
|
||||
</>
|
||||
)}
|
||||
{f.kind === 'action' && (
|
||||
<span className="bo-grow bo-muted">
|
||||
<b style={{ color: 'var(--bc-ink)' }}>{f.by ?? '—'}</b> {actionLabel(t, f.action)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function actionLabel(t: ReturnType<typeof useT>['t'], action: string) {
|
||||
const key = `audit.${action}`;
|
||||
const tr = t(key);
|
||||
return tr === key ? action : tr;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Logo } from '@barev/ui';
|
||||
import { useTRPC } from '@barev/web';
|
||||
import QRCode from 'qrcode';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BO_LOCALE_LABELS, BO_LOCALES, useT } from '../i18n.tsx';
|
||||
import { I } from '../components/kit.tsx';
|
||||
|
||||
/** Вход: ссылка в рабочий бот → бот подтверждает → страница опрашивает и получает сессию */
|
||||
export function Login() {
|
||||
const { t, locale, setLocale } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const start = useMutation(trpc.bo.auth.start.mutationOptions());
|
||||
const poll = useMutation(trpc.bo.auth.poll.mutationOptions());
|
||||
const devUsers = useQuery(trpc.bo.auth.devUsers.queryOptions());
|
||||
const devLogin = useMutation(trpc.bo.auth.devLogin.mutationOptions({ onSuccess: () => qc.invalidateQueries() }));
|
||||
const [qr, setQr] = useState<string>();
|
||||
const [expired, setExpired] = useState(false);
|
||||
|
||||
const req = start.data;
|
||||
useEffect(() => {
|
||||
start.mutate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!req) return;
|
||||
setExpired(false);
|
||||
QRCode.toDataURL(req.url, { margin: 0, width: 312, color: { dark: '#4a1028', light: '#ffffff' } }).then(setQr, () => setQr(undefined));
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const r = await poll.mutateAsync({ code: req.code, pollKey: req.pollKey });
|
||||
if (r.status === 'ok') {
|
||||
clearInterval(timer);
|
||||
await qc.invalidateQueries();
|
||||
} else if (r.status === 'expired') {
|
||||
clearInterval(timer);
|
||||
setExpired(true);
|
||||
}
|
||||
} catch {
|
||||
/* сеть моргнула — следующий опрос */
|
||||
}
|
||||
}, 2000);
|
||||
return () => clearInterval(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [req?.code]);
|
||||
|
||||
return (
|
||||
<div className="bo-login">
|
||||
<section className="bo-login__cover">
|
||||
<Logo size={24} color="#f6e9dc" />
|
||||
<p className="bo-login__word">
|
||||
Barev,
|
||||
<br />
|
||||
<i>{t('login.coverWord')}</i>
|
||||
</p>
|
||||
<div className="bo-langs" role="group" aria-label="Language">
|
||||
{BO_LOCALES.map((l) => (
|
||||
<button key={l} type="button" className="bo-lang" aria-pressed={l === locale} onClick={() => setLocale(l)}>
|
||||
{BO_LOCALE_LABELS[l]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bo-login__form">
|
||||
<h1 className="bo-title">{t('login.title')}</h1>
|
||||
<p className="bo-sub" style={{ marginTop: 0 }}>
|
||||
{t('login.lead')}
|
||||
</p>
|
||||
|
||||
{expired ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'flex-start' }}>
|
||||
<span className="bo-muted">{t('login.expired')}</span>
|
||||
<button type="button" className="bo-btn" onClick={() => start.mutate()}>
|
||||
{t('login.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<a className="bo-btn bo-btn--accent" style={{ alignSelf: 'flex-start', height: 50, padding: '0 24px' }} href={req?.url} target="_blank" rel="noreferrer" aria-disabled={!req}>
|
||||
<I name="telegram" size={18} />
|
||||
{t('login.open')}
|
||||
</a>
|
||||
{qr && (
|
||||
<div className="bo-qr-wrap" style={{ display: 'flex', gap: 18, alignItems: 'center' }}>
|
||||
<img className="bo-qr" src={qr} alt="" />
|
||||
<span className="bo-muted bo-small" style={{ maxWidth: 200 }}>
|
||||
{t('login.qr')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{req && (
|
||||
<span className="bo-wait" role="status">
|
||||
<span className="bo-pulse" />
|
||||
{t('login.waiting')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="bo-faint bo-small" style={{ margin: 0 }}>
|
||||
{t('login.noTelegram')}
|
||||
</p>
|
||||
|
||||
{!!devUsers.data?.length && (
|
||||
<div className="bo-dev">
|
||||
<div>
|
||||
<div className="bo-h3">{t('login.dev')}</div>
|
||||
<div className="bo-faint bo-small">{t('login.devHint')}</div>
|
||||
</div>
|
||||
<div className="bo-pills">
|
||||
{devUsers.data.map((u) => (
|
||||
<button key={u.id} type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => devLogin.mutate({ id: u.id })}>
|
||||
{u.name} · {u.roles.map((r) => t(`role.${r}`)).join(', ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useT } from '../i18n.tsx';
|
||||
import { I, OLD_ADMIN_URL } from '../components/kit.tsx';
|
||||
|
||||
export function Soon({ title, stage }: { title: string; stage: number }) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<h1 className="bo-title">{title}</h1>
|
||||
</div>
|
||||
<div className="bo-soon">
|
||||
<h2 className="bo-h2">{t('soon.title', { section: title })}</h2>
|
||||
<p className="bo-sub" style={{ marginBottom: 20 }}>
|
||||
{t('soon.text', { n: stage })}
|
||||
</p>
|
||||
<a className="bo-btn" href={OLD_ADMIN_URL} target="_blank" rel="noreferrer">
|
||||
{t('soon.open')}
|
||||
<I name="external" size={16} />
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
import { errText, useT, type BoKey } from '../i18n.tsx';
|
||||
import { Dialog, Field } from '../components/kit.tsx';
|
||||
|
||||
export type SubMode = 'gift' | 'sell' | 'extend' | 'cancel' | 'plan' | 'refund';
|
||||
export interface SubTarget {
|
||||
id: string;
|
||||
plan: 'BAREV_PLUS' | 'ELITE';
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
amount: number | null;
|
||||
refundAmount: number | null;
|
||||
}
|
||||
type Plan = 'BAREV_PLUS' | 'ELITE';
|
||||
type PayMethod = 'cash' | 'transfer' | 'card' | 'link';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const PERIODS = [
|
||||
{ days: 30, key: 'dlg.period1' },
|
||||
{ days: 90, key: 'dlg.period3' },
|
||||
{ days: 180, key: 'dlg.period6' },
|
||||
{ days: 365, key: 'dlg.period12' },
|
||||
] as const;
|
||||
|
||||
const TITLE: Record<SubMode, BoKey> = { gift: 'dlg.giftTitle', sell: 'dlg.sellTitle', extend: 'dlg.extendTitle', cancel: 'dlg.cancelTitle', plan: 'dlg.planTitle', refund: 'dlg.refundTitle' };
|
||||
const CONFIRM: Record<SubMode, BoKey> = { gift: 'dlg.giftConfirm', sell: 'dlg.sellConfirm', extend: 'dlg.extendConfirm', cancel: 'dlg.cancelConfirm', plan: 'dlg.planConfirm', refund: 'dlg.refundConfirm' };
|
||||
const DONE: Record<SubMode, BoKey> = { gift: 'done.gift', sell: 'done.sell', extend: 'done.extend', cancel: 'done.cancel', plan: 'done.changePlan', refund: 'done.refund' };
|
||||
|
||||
export function SubDialog({ clientId, mode, sub, current, onClose }: { clientId: string; mode: SubMode; sub?: SubTarget; current?: SubTarget; onClose: () => void }) {
|
||||
const { t, amd, date } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
const [plan, setPlan] = useState<Plan>(mode === 'plan' && sub ? (sub.plan === 'ELITE' ? 'BAREV_PLUS' : 'ELITE') : (current?.plan ?? 'BAREV_PLUS'));
|
||||
const [days, setDays] = useState(30);
|
||||
const [custom, setCustom] = useState(false);
|
||||
const [amount, setAmount] = useState('');
|
||||
const [method, setMethod] = useState<PayMethod>('cash');
|
||||
const [note, setNote] = useState('');
|
||||
const [delta, setDelta] = useState('30');
|
||||
const [refund, setRefund] = useState(sub?.amount ? String(sub.amount - (sub.refundAmount ?? 0)) : '');
|
||||
const [cancelToo, setCancelToo] = useState(true);
|
||||
|
||||
const opts = {
|
||||
onSuccess: () => {
|
||||
toast(t(DONE[mode]));
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.clients.get.queryKey({ id: clientId }) });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.clients.list.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.dashboard.overview.queryKey() });
|
||||
onClose();
|
||||
},
|
||||
onError: (e: unknown) => toast(errText(t, e), 'error'),
|
||||
};
|
||||
const gift = useMutation(trpc.bo.clients.gift.mutationOptions(opts));
|
||||
const sell = useMutation(trpc.bo.clients.sell.mutationOptions(opts));
|
||||
const extend = useMutation(trpc.bo.clients.extend.mutationOptions(opts));
|
||||
const cancel = useMutation(trpc.bo.clients.cancel.mutationOptions(opts));
|
||||
const changePlan = useMutation(trpc.bo.clients.changePlan.mutationOptions(opts));
|
||||
const refundM = useMutation(trpc.bo.clients.refund.mutationOptions(opts));
|
||||
const pending = [gift, sell, extend, cancel, changePlan, refundM].some((m) => m.isPending);
|
||||
|
||||
// новая подписка того же тарифа встаёт в очередь за текущей
|
||||
const queuedAfter = (mode === 'gift' || mode === 'sell') && current && current.plan === plan ? current.endsAt : null;
|
||||
const deltaN = Number(delta) || 0;
|
||||
const amountN = Number(amount.replace(/\s/g, ''));
|
||||
const refundN = Number(refund.replace(/\s/g, ''));
|
||||
|
||||
const valid =
|
||||
mode === 'gift'
|
||||
? days > 0
|
||||
: mode === 'sell'
|
||||
? days > 0 && amount !== '' && amountN >= 0 && method !== 'link'
|
||||
: mode === 'extend'
|
||||
? deltaN !== 0
|
||||
: mode === 'refund'
|
||||
? refundN > 0
|
||||
: true;
|
||||
|
||||
const submit = () => {
|
||||
const base = { id: clientId };
|
||||
if (mode === 'gift') gift.mutate({ ...base, plan, days, note: note || undefined });
|
||||
if (mode === 'sell') sell.mutate({ ...base, plan, days, amount: amountN, payMethod: method, note: note || undefined });
|
||||
if (mode === 'extend' && sub) extend.mutate({ ...base, subscriptionId: sub.id, days: deltaN });
|
||||
if (mode === 'cancel' && sub) cancel.mutate({ ...base, subscriptionId: sub.id, reason: note || undefined });
|
||||
if (mode === 'plan' && sub) changePlan.mutate({ ...base, subscriptionId: sub.id, plan });
|
||||
if (mode === 'refund' && sub) refundM.mutate({ ...base, subscriptionId: sub.id, amount: refundN, cancel: cancelToo });
|
||||
};
|
||||
|
||||
const planPicker = (
|
||||
<Field label={t('dlg.plan')}>
|
||||
<div className="bo-pills">
|
||||
{(['BAREV_PLUS', 'ELITE'] as const).map((p) => (
|
||||
<button key={p} type="button" className="bo-pill" aria-pressed={plan === p} onClick={() => setPlan(p)} disabled={mode === 'plan' && sub?.plan === p}>
|
||||
{t(`plan.${p}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={t(TITLE[mode])}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className={`bo-btn ${mode === 'cancel' || mode === 'refund' ? 'bo-btn--accent' : ''}`} disabled={!valid || pending} onClick={submit}>
|
||||
{t(CONFIRM[mode])}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{(mode === 'gift' || mode === 'sell') && (
|
||||
<>
|
||||
{mode === 'gift' && <p className="bo-hint">{t('dlg.giftHint')}</p>}
|
||||
{planPicker}
|
||||
<Field label={t('dlg.period')}>
|
||||
<div className="bo-pills">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.days}
|
||||
type="button"
|
||||
className="bo-pill"
|
||||
aria-pressed={!custom && days === p.days}
|
||||
onClick={() => {
|
||||
setCustom(false);
|
||||
setDays(p.days);
|
||||
}}
|
||||
>
|
||||
{t(p.key)}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="bo-pill" aria-pressed={custom} onClick={() => setCustom(true)}>
|
||||
{t('dlg.periodCustom')}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
{custom && (
|
||||
<Field label={t('dlg.days')}>
|
||||
<input className="bo-input" type="number" min={1} max={3660} value={days} onChange={(e) => setDays(Math.max(0, Number(e.target.value)))} />
|
||||
</Field>
|
||||
)}
|
||||
{queuedAfter && <p className="bo-hint">{t('card.upcoming', { date: date(queuedAfter) })}</p>}
|
||||
{mode === 'sell' && (
|
||||
<>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('dlg.amount')}>
|
||||
<input className="bo-input" inputMode="numeric" value={amount} onChange={(e) => setAmount(e.target.value.replace(/[^\d\s]/g, ''))} />
|
||||
</Field>
|
||||
<Field label={t('dlg.method')}>
|
||||
<select className="bo-select" value={method} onChange={(e) => setMethod(e.target.value as PayMethod)}>
|
||||
{(['cash', 'transfer', 'card', 'link'] as const).map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{t(`pay.${m}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
{method === 'link' && <p className="bo-hint">{t('dlg.linkSoon')}</p>}
|
||||
</>
|
||||
)}
|
||||
<Field label={t('dlg.note')}>
|
||||
<input className="bo-input" value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'extend' && sub && (
|
||||
<>
|
||||
<Field label={t('dlg.days')} hint={t('dlg.extendHint')}>
|
||||
<input className="bo-input" type="number" value={delta} onChange={(e) => setDelta(e.target.value)} autoFocus />
|
||||
</Field>
|
||||
<div className="bo-pills">
|
||||
{[7, 30, 90].map((n) => (
|
||||
<button key={n} type="button" className="bo-pill" onClick={() => setDelta(String(n))} aria-pressed={deltaN === n}>
|
||||
+{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{deltaN !== 0 && <p style={{ margin: 0, fontWeight: 600 }}>{t('dlg.newEnd', { date: date(new Date(sub.endsAt.getTime() + deltaN * DAY)) })}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'plan' && planPicker}
|
||||
|
||||
{mode === 'cancel' && (
|
||||
<>
|
||||
<p className="bo-hint">{t('dlg.cancelHint')}</p>
|
||||
<Field label={t('dlg.reason')}>
|
||||
<input className="bo-input" value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} autoFocus />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'refund' && sub && (
|
||||
<>
|
||||
<p className="bo-hint">{t('dlg.refundPaid', { sum: amd(sub.amount ?? 0), back: amd(sub.refundAmount ?? 0) })}</p>
|
||||
<Field label={t('dlg.amount')}>
|
||||
<input className="bo-input" inputMode="numeric" value={refund} onChange={(e) => setRefund(e.target.value.replace(/[^\d\s]/g, ''))} autoFocus />
|
||||
</Field>
|
||||
<label className="bo-check">
|
||||
<input type="checkbox" checked={cancelToo} onChange={(e) => setCancelToo(e.target.checked)} />
|
||||
{t('dlg.refundCancel')}
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
import type { Me, Role } from '../App.tsx';
|
||||
import { errText, useT } from '../i18n.tsx';
|
||||
import { Avatar, Dialog, Field, I, Skeleton } from '../components/kit.tsx';
|
||||
|
||||
const ROLES: Role[] = ['SUPERADMIN', 'SUPPORT', 'CONCIERGE', 'SALES'];
|
||||
|
||||
export function Team({ me }: { me: Me }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const list = useQuery(trpc.bo.team.list.queryOptions());
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: trpc.bo.team.list.queryKey() });
|
||||
const update = useMutation(trpc.bo.team.update.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') }));
|
||||
const [link, setLink] = useState<{ name: string; url: string } | null>(null);
|
||||
const linkUrl = useMutation(trpc.bo.team.linkUrl.mutationOptions({ onError: (e) => toast(errText(t, e), 'error') }));
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<div>
|
||||
<h1 className="bo-title">{t('team.title')}</h1>
|
||||
<p className="bo-sub">{t('team.lead')}</p>
|
||||
</div>
|
||||
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setAdding(true)}>
|
||||
<I name="plus" size={16} />
|
||||
{t('team.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!list.data ? (
|
||||
<Skeleton h={240} />
|
||||
) : (
|
||||
<div className="bo-team">
|
||||
{list.data.map((m) => {
|
||||
const roles = m.roles as Role[];
|
||||
const self = m.id === me.id;
|
||||
return (
|
||||
<div key={m.id} className={`bo-member${m.active ? '' : ' bo-member--off'}`}>
|
||||
<Avatar name={m.name} id={m.id} size={44} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{m.name} {self && <span className="bo-faint bo-small">({t('team.you')})</span>}
|
||||
</div>
|
||||
<div className="bo-small" style={{ color: m.telegramLinked ? 'var(--bc-ok)' : 'var(--bc-ink-3)' }}>
|
||||
{!m.active ? t('team.disabled') : m.telegramLinked ? t('team.tgLinked') : t('team.tgNotLinked')}
|
||||
</div>
|
||||
<div className="bo-roles" role="group" aria-label={t('team.roles')}>
|
||||
{ROLES.map((r) => {
|
||||
const on = roles.includes(r);
|
||||
return (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className="bo-role"
|
||||
aria-pressed={on}
|
||||
title={t(`roleHint.${r}`)}
|
||||
disabled={update.isPending || (self && r === 'SUPERADMIN')}
|
||||
onClick={() => {
|
||||
const next = on ? roles.filter((x) => x !== r) : [...roles, r];
|
||||
if (next.length) update.mutate({ id: m.id, roles: next }, { onSuccess: () => toast(t('team.saved')) });
|
||||
}}
|
||||
>
|
||||
{on && <I name="check" size={14} stroke={2.4} />}
|
||||
{t(`role.${r}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{m.active && (
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn bo-btn--ghost bo-btn--sm"
|
||||
onClick={() => linkUrl.mutate({ id: m.id }, { onSuccess: (r) => setLink({ name: m.name, url: r.url }) })}
|
||||
>
|
||||
<I name="telegram" size={15} />
|
||||
{t('team.newLink')}
|
||||
</button>
|
||||
)}
|
||||
{!self && (
|
||||
<button type="button" className={`bo-btn bo-btn--sm ${m.active ? 'bo-btn--danger' : 'bo-btn--ghost'}`} onClick={() => update.mutate({ id: m.id, active: !m.active })}>
|
||||
{m.active ? t('team.disable') : t('team.enable')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddMember
|
||||
open={adding}
|
||||
onClose={() => setAdding(false)}
|
||||
onCreated={(name, url) => {
|
||||
setAdding(false);
|
||||
refresh();
|
||||
setLink({ name, url });
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={!!link}
|
||||
onClose={() => setLink(null)}
|
||||
title={link ? t('team.linkTitle', { name: link.name }) : ''}
|
||||
footer={
|
||||
<button type="button" className="bo-btn" onClick={() => setLink(null)}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<p className="bo-hint">{t('team.linkHint')}</p>
|
||||
{link && (
|
||||
<div className="bo-linkbox">
|
||||
<span style={{ flex: 1 }}>{link.url}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn bo-btn--sm"
|
||||
onClick={() => navigator.clipboard.writeText(link.url).then(() => toast(t('ui.copied')), () => undefined)}
|
||||
>
|
||||
{t('ui.copy')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddMember({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: (name: string, url: string) => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const [name, setName] = useState('');
|
||||
const [roles, setRoles] = useState<Role[]>(['SUPPORT']);
|
||||
const create = useMutation(
|
||||
trpc.bo.team.create.mutationOptions({
|
||||
onSuccess: (r) => {
|
||||
onCreated(r.name, r.linkUrl);
|
||||
setName('');
|
||||
setRoles(['SUPPORT']);
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('team.add')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" disabled={!name.trim() || !roles.length || create.isPending} onClick={() => create.mutate({ name: name.trim(), roles })}>
|
||||
{t('team.create')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label={t('team.name')}>
|
||||
<input className="bo-input" value={name} onChange={(e) => setName(e.target.value)} maxLength={80} autoFocus />
|
||||
</Field>
|
||||
<Field label={t('team.roles')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{ROLES.map((r) => (
|
||||
<label key={r} className="bo-check">
|
||||
<input type="checkbox" checked={roles.includes(r)} onChange={(e) => setRoles(e.target.checked ? [...roles, r] : roles.filter((x) => x !== r))} />
|
||||
<span>
|
||||
<b style={{ fontWeight: 600 }}>{t(`role.${r}`)}</b> <span className="bo-muted">— {t(`roleHint.${r}`)}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "types": ["node", "vite/client"] },
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Новая админка. API проксируется — cookie-сессия на том же origin.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: true,
|
||||
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000' },
|
||||
},
|
||||
build: { target: 'es2022', sourcemap: true },
|
||||
});
|
||||
Reference in new issue
Block a user