Lead Radar: исходный код
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
9749da5ae7
201 files changed
+97685
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user