import { useEffect, useRef, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, PageHeader, Skeleton, Toolbar } from '@sims/ui' import { getClients, CLIENT_STATUSES, type ClientStatus, type DocStatus } from '../api' import { ClientFormDialog } from '../components/ClientFormDialog' /** * Tiny fetch-render hook shared by the HQ pages (same shape as backoffice live.tsx). * * When `deps` change we clear the previous `data` first, so a new subject (e.g. a * different client id) shows a loading state instead of flashing the old record * underneath the new one. A manual `reload()` is a refresh, not a new subject, so it * keeps the current data on screen while it refetches. `isLoading` is additive — * existing callers that only read `{ data, error, reload }` are unaffected. */ export function useData(loader: () => Promise, deps: unknown[] = []): { data?: T; error?: string; reload: () => void; isLoading: boolean } { const [data, setData] = useState() const [error, setError] = useState() const [isLoading, setIsLoading] = useState(true) const seqRef = useRef(0) // Latest loader without making it a dep of the fetch effect (deps drive refetch). const loaderRef = useRef(loader) loaderRef.current = loader const run = (reset: boolean) => { setError(undefined) if (reset) setData(undefined) setIsLoading(true) const mySeq = ++seqRef.current loaderRef.current() .then((d) => { if (seqRef.current === mySeq) { setData(d); setIsLoading(false) } }) .catch((e: Error) => { if (seqRef.current === mySeq) { setError(e.message); setIsLoading(false) } }) } const reload = () => run(false) // Deps changed → new subject: reset so the previous record can't flash through. // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => run(true), deps) return { data, error, reload, isLoading } } /** Debounce a fast-changing value (e.g. a search box) so a dep fires ~300ms after typing stops. */ export function useDebounced(value: T, ms = 300): T { const [debounced, setDebounced] = useState(value) useEffect(() => { const t = setTimeout(() => setDebounced(value), ms) return () => clearTimeout(t) }, [value, ms]) return debounced } type Tone = 'ok' | 'warn' | 'err' | 'accent' export const CLIENT_TONE: Record = { lead: 'accent', active: 'ok', dormant: 'warn', lost: 'err', } export const DOC_TONE: Record = { draft: 'warn', sent: 'accent', accepted: 'ok', invoiced: 'accent', part_paid: 'warn', paid: 'ok', lost: 'err', cancelled: 'err', } /** Client registry — search, list, inline create; row click opens the Client 360°. */ export function Clients() { const nav = useNavigate() const [q, setQ] = useState('') // D18 WS-F: the old book's two working filters, applied server-side. const [district, setDistrict] = useState('') const [sector, setSector] = useState('') // Debounced so typing in the search box doesn't fire a server request per keystroke. const qDebounced = useDebounced(q) const { data, error, reload } = useData( () => getClients(qDebounced, { district, sector }), [qDebounced, district, sector], ) const [sp, setSp] = useSearchParams() const [creating, setCreating] = useState(sp.get('new') === '1') const [statusFilter, setStatusFilter] = useState('all') useEffect(() => { if (sp.get('new') === '1') { setCreating(true); setSp({}, { replace: true }) } }, [sp, setSp]) const counts = new Map() for (const c of data ?? []) counts.set(c.status, (counts.get(c.status) ?? 0) + 1) const shown = (data ?? []).filter((c) => statusFilter === 'all' || c.status === statusFilter) const why = [ q !== '' ? ` matching “${q}”` : '', statusFilter !== 'all' ? ` with status ${statusFilter}` : '', ].filter((x) => x !== '').join('') return (
setCreating((v) => !v)}>New client} /> setQ(e.target.value)} /> setDistrict(e.target.value)} /> setSector(e.target.value)} /> ({ key: s, label: s, count: counts.get(s) ?? 0 })), ]} /> {shown.length} shown setCreating(false)} onSaved={(c) => { reload(); nav(`/clients/${c.id}`) }} /> {error !== undefined && } {data === undefined && error === undefined ? : shown.length === 0 ? No clients{why !== '' ? why : ' yet — add the first one'}. : ( nav(`/clients/${shown[i]!.id}`)} rows={shown.map((c) => ({ code: c.code, name: c.name, status: {c.status}, state: c.stateCode, contact: c.contacts[0]?.email ?? c.contacts[0]?.phone ?? '—', }))} /> )}
) }