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>
);
}