fix(review): frontend quality pass — a11y, UX safety, error handling (medium/low)

Red-team frontend clusters (a11y + UX + code quality), three coordinated passes:

Accessibility:
- DataTable clickable rows keyboard-operable (role=button, tabindex, Enter/Space).
- <main> landmark + skip link; icon-only buttons get aria-labels; inputs relying
  on placeholder alone get aria-labels; AWS-ranking link is a real <button>;
  Login fields wrapped in a <form> so Enter submits from either field; toasts
  role=alert for errors (urgency not by color alone).

UX safety:
- 'Notify all' and reminder 'Send' now behind ConfirmDialogs naming the count/
  client; document action bar disables while an action is in flight (no
  double-submit dupes); SendDialog surfaces why a send is blocked instead of
  failing silently; client search + ticket search debounced (~300ms); inline
  Client-360 edit failures surface via toast, not an off-screen banner; LivePreview
  gets a real Retry; destructive client-status transitions confirm first.

Code quality:
- ErrorBoundary wraps the app (a render throw no longer white-screens).
- useData clears stale data on dep change (no record flashing under a new id).
- Shared ErrorBoundary/SecretField/Pager components extracted; Pager adopted in
  Documents; write-call api types tightened off Record<string,unknown>.

typecheck clean (root + both workspaces), web build clean, web tests 4/4, full
suite 388 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 4cf513d528
commit 35a26de442

