Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:23:32 +04:00
1 parent 45d7512d2b
commit d452824c62
314 files changed
+44394 -121

No files matched your search

+423
View File
@@ -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;
}