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:
1 parent
45d7512d2b
commit
d452824c62
314 files changed
+44394
-121
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user