133 lines
5.2 KiB
TypeScript
133 lines
5.2 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Card, KpiCard, LeadRow, LEAD_GRID, Pagination } from '../';
|
|
import { LeadFilters } from '../LeadFilters';
|
|
import { cn } from '../../lib/cn';
|
|
import { useLeads } from '../../api/leads';
|
|
import { recordToLead } from '../../api/adapters';
|
|
import { applyFilters, EMPTY_FILTER, type FilterState } from '../../api/filters';
|
|
import type { LeadRecord } from '../../api/types';
|
|
import { LeadActions, AddLeadButton } from './ActivityActions';
|
|
|
|
const PAGE_SIZE = 10;
|
|
|
|
/** Epoch ms for a recordview timestamp; 0 (oldest) when missing/unparseable. */
|
|
function ms(ts?: string | null): number {
|
|
if (!ts) return 0;
|
|
const t = new Date(ts).getTime();
|
|
return Number.isNaN(t) ? 0 : t;
|
|
}
|
|
|
|
/** A compact stat tile computed over this screen's (already state-filtered) rows. */
|
|
export interface TileSpec {
|
|
label: string;
|
|
value: (rows: LeadRecord[]) => string;
|
|
}
|
|
|
|
export interface WorklistProps {
|
|
/** Lifecycle states this screen lists (in display order). */
|
|
states: readonly string[];
|
|
emptyHint?: string;
|
|
showAddLead?: boolean;
|
|
/** A small set of stat tiles shown above the table (keep it to 2-4). */
|
|
tiles?: TileSpec[];
|
|
}
|
|
|
|
/** A state-scoped lead worklist styled like the Lead Pipeline screen: a KPI row
|
|
* + the shared leads table (LeadRow), with each row's one primary action
|
|
* (+ Disqualify) in its action cell, launched in a modal. */
|
|
export function Worklist({ states, emptyHint, showAddLead, tiles }: WorklistProps) {
|
|
const navigate = useNavigate();
|
|
const { records, fields, loading, refetch } = useLeads();
|
|
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
|
|
|
|
const allowed = useMemo(() => new Set(states), [states]);
|
|
// Scope to this screen's states, latest-first: most recently actioned
|
|
// (updated_at) at the top, instance_id as the tiebreaker. Sorting by state
|
|
// would bury a freshly-updated lead under older ones in an earlier state.
|
|
const scoped = useMemo(
|
|
() =>
|
|
records
|
|
.filter((r) => allowed.has(r.current_state_name ?? ''))
|
|
.sort((a, b) => ms(b.updated_at) - ms(a.updated_at) || Number(b.instance_id) - Number(a.instance_id)),
|
|
[records, allowed],
|
|
);
|
|
const recs = useMemo(() => applyFilters(scoped, filters, fields), [scoped, filters, fields]);
|
|
const rows = useMemo(() => recs.map((record) => ({ record, lead: recordToLead(record) })), [recs]);
|
|
|
|
// Client-side pagination over the filtered rows (all leads are already loaded
|
|
// via useLeads). Page is clamped so it stays valid after rows shrink.
|
|
const [page, setPage] = useState(1);
|
|
// Snap back to the first page whenever the filter set changes.
|
|
useEffect(() => setPage(1), [filters]);
|
|
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
|
|
const safePage = Math.min(page, totalPages);
|
|
const start = (safePage - 1) * PAGE_SIZE;
|
|
const pageRows = rows.slice(start, start + PAGE_SIZE);
|
|
|
|
return (
|
|
<div className="mx-auto flex w-full max-w-[1240px] flex-col gap-5">
|
|
{/* KPI row — Pipeline-screen tiles, width-capped so a couple don't stretch huge. */}
|
|
{tiles?.length ? (
|
|
<div className="grid grid-cols-2 gap-4 sm:flex sm:flex-wrap">
|
|
{tiles.map((t) => (
|
|
<KpiCard key={t.label} label={t.label} value={t.value(recs)} className="sm:w-[240px]" />
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Leads — shared table, with a per-row action cell. */}
|
|
<Card
|
|
title="Leads in queue"
|
|
pad={false}
|
|
action={showAddLead ? <AddLeadButton onDone={refetch} /> : undefined}
|
|
>
|
|
<LeadFilters
|
|
records={scoped}
|
|
fields={fields}
|
|
value={filters}
|
|
onChange={setFilters}
|
|
excludeKeys={['current_state_name']}
|
|
/>
|
|
{loading && !rows.length ? (
|
|
<div className="p-10 text-center text-sm text-faint">Loading leads…</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<div className="min-w-[820px]">
|
|
<div
|
|
className={cn(
|
|
'grid gap-3 px-[18px] py-[11px] bg-slate-50 border-b border-border-subtle text-[10px] font-bold uppercase tracking-[0.06em] text-faint',
|
|
LEAD_GRID,
|
|
)}
|
|
>
|
|
<span>Lead</span>
|
|
<span>Score</span>
|
|
<span>Segment</span>
|
|
<span>Channel</span>
|
|
<span>Owner</span>
|
|
<span className="text-right">Action</span>
|
|
</div>
|
|
{rows.length ? (
|
|
pageRows.map(({ record, lead }) => (
|
|
<LeadRow
|
|
key={record.instance_id}
|
|
lead={lead}
|
|
onClick={() => navigate(`/leads/${record.instance_id}`)}
|
|
action={<LeadActions record={record} onDone={refetch} />}
|
|
/>
|
|
))
|
|
) : (
|
|
<div className="p-10 text-center text-sm text-faint">
|
|
{scoped.length ? 'No leads match these filters.' : emptyHint ?? 'No leads at this stage.'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Pagination page={safePage} pageSize={PAGE_SIZE} total={rows.length} onPage={setPage} />
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|