Files
CRM-LeadRadar/apps/web/src/crm/LeadsPage.tsx
T
yuriandClaude Opus 5.5 9749da5ae7 Lead Radar: исходный код
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:11:04 +04:00

459 lines
24 KiB
TypeScript

import { CURRENCIES, DEAL_TYPES, PROPERTY_TYPES, REGIONS, YEREVAN_DISTRICTS } from '@lr/shared';
import { useQuery } from '@tanstack/react-query';
import { Columns3, EyeOff, Link2, List, Plus, Search, SlidersHorizontal, Star } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
import { api, type Me } from '../api.ts';
import { ErrorNote, PageTitle } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { qs, useDepartments, useFilters, useMembers, usePipeline, useRates, type Lead } from './api.ts';
import { useCrm } from './i18n.ts';
import { Chips } from './FiltersPage.tsx';
import { LeadDrawer } from './LeadDrawer.tsx';
import { Dialog, Kanban, StatusChip, useSpecs, useStatusChange } from './parts.tsx';
type Tab = 'pool' | 'mine' | 'all';
function ManualLeadDialog({ onClose }: { onClose: (id?: number) => void }) {
const { c, t } = useCrm();
const d = c.dlg;
const [f, setF] = useState<Record<string, string>>({ currency: 'USD', dealType: 'sale', propertyType: 'apartment' });
const create = useAction(() => {
const body = Object.fromEntries(Object.entries(f).filter(([, v]) => v !== ''));
return api<{ id: number }>('/leads', { method: 'POST', body });
});
const input = (key: string, label: string, props: React.InputHTMLAttributes<HTMLInputElement> = {}) => (
<div>
<label className="label" htmlFor={`m-${key}`}>{label}</label>
<input id={`m-${key}`} className="field" value={f[key] ?? ''} onChange={(e) => setF({ ...f, [key]: e.target.value })} {...props} />
</div>
);
const select = (key: string, label: string, options: [string, string][]) => (
<div>
<label className="label" htmlFor={`m-${key}`}>{label}</label>
<select id={`m-${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>
);
return (
<Dialog title={c.add} onClose={() => onClose()} wide>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); create.mutate(undefined, { onSuccess: (r) => onClose(r.id) }); }}>
{input('title', d.title, { required: true, maxLength: 200, autoFocus: true })}
<div className="grid gap-4 sm:grid-cols-2">
{select('dealType', c.filters.dealType, DEAL_TYPES.map((x) => [x, c.deal[x]]))}
{select('propertyType', c.filters.types, PROPERTY_TYPES.map((x) => [x, c.ptype[x]]))}
{select('region', c.filters.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 })}
{select('currency', d.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' })}
</div>
<div className="grid gap-4 sm:grid-cols-2">
{input('authorName', d.author, { maxLength: 100 })}
{input('phone', d.phone, { type: 'tel', maxLength: 40 })}
</div>
{input('url', d.link, { type: 'url', maxLength: 1000 })}
<ErrorNote error={create.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={create.isPending}>{t.common.create}</button>
</div>
</form>
</Dialog>
);
}
type ByUrlResult = { result: 'taken' | 'created' | 'mine'; id: number } | { result: 'busy'; id: number | null; assigneeName: string | null };
/**
* Объект по ссылке на List.am: свободный из CRM закрепляется за агентом, занятый — показывает чей,
* отсутствующий — скачивается с List.am и заводится на агента.
*/
function ByUrlDialog({ onClose, onOpen }: { onClose: () => void; onOpen: (id: number) => void }) {
const { c, t } = useCrm();
const [url, setUrl] = useState('');
const check = useAction(() => api<ByUrlResult>('/leads/by-url', { method: 'POST', body: { url } }), ['leads']);
const r = check.data;
return (
<Dialog title={c.x.byUrlTitle} onClose={onClose}>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); check.mutate(); }}>
<div>
<label className="label" htmlFor="by-url">{c.x.byUrlLabel}</label>
<input
id="by-url" type="url" inputMode="url" className="field" required maxLength={1000} autoFocus
placeholder="https://www.list.am/item/…" value={url} onChange={(e) => { setUrl(e.target.value); check.reset(); }}
/>
<p className="mt-1.5 text-xs text-muted">{c.x.byUrlHint}</p>
</div>
{check.isPending && <p className="text-sm text-muted" role="status">{c.x.byUrlLoading}</p>}
<ErrorNote error={check.error} />
{r && (
<p role="status" className={`rounded-xl border px-3.5 py-2.5 text-sm ${r.result === 'busy' ? 'border-warn/30 bg-warn/10 text-warn' : 'border-capture/30 bg-capture/10'}`}>
{r.result === 'busy' ? c.x.busy(r.assigneeName) : c.x[r.result]}
</p>
)}
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={onClose}>{t.common.cancel}</button>
{r?.id ? (
<button type="button" className="btn btn-primary" onClick={() => onOpen(r.id!)}>{c.x.open}</button>
) : (
<button className="btn btn-primary" disabled={check.isPending || Boolean(r)}>{c.x.byUrlGo}</button>
)}
</div>
</form>
</Dialog>
);
}
/** Звёздочка «Избранное» в строке списка. */
function FavoriteButton({ lead, className = '' }: { lead: Lead; className?: string }) {
const { c } = useCrm();
const toggle = useAction(() => api(`/leads/${lead.id}/favorite`, { method: 'POST', body: { on: !lead.favorite } }), ['leads']);
return (
<button
type="button" aria-pressed={lead.favorite} aria-label={lead.favorite ? c.dp.removeFav : c.dp.addFav} title={lead.favorite ? c.dp.removeFav : c.dp.addFav}
disabled={toggle.isPending}
className={`flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-raised ${lead.favorite ? 'text-warn' : 'text-muted hover:text-text'} ${className}`}
onClick={(e) => { e.stopPropagation(); toggle.mutate(); }}
>
<Star size={16} aria-hidden="true" fill={lead.favorite ? 'currentColor' : 'none'} />
</button>
);
}
/** Точка у объекта, карточку которого пользователь ещё не открывал. */
function UnseenDot({ lead }: { lead: Lead }) {
const { c } = useCrm();
if (lead.seen !== false) return null;
return <span className="mr-1.5 inline-block size-2 shrink-0 rounded-full bg-signal align-middle" role="img" aria-label={c.dp.unseenMark} title={c.dp.unseenMark} />;
}
type PriceCurrency = 'AMD' | 'USD';
function Thumb({ lead, className }: { lead: Lead; className: string }) {
return lead.photos[0] ? (
<img src={lead.photos[0]} alt="" loading="lazy" referrerPolicy="no-referrer" className={`${className} rounded-lg bg-raised object-cover`} />
) : (
<div className={`${className} rounded-lg bg-raised`} aria-hidden="true" />
);
}
export function LeadsPage({ me }: { me: Me }) {
const { c, t, date, price } = useCrm();
const specs = useSpecs();
const navigate = useNavigate();
const { id: openId } = useParams();
const isManager = me.user.role === 'manager';
const seeAll = isManager || Boolean(me.company?.settings.agentsSeeAll);
const readOnly = me.company?.access === 'restricted' || me.company?.access === 'disabled';
const [tab, setTab] = useState<Tab>('pool');
const [view, setView] = useState<'table' | 'kanban'>(() => (localStorage.getItem('lr_leads_view') === 'kanban' ? 'kanban' : 'table'));
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [showFilters, setShowFilters] = useState(false);
const empty = { statusId: '', assigneeId: '', priceFrom: '', priceTo: '', departmentId: '' };
const [f, setF] = useState(empty);
// Валюта границ цены: цены объектов в другой валюте сервер пересчитывает по курсу.
const [currency, setCurrency] = useState<PriceCurrency>(() => (localStorage.getItem('lr_price_currency') === 'AMD' ? 'AMD' : 'USD'));
const [favorite, setFavorite] = useState(false);
const [unseen, setUnseen] = useState(false);
const rates = useRates();
const departments = useDepartments();
const [districts, setDistricts] = useState<string[]>([]);
const [adding, setAdding] = useState(false);
const [byUrl, setByUrl] = useState(false);
// Поиск срабатывает через паузу после ввода.
useEffect(() => {
const timer = setTimeout(() => setQuery(search.trim()), 300);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => localStorage.setItem('lr_leads_view', view), [view]);
useEffect(() => localStorage.setItem('lr_price_currency', currency), [currency]);
/** Смена валюты пересчитывает уже введённые границы по курсу. */
const switchCurrency = (next: PriceCurrency) => {
if (next === currency) return;
const perUsd = rates.data ?? { USD: 1, AMD: 385 };
const k = (perUsd[next] ?? 1) / (perUsd[currency] ?? 1);
// Драмы округляем до тысяч, доллары — до целых.
const convert = (v: string) => (v === '' ? '' : String(next === 'AMD' ? Math.round((Number(v) * k) / 1000) * 1000 : Math.round(Number(v) * k)));
setF({ ...f, priceFrom: convert(f.priceFrom), priceTo: convert(f.priceTo) });
setCurrency(next);
};
const pipeline = usePipeline();
const members = useMembers();
const filters = useFilters();
const hasPrice = f.priceFrom !== '' || f.priceTo !== '';
const params = {
tab, q: query, ...f, districts: districts.join(','),
priceCurrency: hasPrice ? currency : '',
favorite: favorite ? '1' : '', unseen: unseen ? '1' : '',
departmentId: isManager ? f.departmentId : '',
};
const leads = useQuery({
queryKey: ['leads', params],
queryFn: () => api<Lead[]>(`/leads${qs(params)}`),
// Пул меняется сам: новые объекты приходят из парсера, их берут другие агенты.
refetchInterval: 15_000,
});
const summary = useQuery({
queryKey: ['leads', 'summary'],
queryFn: () => api<Record<Tab, number>>('/leads-summary'),
refetchInterval: 15_000,
});
const take = useAction((id: number) => api(`/leads/${id}/take`, { method: 'POST' }));
const status = useStatusChange(pipeline.data);
const statusOf = (l: Lead) => pipeline.data?.statuses.find((s) => s.id === l.statusId);
const open = (id: number) => navigate(`/leads/${id}`);
const tabs: Tab[] = seeAll ? ['pool', 'mine', 'all'] : ['pool', 'mine'];
const kanban = view === 'kanban' && tab !== 'pool';
const noFilters = filters.data?.length === 0;
return (
<>
<PageTitle title={t.nav.leads}>
{!readOnly && (
<>
<button className="btn btn-ghost" onClick={() => setByUrl(true)}>
<Link2 size={17} aria-hidden="true" />
{c.x.byUrl}
</button>
<button className="btn btn-primary" onClick={() => setAdding(true)}>
<Plus size={17} aria-hidden="true" />
{c.add}
</button>
</>
)}
</PageTitle>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex rounded-xl border border-line p-1" role="tablist">
{tabs.map((x) => (
<button
key={x}
role="tab"
aria-selected={tab === x}
onClick={() => setTab(x)}
className={`min-h-9 cursor-pointer rounded-lg px-3 text-sm transition-colors ${tab === x ? 'bg-raised text-text' : 'text-muted hover:text-text'}`}
>
{c.tabs[x]}
{summary.data && (
<span className={`ml-1.5 tabular-nums ${x === 'pool' && summary.data.pool > 0 ? 'text-signal' : 'text-muted'}`}>{summary.data[x]}</span>
)}
</button>
))}
</div>
<div className="relative min-w-48 flex-1">
<Search size={16} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-muted" aria-hidden="true" />
<input className="field pl-10" type="search" placeholder={c.search} aria-label={c.search} value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{/* «Избранное» и «Не просмотренные» включаются вместе и со всеми остальными фильтрами. */}
<button className={`btn btn-ghost ${favorite ? 'border-warn/50 bg-warn/10 text-warn' : ''}`} aria-pressed={favorite} onClick={() => setFavorite((v) => !v)}>
<Star size={16} aria-hidden="true" fill={favorite ? 'currentColor' : 'none'} />
<span className="hidden sm:inline">{c.dp.favorite}</span>
</button>
<button className={`btn btn-ghost ${unseen ? 'border-signal/50 bg-signal/10 text-signal' : ''}`} aria-pressed={unseen} onClick={() => setUnseen((v) => !v)}>
<EyeOff size={16} aria-hidden="true" />
<span className="hidden sm:inline">{c.dp.unseen}</span>
</button>
<button className={`btn btn-ghost ${showFilters ? 'bg-raised' : ''}`} aria-expanded={showFilters} onClick={() => setShowFilters((v) => !v)}>
<SlidersHorizontal size={16} aria-hidden="true" />
<span className="hidden sm:inline">{c.f.more}</span>
</button>
{tab !== 'pool' && (
<div className="flex rounded-xl border border-line p-1">
{(['table', 'kanban'] as const).map((v) => (
<button
key={v}
aria-pressed={view === v}
aria-label={c.view[v]}
title={c.view[v]}
onClick={() => setView(v)}
className={`flex size-9 cursor-pointer items-center justify-center rounded-lg ${view === v ? 'bg-raised text-text' : 'text-muted hover:text-text'}`}
>
{v === 'table' ? <List size={16} /> : <Columns3 size={16} />}
</button>
))}
</div>
)}
</div>
{showFilters && (
<div className="card mb-4 grid grid-cols-2 gap-3 p-4 md:grid-cols-4 lg:grid-cols-7">
{tab !== 'pool' && (
<div>
<label className="label" htmlFor="f-status">{c.f.status}</label>
<select id="f-status" className="field" value={f.statusId} onChange={(e) => setF({ ...f, statusId: e.target.value })}>
<option value="">{c.f.any}</option>
{/* Закрытые сделкой и отказом объекты — в «Базе», в рабочем списке их нет. */}
{pipeline.data?.statuses.filter((s) => s.code !== 'deal' && s.code !== 'rejected').map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
)}
{tab === 'all' && (
<div>
<label className="label" htmlFor="f-agent">{c.f.agent}</label>
<select id="f-agent" className="field" value={f.assigneeId} onChange={(e) => setF({ ...f, assigneeId: e.target.value })}>
<option value="">{c.f.any}</option>
{members.data?.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
)}
{isManager && Boolean(departments.data?.length) && (
<div>
<label className="label" htmlFor="f-dept">{c.dp.department}</label>
<select id="f-dept" className="field" value={f.departmentId} onChange={(e) => setF({ ...f, departmentId: e.target.value })}>
<option value="">{c.dp.allDepartments}</option>
{departments.data!.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
<option value="none">{c.dp.noDepartment}</option>
</select>
</div>
)}
<div>
<label className="label" htmlFor="f-pf">{c.f.priceFrom}</label>
<input id="f-pf" type="number" min={0} inputMode="numeric" className="field" value={f.priceFrom} onChange={(e) => setF({ ...f, priceFrom: e.target.value })} />
</div>
<div>
<label className="label" htmlFor="f-pt">{c.f.priceTo}</label>
<input id="f-pt" type="number" min={0} inputMode="numeric" className="field" value={f.priceTo} onChange={(e) => setF({ ...f, priceTo: e.target.value })} />
</div>
<fieldset>
<legend className="label">{c.dp.currency}</legend>
<div className="flex rounded-xl border border-line p-1" title={c.dp.priceHint}>
{(['AMD', 'USD'] as const).map((cur) => (
<button
key={cur} type="button" aria-pressed={currency === cur} onClick={() => switchCurrency(cur)}
className={`min-h-9 flex-1 cursor-pointer rounded-lg px-3 text-sm ${currency === cur ? 'bg-raised text-text' : 'text-muted hover:text-text'}`}
>
{c.dp.cur[cur]}
</button>
))}
</div>
</fieldset>
<div className="col-span-2 md:col-span-4 lg:col-span-7">
<Chips label={c.x.districts} options={YEREVAN_DISTRICTS.map((d) => [d as string, d as string])} value={districts} onChange={setDistricts} />
</div>
<div className="col-span-2 flex items-end md:col-span-4 lg:col-span-7">
<button className="btn btn-ghost btn-sm" onClick={() => { setF(empty); setDistricts([]); setFavorite(false); setUnseen(false); }}>{c.f.reset}</button>
</div>
</div>
)}
<ErrorNote error={leads.error ?? take.error ?? status.error} />
{noFilters && tab === 'pool' && (
<div className="card mb-4 flex flex-wrap items-center justify-between gap-3 border-signal/30 p-4">
<p>{c.noFilters}</p>
<Link to="/filters" className="btn btn-primary btn-sm">{c.toFilters}</Link>
</div>
)}
{leads.data?.length === 0 && !(noFilters && tab === 'pool') && (
<div className="card flex min-h-40 items-center justify-center p-8 text-center text-muted">{c.empty[tab]}</div>
)}
{Boolean(leads.data?.length) && kanban && pipeline.data && (
<Kanban items={leads.data!} statuses={pipeline.data.statuses} onOpen={open} onMove={status.request} locked="new" canDrag={(l) => l.assigneeId !== null}>
{(l) => (
<>
<p className="font-medium tabular-nums">{price(l.price, l.currency, l.dealType)}</p>
<p className="mt-0.5 line-clamp-2 text-sm leading-snug">{l.title}</p>
<p className="mt-1.5 truncate text-xs text-muted">{l.assigneeName ?? c.pool}</p>
</>
)}
</Kanban>
)}
{Boolean(leads.data?.length) && !kanban && (
<>
{/* Телефон: карточки */}
<ul className="space-y-2 md:hidden">
{leads.data!.map((l) => (
<li key={l.id} className="card flex gap-3 p-3">
<button type="button" className="flex min-w-0 flex-1 cursor-pointer gap-3 text-left" onClick={() => open(l.id)}>
<Thumb lead={l} className="size-20 shrink-0" />
<span className="min-w-0">
<span className="block font-semibold tabular-nums"><UnseenDot lead={l} />{price(l.price, l.currency, l.dealType)}</span>
<span className="line-clamp-2 block text-sm leading-snug">{l.title}</span>
<span className="mt-0.5 block truncate text-xs text-muted">{[l.district ?? l.region, specs(l)].filter(Boolean).join(' · ')}</span>
</span>
</button>
<div className="flex flex-col items-end justify-between gap-1">
<FavoriteButton lead={l} className="-mr-1.5 -mt-1.5" />
{l.assigneeId === null && !readOnly ? (
<button className="btn btn-primary btn-sm" disabled={take.isPending} onClick={() => take.mutate(l.id)}>{c.take}</button>
) : (
<StatusChip status={statusOf(l)} />
)}
</div>
</li>
))}
</ul>
{/* Компьютер: таблица */}
<div className="card hidden overflow-x-auto md:block">
<table className="w-full text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
<th className="w-12 px-2 py-3"><span className="sr-only">{c.dp.favorite}</span></th>
{[c.col.object, c.col.price, c.col.place, c.col.status, c.col.agent, c.col.created].map((h) => (
<th key={h} className="px-4 py-3 font-normal">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line">
{leads.data!.map((l) => (
<tr key={l.id} className="cursor-pointer transition-colors hover:bg-raised/50" onClick={() => open(l.id)}>
<td className="px-2 py-2.5"><FavoriteButton lead={l} /></td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-3">
<Thumb lead={l} className="size-12 shrink-0" />
<div className="min-w-0 max-w-sm">
<button type="button" className={`block max-w-full cursor-pointer truncate text-left ${l.seen === false ? 'font-semibold' : 'font-medium'}`} onClick={(e) => { e.stopPropagation(); open(l.id); }}>
<UnseenDot lead={l} />
{l.title}
</button>
<p className="truncate text-xs text-muted">{[l.address, specs(l)].filter(Boolean).join(' · ')}</p>
</div>
</div>
</td>
<td className="whitespace-nowrap px-4 py-2.5 font-medium tabular-nums">{price(l.price, l.currency, l.dealType)}</td>
<td className="px-4 py-2.5">
{l.district ?? l.region ?? '—'}
{isManager && l.departmentName && <span className="block text-xs text-muted">{l.departmentName}</span>}
</td>
<td className="px-4 py-2.5"><StatusChip status={statusOf(l)} /></td>
<td className="px-4 py-2.5">
{l.assigneeId === null && !readOnly ? (
<button className="btn btn-primary btn-sm" disabled={take.isPending} onClick={(e) => { e.stopPropagation(); take.mutate(l.id); }}>{c.take}</button>
) : (
(l.assigneeName ?? <span className="text-muted">{c.pool}</span>)
)}
</td>
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-muted">
{l.bumpedAt ? <span className="text-capture" title={c.bumped}>↑ {date(l.bumpedAt)}</span> : date(l.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{status.dialog}
{adding && <ManualLeadDialog onClose={(id) => { setAdding(false); if (id) open(id); }} />}
{byUrl && <ByUrlDialog onClose={() => setByUrl(false)} onOpen={(id) => { setByUrl(false); open(id); }} />}
{openId && <LeadDrawer key={openId} id={Number(openId)} me={me} onClose={() => navigate('/leads')} />}
</>
);
}