diff --git a/apps/hq-web/src/Layout.tsx b/apps/hq-web/src/Layout.tsx index b47d4d6..550bb9f 100644 --- a/apps/hq-web/src/Layout.tsx +++ b/apps/hq-web/src/Layout.tsx @@ -140,6 +140,7 @@ export function Layout() { return (
+ Skip to content {menuOpen &&
setMenuOpen(false)} />}
) diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 9e96354..6ef5185 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -209,6 +209,28 @@ export interface Ledger { // ---------- typed calls ---------- +/** + * Create/patch body for a client (shared by POST and PATCH). Every field is + * optional — PATCH sends only what changed; POST omits blanks. Value types match + * what the forms actually produce (kept permissive on purpose): `gstin: null` + * clears on PATCH, `status`/`dbPassword` ride the inline quick-edits. + */ +export interface ClientWrite { + name?: string + gstin?: string | null + stateCode?: string + address?: string + notes?: string + contacts?: ClientContact[] + status?: ClientStatus + anydesk?: string + os?: string + district?: string + sector?: string + /** Set the encrypted DB password (owner/manager; server audits, ignores '' as a no-op). */ + dbPassword?: string +} + export const getClients = ( q?: string, filters?: { district?: string; sector?: string }, ): Promise => { @@ -225,9 +247,9 @@ export const revealDbPassword = (id: string): Promise => .then((r) => r.password) export const getClient = (id: string): Promise => apiFetch<{ client: Client }>(`/clients/${id}`).then((r) => r.client) -export const createClient = (body: Record): Promise => +export const createClient = (body: ClientWrite): Promise => apiFetch<{ client: Client }>('/clients', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.client) -export const patchClient = (id: string, body: Record): Promise => +export const patchClient = (id: string, body: ClientWrite): Promise => apiFetch<{ client: Client }>(`/clients/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.client) /** Assign/clear the account owner — owner/manager only; audited server-side. `null` clears. */ export const setClientOwner = (id: string, ownerId: string | null): Promise => @@ -412,7 +434,12 @@ export const revokeShare = (id: string, shareId: string): Promise<{ share: Share method: 'POST', body: '{}', }) -export const recordPayment = (body: Record): Promise => +/** A payment against a client — amounts already converted to integer paise at the edge. */ +export interface PaymentWrite { + clientId: string; receivedOn: string; mode: PaymentMode + reference?: string; amountPaise: number; tdsPaise?: number +} +export const recordPayment = (body: PaymentWrite): Promise => apiFetch<{ payment: Payment }>('/payments', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.payment) export const getLedger = (clientId: string): Promise => apiFetch(`/clients/${clientId}/ledger`) @@ -570,20 +597,35 @@ export const sendReminder = (id: string): Promise => export const dismissReminder = (id: string): Promise => apiFetch<{ reminder: Reminder }>(`/reminders/${id}/dismiss`, { method: 'POST', body: '{}' }).then((r) => r.reminder) +/** Recurring-plan create/patch body. `cadence`/`policy` carry the loose form-state + * strings (valid: cadence 'monthly'|'yearly', policy 'auto'|'manual'); `amountPaise: + * null` on PATCH reverts the override to the price book. */ +export interface RecurringPlanWrite { + clientModuleId?: string + cadence?: string + policy?: string + nextRun?: string + amountPaise?: number | null +} export const getRecurringPlans = (clientId: string): Promise => apiFetch<{ plans: RecurringPlan[] }>(`/recurring?clientId=${clientId}`).then((r) => r.plans) -export const createRecurringPlan = (clientId: string, body: Record): Promise => +export const createRecurringPlan = (clientId: string, body: RecurringPlanWrite): Promise => apiFetch<{ plan: RecurringPlan }>(`/clients/${clientId}/recurring`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.plan) -export const updateRecurringPlan = (id: string, body: Record): Promise => +export const updateRecurringPlan = (id: string, body: RecurringPlanWrite): Promise => apiFetch<{ plan: RecurringPlan }>(`/recurring/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.plan) export const deactivateRecurringPlan = (id: string): Promise => apiFetch<{ plan: RecurringPlan }>(`/recurring/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.plan) +/** AMC contract create/patch body (amounts already in integer paise). */ +export interface AmcWrite { + coverage?: string; periodFrom?: string; periodTo?: string + amountPaise?: number; renewalReminderDays?: number +} export const getAmc = (clientId: string): Promise => apiFetch<{ contracts: AmcContract[] }>(`/clients/${clientId}/amc`).then((r) => r.contracts) -export const createAmc = (clientId: string, body: Record): Promise => +export const createAmc = (clientId: string, body: AmcWrite): Promise => apiFetch<{ contract: AmcContract }>(`/clients/${clientId}/amc`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.contract) -export const updateAmc = (id: string, body: Record): Promise => +export const updateAmc = (id: string, body: AmcWrite): Promise => apiFetch<{ contract: AmcContract }>(`/amc/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.contract) export const deactivateAmc = (id: string): Promise => apiFetch<{ contract: AmcContract }>(`/amc/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.contract) @@ -592,11 +634,21 @@ export const generateAmcRenewalInvoice = (id: string): Promise => export const getInteractionTypes = (): Promise => apiFetch<{ types: InteractionType[] }>('/interaction-types').then((r) => r.types) +/** Interaction create/patch body. PATCH can't change type/date (edit mode omits them); + * `outcome`/`followUpOn: null` clears the column. `outcome` carries the loose + * form-state string (valid: 'positive'|'neutral'|'negative'). */ +export interface InteractionWrite { + typeCode?: string + onDate?: string + notes?: string + outcome?: string | null + followUpOn?: string | null +} export const getInteractions = (clientId: string): Promise => apiFetch<{ interactions: Interaction[] }>(`/clients/${clientId}/interactions`).then((r) => r.interactions) -export const createInteraction = (clientId: string, body: Record): Promise => +export const createInteraction = (clientId: string, body: InteractionWrite): Promise => apiFetch<{ interaction: Interaction }>(`/clients/${clientId}/interactions`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.interaction) -export const updateInteraction = (id: string, body: Record): Promise => +export const updateInteraction = (id: string, body: InteractionWrite): Promise => apiFetch<{ interaction: Interaction }>(`/interactions/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.interaction) // ---------- HQ-3a shapes ---------- diff --git a/apps/hq-web/src/app.css b/apps/hq-web/src/app.css index 428fde0..0f0586c 100644 --- a/apps/hq-web/src/app.css +++ b/apps/hq-web/src/app.css @@ -222,3 +222,31 @@ table.wf { display: block; overflow-x: auto; } .set-wrap flips to column it would pin the rail's HEIGHT to 190px. */ .set-rail { flex-direction: row; flex-wrap: wrap; flex-basis: auto; } } + +/* ================= accessibility helpers ================= */ +/* Skip link (WCAG 2.4.1): off-screen until Tab-focused, then pinned top-left over + the shell so keyboard users can jump past the sidebar straight to
. */ +.skip-link { + position: absolute; left: 8px; top: 8px; z-index: 100; + transform: translateY(-150%); + background: var(--bg-raised); color: var(--text); text-decoration: none; + border: 1px solid var(--border-strong); border-radius: var(--radius); + padding: 8px 14px; font: 500 13px var(--font); box-shadow: var(--shadow-lg); + transition: transform var(--speed); +} +.skip-link:focus { transform: translateY(0); } +/* Programmatic focus target — no outline when jumped to via the skip link. */ +main#main:focus { outline: none; } + +/* Screen-reader-only text (visually hidden but read aloud). */ +.visually-hidden { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} + +/* Keyboard focus for clickable DataTable rows (role=button + tabindex): the table + clips outlines (overflow:hidden), so mark the focused row with an inset accent + bar + soft wash instead — visible in both themes and over tone-* rows. */ +table.wf tbody tr[role='button']:focus-visible { outline: none; } +table.wf tbody tr[role='button']:focus-visible td { background: var(--accent-soft); } +table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inset 3px 0 0 var(--accent); } diff --git a/apps/hq-web/src/components/ClientFormDialog.tsx b/apps/hq-web/src/components/ClientFormDialog.tsx index 26976b9..d6f4b70 100644 --- a/apps/hq-web/src/components/ClientFormDialog.tsx +++ b/apps/hq-web/src/components/ClientFormDialog.tsx @@ -110,12 +110,12 @@ export function ClientFormDialog(props: {

Contacts

{contacts.map((c, i) => (
- - - + + + {/* Typed roles (D18 WS-F): whatsapp/secretary get labeled rows on the support card; free text stays allowed. */} - - + +
))} diff --git a/apps/hq-web/src/components/ErrorBoundary.tsx b/apps/hq-web/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..0e4b4b6 --- /dev/null +++ b/apps/hq-web/src/components/ErrorBoundary.tsx @@ -0,0 +1,77 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' + +interface Props { children: ReactNode } +interface State { error: Error | undefined } + +/** + * Top-level render guard. Without this, any throw during render white-screens the + * whole console (the whole point of finding #1). We catch it, log it for the + * console, and show a friendly recover-with-reload fallback instead of a blank page. + * + * Deliberately self-contained (no @sims/ui import): the boundary must keep working + * even when the failure is in a shared component it would otherwise render. + */ +export class ErrorBoundary extends Component { + state: State = { error: undefined } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + // Surface it in the browser console for whoever's debugging; no telemetry sink yet. + console.error('Console crashed while rendering:', error, info.componentStack) + } + + private readonly reset = (): void => this.setState({ error: undefined }) + + render(): ReactNode { + const { error } = this.state + if (error === undefined) return this.props.children + + return ( +
+
⚠️
+

Something went wrong

+

+ The console hit an unexpected error and couldn’t finish rendering this screen. + Your data is safe on the server — reloading usually clears it. +

+
{error.message}
+
+ + +
+
+ ) + } +} diff --git a/apps/hq-web/src/components/LivePreview.tsx b/apps/hq-web/src/components/LivePreview.tsx index eb7f9c6..9a55159 100644 --- a/apps/hq-web/src/components/LivePreview.tsx +++ b/apps/hq-web/src/components/LivePreview.tsx @@ -36,14 +36,17 @@ export function LivePreview({ body, commitNonce, fetchPreview, onResult, onStatu const seq = useRef(0) // sequence guard: only the latest response wins const lastBody = useRef(null) // skip-if-unchanged const prevCommit = useRef(commitNonce) + const [retryNonce, setRetryNonce] = useState(0) // manual "Retry" from the paused state + const prevRetry = useRef(retryNonce) const shownRef = useRef(aShown) shownRef.current = aShown useEffect(() => { onStatus?.({ busy, error }) }, [busy, error, onStatus]) useEffect(() => { - const immediate = commitNonce !== prevCommit.current + const immediate = commitNonce !== prevCommit.current || retryNonce !== prevRetry.current prevCommit.current = commitNonce + prevRetry.current = retryNonce if (body === lastBody.current) return // identical body — no POST (design §3) const controller = new AbortController() @@ -67,7 +70,7 @@ export function LivePreview({ body, commitNonce, fetchPreview, onResult, onStatu return () => { controller.abort(); clearTimeout(timer) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [body, commitNonce]) + }, [body, commitNonce, retryNonce]) /** Write html into the hidden buffer; on load restore scroll then cross-fade. */ function swapIn(html: string): void { @@ -98,8 +101,9 @@ export function LivePreview({ body, commitNonce, fetchPreview, onResult, onStatu
{busy &&
} {error !== null && ( -
- Preview paused — retrying +
+ Preview paused — edit to refresh, or +
)} {/* No allow-scripts ⇒ the pure-HTML/CSS template can never run JS; allow-same-origin diff --git a/apps/hq-web/src/components/Pager.tsx b/apps/hq-web/src/components/Pager.tsx new file mode 100644 index 0000000..5925299 --- /dev/null +++ b/apps/hq-web/src/components/Pager.tsx @@ -0,0 +1,40 @@ +import { Badge, Toolbar } from '@sims/ui' + +/** + * Shared list footer: the "Showing X–Y of N" summary + Prev / page-of / Next controls, + * with the pagination math (totalPages / from / to) done once here. Factors out the + * copy-pasted block in every server-paginated list (finding #5). + * + * Adoption: wired into Documents here; Pipeline and the agent-owned pages (Tickets, + * Reminders, the Modules roster) should swap their inline footers to this in a later pass. + */ +export function Pager(props: { + total: number + page: number + pageSize: number + onPage: (page: number) => void +}) { + const { total, page, pageSize, onPage } = props + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + const from = total > 0 ? (page - 1) * pageSize + 1 : 0 + const to = Math.min(page * pageSize, total) + return ( + + Showing {from}–{to} of {total} + + + Page {page} / {totalPages} + + + ) +} diff --git a/apps/hq-web/src/components/ReminderQueue.tsx b/apps/hq-web/src/components/ReminderQueue.tsx index 872f5f8..451054e 100644 --- a/apps/hq-web/src/components/ReminderQueue.tsx +++ b/apps/hq-web/src/components/ReminderQueue.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' -import { Button, useToast } from '@sims/ui' -import { dismissReminder, getReminderPreview, sendReminder, type Reminder } from '../api' +import { Button, ConfirmDialog, useToast } from '@sims/ui' +import { dismissReminder, getReminderPreview, sendReminder, type QueueItem } from '../api' /** Fallback kind labels for queue rows whose server `label` is empty. */ export const RULE_LABEL: Record = { @@ -22,11 +22,12 @@ export const reminderTone = (status: string): 'ok' | 'warn' | 'err' | undefined * on failure: the server has already parked the reminder as 'failed' with its error, * and the row must flip live. */ -export function QueueActions(props: { rem: Reminder; onDone: () => void; onError: (m: string) => void }) { +export function QueueActions(props: { rem: QueueItem; onDone: () => void; onError: (m: string) => void }) { const toast = useToast() const [busy, setBusy] = useState(false) const [mail, setMail] = useState<{ subject: string; body: string } | undefined>() const [loading, setLoading] = useState(false) + const [confirmSend, setConfirmSend] = useState(false) // Toast on success only — onDone still fires on failure too (the server has // already parked the reminder as 'failed' and the row must flip live). const run = (start: () => Promise, okMsg: string) => { @@ -53,11 +54,21 @@ export function QueueActions(props: { rem: Reminder; onDone: () => void; onError {SENDABLE.has(props.rem.ruleKind) && ( <> - + )}
+ {confirmSend && ( + setConfirmSend(false)} + onConfirm={() => run(() => sendReminder(props.rem.id), 'Reminder sent')} + title="Send reminder" + body={`Email this reminder to ${props.rem.clientName}${props.rem.docNo !== null ? ` for ${props.rem.docNo}` : ''} now?`} + confirmLabel="Send" + /> + )} {mail !== undefined && (
Subject
diff --git a/apps/hq-web/src/components/SecretField.tsx b/apps/hq-web/src/components/SecretField.tsx new file mode 100644 index 0000000..b3f5ca1 --- /dev/null +++ b/apps/hq-web/src/components/SecretField.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react' +import { Button, useToast } from '@sims/ui' + +/** + * Reveal-only secret cell: shows `••••••••` for a stored value, an owner/manager-gated + * audited Reveal → Copy/Hide, and an optional inline Set/Update editor. Factors out the + * three near-identical copies in ClientDetail (finding #4): + * - SupportCard DB password → onReveal: revealDbPassword(id), onSave: patchClient(id, { dbPassword }) + * - ServiceDataCard portal password → onReveal: revealModulePassword(cmId), onSave: patchClientModule(cmId, { password }) + * - SecretField module secret → onReveal: revealModuleSecret(cmId, k), onSave: setModuleSecret(cmId, k, v) + * + * Adoption note: ClientDetail.tsx is currently owned by another agent, so those three + * are NOT rewired here — swap them to this component in a later pass to delete the copies. + */ +export function SecretField(props: { + /** Uppercased eyebrow label (pass `${field.label} *` yourself for required fields). */ + label: string + /** Whether a value is stored — drives `••••` vs "not set". */ + hasValue: boolean + /** Owner/manager gate: Reveal + Set/Update render only when true. */ + canManage: boolean + /** Decrypt-and-return the value (audited server-side). */ + onReveal: () => Promise + /** Store/replace the value ('' may clear server-side). Omit to hide the Set/Update editor. */ + onSave?: (value: string) => Promise + /** Toast shown after a successful save (default: `${label} stored (encrypted)`). */ + savedMessage?: string + /** Set/Update input placeholder (default: the label). */ + placeholder?: string + /** Extra classes for the value/input span (e.g. 'mono'). Defaults to 'mono'. */ + valueClassName?: string +}) { + const toast = useToast() + const { label, hasValue, canManage, onReveal, onSave } = props + const cls = props.valueClassName ?? 'mono' + const [revealed, setRevealed] = useState() + const [setting, setSetting] = useState(false) + const [val, setVal] = useState('') + const [busy, setBusy] = useState(false) + + const copy = (text: string) => { + navigator.clipboard.writeText(text) + .then(() => toast.ok(`${label} copied`)) + .catch(() => toast.err('Copy failed')) + } + const reveal = () => { + if (busy) return + setBusy(true) + onReveal() + .then((v) => setRevealed(v)) + .catch((e: Error) => toast.err(e.message)) + .finally(() => setBusy(false)) + } + const save = () => { + if (busy || onSave === undefined) return + setBusy(true) + onSave(val) + .then(() => { + toast.ok(props.savedMessage ?? `${label} stored (encrypted)`) + setSetting(false); setVal(''); setRevealed(undefined) + }) + .catch((e: Error) => toast.err(e.message)) + .finally(() => setBusy(false)) + } + + return ( + + {label} + {revealed !== undefined + ? ( + <> + {revealed} + + + + ) + : hasValue + ? <>••••••••{canManage && } + : not set} + {onSave !== undefined && canManage && !setting && ( + + )} + {onSave !== undefined && canManage && setting && ( + <> + setVal(e.target.value)} + /> + + + + )} + + ) +} diff --git a/apps/hq-web/src/components/SendDialog.tsx b/apps/hq-web/src/components/SendDialog.tsx index f240ddc..dcb07f5 100644 --- a/apps/hq-web/src/components/SendDialog.tsx +++ b/apps/hq-web/src/components/SendDialog.tsx @@ -20,6 +20,7 @@ export function SendDialog(props: { const [other, setOther] = useState('') const [subject, setSubject] = useState('') const [gmail, setGmail] = useState() + const [err, setErr] = useState() const wasOpen = useRef(false) // Prefill ONLY on the false→true open transition — emails/doc are read fresh at @@ -30,6 +31,7 @@ export function SendDialog(props: { setPick(emails[0] ?? OTHER) setOther('') setSubject(`${props.doc.docType} ${props.doc.docNo ?? ''}`.trim()) + setErr(undefined) getEmailStatus().then(setGmail).catch(() => setGmail(undefined)) } wasOpen.current = props.open @@ -37,7 +39,16 @@ export function SendDialog(props: { const to = pick === OTHER ? other.trim() : pick const down = gmail !== undefined && (!gmail.connected || gmail.dead) - const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to) && subject.trim() !== '' && !down + + // Don't fail silently: on an invalid address / empty subject / Gmail down, say why. + const submit = () => { + if (down) { setErr('Gmail is disconnected — reconnect it on the server, then retry.'); return } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) { setErr('Enter a valid email address before sending.'); return } + if (subject.trim() === '') { setErr('A subject is required.'); return } + setErr(undefined) + props.onSend(to, subject.trim()) + props.onClose() + } return ( - @@ -56,6 +67,7 @@ export function SendDialog(props: { {down && ( Gmail is disconnected — run gmail-connect on the server, then retry. )} + {err !== undefined && {err}} { - setActionErr(undefined) - patchClient(id, { status: e.target.value as ClientStatus }) + const next = e.target.value as ClientStatus + const apply = () => patchClient(id, { status: next }) .then(() => { toast.ok('Status updated'); client.reload() }) - .catch((err: Error) => setActionErr(err.message)) + .catch((err: Error) => toast.err(err.message)) + // Destructive transition — confirm before dropping a client to lost. + if (next === 'lost' && c.status !== 'lost') { + setConfirm({ + title: 'Mark client lost', + body: `Mark ${c.name} as lost? They drop out of the active book — you can change the status back later.`, + label: 'Mark lost', + tone: 'danger', + run: apply, + }) + } else { + void apply() + } }} > {CLIENT_STATUSES.map((s) => )} @@ -166,15 +177,13 @@ export function ClientDetail() { ownerId={c.ownerId} employees={employees.data.employees} onChange={(ownerId) => { - setActionErr(undefined) setClientOwner(id, ownerId) .then(() => { toast.ok('Owner updated'); client.reload() }) - .catch((err: Error) => setActionErr(err.message)) + .catch((err: Error) => toast.err(err.message)) }} /> )} - {actionErr !== undefined && } client.reload()} /> { - setActionErr(undefined) assignClientModule(id, { moduleId, kind }) .then(() => { cms.reload(); ledger.reload() }) - .catch((err: Error) => setActionErr(err.message)) + .catch((err: Error) => toast.err(err.message)) }} /> )} @@ -265,10 +273,10 @@ export function ClientDetail() { return { module: moduleName(cm.moduleId), kind: KIND_LABEL[cm.kind], - status: cms.reload()} onError={setActionErr} />, - installed: cms.reload()} onError={setActionErr} />, - completed: cms.reload()} onError={setActionErr} />, - trained: cms.reload()} onError={setActionErr} />, + status: cms.reload()} onError={toast.err} />, + installed: cms.reload()} onError={toast.err} />, + completed: cms.reload()} onError={toast.err} />, + trained: cms.reload()} onError={toast.err} />, billed: paid !== undefined ? inr(paid.billedPaise) : '—', settled: paid !== undefined ? inr(paid.settledPaise) : '—', } @@ -318,7 +326,7 @@ export function ClientDetail() { desc: , assigned: t.assignedName ?? '—', status: {TICKET_STATUS_LABEL[t.status]}, - act: , + act: , }))} /> {Math.ceil(tickets.data.total / tickets.data.pageSize) > 1 && ( @@ -469,9 +477,8 @@ export function ClientDetail() { {isOwner && } {a.paidStatus !== 'unpaid' && ( )} {a.active && ( @@ -522,9 +529,8 @@ export function ClientDetail() { type="date" className="wf" style={{ width: 140 }} value={i.followUpOn ?? ''} onChange={(e) => { - setActionErr(undefined) updateInteraction(i.id, { followUpOn: e.target.value !== '' ? e.target.value : null }) - .then(() => interactions.reload()).catch((err: Error) => setActionErr(err.message)) + .then(() => interactions.reload()).catch((err: Error) => toast.err(err.message)) }} /> ), @@ -653,7 +659,7 @@ function SupportCard(props: { client: Client; onChanged: () => void }) { {managerial && !settingPw && } {managerial && settingPw && ( <> - setPw(e.target.value)} /> @@ -830,14 +836,14 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie {details.map((d, i) => (
setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} /> setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)))} /> - +
))}
@@ -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 && ( - )} {issued && !cancelled && ( - )} {issued && doc.status === 'draft' && ( - + )} {doc.docType === 'QUOTATION' && doc.status === 'sent' && ( <> - - + + )} {doc.docType === 'QUOTATION' && !cancelled && ( - )} {(doc.docType === 'QUOTATION' || doc.docType === 'PROFORMA') && !cancelled && ( - )} @@ -141,6 +145,7 @@ export function DocumentView() { {/* Spec §8 one-click: convert → issue → email, in one action. */} {/* Spec §8 cancel & recreate: retire this proforma, reopen its lines as a fresh draft. */} + )} {issued && !cancelled && !settling && ( - Page {data!.page} / {totalPages} - - + )}
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 }) {
)} + {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: ( - + ), } @@ -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) => (
- - + + setRow(i, { required: e.target.checked })} /> required - +
))} 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
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
(Enter from any field). */ + type?: 'button' | 'submit' + /** Accessible name — required when the visible content is icon/glyph only. */ + 'aria-label'?: string }) { const cls = `wf${props.tone === 'primary' ? ' primary' : props.tone === 'danger' ? ' danger' : ''}${props.pill === true ? ' pill' : ''}` return (