You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
150 lines
6.2 KiB
TypeScript
150 lines
6.2 KiB
TypeScript
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<T>(loader: () => Promise<T>, deps: unknown[] = []): {
|
|
data?: T; error?: string; reload: () => void; isLoading: boolean
|
|
} {
|
|
const [data, setData] = useState<T | undefined>()
|
|
const [error, setError] = useState<string | undefined>()
|
|
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<T>(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<ClientStatus, Tone> = {
|
|
lead: 'accent', active: 'ok', dormant: 'warn', lost: 'err',
|
|
}
|
|
|
|
export const DOC_TONE: Record<DocStatus, Tone> = {
|
|
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<string, number>()
|
|
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 (
|
|
<div className="wf-page">
|
|
<PageHeader
|
|
title="Clients"
|
|
desc="Every shop we serve — leads through lost. Click a row for the client 360°."
|
|
actions={<Button tone="primary" onClick={() => setCreating((v) => !v)}>New client</Button>}
|
|
/>
|
|
<Toolbar>
|
|
<input
|
|
className="wf" style={{ maxWidth: 280 }} placeholder="Search name / code / GSTIN…"
|
|
aria-label="Search clients"
|
|
value={q} onChange={(e) => setQ(e.target.value)}
|
|
/>
|
|
<input
|
|
className="wf" style={{ maxWidth: 140 }} placeholder="District" aria-label="District"
|
|
value={district} onChange={(e) => setDistrict(e.target.value)}
|
|
/>
|
|
<input
|
|
className="wf" style={{ maxWidth: 120 }} placeholder="Sector" aria-label="Sector"
|
|
value={sector} onChange={(e) => setSector(e.target.value)}
|
|
/>
|
|
<FilterChips
|
|
active={statusFilter}
|
|
onChange={setStatusFilter}
|
|
chips={[
|
|
{ key: 'all', label: 'All', count: data?.length ?? 0 },
|
|
...CLIENT_STATUSES.map((s) => ({ key: s, label: s, count: counts.get(s) ?? 0 })),
|
|
]}
|
|
/>
|
|
<span className="spacer" />
|
|
<Badge>{shown.length} shown</Badge>
|
|
</Toolbar>
|
|
<ClientFormDialog
|
|
open={creating}
|
|
onClose={() => setCreating(false)}
|
|
onSaved={(c) => { reload(); nav(`/clients/${c.id}`) }}
|
|
/>
|
|
{error !== undefined && <ErrorState message={error} onRetry={reload} />}
|
|
{data === undefined && error === undefined ? <Skeleton rows={6} />
|
|
: shown.length === 0 ? <EmptyState>No clients{why !== '' ? why : ' yet — add the first one'}.</EmptyState> : (
|
|
<DataTable
|
|
columns={[
|
|
{ key: 'code', label: 'Code', mono: true }, { key: 'name', label: 'Name' },
|
|
{ key: 'status', label: 'Status' }, { key: 'state', label: 'State' },
|
|
{ key: 'contact', label: 'Contact' },
|
|
]}
|
|
onRowClick={(_row, i) => nav(`/clients/${shown[i]!.id}`)}
|
|
rows={shown.map((c) => ({
|
|
code: c.code, name: c.name,
|
|
status: <Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>,
|
|
state: c.stateCode,
|
|
contact: c.contacts[0]?.email ?? c.contacts[0]?.phone ?? '—',
|
|
}))}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|