|
|
# 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 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):
|
|
|
|
|
|
```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 ~961–1103) and fold `ModuleFieldsForm` (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:
|
|
|
|
|
|
```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 ~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**
|
|
|
|
|
|
```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` (~1270–1357) 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` (~300–319), 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 (~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:
|
|
|
|
|
|
```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 ~275–298)
|
|
|
- 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 1–8 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 F2–F5; `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 F2–F5 render correctly; F9 last as the gate. Each task is independently reviewable and ends green.
|