Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
459 lines
24 KiB
TypeScript
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')} />}
|
|
</>
|
|
);
|
|
}
|