@@ -868,7 +874,7 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
{managerial && !settingPw &&
}
{managerial && settingPw && (
<>
-
setPw(e.target.value)} />
@@ -1027,7 +1033,7 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
{managerial && !setting &&
}
{managerial && setting && (
<>
-
setVal(e.target.value)} />
diff --git a/apps/hq-web/src/pages/Clients.tsx b/apps/hq-web/src/pages/Clients.tsx
index 1a54108..f1e538f 100644
--- a/apps/hq-web/src/pages/Clients.tsx
+++ b/apps/hq-web/src/pages/Clients.tsx
@@ -4,23 +4,52 @@ import { Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, PageHead
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). */
+/**
+ * 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
+ 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)
- const reload = () => {
+ // 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
- loader()
- .then((d) => { if (seqRef.current === mySeq) setData(d) })
- .catch((e: Error) => { if (seqRef.current === mySeq) setError(e.message) })
+ 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(reload, deps)
- return { data, error, reload }
+ 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'
@@ -41,8 +70,10 @@ export function Clients() {
// 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(q, { district, sector }), [q, district, sector],
+ () => getClients(qDebounced, { district, sector }), [qDebounced, district, sector],
)
const [sp, setSp] = useSearchParams()
const [creating, setCreating] = useState(sp.get('new') === '1')
@@ -68,6 +99,7 @@ export function Clients() {
setQ(e.target.value)}
/>
, opts: { navigate?: boolean; ok?: string } = {}) => {
+ if (busy !== undefined) return // in-flight guard: a double-click must not fire the action twice
setBusy(action)
documentAction(id, action, body)
.then((out) => {
@@ -84,6 +85,9 @@ export function DocumentView() {
const issued = doc.docNo !== null
const cancelled = doc.status === 'cancelled'
const settling = doc.status === 'part_paid' || doc.status === 'paid'
+ // While any action is in flight, every action-bar button is disabled — the guard in
+ // act() blocks re-entry, this makes it visible and stops a second click landing.
+ const acting = busy !== undefined
return (
@@ -108,31 +112,31 @@ export function DocumentView() {
{!issued && !cancelled && (
-
diff --git a/apps/hq-web/src/pages/Modules.tsx b/apps/hq-web/src/pages/Modules.tsx
index 7f8be10..f629395 100644
--- a/apps/hq-web/src/pages/Modules.tsx
+++ b/apps/hq-web/src/pages/Modules.tsx
@@ -85,6 +85,7 @@ function ModuleClients(props: { module: Module }) {
const [notifying, setNotifying] = useState(false)
const [subject, setSubject] = useState('')
const [bodyText, setBodyText] = useState('')
+ const [confirmNotify, setConfirmNotify] = useState(false)
const [busy, setBusy] = useState(false)
const [result, setResult] = useState()
const [err, setErr] = useState()
@@ -127,9 +128,11 @@ function ModuleClients(props: { module: Module }) {
.finally(() => setBusy(false))
}
+ // Returns the promise so the ConfirmDialog tracks busy and auto-closes on resolve;
+ // the sent/unreached summary and any error surface in the Notices below.
const doNotify = () => {
setBusy(true); setErr(undefined); setResult(undefined)
- notifyModuleClients(props.module.id, { subject, body: bodyText })
+ return notifyModuleClients(props.module.id, { subject, body: bodyText })
.then((out) => {
setResult(`Sent ${out.sent}/${out.total}`
+ (out.failed.length > 0 ? ` — unreached: ${out.failed.map((f) => `${f.client} (${f.error})`).join(', ')}` : ''))
@@ -193,12 +196,23 @@ function ModuleClients(props: { module: Module }) {
{ if (!busy && subject.trim() !== '' && bodyText.trim() !== '') doNotify() }}
+ disabled={busy}
+ onClick={() => { if (!busy && subject.trim() !== '' && bodyText.trim() !== '') setConfirmNotify(true) }}
>
{busy ? 'Sending…' : `Send to every client on ${props.module.code}`}
)}
+ {confirmNotify && (
+ setConfirmNotify(false)}
+ onConfirm={doNotify}
+ title="Notify every client on this module"
+ body={`This emails all ${data?.total ?? 0} client(s) on ${props.module.code} right now. Send it?`}
+ confirmLabel={`Email ${data?.total ?? 0} client(s)`}
+ />
+ )}
{result !== undefined && {result}}
{err !== undefined && {err}}
{roster.error !== undefined ?
@@ -224,7 +238,7 @@ function ModuleClients(props: { module: Module }) {
actions: (
setEdit({ cmId: c.cmId, kind: c.kind, edition: c.edition, renewal: c.nextRenewal ?? '', status: c.status })}>Edit
- setUnassign({ cmId: c.cmId, clientName: c.clientName })}>✕
+ setUnassign({ cmId: c.cmId, clientName: c.clientName })}>✕
),
}
@@ -408,8 +422,8 @@ function FieldSpecEditor(props: { module: Module; isOwner: boolean; onSaved: ()
{rows.length === 0 ? No fields yet — add the first below. : rows.map((r, i) => (
- move(i, -1)}>↑
- move(i, 1)}>↓
+ move(i, -1)}>↑
+ move(i, 1)}>↓
setRow(i, { required: e.target.checked })} />
required
- setRows((prev) => prev.filter((_x, j) => j !== i))}>✕
+ setRows((prev) => prev.filter((_x, j) => j !== i))}>✕
))}
diff --git a/apps/hq-web/src/pages/Reports.tsx b/apps/hq-web/src/pages/Reports.tsx
index e88915d..98b8142 100644
--- a/apps/hq-web/src/pages/Reports.tsx
+++ b/apps/hq-web/src/pages/Reports.tsx
@@ -101,7 +101,15 @@ export function Reports() {
{ranking.data.rows.map((row) => (
-
nav(`/clients/${row.clientId}`)}>{row.clientName}
+ {/* Real
so the client link is focusable + Enter/Space works. */}
+ nav(`/clients/${row.clientId}`)}
+ style={{
+ width: 160, textAlign: 'left', cursor: 'pointer', background: 'none',
+ border: 'none', padding: 0, font: 'inherit', color: 'inherit',
+ }}
+ >{row.clientName}
diff --git a/apps/hq-web/src/pages/Tickets.tsx b/apps/hq-web/src/pages/Tickets.tsx
index dc6b3e2..f1c2d43 100644
--- a/apps/hq-web/src/pages/Tickets.tsx
+++ b/apps/hq-web/src/pages/Tickets.tsx
@@ -9,7 +9,7 @@ import {
TICKET_STATUSES,
type Branch, type Client, type Employee, type Ticket, type TicketStatus,
} from '../api'
-import { useData } from './Clients'
+import { useData, useDebounced } from './Clients'
const PAGE_SIZE = 50
@@ -260,15 +260,17 @@ export function Tickets() {
const [creating, setCreating] = useState(false)
const me = staffId()
const employees = useData(() => getEmployees(), [])
+ // Debounced so typing in the search box doesn't fire a server request per keystroke.
+ const qDebounced = useDebounced(q)
const list = useData(
() => getTickets({
...(status !== 'all' ? { status: status as TicketStatus } : {}),
...(scope === 'mine' ? { mine: true } : {}),
- ...(q !== '' ? { q } : {}),
+ ...(qDebounced !== '' ? { q: qDebounced } : {}),
page, pageSize: PAGE_SIZE,
}),
- [status, scope, q, page],
+ [status, scope, qDebounced, page],
)
const data = list.data
@@ -306,6 +308,7 @@ export function Tickets() {
)}
{ setQ(e.target.value); setPage(1) }}
/>
diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx
index 5e4e8e5..4dc4d07 100644
--- a/packages/ui/src/components.tsx
+++ b/packages/ui/src/components.tsx
@@ -37,15 +37,20 @@ export function Button(props: {
hotkey?: string
pill?: boolean
disabled?: boolean
+ /** 'submit' lets the button submit an enclosing