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 = { 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 = ( {t('crm.back')} ); if (lead.isError) return ( <> {back}
{errText(t, lead.error)}
); if (!lead.data || !stages.data) return (
); 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}

{d.name}

{t(`crm.kind1.${d.kind}`)} {d.category && {t(`venue.cat.${d.category}`)}} {d.owner ? t('crm.ownerIs', { name: d.owner.name }) : t('crm.noOwner')} {t('crm.created', { date: date(d.createdAt) })}
{tel && ( {t('ord.call')} )} {tgName && ( Telegram )}
{/* этапы: клик — перевести */}
    {stages.data.map((s) => { const cur = s.id === d.stageId; return (
  1. ); })}
{d.lostReason &&
{t('crm.lostBanner', { reason: d.lostReason })}
}

{t('crm.history')}

{d.activities.length === 0 ? (

{t('crm.noHistory')}

) : (
    {d.activities.map((a) => (
  1. {t(`crm.act.${a.type}`)} · {a.author ?? '—'} · {dateTime(a.at)} {a.text && {a.text}} {a.file && (a.file.mime?.startsWith('image/') ? ( {a.file.name ) : ( {a.file.name} ))}
  2. ))}
)}

{converted ? t('crm.connected') : t('crm.connect')}

{d.partner ? ( <>

{d.partner.ownerLinked ? t('crm.ownerLinked') : t('crm.ownerNotYet')}

{!d.partner.ownerLinked && d.partner.ownerInviteUrl && (
)} {isSuper && ( {t('crm.openVenue')} )} ) : d.agency ? ( <>

{t('crm.agencyInfo', { code: d.agency.code, n: d.agency.clients })}

{isSuper && ( {t('crm.openAgency')} )} ) : ( <>

{d.kind === 'VENUE' ? t('crm.convertVenueHint') : t('crm.convertAgencyHint')}

)}

{t('crm.tasks')}

{d.tasks.length === 0 ? (

{t('crm.noLeadTasks')}

) : (
    {d.tasks.map((x) => { const overdue = !x.doneAt && x.dueAt.getTime() < now; return (
  • {x.title} {dateTime(x.dueAt)}
  • ); })}
)}

{t('crm.contacts')}

{t('crm.contactName')}
{d.contactName ?? '—'}
{t('chat.phone')}
{d.phone ? {d.phone} : '—'}
Telegram
{tgName ? @{tgName} : '—'}
{t('crm.address')}
{d.address ?? '—'}
{d.note &&

{d.note}

} {d.kind === 'VENUE' && (
{d.lat != null && d.lng != null ? ( ) : (

{t('crm.noCoords')}

)}
{ e.preventDefault(); const c = parseCoords(coords); if (!c) return toast(t('crm.coordsBad'), 'error'); update.mutate({ id, lat: c.lat, lng: c.lng }); setCoords(''); }} > setCoords(e.target.value)} placeholder={t('crm.coordsPh')} style={{ minHeight: 36, padding: '6px 12px' }} />
)}
{!converted && ( )}
{dlg === 'edit' && setDlg(null)} />} {dlg === 'task' && setDlg(null)} onDone={refresh} />} {dlg === 'convert' && setDlg(null)} onDone={refresh} />} {dlg && typeof dlg === 'object' && 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('CALL'); const [text, setText] = useState(''); const [busy, setBusy] = useState(false); const file = useRef(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 (
{(['CALL', 'MEETING', 'NOTE'] as const).map((x) => ( ))}