@ -140,6 +140,7 @@ export function Layout() {
return ( return (
<div className={`hq-shell${collapsed ? ' side-collapsed' : ''}`}> <div className={`hq-shell${collapsed ? ' side-collapsed' : ''}`}>
<a href="#main" className="skip-link">Skip to content</a>
{menuOpen && <div className="hq-scrim" onClick={() => setMenuOpen(false)} />} {menuOpen && <div className="hq-scrim" onClick={() => setMenuOpen(false)} />}
<nav className={`hq-side${menuOpen ? ' open' : ''}`}> <nav className={`hq-side${menuOpen ? ' open' : ''}`}>
<div className="hq-brand"> <div className="hq-brand">
@ -150,7 +151,10 @@ export function Layout() {
<div key={g.label} className="hq-group"> <div key={g.label} className="hq-group">
<div className="hq-eyebrow">{g.label}</div> <div className="hq-eyebrow">{g.label}</div>
{g.items.map((n) => ( {g.items.map((n) => (
<NavLink key={n.to} to={n.to} title={n.label} className={({ isActive }) => (isActive ? 'active' : '')} end={n.to === '/' || n.end === true}> /* Collapsed rail hides the label text (display:none off the a11y tree),
so name the link explicitly then. Expanded, the visible label + count
badge form the name an aria-label there would mute the badge. */
<NavLink key={n.to} to={n.to} title={n.label} aria-label={collapsed ? n.label : undefined} className={({ isActive }) => (isActive ? 'active' : '')} end={n.to === '/' || n.end === true}>
<n.icon size={15} strokeWidth={1.8} aria-hidden /> <n.icon size={15} strokeWidth={1.8} aria-hidden />
<span className="lbl">{n.label}</span> <span className="lbl">{n.label}</span>
{n.to === '/' && badgeCount > 0 && ( {n.to === '/' && badgeCount > 0 && (
@ -232,9 +236,9 @@ export function Layout() {
)} )}
</span> </span>
</header> </header>
<div className="hq-content"> <main id="main" className="hq-content" tabIndex={-1}>
<Outlet /> <Outlet />
</div> </main>
</div> </div>
<CommandPalette <CommandPalette
open={paletteOpen} open={paletteOpen}

@ -37,7 +37,8 @@ export function Login() {
<p>The ops console clients, documents, payments, AMC and reminders in one place.</p> <p>The ops console clients, documents, payments, AMC and reminders in one place.</p>
</div> </div>
<div className="login-form"> <div className="login-form">
<div className="login-card"> {/* A real <form> so Enter submits from either field (not just password). */}
<form className="login-card" onSubmit={(e) => { e.preventDefault(); void submit() }}>
<h2 style={{ marginTop: 0 }}>Sign in</h2> <h2 style={{ marginTop: 0 }}>Sign in</h2>
<Field label="Email"> <Field label="Email">
<input <input
@ -49,12 +50,11 @@ export function Login() {
<input <input
className="wf" type="password" value={pw} className="wf" type="password" value={pw}
onChange={(e) => setPw(e.target.value)} onChange={(e) => setPw(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && void submit()}
/> />
</Field> </Field>
<Button tone="primary" disabled={busy} onClick={() => void submit()}>{busy ? 'Signing in…' : 'Sign in'}</Button> <Button tone="primary" type="submit" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</Button>
{error !== undefined && <Notice tone="err">{error}</Notice>} {error !== undefined && <Notice tone="err">{error}</Notice>}
</div> </form>
</div> </div>
</div> </div>
) )

@ -209,6 +209,28 @@ export interface Ledger {
// ---------- typed calls ---------- // ---------- 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 = ( export const getClients = (
q?: string, filters?: { district?: string; sector?: string }, q?: string, filters?: { district?: string; sector?: string },
): Promise<Client[]> => { ): Promise<Client[]> => {
@ -225,9 +247,9 @@ export const revealDbPassword = (id: string): Promise<string> =>
.then((r) => r.password) .then((r) => r.password)
export const getClient = (id: string): Promise<Client> => export const getClient = (id: string): Promise<Client> =>
apiFetch<{ client: Client }>(`/clients/${id}`).then((r) => r.client) apiFetch<{ client: Client }>(`/clients/${id}`).then((r) => r.client)
export const createClient = (body: Record<string, unknown>): Promise<Client> => export const createClient = (body: ClientWrite): Promise<Client> =>
apiFetch<{ client: Client }>('/clients', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.client) apiFetch<{ client: Client }>('/clients', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.client)
export const patchClient = (id: string, body: Record<string, unknown>): Promise<Client> => export const patchClient = (id: string, body: ClientWrite): Promise<Client> =>
apiFetch<{ client: Client }>(`/clients/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.client) 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. */ /** Assign/clear the account owner — owner/manager only; audited server-side. `null` clears. */
export const setClientOwner = (id: string, ownerId: string | null): Promise<Client> => export const setClientOwner = (id: string, ownerId: string | null): Promise<Client> =>
@ -412,7 +434,12 @@ export const revokeShare = (id: string, shareId: string): Promise<{ share: Share
method: 'POST', body: '{}', method: 'POST', body: '{}',
}) })
export const recordPayment = (body: Record<string, unknown>): Promise<Payment> => /** 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<Payment> =>
apiFetch<{ payment: Payment }>('/payments', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.payment) apiFetch<{ payment: Payment }>('/payments', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.payment)
export const getLedger = (clientId: string): Promise<Ledger> => export const getLedger = (clientId: string): Promise<Ledger> =>
apiFetch<Ledger>(`/clients/${clientId}/ledger`) apiFetch<Ledger>(`/clients/${clientId}/ledger`)
@ -570,20 +597,35 @@ export const sendReminder = (id: string): Promise<Reminder> =>
export const dismissReminder = (id: string): Promise<Reminder> => export const dismissReminder = (id: string): Promise<Reminder> =>
apiFetch<{ reminder: Reminder }>(`/reminders/${id}/dismiss`, { method: 'POST', body: '{}' }).then((r) => r.reminder) 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<RecurringPlan[]> => export const getRecurringPlans = (clientId: string): Promise<RecurringPlan[]> =>
apiFetch<{ plans: RecurringPlan[] }>(`/recurring?clientId=${clientId}`).then((r) => r.plans) apiFetch<{ plans: RecurringPlan[] }>(`/recurring?clientId=${clientId}`).then((r) => r.plans)
export const createRecurringPlan = (clientId: string, body: Record<string, unknown>): Promise<RecurringPlan> => export const createRecurringPlan = (clientId: string, body: RecurringPlanWrite): Promise<RecurringPlan> =>
apiFetch<{ plan: RecurringPlan }>(`/clients/${clientId}/recurring`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.plan) apiFetch<{ plan: RecurringPlan }>(`/clients/${clientId}/recurring`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.plan)
export const updateRecurringPlan = (id: string, body: Record<string, unknown>): Promise<RecurringPlan> => export const updateRecurringPlan = (id: string, body: RecurringPlanWrite): Promise<RecurringPlan> =>
apiFetch<{ plan: RecurringPlan }>(`/recurring/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.plan) apiFetch<{ plan: RecurringPlan }>(`/recurring/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.plan)
export const deactivateRecurringPlan = (id: string): Promise<RecurringPlan> => export const deactivateRecurringPlan = (id: string): Promise<RecurringPlan> =>
apiFetch<{ plan: RecurringPlan }>(`/recurring/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.plan) 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<AmcContract[]> => export const getAmc = (clientId: string): Promise<AmcContract[]> =>
apiFetch<{ contracts: AmcContract[] }>(`/clients/${clientId}/amc`).then((r) => r.contracts) apiFetch<{ contracts: AmcContract[] }>(`/clients/${clientId}/amc`).then((r) => r.contracts)
export const createAmc = (clientId: string, body: Record<string, unknown>): Promise<AmcContract> => export const createAmc = (clientId: string, body: AmcWrite): Promise<AmcContract> =>
apiFetch<{ contract: AmcContract }>(`/clients/${clientId}/amc`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.contract) apiFetch<{ contract: AmcContract }>(`/clients/${clientId}/amc`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.contract)
export const updateAmc = (id: string, body: Record<string, unknown>): Promise<AmcContract> => export const updateAmc = (id: string, body: AmcWrite): Promise<AmcContract> =>
apiFetch<{ contract: AmcContract }>(`/amc/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.contract) apiFetch<{ contract: AmcContract }>(`/amc/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.contract)
export const deactivateAmc = (id: string): Promise<AmcContract> => export const deactivateAmc = (id: string): Promise<AmcContract> =>
apiFetch<{ contract: AmcContract }>(`/amc/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.contract) apiFetch<{ contract: AmcContract }>(`/amc/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.contract)
@ -592,11 +634,21 @@ export const generateAmcRenewalInvoice = (id: string): Promise<Doc> =>
export const getInteractionTypes = (): Promise<InteractionType[]> => export const getInteractionTypes = (): Promise<InteractionType[]> =>
apiFetch<{ types: InteractionType[] }>('/interaction-types').then((r) => r.types) 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<Interaction[]> => export const getInteractions = (clientId: string): Promise<Interaction[]> =>
apiFetch<{ interactions: Interaction[] }>(`/clients/${clientId}/interactions`).then((r) => r.interactions) apiFetch<{ interactions: Interaction[] }>(`/clients/${clientId}/interactions`).then((r) => r.interactions)
export const createInteraction = (clientId: string, body: Record<string, unknown>): Promise<Interaction> => export const createInteraction = (clientId: string, body: InteractionWrite): Promise<Interaction> =>
apiFetch<{ interaction: Interaction }>(`/clients/${clientId}/interactions`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.interaction) apiFetch<{ interaction: Interaction }>(`/clients/${clientId}/interactions`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.interaction)
export const updateInteraction = (id: string, body: Record<string, unknown>): Promise<Interaction> => export const updateInteraction = (id: string, body: InteractionWrite): Promise<Interaction> =>
apiFetch<{ interaction: Interaction }>(`/interactions/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.interaction) apiFetch<{ interaction: Interaction }>(`/interactions/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.interaction)
// ---------- HQ-3a shapes ---------- // ---------- HQ-3a shapes ----------

@ -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-wrap flips to column it would pin the rail's HEIGHT to 190px. */
.set-rail { flex-direction: row; flex-wrap: wrap; flex-basis: auto; } .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 <main id="main">. */
.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); }

@ -110,12 +110,12 @@ export function ClientFormDialog(props: {
<h3 style={{ margin: '14px 0 6px', fontSize: 13 }}>Contacts</h3> <h3 style={{ margin: '14px 0 6px', fontSize: 13 }}>Contacts</h3>
{contacts.map((c, i) => ( {contacts.map((c, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}> <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<input className="wf" placeholder="Name" value={c.name} onChange={setContact(i, 'name')} /> <input className="wf" placeholder="Name" aria-label="Contact name" value={c.name} onChange={setContact(i, 'name')} />
<input className="wf" placeholder="Email" type="email" value={c.email} onChange={setContact(i, 'email')} /> <input className="wf" placeholder="Email" aria-label="Contact email" type="email" value={c.email} onChange={setContact(i, 'email')} />
<input className="wf" placeholder="Phone" value={c.phone} onChange={setContact(i, 'phone')} /> <input className="wf" placeholder="Phone" aria-label="Contact phone" value={c.phone} onChange={setContact(i, 'phone')} />
{/* Typed roles (D18 WS-F): whatsapp/secretary get labeled rows on the support card; free text stays allowed. */} {/* Typed roles (D18 WS-F): whatsapp/secretary get labeled rows on the support card; free text stays allowed. */}
<input className="wf" placeholder="Role" list="hq-contact-roles" style={{ maxWidth: 110 }} value={c.role} onChange={setContact(i, 'role')} /> <input className="wf" placeholder="Role" aria-label="Contact role" list="hq-contact-roles" style={{ maxWidth: 110 }} value={c.role} onChange={setContact(i, 'role')} />
<Button onClick={() => setContacts((prev) => prev.filter((_x, j) => j !== i))}></Button> <Button aria-label="Remove contact" onClick={() => setContacts((prev) => prev.filter((_x, j) => j !== i))}></Button>
</div> </div>
))} ))}
<datalist id="hq-contact-roles"> <datalist id="hq-contact-roles">

@ -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<Props, State> {
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 (
<div
role="alert"
style={{
minHeight: '100dvh', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 16, padding: 24,
textAlign: 'center', color: 'var(--fg, #1f2937)',
background: 'var(--bg, #f8fafc)', fontFamily: 'inherit',
}}
>
<div style={{ fontSize: 40, lineHeight: 1 }} aria-hidden></div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>Something went wrong</h1>
<p style={{ margin: 0, maxWidth: 460, fontSize: 14, opacity: 0.75 }}>
The console hit an unexpected error and couldnt finish rendering this screen.
Your data is safe on the server reloading usually clears it.
</p>
<pre
style={{
margin: 0, maxWidth: 460, maxHeight: 120, overflow: 'auto',
fontSize: 12, opacity: 0.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}
>{error.message}</pre>
<div style={{ display: 'flex', gap: 10 }}>
<button
type="button" onClick={this.reset}
style={{
padding: '8px 16px', borderRadius: 8, cursor: 'pointer', fontSize: 14,
border: '1px solid var(--line, #d1d5db)', background: 'transparent', color: 'inherit',
}}
>
Try again
</button>
<button
type="button" onClick={() => window.location.reload()}
style={{
padding: '8px 16px', borderRadius: 8, cursor: 'pointer', fontSize: 14,
border: 'none', background: 'var(--accent, #2563eb)', color: '#fff', fontWeight: 500,
}}
>
Reload console
</button>
</div>
</div>
)
}
}

@ -36,14 +36,17 @@ export function LivePreview({ body, commitNonce, fetchPreview, onResult, onStatu
const seq = useRef(0) // sequence guard: only the latest response wins const seq = useRef(0) // sequence guard: only the latest response wins
const lastBody = useRef<string | null>(null) // skip-if-unchanged const lastBody = useRef<string | null>(null) // skip-if-unchanged
const prevCommit = useRef(commitNonce) const prevCommit = useRef(commitNonce)
const [retryNonce, setRetryNonce] = useState(0) // manual "Retry" from the paused state
const prevRetry = useRef(retryNonce)
const shownRef = useRef(aShown) const shownRef = useRef(aShown)
shownRef.current = aShown shownRef.current = aShown
useEffect(() => { onStatus?.({ busy, error }) }, [busy, error, onStatus]) useEffect(() => { onStatus?.({ busy, error }) }, [busy, error, onStatus])
useEffect(() => { useEffect(() => {
const immediate = commitNonce !== prevCommit.current const immediate = commitNonce !== prevCommit.current || retryNonce !== prevRetry.current
prevCommit.current = commitNonce prevCommit.current = commitNonce
prevRetry.current = retryNonce
if (body === lastBody.current) return // identical body — no POST (design §3) if (body === lastBody.current) return // identical body — no POST (design §3)
const controller = new AbortController() const controller = new AbortController()
@ -67,7 +70,7 @@ export function LivePreview({ body, commitNonce, fetchPreview, onResult, onStatu
return () => { controller.abort(); clearTimeout(timer) } return () => { controller.abort(); clearTimeout(timer) }
// eslint-disable-next-line react-hooks/exhaustive-deps // 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. */ /** Write html into the hidden buffer; on load restore scroll then cross-fade. */
function swapIn(html: string): void { function swapIn(html: string): void {
@ -98,8 +101,9 @@ export function LivePreview({ body, commitNonce, fetchPreview, onResult, onStatu
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}> <div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
{busy && <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: 'var(--accent, #4b8bf5)', opacity: 0.85, zIndex: 3 }} />} {busy && <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: 'var(--accent, #4b8bf5)', opacity: 0.85, zIndex: 3 }} />}
{error !== null && ( {error !== null && (
<div style={{ position: 'absolute', top: 8, left: 8, zIndex: 3, background: 'var(--bg-raised, #fff)', border: '1px solid var(--border, #ccc)', borderRadius: 6, padding: '2px 8px', fontSize: 12 }}> <div style={{ position: 'absolute', top: 8, left: 8, zIndex: 3, display: 'flex', alignItems: 'center', gap: 8, background: 'var(--bg-raised, #fff)', border: '1px solid var(--border, #ccc)', borderRadius: 6, padding: '4px 8px', fontSize: 12 }}>
Preview paused retrying <span>Preview paused edit to refresh, or</span>
<button type="button" className="wf" style={{ fontSize: 12, padding: '1px 8px' }} disabled={busy} onClick={() => setRetryNonce((n) => n + 1)}>Retry</button>
</div> </div>
)} )}
{/* No allow-scripts the pure-HTML/CSS template can never run JS; allow-same-origin {/* No allow-scripts the pure-HTML/CSS template can never run JS; allow-same-origin

@ -0,0 +1,40 @@
import { Badge, Toolbar } from '@sims/ui'
/**
* Shared list footer: the "Showing XY 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 (
<Toolbar>
<Badge>Showing {from}{to} of {total}</Badge>
<span style={{ flex: 1 }} />
<button
type="button" className="wf" disabled={page <= 1}
onClick={() => onPage(Math.max(1, page - 1))}
>
Prev
</button>
<span style={{ fontSize: 12, opacity: 0.7 }}>Page {page} / {totalPages}</span>
<button
type="button" className="wf" disabled={page >= totalPages}
onClick={() => onPage(page + 1)}
>
Next
</button>
</Toolbar>
)
}

@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Button, useToast } from '@sims/ui' import { Button, ConfirmDialog, useToast } from '@sims/ui'
import { dismissReminder, getReminderPreview, sendReminder, type Reminder } from '../api' import { dismissReminder, getReminderPreview, sendReminder, type QueueItem } from '../api'
/** Fallback kind labels for queue rows whose server `label` is empty. */ /** Fallback kind labels for queue rows whose server `label` is empty. */
export const RULE_LABEL: Record<string, string> = { export const RULE_LABEL: Record<string, string> = {
@ -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, * on failure: the server has already parked the reminder as 'failed' with its error,
* and the row must flip live. * 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 toast = useToast()
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [mail, setMail] = useState<{ subject: string; body: string } | undefined>() const [mail, setMail] = useState<{ subject: string; body: string } | undefined>()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [confirmSend, setConfirmSend] = useState(false)
// Toast on success only — onDone still fires on failure too (the server has // 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). // already parked the reminder as 'failed' and the row must flip live).
const run = (start: () => Promise<unknown>, okMsg: string) => { const run = (start: () => Promise<unknown>, okMsg: string) => {
@ -53,11 +54,21 @@ export function QueueActions(props: { rem: Reminder; onDone: () => void; onError
{SENDABLE.has(props.rem.ruleKind) && ( {SENDABLE.has(props.rem.ruleKind) && (
<> <>
<Button onClick={preview}>{loading ? '…' : mail !== undefined ? 'Hide' : 'Preview'}</Button> <Button onClick={preview}>{loading ? '…' : mail !== undefined ? 'Hide' : 'Preview'}</Button>
<Button tone="primary" onClick={() => run(() => sendReminder(props.rem.id), 'Reminder sent')}>{busy ? '…' : 'Send'}</Button> <Button tone="primary" disabled={busy} onClick={() => setConfirmSend(true)}>{busy ? '…' : 'Send'}</Button>
</> </>
)} )}
<Button onClick={() => run(() => dismissReminder(props.rem.id), 'Dismissed')}>Dismiss</Button> <Button onClick={() => run(() => dismissReminder(props.rem.id), 'Dismissed')}>Dismiss</Button>
</div> </div>
{confirmSend && (
<ConfirmDialog
open
onClose={() => 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 && ( {mail !== undefined && (
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '6px 8px', background: 'var(--bg-raised)', maxWidth: 420 }}> <div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '6px 8px', background: 'var(--bg-raised)', maxWidth: 420 }}>
<div style={{ fontSize: 11, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>Subject</div> <div style={{ fontSize: 11, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>Subject</div>

@ -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<string>
/** Store/replace the value ('' may clear server-side). Omit to hide the Set/Update editor. */
onSave?: (value: string) => Promise<void>
/** 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<string | undefined>()
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 (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>{label}</span>
{revealed !== undefined
? (
<>
<span className={cls}>{revealed}</span>
<Button onClick={() => copy(revealed)}>Copy</Button>
<Button onClick={() => setRevealed(undefined)}>Hide</Button>
</>
)
: hasValue
? <><span className={cls}></span>{canManage && <Button onClick={reveal}>{busy ? '…' : 'Reveal'}</Button>}</>
: <span style={{ opacity: 0.6 }}>not set</span>}
{onSave !== undefined && canManage && !setting && (
<Button onClick={() => setSetting(true)}>{hasValue ? 'Update' : 'Set…'}</Button>
)}
{onSave !== undefined && canManage && setting && (
<>
<input
className={`wf ${cls}`.trim()} type="password" style={{ width: 160 }}
placeholder={props.placeholder ?? label} aria-label={label}
value={val} onChange={(e) => setVal(e.target.value)}
/>
<Button tone="primary" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => { setSetting(false); setVal('') }}>Cancel</Button>
</>
)}
</span>
)
}

@ -20,6 +20,7 @@ export function SendDialog(props: {
const [other, setOther] = useState('') const [other, setOther] = useState('')
const [subject, setSubject] = useState('') const [subject, setSubject] = useState('')
const [gmail, setGmail] = useState<EmailStatus | undefined>() const [gmail, setGmail] = useState<EmailStatus | undefined>()
const [err, setErr] = useState<string | undefined>()
const wasOpen = useRef(false) const wasOpen = useRef(false)
// Prefill ONLY on the false→true open transition — emails/doc are read fresh at // 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) setPick(emails[0] ?? OTHER)
setOther('') setOther('')
setSubject(`${props.doc.docType} ${props.doc.docNo ?? ''}`.trim()) setSubject(`${props.doc.docType} ${props.doc.docNo ?? ''}`.trim())
setErr(undefined)
getEmailStatus().then(setGmail).catch(() => setGmail(undefined)) getEmailStatus().then(setGmail).catch(() => setGmail(undefined))
} }
wasOpen.current = props.open wasOpen.current = props.open
@ -37,7 +39,16 @@ export function SendDialog(props: {
const to = pick === OTHER ? other.trim() : pick const to = pick === OTHER ? other.trim() : pick
const down = gmail !== undefined && (!gmail.connected || gmail.dead) 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 ( return (
<Dialog <Dialog
@ -47,7 +58,7 @@ export function SendDialog(props: {
footer={ footer={
<> <>
<Button pill onClick={props.onClose}>Cancel</Button> <Button pill onClick={props.onClose}>Cancel</Button>
<Button pill tone="primary" onClick={() => { if (valid) { props.onSend(to, subject.trim()); props.onClose() } }}> <Button pill tone="primary" onClick={submit}>
Send Send
</Button> </Button>
</> </>
@ -56,6 +67,7 @@ export function SendDialog(props: {
{down && ( {down && (
<Notice tone="warn">Gmail is disconnected run gmail-connect on the server, then retry.</Notice> <Notice tone="warn">Gmail is disconnected run gmail-connect on the server, then retry.</Notice>
)} )}
{err !== undefined && <Notice tone="err">{err}</Notice>}
<Field label="To"> <Field label="To">
<select className="wf" value={pick} onChange={(e) => setPick(e.target.value)}> <select className="wf" value={pick} onChange={(e) => setPick(e.target.value)}>
{emails.map((e) => <option key={e} value={e}>{e}</option>)} {emails.map((e) => <option key={e} value={e}>{e}</option>)}

@ -5,6 +5,7 @@ import '@fontsource-variable/jetbrains-mono'
import '../../../packages/ui/src/tokens.css' import '../../../packages/ui/src/tokens.css'
import './app.css' import './app.css'
import { initTheme, ToastProvider } from '@sims/ui' import { initTheme, ToastProvider } from '@sims/ui'
import { ErrorBoundary } from './components/ErrorBoundary'
import { Layout } from './Layout' import { Layout } from './Layout'
import { Login } from './Login' import { Login } from './Login'
import { Dashboard } from './pages/Dashboard' import { Dashboard } from './pages/Dashboard'
@ -55,4 +56,6 @@ function App() {
} }
initTheme() initTheme()
createRoot(document.getElementById('root')!).render(<ToastProvider><App /></ToastProvider>) createRoot(document.getElementById('root')!).render(
<ErrorBoundary><ToastProvider><App /></ToastProvider></ErrorBoundary>,
)

@ -66,7 +66,6 @@ export function ClientDetail() {
const isOwner = role() === 'owner' const isOwner = role() === 'owner'
const canRoute = isManagerial() const canRoute = isManagerial()
const toast = useToast() const toast = useToast()
const [actionErr, setActionErr] = useState<string | undefined>()
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [amcDialog, setAmcDialog] = useState<{ initial?: AmcContract } | undefined>() const [amcDialog, setAmcDialog] = useState<{ initial?: AmcContract } | undefined>()
const [planDialog, setPlanDialog] = useState<{ initial?: RecurringPlan } | undefined>() const [planDialog, setPlanDialog] = useState<{ initial?: RecurringPlan } | undefined>()
@ -146,10 +145,22 @@ export function ClientDetail() {
<select <select
className="wf" style={{ width: 'auto' }} value={c.status} className="wf" style={{ width: 'auto' }} value={c.status}
onChange={(e) => { onChange={(e) => {
setActionErr(undefined) const next = e.target.value as ClientStatus
patchClient(id, { status: e.target.value as ClientStatus }) const apply = () => patchClient(id, { status: next })
.then(() => { toast.ok('Status updated'); client.reload() }) .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) => <option key={s} value={s}>{s}</option>)} {CLIENT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
@ -166,15 +177,13 @@ export function ClientDetail() {
ownerId={c.ownerId} ownerId={c.ownerId}
employees={employees.data.employees} employees={employees.data.employees}
onChange={(ownerId) => { onChange={(ownerId) => {
setActionErr(undefined)
setClientOwner(id, ownerId) setClientOwner(id, ownerId)
.then(() => { toast.ok('Owner updated'); client.reload() }) .then(() => { toast.ok('Owner updated'); client.reload() })
.catch((err: Error) => setActionErr(err.message)) .catch((err: Error) => toast.err(err.message))
}} }}
/> />
</Toolbar> </Toolbar>
)} )}
{actionErr !== undefined && <ErrorState message={actionErr} />}
<SupportCard client={c} onChanged={() => client.reload()} /> <SupportCard client={c} onChanged={() => client.reload()} />
<ClientFormDialog <ClientFormDialog
open={editing} open={editing}
@ -241,10 +250,9 @@ export function ClientDetail() {
<AssignModuleForm <AssignModuleForm
modules={modules.data} modules={modules.data}
onAssign={(moduleId, kind) => { onAssign={(moduleId, kind) => {
setActionErr(undefined)
assignClientModule(id, { moduleId, kind }) assignClientModule(id, { moduleId, kind })
.then(() => { cms.reload(); ledger.reload() }) .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 { return {
module: moduleName(cm.moduleId), module: moduleName(cm.moduleId),
kind: KIND_LABEL[cm.kind], kind: KIND_LABEL[cm.kind],
status: <ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={setActionErr} />, status: <ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={toast.err} />,
installed: <RowDate cm={cm} field="installedOn" onPatched={() => cms.reload()} onError={setActionErr} />, installed: <RowDate cm={cm} field="installedOn" onPatched={() => cms.reload()} onError={toast.err} />,
completed: <RowDate cm={cm} field="completedOn" onPatched={() => cms.reload()} onError={setActionErr} />, completed: <RowDate cm={cm} field="completedOn" onPatched={() => cms.reload()} onError={toast.err} />,
trained: <RowDate cm={cm} field="trainedOn" onPatched={() => cms.reload()} onError={setActionErr} />, trained: <RowDate cm={cm} field="trainedOn" onPatched={() => cms.reload()} onError={toast.err} />,
billed: paid !== undefined ? inr(paid.billedPaise) : '—', billed: paid !== undefined ? inr(paid.billedPaise) : '—',
settled: paid !== undefined ? inr(paid.settledPaise) : '—', settled: paid !== undefined ? inr(paid.settledPaise) : '—',
} }
@ -318,7 +326,7 @@ export function ClientDetail() {
desc: <TicketDescription text={t.description} />, desc: <TicketDescription text={t.description} />,
assigned: t.assignedName ?? '—', assigned: t.assignedName ?? '—',
status: <Badge tone={TICKET_TONE[t.status]}>{TICKET_STATUS_LABEL[t.status]}</Badge>, status: <Badge tone={TICKET_TONE[t.status]}>{TICKET_STATUS_LABEL[t.status]}</Badge>,
act: <TicketActions t={t} onDone={tickets.reload} onError={setActionErr} />, act: <TicketActions t={t} onDone={tickets.reload} onError={toast.err} />,
}))} }))}
/> />
{Math.ceil(tickets.data.total / tickets.data.pageSize) > 1 && ( {Math.ceil(tickets.data.total / tickets.data.pageSize) > 1 && (
@ -469,9 +477,8 @@ export function ClientDetail() {
{isOwner && <Button onClick={() => setAmcDialog({ initial: a })}>Edit</Button>} {isOwner && <Button onClick={() => setAmcDialog({ initial: a })}>Edit</Button>}
{a.paidStatus !== 'unpaid' && ( {a.paidStatus !== 'unpaid' && (
<Button tone="primary" onClick={() => { <Button tone="primary" onClick={() => {
setActionErr(undefined)
generateAmcRenewalInvoice(a.id) generateAmcRenewalInvoice(a.id)
.then((doc) => nav(`/documents/${doc.id}`)).catch((err: Error) => setActionErr(err.message)) .then((doc) => nav(`/documents/${doc.id}`)).catch((err: Error) => toast.err(err.message))
}}>Generate renewal invoice</Button> }}>Generate renewal invoice</Button>
)} )}
{a.active && ( {a.active && (
@ -522,9 +529,8 @@ export function ClientDetail() {
type="date" className="wf" style={{ width: 140 }} type="date" className="wf" style={{ width: 140 }}
value={i.followUpOn ?? ''} value={i.followUpOn ?? ''}
onChange={(e) => { onChange={(e) => {
setActionErr(undefined)
updateInteraction(i.id, { followUpOn: e.target.value !== '' ? e.target.value : null }) 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 && <Button onClick={() => setSettingPw(true)}>{c.hasDbPassword ? 'Update' : 'Set…'}</Button>} {managerial && !settingPw && <Button onClick={() => setSettingPw(true)}>{c.hasDbPassword ? 'Update' : 'Set…'}</Button>}
{managerial && settingPw && ( {managerial && settingPw && (
<> <>
<input className="wf mono" type="password" style={{ width: 160 }} placeholder="DB password" <input className="wf mono" type="password" style={{ width: 160 }} placeholder="DB password" aria-label="DB password"
value={pw} onChange={(e) => setPw(e.target.value)} /> value={pw} onChange={(e) => setPw(e.target.value)} />
<Button tone="primary" onClick={savePw}>{busy ? 'Saving…' : 'Save'}</Button> <Button tone="primary" onClick={savePw}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => { setSettingPw(false); setPw('') }}>Cancel</Button> <Button onClick={() => { setSettingPw(false); setPw('') }}>Cancel</Button>
@ -830,14 +836,14 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
{details.map((d, i) => ( {details.map((d, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}> <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<input <input
className="wf" placeholder="Label" style={{ maxWidth: 180 }} value={d.label} className="wf" placeholder="Label" aria-label="Detail label" style={{ maxWidth: 180 }} value={d.label}
onChange={(e) => setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} onChange={(e) => setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))}
/> />
<input <input
className="wf" placeholder="Value" value={d.value} className="wf" placeholder="Value" aria-label="Detail value" value={d.value}
onChange={(e) => setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)))} onChange={(e) => setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)))}
/> />
<Button onClick={() => setDetails((prev) => prev.filter((_x, j) => j !== i))}></Button> <Button aria-label="Remove detail" onClick={() => setDetails((prev) => prev.filter((_x, j) => j !== i))}></Button>
</div> </div>
))} ))}
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
@ -868,7 +874,7 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
{managerial && !settingPw && <Button onClick={() => setSettingPw(true)}>{cm.hasPassword ? 'Update' : 'Set…'}</Button>} {managerial && !settingPw && <Button onClick={() => setSettingPw(true)}>{cm.hasPassword ? 'Update' : 'Set…'}</Button>}
{managerial && settingPw && ( {managerial && settingPw && (
<> <>
<input className="wf mono" type="password" style={{ width: 160 }} placeholder="Portal password" <input className="wf mono" type="password" style={{ width: 160 }} placeholder="Portal password" aria-label="Portal password"
value={pw} onChange={(e) => setPw(e.target.value)} /> value={pw} onChange={(e) => setPw(e.target.value)} />
<Button tone="primary" onClick={savePw}>{busy ? 'Saving…' : 'Save'}</Button> <Button tone="primary" onClick={savePw}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => { setSettingPw(false); setPw('') }}>Cancel</Button> <Button onClick={() => { setSettingPw(false); setPw('') }}>Cancel</Button>
@ -1027,7 +1033,7 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
{managerial && !setting && <Button onClick={() => setSetting(true)}>{has ? 'Update' : 'Set…'}</Button>} {managerial && !setting && <Button onClick={() => setSetting(true)}>{has ? 'Update' : 'Set…'}</Button>}
{managerial && setting && ( {managerial && setting && (
<> <>
<input className="wf mono" type="password" style={{ width: 160 }} placeholder={field.label} <input className="wf mono" type="password" style={{ width: 160 }} placeholder={field.label} aria-label={field.label}
value={val} onChange={(e) => setVal(e.target.value)} /> value={val} onChange={(e) => setVal(e.target.value)} />
<Button tone="primary" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</Button> <Button tone="primary" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => { setSetting(false); setVal('') }}>Cancel</Button> <Button onClick={() => { setSetting(false); setVal('') }}>Cancel</Button>

@ -4,23 +4,52 @@ import { Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, PageHead
import { getClients, CLIENT_STATUSES, type ClientStatus, type DocStatus } from '../api' import { getClients, CLIENT_STATUSES, type ClientStatus, type DocStatus } from '../api'
import { ClientFormDialog } from '../components/ClientFormDialog' 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<T>(loader: () => Promise<T>, deps: unknown[] = []): { export function useData<T>(loader: () => Promise<T>, deps: unknown[] = []): {
data?: T; error?: string; reload: () => void data?: T; error?: string; reload: () => void; isLoading: boolean
} { } {
const [data, setData] = useState<T | undefined>() const [data, setData] = useState<T | undefined>()
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [isLoading, setIsLoading] = useState(true)
const seqRef = useRef(0) 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) setError(undefined)
if (reset) setData(undefined)
setIsLoading(true)
const mySeq = ++seqRef.current const mySeq = ++seqRef.current
loader() loaderRef.current()
.then((d) => { if (seqRef.current === mySeq) setData(d) }) .then((d) => { if (seqRef.current === mySeq) { setData(d); setIsLoading(false) } })
.catch((e: Error) => { if (seqRef.current === mySeq) setError(e.message) }) .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 // eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(reload, deps) useEffect(() => run(true), deps)
return { data, error, reload }
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' 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. // D18 WS-F: the old book's two working filters, applied server-side.
const [district, setDistrict] = useState('') const [district, setDistrict] = useState('')
const [sector, setSector] = 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( const { data, error, reload } = useData(
() => getClients(q, { district, sector }), [q, district, sector], () => getClients(qDebounced, { district, sector }), [qDebounced, district, sector],
) )
const [sp, setSp] = useSearchParams() const [sp, setSp] = useSearchParams()
const [creating, setCreating] = useState(sp.get('new') === '1') const [creating, setCreating] = useState(sp.get('new') === '1')
@ -68,6 +99,7 @@ export function Clients() {
<Toolbar> <Toolbar>
<input <input
className="wf" style={{ maxWidth: 280 }} placeholder="Search name / code / GSTIN…" className="wf" style={{ maxWidth: 280 }} placeholder="Search name / code / GSTIN…"
aria-label="Search clients"
value={q} onChange={(e) => setQ(e.target.value)} value={q} onChange={(e) => setQ(e.target.value)}
/> />
<input <input

@ -70,6 +70,7 @@ export function DocumentView() {
/** POST an action; convert/credit-note navigate to the new document; toasts report the outcome. */ /** POST an action; convert/credit-note navigate to the new document; toasts report the outcome. */
const act = (action: string, body?: Record<string, unknown>, opts: { navigate?: boolean; ok?: string } = {}) => { const act = (action: string, body?: Record<string, unknown>, opts: { navigate?: boolean; ok?: string } = {}) => {
if (busy !== undefined) return // in-flight guard: a double-click must not fire the action twice
setBusy(action) setBusy(action)
documentAction(id, action, body) documentAction(id, action, body)
.then((out) => { .then((out) => {
@ -84,6 +85,9 @@ export function DocumentView() {
const issued = doc.docNo !== null const issued = doc.docNo !== null
const cancelled = doc.status === 'cancelled' const cancelled = doc.status === 'cancelled'
const settling = doc.status === 'part_paid' || doc.status === 'paid' 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 ( return (
<div className="wf-page"> <div className="wf-page">
@ -108,31 +112,31 @@ export function DocumentView() {
<Toolbar> <Toolbar>
{!issued && !cancelled && ( {!issued && !cancelled && (
<Button tone="primary" onClick={() => act('issue', undefined, { ok: 'Number issued' })}> <Button tone="primary" disabled={acting} onClick={() => act('issue', undefined, { ok: 'Number issued' })}>
{busy === 'issue' ? 'Issuing…' : 'Issue number'} {busy === 'issue' ? 'Issuing…' : 'Issue number'}
</Button> </Button>
)} )}
{issued && !cancelled && ( {issued && !cancelled && (
<Button tone="primary" onClick={() => setSending(true)}> <Button tone="primary" disabled={acting} onClick={() => setSending(true)}>
{busy === 'send' ? 'Sending…' : 'Send by email'} {busy === 'send' ? 'Sending…' : 'Send by email'}
</Button> </Button>
)} )}
{issued && doc.status === 'draft' && ( {issued && doc.status === 'draft' && (
<Button onClick={() => act('status', { status: 'sent' }, { ok: 'Marked sent' })}>Mark sent (manual)</Button> <Button disabled={acting} onClick={() => act('status', { status: 'sent' }, { ok: 'Marked sent' })}>Mark sent (manual)</Button>
)} )}
{doc.docType === 'QUOTATION' && doc.status === 'sent' && ( {doc.docType === 'QUOTATION' && doc.status === 'sent' && (
<> <>
<Button onClick={() => act('status', { status: 'accepted' }, { ok: 'Marked accepted' })}>Mark accepted</Button> <Button disabled={acting} onClick={() => act('status', { status: 'accepted' }, { ok: 'Marked accepted' })}>Mark accepted</Button>
<Button onClick={() => act('status', { status: 'lost' }, { ok: 'Marked lost' })}>Mark lost</Button> <Button disabled={acting} onClick={() => act('status', { status: 'lost' }, { ok: 'Marked lost' })}>Mark lost</Button>
</> </>
)} )}
{doc.docType === 'QUOTATION' && !cancelled && ( {doc.docType === 'QUOTATION' && !cancelled && (
<Button onClick={() => act('convert', { to: 'PROFORMA' }, { navigate: true, ok: 'Converted to proforma' })}> <Button disabled={acting} onClick={() => act('convert', { to: 'PROFORMA' }, { navigate: true, ok: 'Converted to proforma' })}>
Convert to Proforma Convert to Proforma
</Button> </Button>
)} )}
{(doc.docType === 'QUOTATION' || doc.docType === 'PROFORMA') && !cancelled && ( {(doc.docType === 'QUOTATION' || doc.docType === 'PROFORMA') && !cancelled && (
<Button onClick={() => act('convert', { to: 'INVOICE' }, { navigate: true, ok: 'Converted to invoice' })}> <Button disabled={acting} onClick={() => act('convert', { to: 'INVOICE' }, { navigate: true, ok: 'Converted to invoice' })}>
Convert to Invoice Convert to Invoice
</Button> </Button>
)} )}
@ -141,6 +145,7 @@ export function DocumentView() {
{/* Spec §8 one-click: convert → issue → email, in one action. */} {/* Spec §8 one-click: convert → issue → email, in one action. */}
<Button <Button
tone="primary" tone="primary"
disabled={acting}
onClick={() => setConfirm({ onClick={() => setConfirm({
title: 'Convert to invoice & send', title: 'Convert to invoice & send',
body: 'Convert to invoice, assign its number and email it to the client?', body: 'Convert to invoice, assign its number and email it to the client?',
@ -152,6 +157,7 @@ export function DocumentView() {
</Button> </Button>
{/* Spec §8 cancel & recreate: retire this proforma, reopen its lines as a fresh draft. */} {/* Spec §8 cancel & recreate: retire this proforma, reopen its lines as a fresh draft. */}
<Button <Button
disabled={acting}
onClick={() => setConfirm({ onClick={() => setConfirm({
title: 'Supersede & recreate', title: 'Supersede & recreate',
body: 'Supersede this proforma? It is cancelled (number stays consumed) and a fresh draft opens with the same lines.', body: 'Supersede this proforma? It is cancelled (number stays consumed) and a fresh draft opens with the same lines.',
@ -165,11 +171,12 @@ export function DocumentView() {
</> </>
)} )}
{doc.docType === 'INVOICE' && issued && !cancelled && doc.status !== 'paid' && ( {doc.docType === 'INVOICE' && issued && !cancelled && doc.status !== 'paid' && (
<Button tone="primary" onClick={() => setPaying((v) => !v)}>Record payment</Button> <Button tone="primary" disabled={acting} onClick={() => setPaying((v) => !v)}>Record payment</Button>
)} )}
{issued && !cancelled && !settling && ( {issued && !cancelled && !settling && (
<Button <Button
tone="danger" tone="danger"
disabled={acting}
onClick={() => setConfirm({ onClick={() => setConfirm({
title: 'Cancel document', title: 'Cancel document',
body: `Cancel ${doc.docNo}? The number stays consumed.`, body: `Cancel ${doc.docNo}? The number stays consumed.`,
@ -183,6 +190,7 @@ export function DocumentView() {
)} )}
{doc.docType === 'INVOICE' && issued && !cancelled && ( {doc.docType === 'INVOICE' && issued && !cancelled && (
<Button <Button
disabled={acting}
onClick={() => setConfirm({ onClick={() => setConfirm({
title: 'Create credit note', title: 'Create credit note',
body: `Create a full-value credit note against ${doc.docNo}?`, body: `Create a full-value credit note against ${doc.docNo}?`,

@ -6,6 +6,7 @@ import {
Skeleton, Toolbar, useToast, Skeleton, Toolbar, useToast,
} from '@sims/ui' } from '@sims/ui'
import { documentAction, getDocuments, DOC_STATUSES, type DocStatus, type DocType } from '../api' import { documentAction, getDocuments, DOC_STATUSES, type DocStatus, type DocType } from '../api'
import { Pager } from '../components/Pager'
import { useData, DOC_TONE } from './Clients' import { useData, DOC_TONE } from './Clients'
const PAGE_SIZE = 50 const PAGE_SIZE = 50
@ -48,9 +49,6 @@ export function Documents() {
const data = list.data const data = list.data
const docs = data?.documents const docs = data?.documents
const totalPages = data !== undefined ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1
const from = data !== undefined && data.total > 0 ? (data.page - 1) * data.pageSize + 1 : 0
const to = data !== undefined ? Math.min(data.page * data.pageSize, data.total) : 0
const why = [ const why = [
type !== 'all' ? ` of type ${TYPE_LABEL[type as DocType].toLowerCase()}` : '', type !== 'all' ? ` of type ${TYPE_LABEL[type as DocType].toLowerCase()}` : '',
status !== '' ? ` with status ${status}` : '', status !== '' ? ` with status ${status}` : '',
@ -130,23 +128,7 @@ export function Documents() {
confirmLabel="Convert & send" confirmLabel="Convert & send"
/> />
)} )}
<Toolbar> <Pager total={data!.total} page={data!.page} pageSize={data!.pageSize} onPage={setPage} />
<Badge>Showing {from}{to} of {data!.total}</Badge>
<span style={{ flex: 1 }} />
<button
type="button" className="wf" disabled={data!.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<span style={{ fontSize: 12, opacity: 0.7 }}>Page {data!.page} / {totalPages}</span>
<button
type="button" className="wf" disabled={data!.page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</Toolbar>
</> </>
)} )}
</div> </div>

@ -85,6 +85,7 @@ function ModuleClients(props: { module: Module }) {
const [notifying, setNotifying] = useState(false) const [notifying, setNotifying] = useState(false)
const [subject, setSubject] = useState('') const [subject, setSubject] = useState('')
const [bodyText, setBodyText] = useState('') const [bodyText, setBodyText] = useState('')
const [confirmNotify, setConfirmNotify] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [result, setResult] = useState<string | undefined>() const [result, setResult] = useState<string | undefined>()
const [err, setErr] = useState<string | undefined>() const [err, setErr] = useState<string | undefined>()
@ -127,9 +128,11 @@ function ModuleClients(props: { module: Module }) {
.finally(() => setBusy(false)) .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 = () => { const doNotify = () => {
setBusy(true); setErr(undefined); setResult(undefined) setBusy(true); setErr(undefined); setResult(undefined)
notifyModuleClients(props.module.id, { subject, body: bodyText }) return notifyModuleClients(props.module.id, { subject, body: bodyText })
.then((out) => { .then((out) => {
setResult(`Sent ${out.sent}/${out.total}` setResult(`Sent ${out.sent}/${out.total}`
+ (out.failed.length > 0 ? ` — unreached: ${out.failed.map((f) => `${f.client} (${f.error})`).join(', ')}` : '')) + (out.failed.length > 0 ? ` — unreached: ${out.failed.map((f) => `${f.client} (${f.error})`).join(', ')}` : ''))
@ -193,12 +196,23 @@ function ModuleClients(props: { module: Module }) {
</Field> </Field>
<Button <Button
tone="primary" tone="primary"
onClick={() => { 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}`} {busy ? 'Sending…' : `Send to every client on ${props.module.code}`}
</Button> </Button>
</div> </div>
)} )}
{confirmNotify && (
<ConfirmDialog
open
onClose={() => 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 && <Notice tone="ok">{result}</Notice>} {result !== undefined && <Notice tone="ok">{result}</Notice>}
{err !== undefined && <Notice tone="err">{err}</Notice>} {err !== undefined && <Notice tone="err">{err}</Notice>}
{roster.error !== undefined ? <ErrorState message={roster.error} onRetry={roster.reload} /> {roster.error !== undefined ? <ErrorState message={roster.error} onRetry={roster.reload} />
@ -224,7 +238,7 @@ function ModuleClients(props: { module: Module }) {
actions: ( actions: (
<span style={{ display: 'flex', gap: 6 }}> <span style={{ display: 'flex', gap: 6 }}>
<Button onClick={() => setEdit({ cmId: c.cmId, kind: c.kind, edition: c.edition, renewal: c.nextRenewal ?? '', status: c.status })}>Edit</Button> <Button onClick={() => setEdit({ cmId: c.cmId, kind: c.kind, edition: c.edition, renewal: c.nextRenewal ?? '', status: c.status })}>Edit</Button>
<Button tone="danger" onClick={() => setUnassign({ cmId: c.cmId, clientName: c.clientName })}></Button> <Button tone="danger" aria-label={`Unassign ${c.clientName}`} onClick={() => setUnassign({ cmId: c.cmId, clientName: c.clientName })}></Button>
</span> </span>
), ),
} }
@ -408,8 +422,8 @@ function FieldSpecEditor(props: { module: Module; isOwner: boolean; onSaved: ()
{rows.length === 0 ? <EmptyState>No fields yet add the first below.</EmptyState> : rows.map((r, i) => ( {rows.length === 0 ? <EmptyState>No fields yet add the first below.</EmptyState> : rows.map((r, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 8 }}> <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 8 }}>
<span style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <span style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button disabled={i === 0} onClick={() => move(i, -1)}></Button> <Button disabled={i === 0} aria-label="Move field up" onClick={() => move(i, -1)}></Button>
<Button disabled={i === rows.length - 1} onClick={() => move(i, 1)}></Button> <Button disabled={i === rows.length - 1} aria-label="Move field down" onClick={() => move(i, 1)}></Button>
</span> </span>
<input <input
className="wf" placeholder="Label" style={{ width: 160 }} aria-label="Field label" className="wf" placeholder="Label" style={{ width: 160 }} aria-label="Field label"
@ -439,7 +453,7 @@ function FieldSpecEditor(props: { module: Module; isOwner: boolean; onSaved: ()
<input type="checkbox" checked={r.required} onChange={(e) => setRow(i, { required: e.target.checked })} /> <input type="checkbox" checked={r.required} onChange={(e) => setRow(i, { required: e.target.checked })} />
required required
</label> </label>
<Button tone="danger" onClick={() => setRows((prev) => prev.filter((_x, j) => j !== i))}></Button> <Button tone="danger" aria-label="Remove field" onClick={() => setRows((prev) => prev.filter((_x, j) => j !== i))}></Button>
</div> </div>
))} ))}
<Toolbar> <Toolbar>

@ -101,7 +101,15 @@ export function Reports() {
<div style={{ margin: '10px 0' }}> <div style={{ margin: '10px 0' }}>
{ranking.data.rows.map((row) => ( {ranking.data.rows.map((row) => (
<div key={row.clientId} style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '4px 0' }}> <div key={row.clientId} style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '4px 0' }}>
<div style={{ width: 160, cursor: 'pointer' }} onClick={() => nav(`/clients/${row.clientId}`)}>{row.clientName}</div> {/* Real <button> so the client link is focusable + Enter/Space works. */}
<button
type="button"
onClick={() => nav(`/clients/${row.clientId}`)}
style={{
width: 160, textAlign: 'left', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, font: 'inherit', color: 'inherit',
}}
>{row.clientName}</button>
<div style={{ flex: 1, background: 'var(--border)', borderRadius: 4, height: 16 }}> <div style={{ flex: 1, background: 'var(--border)', borderRadius: 4, height: 16 }}>
<div style={{ width: `${row.sharePctBp / 100}%`, background: 'var(--accent, #4a7)', height: '100%', borderRadius: 4 }} /> <div style={{ width: `${row.sharePctBp / 100}%`, background: 'var(--accent, #4a7)', height: '100%', borderRadius: 4 }} />
</div> </div>

@ -9,7 +9,7 @@ import {
TICKET_STATUSES, TICKET_STATUSES,
type Branch, type Client, type Employee, type Ticket, type TicketStatus, type Branch, type Client, type Employee, type Ticket, type TicketStatus,
} from '../api' } from '../api'
import { useData } from './Clients' import { useData, useDebounced } from './Clients'
const PAGE_SIZE = 50 const PAGE_SIZE = 50
@ -260,15 +260,17 @@ export function Tickets() {
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const me = staffId() const me = staffId()
const employees = useData(() => getEmployees(), []) 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( const list = useData(
() => getTickets({ () => getTickets({
...(status !== 'all' ? { status: status as TicketStatus } : {}), ...(status !== 'all' ? { status: status as TicketStatus } : {}),
...(scope === 'mine' ? { mine: true } : {}), ...(scope === 'mine' ? { mine: true } : {}),
...(q !== '' ? { q } : {}), ...(qDebounced !== '' ? { q: qDebounced } : {}),
page, pageSize: PAGE_SIZE, page, pageSize: PAGE_SIZE,
}), }),
[status, scope, q, page], [status, scope, qDebounced, page],
) )
const data = list.data const data = list.data
@ -306,6 +308,7 @@ export function Tickets() {
)} )}
<input <input
className="wf" style={{ maxWidth: 240 }} placeholder="Search description / kind / client…" className="wf" style={{ maxWidth: 240 }} placeholder="Search description / kind / client…"
aria-label="Search tickets"
value={q} onChange={(e) => { setQ(e.target.value); setPage(1) }} value={q} onChange={(e) => { setQ(e.target.value); setPage(1) }}
/> />
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />

@ -37,15 +37,20 @@ export function Button(props: {
hotkey?: string hotkey?: string
pill?: boolean pill?: boolean
disabled?: boolean disabled?: boolean
/** 'submit' lets the button submit an enclosing <form> (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' : ''}` const cls = `wf${props.tone === 'primary' ? ' primary' : props.tone === 'danger' ? ' danger' : ''}${props.pill === true ? ' pill' : ''}`
return ( return (
<button <button
type="button" type={props.type ?? 'button'}
className={cls} className={cls}
onClick={props.onClick} onClick={props.onClick}
disabled={props.disabled === true} disabled={props.disabled === true}
aria-disabled={props.disabled === true ? true : undefined} aria-disabled={props.disabled === true ? true : undefined}
aria-label={props['aria-label']}
> >
{props.children} {props.children}
{props.hotkey !== undefined && <kbd>{props.hotkey}</kbd>} {props.hotkey !== undefined && <kbd>{props.hotkey}</kbd>}
@ -86,12 +91,22 @@ export function DataTable(props: {
<tbody> <tbody>
{props.rows.map((row, i) => { {props.rows.map((row, i) => {
const tone = props.rowTone?.(i) const tone = props.rowTone?.(i)
const clickable = props.onRowClick !== undefined
return ( return (
<tr <tr
key={i} key={i}
className={tone !== undefined ? `tone-${tone}` : undefined} className={tone !== undefined ? `tone-${tone}` : undefined}
onClick={() => props.onRowClick?.(row, i)} // Row click is keyboard-operable (WCAG 2.1.1): role=button + tabindex
style={props.onRowClick !== undefined ? { cursor: 'pointer' } : undefined} // make it a focusable control, and Enter/Space fire the same handler.
onClick={clickable ? () => props.onRowClick?.(row, i) : undefined}
onKeyDown={clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onRowClick?.(row, i) }
}
: undefined}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
style={clickable ? { cursor: 'pointer' } : undefined}
> >
{props.columns.map((c) => ( {props.columns.map((c) => (
<td key={c.key} className={cellClass(c)}>{row[c.key]}</td> <td key={c.key} className={cellClass(c)}>{row[c.key]}</td>

@ -67,6 +67,7 @@ export function CommandPalette(props: {
ref={inputRef} ref={inputRef}
value={q} value={q}
placeholder={props.placeholder ?? 'Search…'} placeholder={props.placeholder ?? 'Search…'}
aria-label={props.placeholder ?? 'Search'}
role="combobox" role="combobox"
aria-controls="wf-palette-listbox" aria-controls="wf-palette-listbox"
aria-expanded="true" aria-expanded="true"

@ -51,6 +51,9 @@ export function ToastProvider(props: { children: ReactNode }) {
{state.toasts.map((t) => ( {state.toasts.map((t) => (
<div <div
key={t.id} key={t.id}
// Errors interrupt (assertive) via role=alert; successes announce
// politely via role=status — urgency isn't left to colour alone.
role={t.tone === 'err' ? 'alert' : 'status'}
className={`wf-toast ${t.tone}`} className={`wf-toast ${t.tone}`}
onMouseEnter={() => pause(t.id)} onMouseEnter={() => pause(t.id)}
onMouseLeave={() => arm(t.id)} onMouseLeave={() => arm(t.id)}

Loading…
Cancel
Save