Lead Radar: исходный код

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuriandClaude Opus 5.5 committed 2026-10-25 23:11:04 +04:00
commit 9749da5ae7
201 files changed
+97685

No files matched your search

+468
View File
@@ -0,0 +1,468 @@
import { CURRENCIES, DEAL_TYPES, PROPERTY_TYPES, REGIONS } from '@lr/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Bot, Copy, ExternalLink, EyeOff, MessageCircle, Pencil, Phone, Send, Star, Trash2, Undo2, UserX } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { api, type Me } from '../api.ts';
import { TelegramLink } from '../pages/Settings.tsx';
import { ErrorNote } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { useMembers, usePipeline, type LeadCard } from './api.ts';
import { CommissionBlock } from './commission.tsx';
import { Features } from './household.tsx';
import { useCrm } from './i18n.ts';
import { CommentBox, DealNote, Dialog, Drawer, History, NextAction, StatusChip, useSpecs, useStatusChange } from './parts.tsx';
/** Насколько номеру можно доверять: совпал у двух компаний — 80 %, у трёх и больше — 100 %. */
function Confidence({ v }: { v: { votes: number; confidence: number | null } }) {
const { c } = useCrm();
return (
<span className={`block text-xs ${v.confidence === 100 ? 'text-capture' : v.confidence ? 'text-warn' : 'text-muted'}`}>
{v.confidence ? c.sh.confidence(v.confidence, v.votes) : c.sh.unconfirmed}
</span>
);
}
const EDIT_FIELDS = ['title', 'description', 'dealType', 'propertyType', 'region', 'district', 'address', 'price', 'currency', 'rooms', 'area', 'floor', 'floorsTotal', 'authorName'] as const;
/** Правка данных объекта. Отправляются только изменённые поля; пустое поле стирает значение. */
function LeadEditDialog({ lead, onClose }: { lead: LeadCard; onClose: () => void }) {
const { c, t } = useCrm();
const d = c.dlg;
const initial = Object.fromEntries(EDIT_FIELDS.map((k) => [k, lead[k] == null ? '' : String(lead[k])])) as Record<(typeof EDIT_FIELDS)[number], string>;
const [f, setF] = useState(initial);
const save = useAction(
() => api(`/leads/${lead.id}`, { method: 'PATCH', body: Object.fromEntries(EDIT_FIELDS.filter((k) => f[k] !== initial[k]).map((k) => [k, f[k]])) }),
['lead', lead.id],
);
const changed = EDIT_FIELDS.some((k) => f[k] !== initial[k]);
const input = (key: (typeof EDIT_FIELDS)[number], label: string, props: React.InputHTMLAttributes<HTMLInputElement> = {}) => (
<div>
<label className="label" htmlFor={`e-${key}`}>{label}</label>
<input id={`e-${key}`} className="field" value={f[key]} onChange={(e) => setF({ ...f, [key]: e.target.value })} {...props} />
</div>
);
const select = (key: (typeof EDIT_FIELDS)[number], label: string, options: [string, string][]) => (
<div>
<label className="label" htmlFor={`e-${key}`}>{label}</label>
<select id={`e-${key}`} className="field" value={f[key]} onChange={(e) => setF({ ...f, [key]: e.target.value })}>
{options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
);
// Значение из объявления, которого нет в списке (например, регион), остаётся выбранным.
const withCurrent = (key: (typeof EDIT_FIELDS)[number], options: [string, string][]) =>
initial[key] && !options.some(([v]) => v === initial[key]) ? [...options, [initial[key], initial[key]] as [string, string]] : options;
return (
<Dialog title={c.card.editTitle} onClose={onClose} wide>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); if (changed) save.mutate(undefined, { onSuccess: onClose }); else onClose(); }}>
{input('title', d.title, { required: true, maxLength: 200 })}
<div className="grid gap-4 sm:grid-cols-2">
{select('dealType', c.filters.dealType, [['', '—'], ...DEAL_TYPES.map((x) => [x, c.deal[x]] as [string, string])])}
{select('propertyType', c.filters.types, withCurrent('propertyType', [['', '—'], ...PROPERTY_TYPES.map((x) => [x, c.ptype[x]] as [string, string])]))}
{select('region', c.filters.region, withCurrent('region', [['', '—'], ...REGIONS.map((r) => [r, r] as [string, string])]))}
{input('district', c.col.place, { maxLength: 60 })}
</div>
{input('address', d.address, { maxLength: 300 })}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{input('price', d.price, { type: 'number', min: 0, step: 'any' })}
{select('currency', d.currency, withCurrent('currency', CURRENCIES.map((x) => [x, x])))}
{input('rooms', d.rooms, { type: 'number', min: 0, max: 50 })}
{input('area', d.area, { type: 'number', min: 0, step: 'any' })}
{input('floor', d.floor, { type: 'number', min: -5, max: 200 })}
{input('floorsTotal', d.floors, { type: 'number', min: 0, max: 200 })}
</div>
{input('authorName', d.author, { maxLength: 100 })}
<div>
<label className="label" htmlFor="e-description">{d.description}</label>
<textarea id="e-description" className="field min-h-32" maxLength={10_000} value={f.description} onChange={(e) => setF({ ...f, description: e.target.value })} />
</div>
<ErrorNote error={save.error} />
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={onClose}>{t.common.cancel}</button>
<button className="btn btn-primary" disabled={save.isPending}>{t.common.save}</button>
</div>
</form>
</Dialog>
);
}
/**
* Не удалось отправить объект в бот. Telegram не привязан — инструкция и кнопка привязки;
* бот заблокирован или чат не начат — как это исправить; остальное — обычная ошибка.
*/
function BotSendError({ error, linked }: { error: unknown; linked: boolean }) {
const { c } = useCrm();
const code = (error as { code?: string } | null)?.code;
if (code === 'TELEGRAM_NOT_LINKED') {
return (
<div className="mt-3 space-y-2 rounded-xl border border-warn/30 bg-warn/10 px-3.5 py-3 text-sm" role="alert">
<p className="font-medium text-warn">{c.dp.linkTitle}</p>
<p>{c.dp.linkText}</p>
<TelegramLink linked={linked} />
</div>
);
}
if (code === 'BOT_CHAT_UNAVAILABLE') {
return <p className="mt-3 rounded-xl border border-warn/30 bg-warn/10 px-3.5 py-2.5 text-sm text-warn" role="alert">{c.dp.blockedText}</p>;
}
return <ErrorNote error={error} />;
}
/** Карточка лида: данные объявления, контакт, ответственный, статус, напоминание, комментарии, история. */
export function LeadDrawer({ id, me, onClose }: { id: number; me: Me; onClose: () => void }) {
const { c, t, err, price, date } = useCrm();
const specs = useSpecs();
const pipeline = usePipeline();
const isManager = me.user.role === 'manager';
const members = useMembers();
const lead = useQuery({
queryKey: ['lead', id],
queryFn: () => api<LeadCard>(`/leads/${id}`),
retry: false,
// Пока фото догружаются в фоне, карточка перечитывается сама: первые две минуты часто, потом реже —
// в очереди за ридером объект может ждать дольше. Не дольше ~15 минут.
refetchInterval: (q) =>
q.state.data?.gallery !== 'pending' ? false : q.state.dataUpdateCount < 24 ? 5_000 : q.state.dataUpdateCount < 64 ? 20_000 : false,
});
const savePhone = useAction((phone: string) => post('phone', { phone }), ['lead', id]);
const [phoneDraft, setPhoneDraft] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const status = useStatusChange(pipeline.data);
const post = (path: string, body?: object) => api(`/leads/${id}/${path}`, { method: 'POST', body: body ?? {} });
const take = useAction(() => post('take'));
const release = useAction(() => post('release'));
const agent = useAction(() => post('agent'));
const gone = useAction(() => post('removed'));
const remove = useAction(() => api(`/leads/${id}`, { method: 'DELETE' }), ['leads']);
const toGroup = useAction(() => post('group'));
// «Отправить в бот»: объект приходит в личный чат с ботом тому, кто нажал.
const toBot = useAction(() => post('bot'), ['none']);
// Избранное меняет и карточку, и списки — обновляем всё.
const favorite = useAction((on: boolean) => post('favorite', { on }));
const [editing, setEditing] = useState(false);
const assign = useAction((userId: number) => post('assign', { userId }));
const comment = useAction((text: string) => post('comments', { text }));
const next = useAction((v: { at: string | null; note: string | null }) => post('next-action', v));
// К клиенту объект прикрепляет тот, кто ведёт клиента: в списке — свои клиенты в работе.
const clientOptions = useQuery({ queryKey: ['clients', 'options'], queryFn: () => api<{ id: number; name: string }[]>('/clients-options') });
const attach = useAction((clientId: number) => api(`/clients/${clientId}/leads`, { method: 'POST', body: { leadId: id } }));
const l = lead.data;
// Открытая карточка считается просмотренной: список перечитывается, чтобы снять отметку «Не просмотрен».
const qc = useQueryClient();
const loaded = Boolean(l);
useEffect(() => {
if (loaded) void qc.invalidateQueries({ queryKey: ['leads'] });
}, [loaded, qc]);
const readOnly = me.company?.access === 'restricted' || me.company?.access === 'disabled';
const mine = l?.assigneeId === me.user.id;
const canWork = !readOnly && Boolean(l) && l!.assigneeId !== null && (mine || isManager);
const inPool = l?.assigneeId === null;
// Данные объекта правит ответственный и руководитель (руководитель — и объект из пула).
const canEdit = !readOnly && Boolean(l) && (mine || isManager);
const current = pipeline.data?.statuses.find((s) => s.id === l?.statusId);
const error = take.error ?? release.error ?? agent.error ?? gone.error ?? assign.error ?? comment.error ?? next.error ?? attach.error ?? status.error;
const freeClients = clientOptions.data?.filter((o) => !l?.clients.some((x) => x.id === o.id)) ?? [];
const phoneDigits = l?.phone?.replace(/\D/g, '');
const ownVariant = l?.phoneVariants.find((v) => v.phone === l.phone);
const otherVariants = l?.phoneVariants.filter((v) => v.phone !== l.phone) ?? [];
return (
<Drawer
label={l?.title ?? ''}
onClose={onClose}
header={
<>
<StatusChip status={current} />
{l?.source === 'manual' && <span className="chip bg-raised text-muted">{c.card.manual}</span>}
{l?.departmentName && <span className="chip bg-raised text-muted">{l.departmentName}</span>}
</>
}
>
{lead.isPending && <p className="p-5 text-muted">{t.common.loading}</p>}
{lead.isError && <p className="p-5 text-danger">{err(lead.error)}</p>}
{l && (
<div className="flex-1 space-y-6 overflow-y-auto px-5 py-5">
{l.photos.length > 0 && (
<div>
<div className="relative">
{l.photos.length > 1 && <span className="absolute right-2 top-2 z-10 rounded-md bg-ink/75 px-2 py-0.5 text-xs tabular-nums">1 / {l.photos.length} →</span>}
<div className="flex snap-x snap-mandatory gap-1 overflow-x-auto rounded-xl bg-ink">
{l.photos.map((src, i) => (
<img key={src} src={src} alt="" loading={i ? 'lazy' : 'eager'} referrerPolicy="no-referrer" onError={(e) => { e.currentTarget.style.display = 'none'; }} className="aspect-[16/10] w-full shrink-0 snap-center object-cover" />
))}
</div>
</div>
{l.gallery === 'pending' && <p className="mt-1.5 text-xs text-muted" role="status">{c.sh.photosLoading}</p>}
</div>
)}
<div>
<p className="text-2xl font-semibold tabular-nums tracking-tight">{price(l.price, l.currency, l.dealType)}</p>
<h2 className="mt-1 text-lg font-medium leading-snug">{l.title}</h2>
<p className="mt-1 text-muted">{[l.district ?? l.region, l.address].filter(Boolean).join(', ')}</p>
<p className="mt-1 text-sm text-muted">
{[l.dealType && c.deal[l.dealType], l.propertyType && (c.ptype as Record<string, string>)[l.propertyType], specs(l)].filter(Boolean).join(' · ')}
</p>
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2">
{l.url && (
<a href={l.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1.5 text-signal hover:underline">
<ExternalLink size={15} aria-hidden="true" />
{c.card.onListAm}
</a>
)}
{canEdit && (
<button type="button" className="inline-flex cursor-pointer items-center gap-1.5 text-muted hover:text-text" onClick={() => setEditing(true)}>
<Pencil size={15} aria-hidden="true" />
{c.dlg.edit}
</button>
)}
{/* Избранное — своё у каждого пользователя. */}
<button
type="button" aria-pressed={l.favorite} disabled={favorite.isPending}
className={`inline-flex cursor-pointer items-center gap-1.5 hover:text-text ${l.favorite ? 'text-warn' : 'text-muted'}`}
onClick={() => favorite.mutate(!l.favorite)}
>
<Star size={15} aria-hidden="true" fill={l.favorite ? 'currentColor' : 'none'} />
{l.favorite ? c.dp.removeFav : c.dp.addFav}
</button>
{/* Объект в группу компании в Telegram — у компаний с настроенным ботом. */}
{me.company?.groupBot ? (
<button
type="button" className="inline-flex cursor-pointer items-center gap-1.5 text-signal hover:underline disabled:cursor-default disabled:opacity-60"
disabled={readOnly || toGroup.isPending} onClick={() => toGroup.mutate()}
>
<Send size={15} aria-hidden="true" />
{toGroup.isPending ? c.x.sendingTg : toGroup.isSuccess ? c.x.sentTg : c.x.sendTg}
</button>
) : (
<span className="inline-flex items-center gap-1.5 text-sm text-muted"><Send size={15} aria-hidden="true" />{c.x.botNotSet}</span>
)}
<button
type="button" className="inline-flex cursor-pointer items-center gap-1.5 text-signal hover:underline disabled:cursor-default disabled:opacity-60"
disabled={toBot.isPending} onClick={() => toBot.mutate()}
>
<Bot size={15} aria-hidden="true" />
{toBot.isPending ? c.dp.sendingBot : toBot.isSuccess ? c.dp.sentBot : c.dp.sendBot}
</button>
</div>
<ErrorNote error={toGroup.error} />
<BotSendError error={toBot.error} linked={me.user.telegramLinked} />
</div>
{l.agentPhrase && (
<p className="rounded-xl border border-warn/30 bg-warn/10 px-3.5 py-2.5 text-sm text-warn">
{c.x.phrase[l.agentPhrase.kind]}: «{l.agentPhrase.phrase}»
</p>
)}
{l.removedAt && (
<p className="rounded-xl border border-warn/30 bg-warn/10 px-3.5 py-2.5 text-sm text-warn">{c.ar.removed(date(l.removedAt))}</p>
)}
{l.previous && (
<section className="rounded-xl border border-warn/30 bg-warn/10 px-3.5 py-3 text-sm">
<h3 className="font-medium text-warn">{c.card.previous}</h3>
<p className="mt-1">
{l.previous.id ? <Link to={`/leads/${l.previous.id}`} className="text-signal hover:underline">{l.previous.title}</Link> : l.previous.title}
</p>
<p className="mt-1 text-muted">
{[
`${c.card.status}: ${l.previous.status}`,
l.previous.assigneeName && `${c.card.assignee}: ${l.previous.assigneeName}`,
l.previous.removedAt && c.ar.removed(date(l.previous.removedAt)),
].filter(Boolean).join(' · ')}
</p>
</section>
)}
<ErrorNote error={error} />
{l.deal && (
<DealNote
deal={l.deal}
canEdit={isManager && !readOnly}
pair={l.deal.clientId && <>{c.cl.one}: <Link to={`/clients/${l.deal.clientId}`} className="text-signal hover:underline">{l.deal.clientName}</Link></>}
/>
)}
{inPool && !readOnly && (
<button className="btn btn-primary w-full" disabled={take.isPending} onClick={() => take.mutate()}>{c.take}</button>
)}
<section>
<h3 className="label">{c.card.contact}</h3>
{l.phone ? (
<div className="space-y-3">
<p className="text-lg font-medium tabular-nums">{l.authorName ? `${l.authorName} · ` : ''}{l.phone}</p>
{ownVariant && <Confidence v={ownVariant} />}
<div className="flex flex-wrap gap-2">
<a className="btn btn-primary btn-sm" href={`tel:${l.phone}`}><Phone size={15} aria-hidden="true" />{c.card.call}</a>
<a className="btn btn-ghost btn-sm" href={`https://wa.me/${phoneDigits}`} target="_blank" rel="noopener noreferrer"><MessageCircle size={15} aria-hidden="true" />WhatsApp</a>
<a className="btn btn-ghost btn-sm" href={`https://t.me/+${phoneDigits}`} target="_blank" rel="noopener noreferrer"><Send size={15} aria-hidden="true" />Telegram</a>
</div>
</div>
) : (
<p className="text-sm text-muted">{l.phoneHidden ? c.card.phoneHidden : c.card.noPhone}</p>
)}
{/* Номер агент берёт из объявления сам и вносит сюда: дальше он хранится в карточке. */}
{canWork && (phoneDraft !== null || !l.phone) && (
<form
className="mt-3 space-y-2"
onSubmit={(e) => { e.preventDefault(); savePhone.mutate(phoneDraft ?? '', { onSuccess: () => setPhoneDraft(null) }); }}
>
<label className="label" htmlFor="l-phone">{c.sh.ownerPhone}</label>
<div className="flex gap-2">
<input id="l-phone" type="tel" inputMode="tel" className="field" maxLength={40} placeholder="091 123 456" value={phoneDraft ?? ''} onChange={(e) => setPhoneDraft(e.target.value)} />
<button className="btn btn-primary shrink-0" disabled={savePhone.isPending || (!phoneDraft && !l.phone)}>{c.sh.savePhone}</button>
</div>
{!l.phone && <p className="text-xs text-muted">{c.sh.ownerPhoneHint}</p>}
<ErrorNote error={savePhone.error} />
</form>
)}
{/* Другие варианты номера по этому объявлению: чем больше агентов указали один и тот же, тем он надёжнее. */}
{otherVariants.length > 0 && (
<div className="mt-4">
<p className="text-sm text-muted">{c.sh.variants}</p>
<ul className="mt-2 space-y-2">
{otherVariants.map((v) => (
<li key={v.phone} className="flex flex-wrap items-center justify-between gap-2 rounded-xl bg-raised px-3 py-2">
<span>
<span className="font-medium tabular-nums">{v.phone}</span>
<Confidence v={v} />
</span>
{canWork && <button type="button" className="btn btn-ghost btn-sm" disabled={savePhone.isPending} onClick={() => savePhone.mutate(v.phone)}>{c.sh.useVariant}</button>}
</li>
))}
</ul>
<p className="mt-1.5 text-xs text-muted">{c.sh.variantsHint}</p>
</div>
)}
{canWork && l.phone && phoneDraft === null && (
<button type="button" className="mt-3 cursor-pointer text-sm text-muted hover:text-text" onClick={() => setPhoneDraft(l.phone ?? '')}>{c.sh.changePhone}</button>
)}
</section>
{canWork && <CommissionBlock kind="leads" id={id} info={l.commission} rent={l.dealType === 'rent'} canEdit={canWork} />}
{l.clientText && (
<section>
<div className="mb-1.5 flex items-center justify-between gap-3">
<h3 className="label mb-0">{c.sh.clientText}</h3>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={async () => { await navigator.clipboard.writeText(l.clientText); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
>
<Copy size={14} aria-hidden="true" />
{copied ? t.common.copied : c.sh.copyText}
</button>
</div>
<p className="whitespace-pre-wrap break-words rounded-xl bg-raised p-3.5 text-sm leading-relaxed">{l.clientText}</p>
</section>
)}
{(l.description || l.features) && (
<details>
<summary className="cursor-pointer text-sm text-muted hover:text-text">{c.sh.original}</summary>
<div className="mt-3 space-y-4">
{l.description && <p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-muted">{l.description}</p>}
<Features features={l.features} />
</div>
</details>
)}
{!inPool && (
<section className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label" htmlFor="l-status">{c.card.status}</label>
<select id="l-status" className="field" disabled={!canWork} value={l.statusId} onChange={(e) => status.request(id, Number(e.target.value))}>
{pipeline.data?.statuses.filter((s) => s.code !== 'new').map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="label" htmlFor="l-assignee">{c.card.assignee}</label>
{isManager && !readOnly ? (
<select id="l-assignee" className="field" value={l.assigneeId ?? ''} onChange={(e) => assign.mutate(Number(e.target.value))}>
{members.data?.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
) : (
<p id="l-assignee" className="flex min-h-11 items-center">{l.assigneeName ?? '—'}</p>
)}
</div>
</section>
)}
{inPool && isManager && !readOnly && (
<div>
<label className="label" htmlFor="l-assign">{c.card.assignee}</label>
<select id="l-assign" className="field" value="" onChange={(e) => e.target.value && assign.mutate(Number(e.target.value))}>
<option value="">{c.pool}</option>
{members.data?.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
)}
{(l.clients.length > 0 || (!readOnly && freeClients.length > 0)) && (
<section>
<h3 className="label">{c.card.clients}</h3>
<ul className="space-y-1.5">
{l.clients.map((x) => (
<li key={x.id} className="flex items-center justify-between gap-3 rounded-xl bg-raised px-3 py-2">
<Link to={`/clients/${x.id}`} className="min-w-0 truncate hover:underline">{x.name}</Link>
<span className={`chip shrink-0 ${x.state === 'shown' ? 'bg-capture/15 text-capture' : 'bg-surface text-muted'}`}>{c.cl.state[x.state]}</span>
</li>
))}
</ul>
{!readOnly && freeClients.length > 0 && (
<select className="field mt-2" aria-label={c.card.attach} value="" disabled={attach.isPending} onChange={(e) => e.target.value && attach.mutate(Number(e.target.value))}>
<option value="">{c.card.attach}</option>
{freeClients.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
)}
</section>
)}
{canWork && <NextAction at={l.nextActionAt} note={l.nextActionNote} pending={next.isPending} onSave={(v) => next.mutate(v)} />}
{canWork && <CommentBox pending={comment.isPending} onSave={(text, done) => comment.mutate(text, { onSuccess: done })} />}
<History events={l.events} />
{!readOnly && (inPool || canWork) && current?.code !== 'rejected' && current?.code !== 'deal' && (
<div className="flex flex-wrap gap-2 border-t border-line pt-5">
{!inPool && (
<button className="btn btn-ghost btn-sm" disabled={release.isPending} onClick={() => confirm(c.card.confirmPool) && release.mutate()}>
<Undo2 size={15} aria-hidden="true" />{c.card.toPool}
</button>
)}
{l.source === 'listam' && !l.removedAt && (
<button className="btn btn-ghost btn-sm" disabled={gone.isPending} onClick={() => confirm(c.card.confirmGone) && gone.mutate()}>
<EyeOff size={15} aria-hidden="true" />{c.card.gone}
</button>
)}
<button className="btn btn-danger btn-sm" disabled={agent.isPending} onClick={() => confirm(c.card.confirmAgent) && agent.mutate()}>
<UserX size={15} aria-hidden="true" />{c.card.isAgent}
</button>
</div>
)}
{/* Удалить карточку может руководитель; карточку со сделкой — только суперадмин. */}
{isManager && !readOnly && (
<div className="space-y-2 border-t border-line pt-5">
{l.deal && !me.user.isSuperadmin ? (
<p className="text-sm text-muted">{c.x.delDeal}</p>
) : (
<button className="btn btn-ghost btn-sm text-danger" disabled={remove.isPending} onClick={() => confirm(c.x.delConfirm) && remove.mutate(undefined, { onSuccess: onClose })}>
<Trash2 size={15} aria-hidden="true" />{c.x.del}
</button>
)}
<ErrorNote error={remove.error} />
</div>
)}
</div>
)}
{status.dialog}
{editing && l && <LeadEditDialog lead={l} onClose={() => setEditing(false)} />}
</Drawer>
);
}