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, 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 (
<>
{t(greetingKey(), { name: firstName })}
{!ov.data ? (
) : (
<>
{ov.data.money && }
{ov.data.team && }
>
)}
>
);
}
// ───── требует внимания ─────
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?group=new', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ key: 'unreadChats', href: '#/chats', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ key: 'webRequests', href: '#/requests', fill: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
{ key: 'leads', href: '#/moves', fill: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)' },
{ key: 'appRequests', href: '#/requests', fill: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
{ key: 'lowRatings', href: '#/providers', 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 ;
const items = ATT.filter((a) => data[a.key] > 0);
return (
{items.length === 0 ? (
{t('dash.allClear')}
) : (
items.map((a) => {
const external = a.href.startsWith('http');
return (
{data[a.key]}
{t(`att.${a.key}`)}
);
})
)}
);
}
// ───── деньги ─────
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 (
{Math.abs(pct)}%
);
}
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 (
{periods.map((p) => (
{p.label}
{amd(total(p.cur))}
{t('dash.orders')} {amdShort(p.cur.orders)}
{t('dash.subs')} {amdShort(p.cur.subs)}
))}
{m.series.map((d) => {
const date = dayFmt.format(new Date(`${d.day}T12:00:00+04:00`));
return (
{d.orders > 0 && }
{d.subs > 0 && }
);
})}
{t('dash.chart')}
{t('dash.orders')}
{t('dash.subs')}
);
}
// ───── подписки ─────
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 (
{t('dash.conversion')}
{conv}%
{t('dash.gifts', { n: s.gifts })}. {t('dash.conversionHint', { paid: s.giftConverted, total: s.giftClients })}.
{t('dash.expiring')}
{s.expiring.length === 0 ? (
{t('dash.noExpiring')}
) : (
{s.expiring.map((e) => (
-
{e.name}
{date(e.endsAt)}
))}
)}
);
}
// ───── рост ─────
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 (
{t('dash.growth')}
{t('dash.newClients')}
{g.month} {t('dash.month')}
{g.week}
{' '}
{t('dash.week')}
{g.today}
{' '}
{t('dash.today')}
{t('dash.prevMonth', { n: g.prevMonth })}
{t('dash.sources')}
{g.sources.length === 0 ? (
{t('dash.empty')}
) : (
g.sources.slice(0, 5).map((s) => (
{sourceKeyLabel(t, s.key)}
{s.count}
))
)}
{t('dash.funnel')}
{steps.map((s) => (
{t(s.key)}
{s.n}
))}
);
}
// ───── команда ─────
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]) => (
{p.name}
{t('dash.ratings', { n: p.count })}
★ {p.avg.toFixed(1)}
);
return (
{t('dash.team')}
{t('dash.topProviders')}
{team.topProviders.length ? {team.topProviders.map(prov)}
: {t('dash.empty')}
}
{team.bottomProviders.length > 0 && (
<>
{t('dash.bottomProviders')}
{team.bottomProviders.map(prov)}
>
)}
{t('dash.topPartners')}
{team.topPartners.length ? (
{team.topPartners.map((p) => (
-
{p.name}
{t('dash.scans', { n: p.scans })}
))}
) : (
{t('dash.empty')}
)}
{t('dash.salesSoon')}
);
}
// ───── лента ─────
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 (
{t('dash.feed')}
{items.length === 0 ? (
{t('dash.empty')}
) : (
{items.map((f, i) => (
-
{dateTime(f.at)}
{f.kind === 'subscription' && (
<>
{f.who} · {t(`subSource.${f.source}`)}
{f.amount != null && {amd(f.amount)}}
>
)}
{f.kind === 'payment' && (
<>
{f.who} · {shared(`services.${f.service}.name`)} · {t('feed.payment', { number: f.number })}
{f.amount != null && {amd(f.amount)}}
>
)}
{f.kind === 'action' && (
{f.by ?? '—'} {actionLabel(t, f.action)}
)}
))}
)}
);
}
export function actionLabel(t: ReturnType['t'], action: string) {
const key = `audit.${action}`;
const tr = t(key);
return tr === key ? action : tr;
}