Files
BarevClub/apps/backoffice/src/pages/LeadCard.tsx
T
zabudko-ruandClaude Opus 5.5 ac786cc5cc Backoffice stage 4: partners (agencies, venues) and sales CRM
- CRM: configurable pipeline (default: New → Contact → Meeting → Negotiation →
  Connected / Lost), drag-and-drop board, lead card with call/meeting/note history,
  photos and files, tasks with Telegram reminders at the due time, map of venues
  (OpenStreetMap via Leaflet), lost reason. Sales reps see only their own leads.
- "Connected" in one click: a venue becomes a club card partner with a one-time
  owner link to the work bot; an agency gets a code and a client link. The sales rep
  is recorded on the partner for the bonus.
- Sales bonus formula in settings (per venue, per agency, % of revenue from the
  rep's agency clients for N months); defaults are 0 until the owner sets them.
- Agencies (superadmin): clients, gift subscriptions, reward by month, payouts,
  client link, edit. Venues: offers by membership level (5 languages), staff,
  card checks, 8-week activity, owner link, map point.
- Schema: SalesStage/SalesLead/SalesActivity/SalesTask; salesId on Agency and
  Partner; lat/lng/phone/note on Partner; agencyId on Payout.
- Tests for stage rules, conversion, task reminders and bonus; dev:crm-demo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 03:02:03 +04:00

355 lines
15 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, useToast, useTRPC } from '@barev/web';
import { useRef, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { Avatar, I, Skeleton } from '../components/kit.tsx';
import { MapView, parseCoords, STAGE_COLORS } from '../components/map.tsx';
import { ConvertDialog, LeadDialog, LostDialog, TaskDialog } from './Crm.tsx';
import { CopyLink } from './Agencies.tsx';
type ActType = 'CALL' | 'MEETING' | 'NOTE';
const ACT_ICON: Record<string, 'phone' | 'clients' | 'journal' | 'file' | 'swap' | 'check'> = { CALL: 'phone', MEETING: 'clients', NOTE: 'journal', FILE: 'file', STAGE: 'swap', WON: 'check' };
export function LeadCard({ id, me }: { id: string; me: Me }) {
const { t, date, dateTime } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const lead = useQuery(trpc.bo.crm.get.queryOptions({ id }));
const stages = useQuery(trpc.bo.crm.stages.queryOptions());
const [dlg, setDlg] = useState<'edit' | 'task' | 'convert' | { lost: string } | null>(null);
const refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.crm.get.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.crm.board.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.crm.summary.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.crm.tasks.queryKey() });
};
const onError = (e: unknown) => toast(errText(t, e), 'error');
const move = useMutation(trpc.bo.crm.move.mutationOptions({ onSuccess: refresh, onError }));
const toggleTask = useMutation(trpc.bo.crm.setTaskDone.mutationOptions({ onSuccess: refresh, onError }));
const update = useMutation(trpc.bo.crm.update.mutationOptions({ onSuccess: refresh, onError }));
const remove = useMutation(trpc.bo.crm.remove.mutationOptions({ onSuccess: () => navigate('/crm'), onError }));
const [coords, setCoords] = useState('');
const isSuper = me.roles.includes('SUPERADMIN');
const back = (
<a className="bo-back" href="#/crm">
<I name="back" size={16} />
{t('crm.back')}
</a>
);
if (lead.isError)
return (
<>
{back}
<div className="bo-panel bo-empty">{errText(t, lead.error)}</div>
</>
);
if (!lead.data || !stages.data)
return (
<div className="bo-col">
<Skeleton h={90} />
<Skeleton h={300} />
</div>
);
const d = lead.data;
const converted = !!(d.partner || d.agency);
const tel = d.phone?.replace(/[^\d+]/g, '');
const tgName = d.telegram?.replace(/^@/, '').replace(/^https?:\/\/t\.me\//, '');
const now = Date.now();
return (
<>
{back}
<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 12px', alignItems: 'center' }}>
<span className={`bo-chip bo-chip--${d.kind === 'AGENCY' ? 'SUPPORT' : 'provider'}`}>{t(`crm.kind1.${d.kind}`)}</span>
{d.category && <span>{t(`venue.cat.${d.category}`)}</span>}
<span>{d.owner ? t('crm.ownerIs', { name: d.owner.name }) : t('crm.noOwner')}</span>
<span>{t('crm.created', { date: date(d.createdAt) })}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{tel && (
<a className="bo-btn" href={`tel:${tel}`}>
<I name="phone" size={16} />
{t('ord.call')}
</a>
)}
{tgName && (
<a className="bo-btn bo-btn--ghost" href={`https://t.me/${tgName}`} target="_blank" rel="noreferrer">
<I name="telegram" size={16} />
Telegram
</a>
)}
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setDlg('edit')}>
{t('prv.edit')}
</button>
</div>
</header>
{/* этапы: клик — перевести */}
<ol className="bo-steps bo-steps--click" aria-label={t('crm.stage')}>
{stages.data.map((s) => {
const cur = s.id === d.stageId;
return (
<li key={s.id} data-state={cur ? 'now' : 'next'} style={cur ? { borderTopColor: STAGE_COLORS[s.color], background: STAGE_COLORS[s.color] } : undefined}>
<button
type="button"
disabled={cur || move.isPending}
onClick={() => {
if (s.kind === 'WON' && !converted) return setDlg('convert');
if (s.kind === 'LOST') return setDlg({ lost: s.id });
move.mutate({ id, stageId: s.id });
}}
>
{s.name}
</button>
</li>
);
})}
</ol>
{d.lostReason && <div className="bo-banner" style={{ background: 'var(--bc-pink)', color: 'var(--bc-plum)' }}>{t('crm.lostBanner', { reason: d.lostReason })}</div>}
<div className="bo-client-grid">
<div className="bo-col">
<Composer leadId={id} onDone={refresh} />
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('crm.history')}</h2>
</div>
{d.activities.length === 0 ? (
<p className="bo-hint">{t('crm.noHistory')}</p>
) : (
<ol className="bo-acts">
{d.activities.map((a) => (
<li key={a.id} data-type={a.type}>
<span className="bo-acts__icon">
<I name={ACT_ICON[a.type] ?? 'journal'} size={16} />
</span>
<span className="bo-grow" style={{ minWidth: 0 }}>
<span className="bo-small bo-faint">
{t(`crm.act.${a.type}`)} · {a.author ?? '—'} · {dateTime(a.at)}
</span>
{a.text && <span className="bo-acts__text">{a.text}</span>}
{a.file &&
(a.file.mime?.startsWith('image/') ? (
<a href={`/files/bo/lead-file/${a.id}`} target="_blank" rel="noreferrer">
<img className="bo-acts__img" src={`/files/bo/lead-file/${a.id}`} alt={a.file.name ?? ''} loading="lazy" />
</a>
) : (
<a className="bo-msg__file" style={{ margin: '6px 0 0', background: 'var(--bo-soft)' }} href={`/files/bo/lead-file/${a.id}`} target="_blank" rel="noreferrer">
<I name="file" size={20} />
<span className="bo-msg__fname">{a.file.name}</span>
</a>
))}
</span>
</li>
))}
</ol>
)}
</section>
</div>
<div className="bo-col">
<section className={`bo-panel${converted ? ' bo-wonbox' : ''}`}>
<div className="bo-panel__head">
<h2 className="bo-h2">{converted ? t('crm.connected') : t('crm.connect')}</h2>
</div>
{d.partner ? (
<>
<p className="bo-hint">{d.partner.ownerLinked ? t('crm.ownerLinked') : t('crm.ownerNotYet')}</p>
{!d.partner.ownerLinked && d.partner.ownerInviteUrl && (
<div style={{ marginBottom: 10 }}>
<CopyLink url={d.partner.ownerInviteUrl} />
</div>
)}
{isSuper && (
<a className="bo-btn bo-btn--sm" href={`#/venues/${d.partner.id}`}>
{t('crm.openVenue')}
</a>
)}
</>
) : d.agency ? (
<>
<p className="bo-hint">{t('crm.agencyInfo', { code: d.agency.code, n: d.agency.clients })}</p>
{isSuper && (
<a className="bo-btn bo-btn--sm" href={`#/agencies/${d.agency.id}`}>
{t('crm.openAgency')}
</a>
)}
</>
) : (
<>
<p className="bo-hint">{d.kind === 'VENUE' ? t('crm.convertVenueHint') : t('crm.convertAgencyHint')}</p>
<button type="button" className="bo-btn bo-btn--accent bo-btn--sm" onClick={() => setDlg('convert')}>
<I name="check" size={16} />
{t('crm.convert')}
</button>
</>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('crm.tasks')}</h2>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg('task')}>
<I name="plus" size={16} />
{t('crm.addTask')}
</button>
</div>
{d.tasks.length === 0 ? (
<p className="bo-hint">{t('crm.noLeadTasks')}</p>
) : (
<ul className="bo-tasks">
{d.tasks.map((x) => {
const overdue = !x.doneAt && x.dueAt.getTime() < now;
return (
<li key={x.id} data-overdue={overdue || undefined} data-done={!!x.doneAt || undefined}>
<label className="bo-tasks__check">
<input type="checkbox" checked={!!x.doneAt} onChange={(e) => toggleTask.mutate({ id: x.id, done: e.target.checked })} aria-label={x.title} />
</label>
<span className="bo-grow">
<b>{x.title}</b>
</span>
<span className="bo-tasks__due">{dateTime(x.dueAt)}</span>
</li>
);
})}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('crm.contacts')}</h2>
</div>
<dl className="bo-kv">
<dt>{t('crm.contactName')}</dt>
<dd>{d.contactName ?? '—'}</dd>
<dt>{t('chat.phone')}</dt>
<dd>{d.phone ? <a href={`tel:${tel}`}>{d.phone}</a> : '—'}</dd>
<dt>Telegram</dt>
<dd>{tgName ? <a href={`https://t.me/${tgName}`} target="_blank" rel="noreferrer">@{tgName}</a> : '—'}</dd>
<dt>{t('crm.address')}</dt>
<dd>{d.address ?? '—'}</dd>
</dl>
{d.note && <p className="bo-side-panel__note" style={{ marginTop: 12 }}>{d.note}</p>}
{d.kind === 'VENUE' && (
<div style={{ marginTop: 14 }}>
{d.lat != null && d.lng != null ? (
<MapView points={[{ id: d.id, lat: d.lat, lng: d.lng, title: d.name, color: '#b8235a', strong: true }]} height={200} focus={[d.lat, d.lng]} />
) : (
<p className="bo-hint">{t('crm.noCoords')}</p>
)}
<form
style={{ display: 'flex', gap: 8, marginTop: 8 }}
onSubmit={(e) => {
e.preventDefault();
const c = parseCoords(coords);
if (!c) return toast(t('crm.coordsBad'), 'error');
update.mutate({ id, lat: c.lat, lng: c.lng });
setCoords('');
}}
>
<input className="bo-input" value={coords} onChange={(e) => setCoords(e.target.value)} placeholder={t('crm.coordsPh')} style={{ minHeight: 36, padding: '6px 12px' }} />
<button type="submit" className="bo-btn bo-btn--sm" disabled={!coords.trim()}>
{t('ui.save')}
</button>
</form>
</div>
)}
</section>
{!converted && (
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-danger)', alignSelf: 'flex-start' }} onClick={() => confirm(t('crm.removeConfirm')) && remove.mutate({ id })}>
{t('crm.remove')}
</button>
)}
</div>
</div>
{dlg === 'edit' && <LeadDialog me={me} lead={d} onClose={() => setDlg(null)} />}
{dlg === 'task' && <TaskDialog leadId={id} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg === 'convert' && <ConvertDialog leadId={id} canOpen={isSuper} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg && typeof dlg === 'object' && <LostDialog leadId={id} stageId={dlg.lost} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
}
/** Записать контакт: звонок / встреча / заметка, можно с фото или файлом */
function Composer({ leadId, onDone }: { leadId: string; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const [type, setType] = useState<ActType>('CALL');
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const file = useRef<HTMLInputElement>(null);
const add = useMutation(
trpc.bo.crm.addActivity.mutationOptions({
onSuccess: () => {
setText('');
onDone();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
const upload = async (f: File) => {
if (f.size > 10 * 1024 * 1024) return toast(t('chat.fileTooBig'), 'error');
setBusy(true);
try {
const form = new FormData();
form.set('text', text);
form.set('file', f);
const res = await fetch(`/files/bo/lead/${leadId}`, { method: 'POST', body: form, credentials: 'include' });
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? String(res.status));
setText('');
onDone();
} catch (e) {
toast(errText(t, e), 'error');
} finally {
setBusy(false);
}
};
return (
<section className="bo-panel">
<div className="bo-composer__tabs" role="tablist">
{(['CALL', 'MEETING', 'NOTE'] as const).map((x) => (
<button key={x} type="button" role="tab" aria-selected={type === x} onClick={() => setType(x)}>
<I name={ACT_ICON[x]!} size={13} />
{t(`crm.act.${x}`)}
</button>
))}
</div>
<textarea className="bo-textarea" style={{ minHeight: 76 }} value={text} maxLength={4000} onChange={(e) => setText(e.target.value)} placeholder={t(`crm.actPh.${type}`)} />
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button type="button" className="bo-btn bo-btn--sm" disabled={!text.trim() || add.isPending} onClick={() => add.mutate({ id: leadId, type, text })}>
{t('crm.addAct')}
</button>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled={busy} onClick={() => file.current?.click()}>
<I name="clip" size={16} />
{t('crm.attach')}
</button>
<input
ref={file}
type="file"
hidden
accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt,.zip"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = '';
if (f) void upload(f);
}}
/>
</div>
</section>
);
}