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

+403
View File
@@ -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>
);
}
+384
View File
@@ -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>
);
}
+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;
}
+125
View File
@@ -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>
);
}
+23
View File
@@ -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>
</>
);
}
+217
View File
@@ -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>
);
}
+185
View File
@@ -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>
);
}