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...

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 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):

/* ---- 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.tsxServiceDataCard (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:

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 ~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
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"

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:

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 — 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:

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 (~300319), 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 (~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:

{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 ~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.

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.tsgetModuleTemplate(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-templatemilestoneTemplate(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.

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.tsgetStalledProjects().
  • 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 18 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 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.