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>
This commit is contained in:
1 parent
a848bb13e2
commit
ac786cc5cc
30 files changed
+4311
-11
No files matched your search
@@ -0,0 +1,354 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user