31 KiB
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 usefont-variant-numeric: tabular-nums. - No frontend unit-test harness exists (tests are backend vitest). Each task's verification is:
npm run typecheckclean,npm run build --workspace @sims/hq-webclean, 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.htmlfor exact layout/CSS.
File Structure
apps/hq-web/src/app.css— MODIFY:.split/.kv,.track/.node,.outoutstanding-panel, doc-label pill classes.apps/hq-web/src/pages/ClientDetail.tsx— MODIFY:ServiceDataCard(50/50 + single edit), replaceMilestoneChecklistwithStatusLine, addModuleDocuments, Payments tab (OutstandingPanel+ Settled column), summary-table trim.apps/hq-web/src/pages/client-forms.tsx— MODIFY:AssignModuleFormcopy → "Add a module".apps/hq-web/src/api.ts— MODIFY: addgenerateBulkSmsPurchase,getStalledProjects,getModuleTemplate/setModuleTemplate, and threadpaymentIdintosetMilestone.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-pillused by later tasks. These MUST match the class names the JSX in Tasks 2–5 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):
/* ---- 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
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 ~961–1103) and foldModuleFieldsForm(lines ~1112–1209) 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:
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:
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 ~1095–1100 (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
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— replaceMilestoneChecklist(~1270–1357) withStatusLine. - Modify:
apps/hq-web/src/api.ts— threadpaymentIdintosetMilestone.
Interfaces:
-
Consumes:
getMilestones,setMilestone(clientModuleId, key, done, doneOn?, paymentId?),addMilestone,Milestone(now withpaymentId). -
Produces:
StatusLinecomponent rendering the horizontal.cdr-track. -
Step 1: Extend the web
setMilestonecall
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:
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
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— addModuleDocuments; render inside each module block afterStatusLine. - Modify:
apps/hq-web/src/api.ts— addgenerateBulkSmsPurchase(cmId, smsQty).
Interfaces:
-
Consumes:
ledger.data.documents(eachDoc.payload.lines[].itemId === moduleId),doc.source(module_renewal/bulk_sms_topup),nav. -
Produces:
ModuleDocumentscomponent; a Bulk SMS action for SMS-coded modules. -
Step 1: Add the web API call
In apps/hq-web/src/api.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
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 (~300–319), inside .mod-section-body, after <StatusLine … /> add:
<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
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 (~398–475).
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:
{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:
{ 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
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 ~275–298) - Modify:
apps/hq-web/src/pages/client-forms.tsx(AssignModuleFormlabels/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.
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 forproject.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→ validatesteps: {key,label}[](non-empty labels; unique keys; slugify missing keys), write thesettingrowproject.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.
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.
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 1–8 built. Do not skip.
- Step 1: Build + serve the real app
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
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 F2–F5;generateBulkSmsPurchase,getStalledProjects,getModuleTemplate/setModuleTemplate,setMilestone(..., paymentId?),ledger.outstanding,payment.allocationsmatch 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 F2–F5 render correctly; F9 last as the gate. Each task is independently reviewable and ends green.