Files
BarevClub/apps/backoffice/src/pages/ClientCard.tsx
T

404 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}