diff --git a/apps/hq-web/src/Layout.tsx b/apps/hq-web/src/Layout.tsx index a798374..3df95b4 100644 --- a/apps/hq-web/src/Layout.tsx +++ b/apps/hq-web/src/Layout.tsx @@ -20,6 +20,7 @@ export function Layout() { const [paletteOpen, setPaletteOpen] = useState(false) const [clientHits, setClientHits] = useState([]) const queryTimer = useRef(undefined) + const querySeq = useRef(0) useEffect(() => { if (!hasSession()) return @@ -42,10 +43,14 @@ export function Layout() { // Close the mobile drawer when the route changes. useEffect(() => { setMenuOpen(false) }, [location.pathname]) + // Cancel any pending palette query on unmount. + useEffect(() => () => window.clearTimeout(queryTimer.current), []) + // Ctrl+K / Cmd+K opens the palette. useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { + if (e.repeat) return e.preventDefault() setPaletteOpen((v) => !v) } @@ -68,17 +73,25 @@ export function Layout() { const onPaletteQuery = (q: string) => { window.clearTimeout(queryTimer.current) - if (q.trim().length < 2) { setClientHits([]); return } + if (q.trim().length < 2) { querySeq.current++; setClientHits([]); return } + const mySeq = ++querySeq.current queryTimer.current = window.setTimeout(() => { getClients(q) - .then((cs) => setClientHits(cs.slice(0, 8).map((c) => ({ - id: `client:${c.id}`, label: `${c.code} · ${c.name}`, group: 'Clients', hint: c.status, - })))) - .catch(() => setClientHits([])) + .then((cs) => { + if (querySeq.current !== mySeq) return + setClientHits(cs.slice(0, 8).map((c) => ({ + id: `client:${c.id}`, label: `${c.code} · ${c.name}`, group: 'Clients', hint: c.status, + }))) + }) + .catch(() => { + if (querySeq.current !== mySeq) return + setClientHits([{ id: 'err:clients', label: 'Client search failed — is the server up?', group: 'Clients' }]) + }) }, 200) } const onPaletteSelect = (item: PaletteItem) => { + if (item.id.startsWith('err:')) return if (item.id.startsWith('nav:')) nav(item.id.slice(4)) else if (item.id.startsWith('act:')) nav(item.id.slice(4)) else if (item.id.startsWith('client:')) nav(`/clients/${item.id.slice(7)}`) @@ -93,6 +106,7 @@ export function Layout() { const name = displayName() const av = avatarColors(name) const badgeCount = queueCounts.queued + queueCounts.failed + const badgeTitle = `${queueCounts.queued} queued${queueCounts.failed > 0 ? `, ${queueCounts.failed} failed` : ''}` return (
@@ -110,7 +124,11 @@ export function Layout() { {n.label} {n.to === '/' && badgeCount > 0 && ( - 0 ? ' err' : ''}`}>{badgeCount} + 0 ? ' err' : ''}`} + title={badgeTitle} + aria-label={badgeTitle} + >{badgeCount} )} ))} @@ -130,7 +148,7 @@ export function Layout() {
)}
- + } diff --git a/apps/hq-web/src/pages/Clients.tsx b/apps/hq-web/src/pages/Clients.tsx index 3d0ad14..8320149 100644 --- a/apps/hq-web/src/pages/Clients.tsx +++ b/apps/hq-web/src/pages/Clients.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { Badge, Button, DataTable, EmptyState, ErrorState, Field, FilterChips, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui' import { createClient, getClients, CLIENT_STATUSES, type ClientStatus, type DocStatus } from '../api' @@ -9,9 +9,13 @@ export function useData(loader: () => Promise, deps: unknown[] = []): { } { const [data, setData] = useState() const [error, setError] = useState() + const seqRef = useRef(0) const reload = () => { setError(undefined) - loader().then(setData).catch((e: Error) => setError(e.message)) + const mySeq = ++seqRef.current + loader() + .then((d) => { if (seqRef.current === mySeq) setData(d) }) + .catch((e: Error) => { if (seqRef.current === mySeq) setError(e.message) }) } // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(reload, deps) @@ -34,12 +38,19 @@ export function Clients() { const nav = useNavigate() const [q, setQ] = useState('') const { data, error, reload } = useData(() => getClients(q), [q]) - const [sp] = useSearchParams() + 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 (
@@ -69,7 +80,7 @@ export function Clients() { )} {error !== undefined && } {data === undefined && error === undefined ? - : shown.length === 0 ? No clients{q !== '' ? ` matching “${q}”` : statusFilter !== 'all' ? ` with status ${statusFilter}` : ' yet — add the first one'}. : ( + : shown.length === 0 ? No clients{why !== '' ? why : ' yet — add the first one'}. : ( void }) { } return ( -
+
diff --git a/apps/hq-web/src/pages/Pipeline.tsx b/apps/hq-web/src/pages/Pipeline.tsx index c491ca0..c0651b7 100644 --- a/apps/hq-web/src/pages/Pipeline.tsx +++ b/apps/hq-web/src/pages/Pipeline.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { formatINR } from '@sims/domain' -import { Badge, Button, DataTable, EmptyState, FilterChips, Notice, PageHeader, Toolbar } from '@sims/ui' +import { Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui' import { documentAction, getEmployees, getPipeline, isManagerial, type Employee, type PipelineFilter, type PipelineRow, type PipelineStage, @@ -120,9 +120,9 @@ export function Pipeline() { )} {error !== undefined && {error}} - {list.error !== undefined && {list.error}} + {list.error !== undefined && } {list.error !== undefined ? null - : rows === undefined ? Loading… + : rows === undefined ? : rows.length === 0 ? Nothing to chase — the pipeline is clear. : ( <> void }) { return ( -
+
{props.chips.map((c) => (