Orders section gets tabs: Orders / Relocations / Requests (concierge sees the last two). Relocation list with statuses, plan progress and current step; relocation card with per-client steps (status, client comment, rename, add, remove), documents, quiz answers, team note, write/call. Requests: website cleaning requests and app requests (membership, call back) with notes, create order, turn on membership. Dashboard links point here instead of the old admin. Fix: leads with an unanswered 'already moved?' (NULL) were skipped by 'moved not true' filters — plan sync and linking to the app client. Client bot: after an order is done, asks for 1-5 stars; low rating asks what went wrong, the reply is saved as the rating comment and goes to the team chat. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
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 (
|
|
<>
|
|
<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?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 <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;
|
|
}
|