You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/superpowers/plans/2026-07-19-client-detail-re...

573 lines
31 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# Client Detail Redesign — Frontend Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rebuild the Modules and Payments tabs of Client Detail — module details as a 50/50 read-only report with a single Edit per group, onboarding as a horizontal per-module status line, per-module Documents (incl. a bulk-SMS-purchase action), and a Payments Outstanding panel — plus a per-module template editor and a stalled-onboarding tile, verified pixel-tight on mobile via a red-team alignment pass.
**Architecture:** React + Vite; all data via the typed `api.ts` calls. Styling is token-based CSS in `apps/hq-web/src/app.css` (Warm/Slate/Graphite/Zinc neutrals × 7 accents × light/dark) — new surfaces use existing tokens only. This plan consumes the backend from `2026-07-19-client-detail-redesign-backend.md` (ledger `outstanding`/`allocations`, per-module templates, `setMilestone(paymentId)`, `generateBulkSmsPurchaseQuote`, `stalledProjects`).
**Tech Stack:** React 18, TypeScript (strict), Vite, `@sims/ui`.
## Global Constraints
- **Prerequisite:** the backend plan is merged (its API shapes exist). If not, stop and do it first.
- No new colour constants — style through CSS custom properties (`--accent`, `--border`, `--ok/--warn/--err`, `--bg-raised`, `--text`, `--text-dim`, `--radius`, `--mono`). Must work in **light and dark** and all four neutrals.
- The page body never scrolls sideways; wide content scrolls inside its own `overflow-x:auto`. Numeric columns use `font-variant-numeric: tabular-nums`.
- No frontend unit-test harness exists (tests are backend vitest). Each task's verification is: `npm run typecheck` clean, `npm run build --workspace @sims/hq-web` clean, and a **manual check in the running app** (backend on :5182 serving the built UI, or Vite dev on :5183). The final task is the responsive + red-team gate.
- Run from repo root. Reference the approved mockup `scratchpad/client-detail-mockup.html` for exact layout/CSS.
---
## File Structure
- `apps/hq-web/src/app.css` — MODIFY: `.split`/`.kv`, `.track`/`.node`, `.out` outstanding-panel, doc-label pill classes.
- `apps/hq-web/src/pages/ClientDetail.tsx` — MODIFY: `ServiceDataCard` (50/50 + single edit), replace `MilestoneChecklist` with `StatusLine`, add `ModuleDocuments`, Payments tab (`OutstandingPanel` + Settled column), summary-table trim.
- `apps/hq-web/src/pages/client-forms.tsx` — MODIFY: `AssignModuleForm` copy → "Add a module".
- `apps/hq-web/src/api.ts` — MODIFY: add `generateBulkSmsPurchase`, `getStalledProjects`, `getModuleTemplate`/`setModuleTemplate`, and thread `paymentId` into `setMilestone`.
- `apps/hq-web/src/pages/Modules.tsx` — MODIFY: per-module onboarding template editor.
- `apps/hq-web/src/pages/Dashboard.tsx` — MODIFY: stalled-onboarding tile.
Each new sub-component (`StatusLine`, `ModuleDocuments`, `OutstandingPanel`) is a local function component in `ClientDetail.tsx` next to the code it replaces (that file already houses `ServiceDataCard`, `MilestoneChecklist`, etc.). Keep them focused; if `ClientDetail.tsx` grows past readability, extract the module-block components into `pages/client-module-block.tsx` as a follow-up (note it, don't force it).
---
## Task 1: CSS foundation (port the mockup styles)
**Files:**
- Modify: `apps/hq-web/src/app.css` (append a new section)
**Interfaces:**
- Produces: classes `.split`, `.kv .k/.val`, `.track`/`.node` (+ `.done/.now`), `.out` (+ `.oh/.orow/.ofoot`), `.doc-pill` used by later tasks. These MUST match the class names the JSX in Tasks 25 references.
- [ ] **Step 1: Append the styles**
Add to `apps/hq-web/src/app.css` (values ported from `scratchpad/client-detail-mockup.html`, swapping the mockup's literal colours for tokens):
```css
/* ---- Client Detail redesign (D-CDR) ---- */
/* 50/50 service sheet */
.cdr-split { display: grid; grid-template-columns: 1fr 1fr; gap: 0 30px; }
.cdr-split > .col { padding: 13px 0; min-width: 0; }
.cdr-split > .col + .col { border-left: 1px solid var(--border); padding-left: 30px; }
.cdr-kv { display: flex; align-items: baseline; gap: 10px; padding: 6px 0; min-width: 0; }
.cdr-kv + .cdr-kv { border-top: 1px dashed var(--border); }
.cdr-kv > .k { flex: none; width: 120px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-dim); font-weight: 600; padding-top: 2px; }
.cdr-kv > .v { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.cdr-kv > .v .txt { overflow: hidden; text-overflow: ellipsis; }
@media (max-width: 720px) {
.cdr-split { grid-template-columns: 1fr; }
.cdr-split > .col + .col { border-left: none; padding-left: 0; border-top: 1px solid var(--border); }
}
/* horizontal onboarding status line */
.cdr-track-wrap { overflow-x: auto; padding: 10px 2px 4px; }
.cdr-track { display: flex; min-width: min-content; }
.cdr-node { flex: 1 0 112px; display: flex; flex-direction: column; align-items: center; text-align: center; position: relative; padding: 0 4px; }
.cdr-node .bar { position: absolute; top: 11px; right: 50%; width: 100%; height: 2px; background: var(--border); }
.cdr-node.done .bar, .cdr-node.now .bar { background: var(--accent); }
.cdr-node:first-child .bar { display: none; }
.cdr-node .dot { position: relative; z-index: 1; width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--border-strong); background: var(--bg-raised); display: grid; place-items: center; font-size: 11px; color: transparent; cursor: pointer; }
.cdr-node.done .dot { background: var(--ok); border-color: var(--ok); color: #fff; }
.cdr-node.now .dot { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
.cdr-node .nm { font-size: 11px; margin-top: 7px; max-width: 104px; line-height: 1.35; }
.cdr-node.done .nm { color: var(--text-dim); }
.cdr-node.now .nm { font-weight: 600; }
.cdr-node .dt { font-size: 10px; color: var(--text-dim); font-family: var(--mono); margin-top: 3px; }
/* outstanding panel */
.cdr-out { border: 1px solid color-mix(in srgb, var(--warn) 30%, var(--border)); border-radius: var(--radius); overflow: hidden; }
.cdr-out .oh { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); }
.cdr-out .orow { display: flex; align-items: center; gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 12.5px; }
.cdr-out .orow:last-child { border-bottom: none; }
.cdr-out .orow .due { margin-left: auto; font-family: var(--mono); font-weight: 600; }
.cdr-out .ofoot { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--bg-inset); }
/* document-type pill */
.doc-pill { font-size: 10.5px; font-weight: 600; letter-spacing: .3px; border-radius: 999px; padding: 1px 8px; border: 1px solid var(--border); color: var(--text-dim); background: var(--bg-inset); }
```
- [ ] **Step 2: Verify build**
Run: `npm run build --workspace @sims/hq-web`
Expected: build succeeds (CSS is valid, no selector errors).
- [ ] **Step 3: Commit**
```bash
git add apps/hq-web/src/app.css
git commit -m "style(client-detail): CSS for 50/50 sheet, status line, outstanding panel"
```
---
## Task 2: Module details → 50/50 report with single Edit per group
**Files:**
- Modify: `apps/hq-web/src/pages/ClientDetail.tsx``ServiceDataCard` (lines ~9611103) and fold `ModuleFieldsForm` (lines ~11121209) presentation into it.
**Interfaces:**
- Consumes: existing `patchClientModule`, `setModuleFields`, `revealModulePassword`, `SecretField`, `cm.details`, `cm.fieldValues`, `props.fieldSpec`.
- Produces: read-only 50/50 report; each group (`Access`, `Details`) has one **Edit** toggling that group's inputs; save = one audited call.
- [ ] **Step 1: Replace the read view of `ServiceDataCard`**
Rewrite the returned read view (the non-editing `return (...)` at ~1066) as the 50/50 report. Keep the existing edit-state machinery; change only what renders. The read view:
```tsx
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div className="cdr-split">
<div className="col">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span className="lbl" style={{ fontWeight: 700 }}>Access</span>
<span style={{ flex: 1, height: 1, background: 'var(--border)' }} />
<Button onClick={startEdit}>Edit</Button>
</div>
<Kv label="Provider" value={cm.provider ?? undefined} />
<Kv label="Login" value={cm.username ?? undefined} mono copyable onCopy={() => copy(cm.username ?? '', 'Username')} />
<div className="cdr-kv">
<span className="k">Password</span>
<span className="v">
{revealed !== undefined
? <><span className="mono">{revealed}</span><Button onClick={() => copy(revealed, 'Portal password')}>Copy</Button><Button onClick={() => setRevealed(undefined)}>Hide</Button></>
: cm.hasPassword
? <><span className="mono">••••••••</span>{managerial && <Button onClick={reveal}>{busy ? '…' : 'Reveal'}</Button>}</>
: <span style={{ opacity: 0.6 }}>not set</span>}
</span>
</div>
{cm.remark !== null && cm.remark !== '' && <Kv label="Remark" value={cm.remark} />}
</div>
<div className="col">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span className="lbl" style={{ fontWeight: 700 }}>Details</span>
<span style={{ flex: 1, height: 1, background: 'var(--border)' }} />
<Button onClick={startEdit}>Edit</Button>
</div>
{props.fieldSpec.filter((f) => f.type !== 'secret').map((f) => (
<Kv key={f.key} label={f.label} value={cm.fieldValues[f.key]} />
))}
{cm.details.map((d, i) => <Kv key={`d${i}`} label={d.label} value={d.value} />)}
{props.fieldSpec.filter((f) => f.type === 'secret').map((f) => (
<div className="cdr-kv" key={f.key}>
<span className="k">{f.label}</span>
<span className="v"><SecretField cm={cm} field={f} managerial={managerial} onChanged={props.onChanged} /></span>
</div>
))}
</div>
</div>
</div>
)
```
Add a small `Kv` helper in the same file:
```tsx
function Kv(props: { label: string; value: string | undefined; mono?: boolean; copyable?: boolean; onCopy?: () => void }) {
const v = props.value
return (
<div className="cdr-kv">
<span className="k">{props.label}</span>
<span className="v">
{v !== undefined && v !== '' ? <span className={`txt${props.mono ? ' mono' : ''}`}>{v}</span> : <span style={{ opacity: 0.5 }}></span>}
{props.copyable === true && v !== undefined && v !== '' && <Button onClick={props.onCopy}>Copy</Button>}
</span>
</div>
)
}
```
- [ ] **Step 2: Fold the fieldSpec edit into the Details Edit**
In the editing branch of `ServiceDataCard` (~1028), extend the edit form to also render the non-secret `fieldSpec` inputs (move the control logic from `ModuleFieldsForm`), so one **Save** persists provider/username/password/remark + details + fieldValues. Remove the separate always-on `ModuleFieldsForm` render at line ~10951100 (secret fields stay as `SecretField` in the read view). Keep `saveEdit` calling `patchClientModule` for the D20 fields and `setModuleFields` for the fieldSpec values (two audited calls in sequence is acceptable; or combine if `patchClientModule` accepts field values — check its signature).
- [ ] **Step 3: Verify**
Run: `npm run typecheck && npm run build --workspace @sims/hq-web`
Then run the app and open a client with SMS + RTGS: confirm the 50/50 read view, one Edit per group, save round-trips, secret reveal still audited, `—` for empty fields, no `*` markers.
- [ ] **Step 4: Commit**
```bash
git add apps/hq-web/src/pages/ClientDetail.tsx
git commit -m "feat(client-detail): module details as 50/50 report with single edit per group"
```
---
## Task 3: Onboarding → horizontal status line (+ payment-step link)
**Files:**
- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` — replace `MilestoneChecklist` (~12701357) with `StatusLine`.
- Modify: `apps/hq-web/src/api.ts` — thread `paymentId` into `setMilestone`.
**Interfaces:**
- Consumes: `getMilestones`, `setMilestone(clientModuleId, key, done, doneOn?, paymentId?)`, `addMilestone`, `Milestone` (now with `paymentId`).
- Produces: `StatusLine` component rendering the horizontal `.cdr-track`.
- [ ] **Step 1: Extend the web `setMilestone` call**
In `apps/hq-web/src/api.ts`, update the milestone POST helper to accept an optional `paymentId` and include it in the body. Confirm the `Milestone` type includes `paymentId: string | null`.
- [ ] **Step 2: Write `StatusLine`**
Replace `MilestoneChecklist` with:
```tsx
function StatusLine(props: { clientModuleId: string; name: string }) {
const toast = useToast()
const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId])
const [busyKey, setBusyKey] = useState('')
const [adding, setAdding] = useState(false)
const [label, setLabel] = useState('')
const toggle = (m: Milestone, done: boolean, doneOn?: string) => {
if (busyKey !== '') return
setBusyKey(m.key)
setMilestone(props.clientModuleId, m.key, done, doneOn)
.then(() => list.reload()).catch((e: Error) => toast.err(e.message)).finally(() => setBusyKey(''))
}
const addStep = () => {
if (label.trim() === '') return
addMilestone(props.clientModuleId, label.trim())
.then(() => { setLabel(''); setAdding(false); list.reload() }).catch((e: Error) => toast.err(e.message))
}
const ms = list.data
const done = ms?.filter((m) => m.done).length ?? 0
// current = first not-done
const currentKey = ms?.find((m) => !m.done)?.key
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontWeight: 600 }}>{props.name} status line</span>
{ms !== undefined && ms.length > 0 && <Badge tone={done === ms.length ? 'ok' : undefined}>{done}/{ms.length}</Badge>}
<span style={{ flex: 1 }} />
{!adding ? <Button onClick={() => setAdding(true)}>+ Add step</Button> : (
<>
<input className="wf" style={{ maxWidth: 200 }} placeholder="Step label" value={label}
onChange={(e) => setLabel(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') addStep() }} />
<Button tone="primary" onClick={addStep}>Add</Button>
<Button onClick={() => { setAdding(false); setLabel('') }}>Cancel</Button>
</>
)}
</div>
{list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
: ms === undefined ? <Skeleton rows={2} />
: ms.length === 0 ? <EmptyState>No steps yet.</EmptyState> : (
<div className="cdr-track-wrap"><div className="cdr-track">
{ms.map((m) => (
<div key={m.key} className={`cdr-node ${m.done ? 'done' : m.key === currentKey ? 'now' : ''}`}
style={{ opacity: busyKey === m.key ? 0.5 : 1 }}>
<span className="bar" />
<button className="dot" title={m.done ? 'Click to un-tick' : 'Click to mark done (today)'}
onClick={() => toggle(m, !m.done)}>{m.done ? '✓' : m.key === currentKey ? '●' : ''}</button>
<span className="nm">{m.label}</span>
<span className="dt">{m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'}</span>
</div>
))}
</div></div>
)}
</div>
)
}
```
Update the modules-tab render (line ~316) to use `<StatusLine clientModuleId={cm.id} name={moduleName(cm.moduleId)} />` and rename the heading if present.
- [ ] **Step 3: Payment-step linking (advance/balance)**
When a node whose key is `advance_payment` or `balance_payment` is ticked, open a small dialog to pick an existing payment (from the client ledger) or record a new one, then call `setMilestone(cmId, key, true, undefined, paymentId)`. Minimum viable: a dialog listing `ledger.payments` (date + amount) to pick from, plus a "Record new payment" shortcut that switches to the Payments tab. Wire the dialog state in `ClientDetail` and pass an `onTickPayment(key)` callback into `StatusLine` for those two keys. (If a lighter first cut is preferred, tick normally with today's date and leave linking as a follow-up — but the spec calls for the link; implement the picker.)
- [ ] **Step 4: Verify**
Run: `npm run typecheck && npm run build --workspace @sims/hq-web`. In-app: tick along the SMS line → dots fill, current ring moves, ticking Installation flips the header status to `installed`, ticking Go-live → `live` (backend Task 3); line scrolls on a narrow window; ticking Advance payment prompts to link a payment.
- [ ] **Step 5: Commit**
```bash
git add apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/api.ts
git commit -m "feat(client-detail): onboarding as horizontal status line with payment-step linking"
```
---
## Task 4: Per-module Documents + Bulk SMS purchase
**Files:**
- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` — add `ModuleDocuments`; render inside each module block after `StatusLine`.
- Modify: `apps/hq-web/src/api.ts` — add `generateBulkSmsPurchase(cmId, smsQty)`.
**Interfaces:**
- Consumes: `ledger.data.documents` (each `Doc.payload.lines[].itemId === moduleId`), `doc.source` (`module_renewal`/`bulk_sms_topup`), `nav`.
- Produces: `ModuleDocuments` component; a Bulk SMS action for SMS-coded modules.
- [ ] **Step 1: Add the web API call**
In `apps/hq-web/src/api.ts`:
```ts
export const generateBulkSmsPurchase = (clientModuleId: string, smsQty: number) =>
post<{ document: Doc }>(`/client-modules/${clientModuleId}/bulk-sms-quote`, { smsQty }).then((r) => r.document)
```
(Match the existing `post`/typed-call idiom in the file.)
- [ ] **Step 2: Write `ModuleDocuments`**
```tsx
const DOC_SOURCE_LABEL: Record<string, string> = {
module_renewal: 'Renewal', bulk_sms_topup: 'Bulk top-up',
}
function docLabel(d: Doc): string {
if (DOC_SOURCE_LABEL[d.source] !== undefined) return DOC_SOURCE_LABEL[d.source]!
return DOC_TYPE_LABEL[d.docType] ?? d.docType
}
function ModuleDocuments(props: { cm: ClientModule; docs: Doc[]; moduleCode: string; onCreated: () => void }) {
const nav = useNavigate()
const toast = useToast()
const [busy, setBusy] = useState(false)
const mine = props.docs
.filter((d) => d.payload.lines.some((l) => l.itemId === props.cm.moduleId))
.sort((a, b) => b.docDate.localeCompare(a.docDate))
const isSms = props.moduleCode.toUpperCase() === 'SMS'
const bulk = () => {
const qty = Number(window.prompt('Bulk SMS quantity to purchase?', '100000') ?? '')
if (!Number.isInteger(qty) || qty <= 0) return
setBusy(true)
generateBulkSmsPurchase(props.cm.id, qty)
.then((doc) => { toast.ok('Bulk SMS proforma created'); nav(`/documents/${doc.id}`) })
.catch((e: Error) => { toast.err(e.message); setBusy(false) })
}
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span className="lbl" style={{ fontWeight: 700 }}>Documents</span>
<Badge>{mine.length}</Badge>
<span style={{ flex: 1 }} />
{isSms && <Button disabled={busy} onClick={bulk}>Bulk SMS purchase</Button>}
</div>
{mine.length === 0 ? <div style={{ color: 'var(--text-dim)', fontSize: 12.5 }}>No documents for this module yet.</div>
: mine.map((d) => (
<div key={d.id} onClick={() => nav(`/documents/${d.id}`)}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '7px 0', borderTop: '1px solid var(--border)', cursor: 'pointer', fontSize: 12.5 }}>
<span className="mono" style={{ minWidth: 74 }}>{d.docNo ?? 'draft'}</span>
<span className="doc-pill">{docLabel(d)}</span>
<span className="mono" style={{ color: 'var(--text-dim)' }}>{d.docDate}</span>
<span className="mono" style={{ marginLeft: 'auto' }}>{inr(d.payablePaise)}</span>
<Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>
</div>
))}
</div>
)
}
```
- [ ] **Step 3: Render it in each module block**
In the modules-tab `.map` (~300319), inside `.mod-section-body`, after `<StatusLine … />` add:
```tsx
<ModuleDocuments cm={cm} docs={ledger.data?.documents ?? []} moduleCode={modules.data?.find((m) => m.id === cm.moduleId)?.code ?? ''} onCreated={() => ledger.reload()} />
```
- [ ] **Step 4: Verify**
Run: `npm run typecheck && npm run build --workspace @sims/hq-web`. In-app: SMS block lists its quotes/proformas with correct pills (Renewal / Bulk top-up / Quote); "Bulk SMS purchase" creates a proforma and opens it; it then appears in the list and (Task 5) in Outstanding.
- [ ] **Step 5: Commit**
```bash
git add apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/api.ts
git commit -m "feat(client-detail): per-module documents list + bulk SMS purchase action"
```
---
## Task 5: Payments tab — Outstanding panel + Settled column
**Files:**
- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` — Payments tab (~398475).
**Interfaces:**
- Consumes: `ledger.data.outstanding`, `ledger.data.payments[].allocations` (backend Task 6).
- [ ] **Step 1: Add the Outstanding panel**
At the top of the `tab === 'payments'` block, before `<PaymentForm …>`, render:
```tsx
{ledger.data !== undefined && ledger.data.outstanding.length > 0 && (
<div className="wf-card">
<h3 style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 10px' }}>
Outstanding
<Badge tone="warn">{inr(ledger.data.outstanding.reduce((s, o) => s + o.outstandingPaise, 0))} due</Badge>
</h3>
<div className="cdr-out">
<div className="oh"><span style={{ fontWeight: 700, fontSize: 13 }}>Open documents</span>
<span style={{ color: 'var(--text-dim)', fontSize: 12 }}> oldest settles first</span></div>
{ledger.data.outstanding.map((o) => (
<div key={o.docId} className="orow" onClick={() => nav(`/documents/${o.docId}`)} style={{ cursor: 'pointer' }}>
<span className="mono" style={{ minWidth: 78 }}>{o.docNo ?? 'draft'}</span>
<span className="doc-pill">{DOC_TYPE_LABEL[o.docType as keyof typeof DOC_TYPE_LABEL] ?? o.docType}</span>
<span style={{ color: 'var(--text-dim)' }}>{o.label}</span>
<span className="due">{inr(o.outstandingPaise)}</span>
</div>
))}
<div className="ofoot"><span style={{ color: 'var(--text-dim)', fontSize: 12 }}>Advance on account</span>
<span className="mono" style={{ marginLeft: 'auto', fontWeight: 600 }}>{formatINR(ledger.data.advancePaise)}</span></div>
</div>
</div>
)}
```
- [ ] **Step 2: Add the Settled column to the payments table**
In the payments `DataTable` (~407), add a `settled` column between `ref` and `amount`:
```tsx
{ key: 'settled', label: 'Settled' },
// in the row map:
settled: p.allocations.length === 0
? <span style={{ color: 'var(--text-dim)' }}>advance</span>
: <span style={{ color: 'var(--text-dim)', fontSize: 12 }}> {p.allocations.map((a) => a.docNo).join(', ')}</span>,
```
- [ ] **Step 3: Verify**
Run: `npm run typecheck && npm run build --workspace @sims/hq-web`. In-app: Payments tab shows Outstanding with correct due totals + advance; each payment shows the doc(s) it settled or "advance"; recording a payment updates both.
- [ ] **Step 4: Commit**
```bash
git add apps/hq-web/src/pages/ClientDetail.tsx
git commit -m "feat(payments): outstanding panel + settled-to-document column"
```
---
## Task 6: Summary-table trim + "Add a module"
**Files:**
- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` (modules summary table ~275298)
- Modify: `apps/hq-web/src/pages/client-forms.tsx` (`AssignModuleForm` labels/copy ~285+)
**Interfaces:** none new.
- [ ] **Step 1: Trim the summary table**
Change the summary `DataTable` columns to `Module · Kind · Status · Billed · Settled` (drop `installed`/`completed`/`trained`; dates now live in the status line). Keep `status` as the `ClientModuleStatusSelect` (still editable), keep Billed/Settled numeric/right-aligned.
- [ ] **Step 2: Reword the assign form**
In `client-forms.tsx` `AssignModuleForm`: change the button/heading text from "Assign module" to **"Add a module"**, and add a one-line hint: `Starts the module at "quoted" and opens its onboarding status line.`
- [ ] **Step 3: Verify + commit**
Run: `npm run typecheck && npm run build --workspace @sims/hq-web`. In-app: summary table is tighter and aligned; "Add a module" reads right.
```bash
git add apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/pages/client-forms.tsx
git commit -m "feat(client-detail): trim module summary table; reword assign as 'Add a module'"
```
---
## Task 7: Per-module onboarding template editor (Modules page)
**Files:**
- Modify: `apps/hq-web/src/pages/Modules.tsx`
- Modify: `apps/hq-web/src/api.ts``getModuleTemplate(code)`, `setModuleTemplate(code, steps)`.
- Modify: `apps/hq/src/api.ts` — GET/PUT routes for `project.milestone_template:<CODE>` (audited).
**Interfaces:**
- Produces: `getModuleTemplate(code): Promise<{ key: string; label: string }[]>`, `setModuleTemplate(code, steps): Promise<void>`.
- [ ] **Step 1: Backend routes**
In `apps/hq/src/api.ts` add owner-only routes:
- `GET /modules/:code/onboarding-template``milestoneTemplate(db, code)`.
- `PUT /modules/:code/onboarding-template` → validate `steps: {key,label}[]` (non-empty labels; unique keys; slugify missing keys), write the `setting` row `project.milestone_template:<CODE>`, audited.
- [ ] **Step 2: Web calls + editor UI**
Add the two api calls, then in `Modules.tsx` add an "Onboarding steps" editor per module row (owner-only): a list of `{label}` rows with add/remove/reorder, Save calls `setModuleTemplate`. Keys auto-derive from labels on save (server slugifies). Note in the UI that changes reach existing projects additively (new steps appear; existing ticks untouched) and that a full re-order/rename for existing projects needs the migration script.
- [ ] **Step 3: Verify + commit**
Run: `npm run typecheck && npm test --workspace @sims/hq && npm run build --workspace @sims/hq-web`. In-app (owner): edit SMS steps, save, reload a client's SMS block → new step appears.
```bash
git add apps/hq-web/src/pages/Modules.tsx apps/hq-web/src/api.ts apps/hq/src/api.ts
git commit -m "feat(modules): owner editor for per-module onboarding templates"
```
---
## Task 8: Stalled-onboarding dashboard tile
**Files:**
- Modify: `apps/hq-web/src/api.ts``getStalledProjects()`.
- Modify: `apps/hq-web/src/pages/Dashboard.tsx` — a tile listing stalled projects.
**Interfaces:**
- Consumes: `GET /projects/stalled` (backend Task 8) → `{ clientModuleId, clientId, clientName, moduleCode, moduleName }[]`.
- [ ] **Step 1: Web call + tile**
Add `getStalledProjects`. In `Dashboard.tsx`, add a read-only card "Onboarding stalled" listing each stalled project as a link to `/clients/:clientId?tab=modules`, matching the existing dashboard card pattern (`.dash-card`/`.dash-row`). Empty → hide the card or show a positive empty state.
- [ ] **Step 2: Verify + commit**
Run: `npm run typecheck && npm run build --workspace @sims/hq-web`. In-app: a project parked past the threshold appears; clicking opens its Modules tab.
```bash
git add apps/hq-web/src/api.ts apps/hq-web/src/pages/Dashboard.tsx
git commit -m "feat(dashboard): stalled-onboarding tile"
```
---
## Task 9: Responsive + red-team alignment pass (landing gate)
**Files:** any of the above, as fixes require.
This task does not land new features — it hardens what Tasks 18 built. **Do not skip.**
- [ ] **Step 1: Build + serve the real app**
```bash
npm run build --workspace @sims/hq
node apps/hq/dist/server.cjs # serves the built UI on :5182
```
Seed demo data if needed (`apps/hq/scripts/demo-seed.ts`) so a client has SMS + RTGS + Mobile App, several documents, payments, and mid-onboarding milestones.
- [ ] **Step 2: Adversarial visual sweep (use the QA/design tooling)**
Use the browser QA tooling (the `browse` / `qa` / `design-review` skills, or chrome-devtools MCP) to load `/clients/:id?tab=modules` and `?tab=payments` at **375px, 768px, and desktop**, in **light and dark** and at least two neutral palettes (Warm + Graphite). Actively hunt for:
- 50/50 sheet: collapses to one column < 720px; labels never clip values; nothing runs together on any width.
- Status line: scrolls horizontally with no clipped nodes/labels; dots stay on the connector; the "now" ring isn't cut at the edge.
- Outstanding panel / payments table / summary table / documents: each scrolls inside its own container; **page body never scrolls sideways**; numeric columns right-aligned with tabular figures.
- No overlap, no collapsed/again-crowded rows, no theme-contrast failures (labels/badges legible on every ground).
- [ ] **Step 3: Fix every finding, then re-check**
Fix in the relevant file, rebuild, and re-run Step 2 until the sweep is clean at all three widths × both themes. Record the widths/themes checked in the commit body.
- [ ] **Step 4: Full green + commit**
```bash
npm run typecheck && npm test --workspace @sims/hq && npm run build --workspace @sims/hq-web
git add -A
git commit -m "fix(client-detail): responsive + red-team alignment pass across widths and themes"
```
---
## Frontend plan self-review
- **Spec coverage:** §1 50/50 report F2; §2 status line F3; §4 per-module docs F4; §4a bulk SMS F4; §5 payments outstanding/settled F5; §6 summary+assign F6; §2 template editor F7; §7.4 stalled tile F8; responsive+red-team gate F9; CSS foundation F1. §7.1 (status auto-drive) and §7.2 (payment link) are backend-driven; F3 consumes them and adds the payment picker UI.
- **Type consistency:** class names in F1 (`cdr-split`, `cdr-kv`, `cdr-track`/`cdr-node`, `cdr-out`, `doc-pill`) match the JSX in F2F5; `generateBulkSmsPurchase`, `getStalledProjects`, `getModuleTemplate`/`setModuleTemplate`, `setMilestone(..., paymentId?)`, `ledger.outstanding`, `payment.allocations` match the backend plan's exports.
- **Placeholder scan:** F3 Step 3 and F7 Step 2 describe interactive UI at component level with the exact calls to make; the payment-picker dialog and the template-editor rows follow the file's existing dialog/list idioms (e.g. `BranchList`, `PlanDialog`) the implementer mirrors those concrete patterns rather than inventing new ones.
- **Ordering:** F1 (CSS) first so F2F5 render correctly; F9 last as the gate. Each task is independently reviewable and ends green.