diff --git a/docs/superpowers/plans/2026-07-19-client-detail-redesign-backend.md b/docs/superpowers/plans/2026-07-19-client-detail-redesign-backend.md new file mode 100644 index 0000000..d02ae34 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-client-detail-redesign-backend.md @@ -0,0 +1,941 @@ +# Client Detail Redesign — Backend & Data Foundation 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:** Build the data + repo layer the redesigned Client Detail page needs — per-module onboarding templates, a milestone→status auto-drive, milestone↔payment links, an extended ledger (outstanding + allocations), and a bulk-SMS-purchase document path — with a one-time migration that moves existing SMS/RTGS/Mobile-App projects onto their new step lists without losing ticks. + +**Architecture:** Everything is a plain exported function taking the `DB` handle first (portable-repo pattern). Onboarding step lists are DB `setting` rows resolved by module code (config over code). Schema changes are additive `ALTER TABLE` in `migrate()` (SQLite) mirrored in `migrations-pg.ts` (Postgres). No money math changes — the ledger extension only *reads* existing `payment_allocation` / `document` rows. Every mutation writes an audit row in the same transaction. + +**Tech Stack:** TypeScript (strict), `better-sqlite3` (dev) / Postgres (prod), Express, Vitest. + +## Global Constraints + +- Money is integer paise; never introduce floats. This plan touches **no** billing math (`computeBill`, allocation) — the ledger extension reads existing columns only. +- Config over code, dated: onboarding step lists are `setting` rows, never code constants (the code default is a fallback only). +- Append-only audit: every mutation calls `writeAudit(db, userId, action, entity, id, before, after)` in the same `db.transaction`. +- Portable SQL: no engine-specific SQL beyond the existing `ON CONFLICT … DO NOTHING` upsert idiom already used in `seed.ts`. Schema changes land in BOTH `apps/hq/src/db.ts` (`migrate()`, SQLite) and `apps/hq/src/migrations-pg.ts` (Postgres). +- Strict TS, green before landing: `npm run typecheck` and `npm test` clean before anything lands. +- Run all commands from the repo root `C:/SiMS/hq`. Tests: `npm test --workspace @sims/hq` (vitest). Typecheck: `npm run typecheck`. + +--- + +## File Structure + +- `apps/hq/src/repos-milestones.ts` — MODIFY: per-module template resolution; step→status auto-drive; `payment_id` on tick; stalled-projects query. +- `apps/hq/src/repos-modules.ts` — MODIFY: `ensureProjectMilestones` call site already lives here (assignment); no change unless noted. +- `apps/hq/src/seed.ts` — MODIFY: seed per-module template settings (SMS / RTGS / Mobile App). +- `apps/hq/src/db.ts` — MODIFY: `project_milestone.payment_id` column (SQLite `migrate()`); `client_module` module-code lookup helper if needed. +- `apps/hq/src/migrations-pg.ts` — MODIFY: mirror the `payment_id` column for Postgres. +- `apps/hq/src/migrate-onboarding-templates.ts` — CREATE: the one-time, idempotent milestone-swap migration function. +- `apps/hq/scripts/migrate-onboarding-templates.ts` — CREATE: thin CLI wrapper that opens the DB and runs the migration (mirrors `scripts/apply-name-fixes.ts`). +- `apps/hq/src/repos-payments.ts` — MODIFY: extend `clientLedger` with `outstanding` + per-payment `allocations`. +- `apps/hq/src/repos-documents.ts` — MODIFY: make `source` a settable draft field (default unchanged). +- `apps/hq/src/repos-renewals.ts` — MODIFY: tag the renewal proforma `source='module_renewal'`; ADD `generateBulkSmsPurchaseQuote`. +- `apps/hq/src/api.ts` — MODIFY: route for bulk-SMS-purchase; ledger route already returns `clientLedger` (shape widens automatically). +- Tests under `apps/hq/test/` — one file per task as noted. + +--- + +## Task 1: Per-module onboarding template resolution + +**Files:** +- Modify: `apps/hq/src/repos-milestones.ts` +- Test: `apps/hq/test/milestones-templates.test.ts` + +**Interfaces:** +- Produces: `milestoneTemplate(db: DB, moduleCode?: string): Promise` — resolves `project.milestone_template:` → `project.milestone_template` → `FALLBACK_TEMPLATE`. +- Produces: `ensureProjectMilestones(db: DB, clientModuleId: string): Promise` — now resolves the template by the client_module's module code. + +- [ ] **Step 1: Write the failing test** + +Create `apps/hq/test/milestones-templates.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest' +import { openDb, type DB } from '../src/db' +import { milestoneTemplate } from '../src/repos-milestones' + +describe('per-module milestone templates', () => { + let db: DB + beforeEach(async () => { db = await openDb(':memory:') }) + + it('falls back to the global template when no module-specific one exists', async () => { + await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`, + JSON.stringify([{ key: 'a', label: 'A' }])) + expect(await milestoneTemplate(db, 'SMS')).toEqual([{ key: 'a', label: 'A' }]) + }) + + it('prefers the module-specific template over the global one', async () => { + await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`, + JSON.stringify([{ key: 'a', label: 'A' }])) + await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template:SMS', ?)`, + JSON.stringify([{ key: 'doc', label: 'Document collection' }])) + expect(await milestoneTemplate(db, 'SMS')).toEqual([{ key: 'doc', label: 'Document collection' }]) + // A different module still gets the global one. + expect(await milestoneTemplate(db, 'RTGS')).toEqual([{ key: 'a', label: 'A' }]) + }) + + it('falls back to the code default when nothing is seeded', async () => { + const t = await milestoneTemplate(db, 'SMS') + expect(t.map((e) => e.key)).toContain('go_live') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- milestones-templates` +Expected: FAIL — `milestoneTemplate` does not accept a module code / does not read the `:CODE` key. + +- [ ] **Step 3: Implement the resolution** + +In `apps/hq/src/repos-milestones.ts`, replace `milestoneTemplate` and update `ensureProjectMilestones`: + +```ts +/** The checklist for a module (owner-editable per module via setting; global default; code fallback). */ +export async function milestoneTemplate(db: DB, moduleCode?: string): Promise { + const parse = (value: string): TemplateEntry[] | undefined => { + try { + const p = JSON.parse(value) as TemplateEntry[] + return Array.isArray(p) && p.length > 0 ? p : undefined + } catch { return undefined } + } + if (moduleCode !== undefined && moduleCode !== '') { + const perModule = await db.get<{ value: string }>( + `SELECT value FROM setting WHERE key=?`, `project.milestone_template:${moduleCode}`) + const parsed = perModule !== undefined ? parse(perModule.value) : undefined + if (parsed !== undefined) return parsed + } + const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='project.milestone_template'`) + const parsedGlobal = row !== undefined ? parse(row.value) : undefined + return parsedGlobal ?? FALLBACK_TEMPLATE +} +``` + +Then update `ensureProjectMilestones` to resolve by module code: + +```ts +export async function ensureProjectMilestones(db: DB, clientModuleId: string): Promise { + const cm = await db.get<{ module_code: string }>( + `SELECT m.code AS module_code FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE cm.id=?`, + clientModuleId) + const template = await milestoneTemplate(db, cm?.module_code) + const existing = new Set( + (await db.all<{ key: string }>(`SELECT key FROM project_milestone WHERE client_module_id=?`, clientModuleId)) + .map((r) => r.key), + ) + await db.transaction(async () => { + let sort = 0 + for (const t of template) { + if (!existing.has(t.key)) { + await db.run( + `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort) + VALUES (?, ?, ?, ?, 0, NULL, ?)`, + uuidv7(), clientModuleId, t.key, t.label, sort, + ) + } + sort++ + } + }) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test --workspace @sims/hq -- milestones-templates` +Expected: PASS (3 tests). + +- [ ] **Step 5: Typecheck + commit** + +```bash +npm run typecheck +git add apps/hq/src/repos-milestones.ts apps/hq/test/milestones-templates.test.ts +git commit -m "feat(onboarding): resolve milestone template per module (config over code)" +``` + +--- + +## Task 2: Seed per-module onboarding templates + +**Files:** +- Modify: `apps/hq/src/seed.ts` +- Test: `apps/hq/test/seed-templates.test.ts` + +**Interfaces:** +- Consumes: `milestoneTemplate` (Task 1). +- Produces: seeded settings `project.milestone_template:SMS|RTGS|MOBILEAPP`. (Confirm the Mobile App module code with `SELECT code FROM module` during Step 3; the mockup assumes `MOBILEAPP` — adjust the key to the real code.) + +- [ ] **Step 1: Write the failing test** + +Create `apps/hq/test/seed-templates.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest' +import { openDb, type DB } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { milestoneTemplate } from '../src/repos-milestones' + +describe('seed per-module onboarding templates', () => { + let db: DB + beforeEach(async () => { db = await openDb(':memory:'); await seedIfEmpty(db) }) + + it('seeds the SMS 9-step list', async () => { + const keys = (await milestoneTemplate(db, 'SMS')).map((e) => e.key) + expect(keys).toEqual([ + 'document_collection', 'dlt_registration', 'account_creation', 'installation', + 'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', + ]) + }) + + it('seeds the RTGS list ending in payment steps', async () => { + const keys = (await milestoneTemplate(db, 'RTGS')).map((e) => e.key) + expect(keys).toEqual([ + 'document_collection', 'server_checkup', 'setup_configuration', 'installation', + 'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', + ]) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- seed-templates` +Expected: FAIL — per-module templates not seeded (falls back to global keys). + +- [ ] **Step 3: Add the seed block** + +In `apps/hq/src/seed.ts`, after the existing `project.milestone_template` seed (line ~101), add: + +```ts + // Per-module onboarding templates (config over code). SMS/RTGS/Mobile App differ; each ends + // with advance + balance payment so a payment tick maps cleanly on migration. + const PER_MODULE_TEMPLATES: Record = { + SMS: [ + { key: 'document_collection', label: 'Document collection' }, + { key: 'dlt_registration', label: 'DLT registration' }, + { key: 'account_creation', label: 'Account creation & registration' }, + { key: 'installation', label: 'Installation' }, + { key: 'testing', label: 'Testing' }, + { key: 'training', label: 'Training' }, + { key: 'go_live', label: 'Go-live' }, + { key: 'advance_payment', label: 'Advance payment' }, + { key: 'balance_payment', label: 'Balance payment' }, + ], + RTGS: [ + { key: 'document_collection', label: 'Document collection' }, + { key: 'server_checkup', label: 'Server checkup' }, + { key: 'setup_configuration', label: 'Setup & configuration' }, + { key: 'installation', label: 'Installation' }, + { key: 'testing', label: 'Testing' }, + { key: 'training', label: 'Training' }, + { key: 'go_live', label: 'Go-live' }, + { key: 'advance_payment', label: 'Advance payment' }, + { key: 'balance_payment', label: 'Balance payment' }, + ], + MOBILEAPP: [ + { key: 'requirement_setup', label: 'Requirement & setup' }, + { key: 'configuration', label: 'Configuration' }, + { key: 'data_import', label: 'Data import' }, + { key: 'installation', label: 'Installation' }, + { key: 'testing', label: 'Testing' }, + { key: 'training', label: 'Training' }, + { key: 'go_live', label: 'Go-live' }, + { key: 'advance_payment', label: 'Advance payment' }, + { key: 'balance_payment', label: 'Balance payment' }, + ], + } + let seededPerModule = 0 + for (const [code, list] of Object.entries(PER_MODULE_TEMPLATES)) { + seededPerModule += (await db.run( + INSERT_SETTING_SQL, `project.milestone_template:${code}`, JSON.stringify(list))).changes + } + if (seededPerModule > 0) { + await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template:*', undefined, + { modules: Object.keys(PER_MODULE_TEMPLATES) }) + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test --workspace @sims/hq -- seed-templates` +Expected: PASS. Also run `npm test --workspace @sims/hq -- milestones-templates` — still green. + +- [ ] **Step 5: Commit** + +```bash +npm run typecheck +git add apps/hq/src/seed.ts apps/hq/test/seed-templates.test.ts +git commit -m "feat(onboarding): seed SMS/RTGS/MobileApp onboarding templates" +``` + +--- + +## Task 3: Milestone→status auto-drive + go-live + +**Files:** +- Modify: `apps/hq/src/repos-milestones.ts` (`setMilestone`) +- Test: `apps/hq/test/milestone-status.test.ts` + +**Interfaces:** +- Consumes: `setMilestone(db, userId, clientModuleId, key, done, doneOn?)` (existing signature unchanged). +- Produces: a module-status advance side-effect keyed by a `STEP_STATUS` map, written in the same transaction and audited. + +- [ ] **Step 1: Write the failing test** + +Create `apps/hq/test/milestone-status.test.ts`. Use the existing test helpers to create a client + module + client_module (copy the setup from `apps/hq/test/tickets.test.ts` which already builds a `client_module`). Assert: + +```ts +// after seeding milestones for an SMS client_module at status 'quoted': +it('ticking installation advances status to installed', async () => { + await setMilestone(db, 'u1', cmId, 'installation', true) + const cm = await getClientModule(db, cmId) + expect(cm!.status).toBe('installed') +}) + +it('ticking go_live advances status to live', async () => { + await setMilestone(db, 'u1', cmId, 'go_live', true) + expect((await getClientModule(db, cmId))!.status).toBe('live') +}) + +it('unticking never downgrades status', async () => { + await setMilestone(db, 'u1', cmId, 'go_live', true) + await setMilestone(db, 'u1', cmId, 'go_live', false) + expect((await getClientModule(db, cmId))!.status).toBe('live') +}) + +it('a lower step never downgrades a higher status', async () => { + await setMilestone(db, 'u1', cmId, 'go_live', true) // live + await setMilestone(db, 'u1', cmId, 'installation', true) // still live + expect((await getClientModule(db, cmId))!.status).toBe('live') +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- milestone-status` +Expected: FAIL — status stays `quoted`. + +- [ ] **Step 3: Implement the auto-drive** + +In `apps/hq/src/repos-milestones.ts`, add the map and fold the advance into `setMilestone`'s transaction (after the `UPDATE project_milestone` and its audit, before `return`): + +```ts +/** Ticking a step can advance the coarse client_module.status — never downgrade. Ranks match + * the status CHECK order: quoted = { + quoted: 0, ordered: 1, installing: 2, installed: 3, trained: 4, live: 5, expired: 6, cancelled: 7, +} +const STEP_STATUS: Record = { + installation: 'installed', training: 'trained', go_live: 'live', +} + +async function advanceStatusForStep(db: DB, userId: string, clientModuleId: string, key: string): Promise { + const target = STEP_STATUS[key] + if (target === undefined) return + const cm = await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, clientModuleId) + if (cm === undefined) return + if ((STATUS_RANK[cm.status] ?? 0) >= (STATUS_RANK[target] ?? 0)) return // never downgrade + await db.run(`UPDATE client_module SET status=? WHERE id=?`, target, clientModuleId) + await writeAudit(db, userId, 'update', 'client_module', clientModuleId, { status: cm.status }, { status: target }) +} +``` + +Inside `setMilestone`, after the milestone `writeAudit` and before `return listProjectMilestones(...)`, add: + +```ts + if (done) await advanceStatusForStep(db, userId, clientModuleId, key) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test --workspace @sims/hq -- milestone-status` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +npm run typecheck +git add apps/hq/src/repos-milestones.ts apps/hq/test/milestone-status.test.ts +git commit -m "feat(onboarding): status line auto-drives client_module.status (never downgrades)" +``` + +--- + +## Task 4: `payment_id` on milestone + link on payment-step tick + +**Files:** +- Modify: `apps/hq/src/db.ts` (`migrate()` SQLite) and `apps/hq/src/migrations-pg.ts` (Postgres) +- Modify: `apps/hq/src/repos-milestones.ts` (`Milestone` type, `listProjectMilestones`, `setMilestone` optional `paymentId`) +- Modify: `apps/hq/src/api.ts` (accept `paymentId` in the milestone POST body) +- Test: `apps/hq/test/milestone-payment.test.ts` + +**Interfaces:** +- Produces: `setMilestone(db, userId, clientModuleId, key, done, doneOn?, paymentId?)` — when a payment step is ticked with a `paymentId`, stores it and mirrors the payment's `received_on` into `done_on`. +- Produces: `Milestone` gains `paymentId: string | null`. + +- [ ] **Step 1: Add the column (schema, both engines)** + +In `apps/hq/src/db.ts` `migrate()`, alongside the other `PRAGMA table_info` blocks, add: + +```ts + const milestoneCols = db.prepare(`PRAGMA table_info(project_milestone)`).all() as { name: string }[] + if (!milestoneCols.some((c) => c.name === 'payment_id')) { + db.exec(`ALTER TABLE project_milestone ADD COLUMN payment_id TEXT`) + } +``` + +In `apps/hq/src/migrations-pg.ts`, mirror it (follow the file's existing additive-migration idiom — an `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS payment_id text`). + +- [ ] **Step 2: Write the failing test** + +Create `apps/hq/test/milestone-payment.test.ts` (reuse the client_module setup + record a payment via `recordPayment`): + +```ts +it('linking a payment to advance_payment stores payment_id and mirrors received_on', async () => { + const pay = await recordPayment(db, 'u1', { + clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00, + }) + await setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id) + const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'advance_payment')! + expect(ms.done).toBe(true) + expect(ms.doneOn).toBe('2026-05-01') + expect(ms.paymentId).toBe(pay.payment.id) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- milestone-payment` +Expected: FAIL — `setMilestone` has no `paymentId` param; `Milestone` has no `paymentId`. + +- [ ] **Step 4: Implement** + +In `repos-milestones.ts`: +- Add `paymentId: string | null` to `interface Milestone` and to `MilestoneRow` (`payment_id: string | null`). +- Include `payment_id` in the `SELECT` of `listProjectMilestones` and map it: `paymentId: r.payment_id`. +- Extend `setMilestone` signature with a trailing `paymentId?: string`. When `done` and `paymentId` is provided, resolve the payment's `received_on` and use it as the stamp, and set `payment_id`: + +```ts +export async function setMilestone( + db: DB, userId: string, clientModuleId: string, key: string, done: boolean, + doneOn?: string, paymentId?: string, +): Promise { + return db.transaction(async () => { + const before = await db.get( + `SELECT key, label, done, done_on, sort, payment_id FROM project_milestone WHERE client_module_id=? AND key=?`, + clientModuleId, key) + if (before === undefined) throw new Error('Milestone not found on this project') + if (done && doneOn !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(doneOn)) { + throw new Error('doneOn must be YYYY-MM-DD') + } + let stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null + let linkedPayment: string | null = null + if (done && paymentId !== undefined && paymentId !== '') { + const pay = await db.get<{ received_on: string }>( + `SELECT received_on FROM payment WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`, + paymentId, clientModuleId) + if (pay === undefined) throw new Error('Payment not found for this client') + linkedPayment = paymentId + if (doneOn === undefined) stamp = pay.received_on // link date wins unless caller forced one + } + await db.run( + `UPDATE project_milestone SET done=?, done_on=?, payment_id=? WHERE client_module_id=? AND key=?`, + done ? 1 : 0, stamp, done ? linkedPayment : null, clientModuleId, key) + await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined, + { key, done, doneOn: stamp, paymentId: linkedPayment }) + if (done) await advanceStatusForStep(db, userId, clientModuleId, key) + return listProjectMilestones(db, clientModuleId) + }) +} +``` + +In `api.ts` milestone POST route, read `paymentId` from the body and pass it through: + +```ts + const milestones = await setMilestone(db, staffId(res), id, body.key, body.done, + typeof body.doneOn === 'string' ? body.doneOn : undefined, + typeof (body as { paymentId?: unknown }).paymentId === 'string' ? (body as { paymentId?: string }).paymentId : undefined) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npm test --workspace @sims/hq -- milestone-payment milestone-status milestones-templates` +Expected: PASS (all). + +- [ ] **Step 6: Commit** + +```bash +npm run typecheck +git add apps/hq/src/db.ts apps/hq/src/migrations-pg.ts apps/hq/src/repos-milestones.ts apps/hq/src/api.ts apps/hq/test/milestone-payment.test.ts +git commit -m "feat(onboarding): link advance/balance payment steps to real payments" +``` + +--- + +## Task 5: One-time migration — swap seeded modules onto new templates, preserve ticks + +**Files:** +- Create: `apps/hq/src/migrate-onboarding-templates.ts` +- Create: `apps/hq/scripts/migrate-onboarding-templates.ts` +- Test: `apps/hq/test/migrate-onboarding-templates.test.ts` + +**Interfaces:** +- Produces: `migrateOnboardingTemplates(db: DB): Promise<{ projects: number; carried: number; dropped: number }>` — idempotent; for every client_module whose module has a per-module template setting, replaces the old rows with the module's template, carrying mapped ticks (`done` + `done_on` + `payment_id`). + +**Mapping (old generic key → new key):** `installed→installation`, `go_live→go_live`, `advance_paid→advance_payment`, `balance_paid→balance_payment`, `setup_done→setup_configuration` (only if the new template has that key). Old keys with no mapped target (e.g. `amc_start`, or `setup_done` for SMS) are dropped and counted. + +- [ ] **Step 1: Write the failing test** + +Create `apps/hq/test/migrate-onboarding-templates.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest' +import { openDb, type DB } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' +import { listProjectMilestones } from '../src/repos-milestones' +// + helpers to create an SMS client_module and seed the OLD generic milestones on it + +describe('onboarding template migration', () => { + let db: DB + beforeEach(async () => { db = await openDb(':memory:'); await seedIfEmpty(db) }) + + it('swaps an SMS project to the 9-step list carrying installed+go_live ticks', async () => { + const cmId = /* create SMS client_module */ '' + // seed OLD generic rows with two ticked: + for (const [i, r] of [ + ['advance_paid', 0, null], ['setup_done', 0, null], ['installed', 1, '2026-03-25'], + ['go_live', 1, '2026-04-02'], ['balance_paid', 0, null], ['amc_start', 0, null], + ].entries()) { + await db.run(`INSERT INTO project_milestone (id,client_module_id,key,label,done,done_on,sort) + VALUES (?,?,?,?,?,?,?)`, `m${i}`, cmId, r[0], String(r[0]), r[1], r[2], i) + } + const res = await migrateOnboardingTemplates(db) + expect(res.projects).toBe(1) + const ms = await listProjectMilestones(db, cmId) + expect(ms.map((m) => m.key)).toEqual([ + 'document_collection','dlt_registration','account_creation','installation', + 'testing','training','go_live','advance_payment','balance_payment']) + const installation = ms.find((m) => m.key === 'installation')! + expect(installation.done).toBe(true) + expect(installation.doneOn).toBe('2026-03-25') + expect(ms.find((m) => m.key === 'go_live')!.done).toBe(true) + // old setup_done / amc_start gone: + expect(ms.some((m) => m.key === 'setup_done' || m.key === 'amc_start')).toBe(false) + }) + + it('is idempotent (second run changes nothing new)', async () => { + /* run twice; assert the milestone set is identical after each */ + }) + + it('leaves a module without a per-module template untouched', async () => { + /* create a client_module for a module with NO :CODE setting; assert its rows unchanged */ + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- migrate-onboarding-templates` +Expected: FAIL — module `migrate-onboarding-templates` does not exist. + +- [ ] **Step 3: Implement the migration** + +Create `apps/hq/src/migrate-onboarding-templates.ts`: + +```ts +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import { milestoneTemplate } from './repos-milestones' +import type { DB } from './db' + +/** old generic key → new template key (only applied if the new template has that key). */ +const KEY_MAP: Record = { + installed: 'installation', go_live: 'go_live', + advance_paid: 'advance_payment', balance_paid: 'balance_payment', + setup_done: 'setup_configuration', +} + +interface OldRow { key: string; done: number; done_on: string | null; payment_id: string | null } + +/** + * Idempotent: for every client_module whose module has a per-module onboarding template, + * rebuild its milestone rows from that template, carrying mapped ticks (done/done_on/payment_id). + * Unmapped old ticks are dropped (counted). Modules with no per-module template are untouched. + */ +export async function migrateOnboardingTemplates(db: DB): Promise<{ projects: number; carried: number; dropped: number }> { + // Which module codes have a per-module template setting? + const codes = (await db.all<{ key: string }>( + `SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`)) + .map((r) => r.key.slice('project.milestone_template:'.length)) + let projects = 0, carried = 0, dropped = 0 + for (const code of codes) { + const template = await milestoneTemplate(db, code) + const templateKeys = new Set(template.map((t) => t.key)) + const cms = await db.all<{ id: string }>( + `SELECT cm.id FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE m.code=?`, code) + for (const { id: cmId } of cms) { + const oldRows = await db.all( + `SELECT key, done, done_on, payment_id FROM project_milestone WHERE client_module_id=?`, cmId) + // Build the carried tick-state keyed by NEW key. + const carriedState = new Map() + let alreadyNew = true + for (const r of oldRows) { + if (!templateKeys.has(r.key)) alreadyNew = false + const target = templateKeys.has(r.key) ? r.key : KEY_MAP[r.key] + if (target !== undefined && templateKeys.has(target)) { + if (r.done === 1) carriedState.set(target, r) + } else if (r.done === 1) { + dropped++ + } + } + // Idempotency: if the project's keys already ARE exactly the template, skip. + const oldKeySet = new Set(oldRows.map((r) => r.key)) + const identical = alreadyNew && oldKeySet.size === templateKeys.size + && [...templateKeys].every((k) => oldKeySet.has(k)) + if (identical) continue + projects++ + await db.transaction(async () => { + await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cmId) + let sort = 0 + for (const t of template) { + const c = carriedState.get(t.key) + await db.run( + `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + uuidv7(), cmId, t.key, t.label, + c !== undefined ? 1 : 0, c?.done_on ?? null, c?.payment_id ?? null, sort) + if (c !== undefined) carried++ + sort++ + } + await writeAudit(db, 'system', 'migrate_onboarding', 'client_module', cmId, undefined, + { module: code, carried: [...carriedState.keys()] }) + }) + } + } + return { projects, carried, dropped } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test --workspace @sims/hq -- migrate-onboarding-templates` +Expected: PASS (3 tests). + +- [ ] **Step 5: Create the CLI wrapper** + +Create `apps/hq/scripts/migrate-onboarding-templates.ts` (mirror `scripts/apply-name-fixes.ts` open-DB pattern): + +```ts +import { openDb } from '../src/db' +import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' + +async function main(): Promise { + const db = await openDb(process.env['HQ_DB_PATH'] ?? 'data/hq.db') + const res = await migrateOnboardingTemplates(db) + console.log(`onboarding migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ${res.dropped} dropped`) +} +main().catch((e) => { console.error(e); process.exit(1) }) +``` + +- [ ] **Step 6: Commit** + +```bash +npm run typecheck +git add apps/hq/src/migrate-onboarding-templates.ts apps/hq/scripts/migrate-onboarding-templates.ts apps/hq/test/migrate-onboarding-templates.test.ts +git commit -m "feat(onboarding): one-time migration swapping seeded modules to new templates (ticks preserved)" +``` + +--- + +## Task 6: Extend the ledger with outstanding + per-payment allocations + +**Files:** +- Modify: `apps/hq/src/repos-payments.ts` (`ClientLedger`, `clientLedger`) +- Modify: `apps/hq-web/src/api.ts` (`Ledger`, `Payment` types — web mirror) +- Test: `apps/hq/test/ledger-outstanding.test.ts` + +**Interfaces:** +- Produces: `ClientLedger` gains + `outstanding: { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number }[]` + and each `Payment` in the ledger gains `allocations: { docNo: string; amountPaise: number }[]`. +- Consumes: existing `outstandingPaise(db, docId)` (already exported in `repos-payments.ts`). + +- [ ] **Step 1: Write the failing test** + +Create `apps/hq/test/ledger-outstanding.test.ts`. Build a client with one issued INVOICE (partly paid) and one open PROFORMA; record a payment; then: + +```ts +it('reports outstanding open documents oldest-first with correct due amounts', async () => { + const led = await clientLedger(db, clientId) + expect(led.outstanding.map((o) => o.docNo)).toEqual(['INV-0001']) // proforma optional; see label rule + expect(led.outstanding[0]!.outstandingPaise).toBe(/* payable - allocated - credited */ 0) +}) + +it('attaches allocations to each payment', async () => { + const led = await clientLedger(db, clientId) + const p = led.payments[0]! + expect(p.allocations.reduce((s, a) => s + a.amountPaise, 0)).toBeLessThanOrEqual(p.amountPaise + p.tdsPaise) +}) +``` + +(Fill in exact expected paise from the fixture you build — pick round amounts so the assertions are concrete.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- ledger-outstanding` +Expected: FAIL — `outstanding` / `allocations` undefined. + +- [ ] **Step 3: Implement the extension** + +In `apps/hq/src/repos-payments.ts`, widen the interface and `clientLedger`: + +```ts +export interface LedgerOutstanding { + docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number +} +export interface LedgerPayment extends Payment { allocations: { docNo: string; amountPaise: number }[] } +export interface ClientLedger { + documents: Doc[]; payments: LedgerPayment[]; advancePaise: number; outstanding: LedgerOutstanding[] +} +``` + +In `clientLedger`, after building `documents`: + +```ts + // Per-payment allocations (docNo + amount) for the "Settled → document" column. + const allocRows = await db.all<{ payment_id: string; doc_no: string | null; amount_paise: number }>( + `SELECT a.payment_id, d.doc_no, a.amount_paise + FROM payment_allocation a JOIN document d ON d.id = a.document_id + JOIN payment p ON p.id = a.payment_id WHERE p.client_id=?`, clientId) + const allocByPayment = new Map() + for (const r of allocRows) { + const list = allocByPayment.get(r.payment_id) ?? [] + list.push({ docNo: r.doc_no ?? '—', amountPaise: r.amount_paise }) + allocByPayment.set(r.payment_id, list) + } + const ledgerPayments: LedgerPayment[] = payments.map((p) => ({ ...p, allocations: allocByPayment.get(p.id) ?? [] })) + + // Open documents with money still owed — issued invoices (outstanding > 0) + open proformas. + const moduleName = async (doc: Doc): Promise => + doc.payload.lines.map((l) => l.name).filter((n) => n !== '').join(', ') + const outstanding: LedgerOutstanding[] = [] + for (const d of documents) { + if (d.status === 'cancelled' || d.status === 'paid' || d.status === 'lost') continue + if (d.docType === 'INVOICE' && d.docNo !== null) { + const out = await outstandingPaise(db, d.id) + if (out > 0) outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: out }) + } else if (d.docType === 'PROFORMA' && (d.status === 'sent' || d.status === 'accepted' || d.status === 'draft')) { + outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: d.payablePaise }) + } + } + outstanding.sort((a, b) => (a.docNo ?? '').localeCompare(b.docNo ?? '')) +``` + +Return `{ documents, payments: ledgerPayments, advancePaise: settling - row.total, outstanding }`. + +- [ ] **Step 4: Mirror the web types** + +In `apps/hq-web/src/api.ts`, update `Payment` (add `allocations: { docNo: string; amountPaise: number }[]`) and `Ledger` (add `outstanding: { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number }[]`). + +- [ ] **Step 5: Run tests + typecheck** + +Run: `npm test --workspace @sims/hq -- ledger-outstanding` → PASS. +Run: `npm test --workspace @sims/hq` (full suite — confirm the 416 existing tests stay green; the ledger shape only widened). `npm run typecheck` clean. + +- [ ] **Step 6: Commit** + +```bash +git add apps/hq/src/repos-payments.ts apps/hq-web/src/api.ts apps/hq/test/ledger-outstanding.test.ts +git commit -m "feat(payments): ledger exposes outstanding docs + per-payment allocations" +``` + +--- + +## Task 7: Bulk-SMS-purchase document + source tagging + +**Files:** +- Modify: `apps/hq/src/repos-documents.ts` (`DraftInput.source?`, `insertDocRow` source, INSERT column) +- Modify: `apps/hq/src/repos-renewals.ts` (`source='module_renewal'`; ADD `generateBulkSmsPurchaseQuote`) +- Modify: `apps/hq/src/api.ts` (POST route for bulk SMS purchase) +- Test: `apps/hq/test/bulk-sms-purchase.test.ts` + +**Interfaces:** +- Produces: `generateBulkSmsPurchaseQuote(db, userId, clientModuleId, smsQty): Promise` — a PROFORMA priced via the usage rate card for `smsQty` SMS, `source='bulk_sms_topup'`. +- Produces: `createDraft` accepts optional `source` (default preserves current behaviour); documents carry it for the frontend's Quote/Renewal/Bulk-top-up label. + +- [ ] **Step 1: Write the failing test** + +Create `apps/hq/test/bulk-sms-purchase.test.ts`. Set up an SMS module with a usage rate card (copy the rate-card setup from an existing rate-card test), an SMS client_module, then: + +```ts +it('raises a proforma tagged bulk_sms_topup for the given SMS quantity', async () => { + const doc = await generateBulkSmsPurchaseQuote(db, 'u1', cmId, 100000) + expect(doc.docType).toBe('PROFORMA') + expect(doc.source).toBe('bulk_sms_topup') + expect(doc.payload.lines[0]!.qty).toBe(100000) + expect(doc.payablePaise).toBeGreaterThan(0) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- bulk-sms-purchase` +Expected: FAIL — `generateBulkSmsPurchaseQuote` not exported. + +- [ ] **Step 3: Make `source` settable on a draft** + +In `apps/hq/src/repos-documents.ts`: +- Add `source?: string` to `interface DraftInput`. +- Thread it into `insertDocRow`: add `source?: string` to its param object, add `source` to the INSERT column list + a `?` placeholder, and pass `a.source ?? 'manual'` (confirm the schema default; keep current behaviour when omitted). In `createDraft`, pass `source: input.source` into `insertDocRow`. + +```ts +// insertDocRow INSERT — add `source` column: +`INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, due_date, status, ref_doc_id, + taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise, + payload, source, created_by, created_at) + VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, +// ...bind a.source ?? 'manual' in the source position. +``` + +- [ ] **Step 4: Add the generator + tag the renewal** + +In `apps/hq/src/repos-renewals.ts`, tag the existing renewal proforma and add the bulk generator: + +```ts +export async function generateModuleRenewalQuote(db: DB, userId: string, clientModuleId: string): Promise { + const cm = await getClientModule(db, clientModuleId) + if (cm === null) throw new Error('Client module not found') + return createDraft(db, userId, { + docType: 'PROFORMA', clientId: cm.clientId, source: 'module_renewal', + lines: [{ moduleId: cm.moduleId, qty: 1, kind: cm.kind, edition: cm.edition }], + }) +} + +/** Ad-hoc bulk SMS credit purchase — a PROFORMA priced by the usage rate card for `smsQty` units. */ +export async function generateBulkSmsPurchaseQuote( + db: DB, userId: string, clientModuleId: string, smsQty: number, +): Promise { + if (!Number.isInteger(smsQty) || smsQty <= 0) throw new Error('SMS quantity must be a positive integer') + const cm = await getClientModule(db, clientModuleId) + if (cm === null) throw new Error('Client module not found') + return createDraft(db, userId, { + docType: 'PROFORMA', clientId: cm.clientId, source: 'bulk_sms_topup', + lines: [{ moduleId: cm.moduleId, qty: smsQty, kind: cm.kind, edition: cm.edition }], + }) +} +``` + +- [ ] **Step 5: Add the API route** + +In `apps/hq/src/api.ts`, near the other document routes, add: + +```ts + r.post('/client-modules/:id/bulk-sms-quote', requireAuth, async (req, res) => { + try { + const qty = Number((req.body as { smsQty?: unknown }).smsQty) + const doc = await generateBulkSmsPurchaseQuote(db, staffId(res), String(req.params['id'] ?? ''), qty) + res.json({ ok: true, document: doc }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) +``` + +Import `generateBulkSmsPurchaseQuote` alongside `generateModuleRenewalQuote`. + +- [ ] **Step 6: Run tests + full suite** + +Run: `npm test --workspace @sims/hq -- bulk-sms-purchase` → PASS. +Run: `npm test --workspace @sims/hq` → all green (source default preserved the existing document tests). `npm run typecheck` clean. + +- [ ] **Step 7: Commit** + +```bash +git add apps/hq/src/repos-documents.ts apps/hq/src/repos-renewals.ts apps/hq/src/api.ts apps/hq/test/bulk-sms-purchase.test.ts +git commit -m "feat(sms): bulk SMS purchase proforma + document source tagging (renewal vs top-up)" +``` + +--- + +## Task 8: Stalled-onboarding query + +**Files:** +- Modify: `apps/hq/src/repos-milestones.ts` +- Modify: `apps/hq/src/api.ts` (route) +- Test: `apps/hq/test/stalled-onboarding.test.ts` + +**Interfaces:** +- Produces: `stalledProjects(db: DB, olderThanDays: number, today: string): Promise` — active projects whose earliest pending step's most recent done_on (or module creation) is older than N days, i.e. parked. Reuses the `PendingProject` shape. + +- [ ] **Step 1: Write the failing test** + +Create `apps/hq/test/stalled-onboarding.test.ts`: build an active SMS project with early steps done on an old date and later steps pending; assert it appears for a small `olderThanDays` and not for a large one. + +```ts +it('lists a project parked past the threshold', async () => { + // last done_on = 2026-03-01, today = 2026-07-19 → ~140 days + const stalled = await stalledProjects(db, 30, '2026-07-19') + expect(stalled.map((p) => p.clientModuleId)).toContain(cmId) +}) +it('excludes a recently-progressed project', async () => { + const stalled = await stalledProjects(db, 365, '2026-07-19') + expect(stalled).toHaveLength(0) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- stalled-onboarding` +Expected: FAIL — `stalledProjects` not exported. + +- [ ] **Step 3: Implement** + +In `apps/hq/src/repos-milestones.ts`: + +```ts +/** Active projects with at least one pending step whose progress last moved > olderThanDays ago. */ +export async function stalledProjects(db: DB, olderThanDays: number, today: string): Promise { + const rows = await db.all( + `SELECT pm.client_module_id AS "clientModuleId", cm.client_id AS "clientId", + c.name AS "clientName", m.code AS "moduleCode", m.name AS "moduleName", + MAX(pm.done_on) AS last_done + FROM project_milestone pm + JOIN client_module cm ON cm.id = pm.client_module_id + JOIN client c ON c.id = cm.client_id + JOIN module m ON m.id = cm.module_id + WHERE cm.active = 1 AND cm.status NOT IN ('live','expired','cancelled') + GROUP BY pm.client_module_id + HAVING SUM(CASE WHEN pm.done = 0 THEN 1 ELSE 0 END) > 0`, + ) + const cutoff = new Date(Date.parse(today) - olderThanDays * 86400_000).toISOString().slice(0, 10) + return rows + .filter((r) => (r.last_done ?? '0000-00-00') < cutoff) + .map(({ last_done: _l, ...rest }) => rest) +} +``` + +In `api.ts` add a route: `r.get('/projects/stalled', requireAuth, …)` reading an `?days=` query (default from a `project.stall_days` setting, fallback 30) and `new Date().toISOString().slice(0,10)` for today. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test --workspace @sims/hq -- stalled-onboarding` → PASS. + +- [ ] **Step 5: Commit** + +```bash +npm run typecheck +git add apps/hq/src/repos-milestones.ts apps/hq/src/api.ts apps/hq/test/stalled-onboarding.test.ts +git commit -m "feat(onboarding): stalled-project query for the dashboard/MIS tile" +``` + +--- + +## Backend plan self-review + +- **Spec coverage:** §2 templates → T1/T2; §3 migration → T5; §7.1 status auto-drive → T3; §7.2 payment link → T4; §5 ledger → T6; §4a bulk SMS + §7.3 doc source → T7; §7.4 stalled → T8. Per-module Documents (§4) is pure frontend (client-side filter) — no backend task, handled in Plan 2. +- **Type consistency:** `milestoneTemplate(db, moduleCode?)`, `setMilestone(..., doneOn?, paymentId?)`, `ClientLedger.outstanding` / `LedgerPayment.allocations`, `generateBulkSmsPurchaseQuote(db, userId, cmId, smsQty)`, `stalledProjects(db, days, today)` — names used consistently across tasks and into Plan 2. +- **Placeholder scan:** test fixtures marked "fill in exact paise" (T6) and the client_module setup helpers (T3–T8) reference the existing `apps/hq/test/tickets.test.ts` setup — the implementer copies that concrete setup; not a code placeholder in shipped source. +- **Verify before done:** every task ends with `npm test` for its file plus `npm run typecheck`; T6 and T7 additionally run the full suite because they touch shared read/write paths. + +**Confirm before building:** the Mobile App module `code` (assumed `MOBILEAPP`) — check `SELECT code, name FROM module` and set the seed key + migration to the real code. diff --git a/docs/superpowers/plans/2026-07-19-client-detail-redesign-frontend.md b/docs/superpowers/plans/2026-07-19-client-detail-redesign-frontend.md new file mode 100644 index 0000000..f0858e8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-client-detail-redesign-frontend.md @@ -0,0 +1,572 @@ +# 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 ( +
+
+
+
+ Access + + +
+ + copy(cm.username ?? '', 'Username')} /> +
+ Password + + {revealed !== undefined + ? <>{revealed} + : cm.hasPassword + ? <>••••••••{managerial && } + : not set} + +
+ {cm.remark !== null && cm.remark !== '' && } +
+
+
+ Details + + +
+ {props.fieldSpec.filter((f) => f.type !== 'secret').map((f) => ( + + ))} + {cm.details.map((d, i) => )} + {props.fieldSpec.filter((f) => f.type === 'secret').map((f) => ( +
+ {f.label} + +
+ ))} +
+
+
+) +``` + +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 ( +
+ {props.label} + + {v !== undefined && v !== '' ? {v} : } + {props.copyable === true && v !== undefined && v !== '' && } + +
+ ) +} +``` + +- [ ] **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 ( +
+
+ {props.name} — status line + {ms !== undefined && ms.length > 0 && {done}/{ms.length}} + + {!adding ? : ( + <> + setLabel(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') addStep() }} /> + + + + )} +
+ {list.error !== undefined ? + : ms === undefined ? + : ms.length === 0 ? No steps yet. : ( +
+ {ms.map((m) => ( +
+ + + {m.label} + {m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'} +
+ ))} +
+ )} +
+ ) +} +``` + +Update the modules-tab render (line ~316) to use `` 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 = { + 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 ( +
+
+ Documents + {mine.length} + + {isSms && } +
+ {mine.length === 0 ?
No documents for this module yet.
+ : mine.map((d) => ( +
nav(`/documents/${d.id}`)} + style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '7px 0', borderTop: '1px solid var(--border)', cursor: 'pointer', fontSize: 12.5 }}> + {d.docNo ?? 'draft'} + {docLabel(d)} + {d.docDate} + {inr(d.payablePaise)} + {d.status} +
+ ))} +
+ ) +} +``` + +- [ ] **Step 3: Render it in each module block** + +In the modules-tab `.map` (~300–319), inside `.mod-section-body`, after `` add: + +```tsx + 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 ``, render: + +```tsx +{ledger.data !== undefined && ledger.data.outstanding.length > 0 && ( +
+

+ Outstanding + {inr(ledger.data.outstanding.reduce((s, o) => s + o.outstandingPaise, 0))} due +

+
+
Open documents + — oldest settles first
+ {ledger.data.outstanding.map((o) => ( +
nav(`/documents/${o.docId}`)} style={{ cursor: 'pointer' }}> + {o.docNo ?? 'draft'} + {DOC_TYPE_LABEL[o.docType as keyof typeof DOC_TYPE_LABEL] ?? o.docType} + {o.label} + {inr(o.outstandingPaise)} +
+ ))} +
Advance on account + {formatINR(ledger.data.advancePaise)}
+
+
+)} +``` + +- [ ] **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 + ? advance + : → {p.allocations.map((a) => a.docNo).join(', ')}, +``` + +- [ ] **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:` (audited). + +**Interfaces:** +- Produces: `getModuleTemplate(code): Promise<{ key: string; label: string }[]>`, `setModuleTemplate(code, steps): Promise`. + +- [ ] **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:`, 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. diff --git a/docs/superpowers/specs/2026-07-19-client-detail-redesign-design.md b/docs/superpowers/specs/2026-07-19-client-detail-redesign-design.md new file mode 100644 index 0000000..e9386e2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-client-detail-redesign-design.md @@ -0,0 +1,257 @@ +# Client Detail redesign — module lifecycle, status line & payments context + +**Date:** 2026-07-19 +**Area:** `apps/hq-web/src/pages/ClientDetail.tsx`, `client-forms.tsx`, `apps/hq-web/src/app.css`, +`apps/hq/src/repos-milestones.ts`, `repos-payments.ts`, `apps/hq/src/api.ts`, seed + migration. +**Mockup:** `scratchpad/client-detail-mockup.html` (v2) — approved direction. + +## Why + +The Client Detail page reads as a crowded dump. The per-module "service details" run every field +into one wrapping row (`provider · username · password · balance · reseller · alerts …`), so you +can't land on what you want. Onboarding is a flat checkbox grid divorced from the module's real +lifecycle. "Assign module" looks like a one-click action when in reality a module is the *end* of a +process (call → conversation → quotation → start → onboarding → live). And "Payments & plans" lets +you record a payment with no view of what is actually owed. + +This redesign restructures the Modules and Payments tabs around how the business actually works, +without changing the money engine, the audit rule, or the data model's spine. + +## Constraints (house rules that bind this work) + +- **Config over code, dated:** per-module onboarding step lists are DB rows resolved by module (and + date), never constants. (`docs/16`, `docs/07`.) +- **Append-only audit:** every mutation (milestone toggle, module-field edit, template change, + go-live auto-status) calls `writeAudit` in the same transaction. The Outstanding panel and the + per-module document list are **reads** — they write nothing. +- **Portable-repo pattern:** plain functions over `DB`; multi-write ops in `db.transaction`. +- **Strict TS, green before landing:** `npm run typecheck` + `npm test` clean; money math stays + test-locked (this work does not touch `computeBill` or allocation math). + +## Locked decisions + +1. **Module details → 50/50 sheet.** Access (credentials) left half, Details right half; aligned + `label → value` rows, read-only, one **Edit** per group. No `*` markers, no always-on input + boxes. Applies to **every** module, not just SMS. Reseller + SMS balance are ordinary editable + Details fields, not headline columns. +2. **Onboarding → horizontal continuous status line** (point-by-point), **per-module** step + templates, owner-editable (config). SMS / RTGS / Mobile App each get their own list. +3. **Lifecycle:** keep the coarse `client_module.status` field (reports/roster read it); the status + line is the detailed view. Ticking the **go-live** step auto-flips status to `live`. "Add module" + still starts at `quoted`. +4. **Migration:** swap SMS (and RTGS / Mobile App) projects from the old generic checklist to their + new per-module list, **preserving mapped ticks** (dates carried). +5. **Per-module Documents:** each block lists documents whose line items include that module. +6. **Payments:** add an **Outstanding** panel (open docs + amount due + advance) and a **Settled → + document** column on each past payment. Record-payment behaviour (auto-allocate oldest invoice + first) is unchanged. + +--- + +## 1 · Module block — 50/50 sheet, report-not-boxes, single Edit + +Rework `ServiceDataCard` + `ModuleFieldsForm` (in `ClientDetail.tsx`) so the **read view is the +default** and editing is behind a button. + +**Layout (per module block, inside the existing `
`):** +- Header unchanged: module name, coarse **status badge**, kind badge. +- Body groups, each with a hairline-ruled header + an **Edit** button: + - **Access** (left half): Provider, Login (username + Copy), Password (`••••` + managerial + Reveal/Copy), Portal (URL field → `Open ↗`). + - **Details** (right half): the module's `fieldSpec` fields **plus** the free-form `details` + rows, shown as `label → value` (dash when empty). Includes SMS balance / reseller. +- CSS: new `.split` (grid `1fr 1fr`, centre divider, stacks < 720px) and `.kv` (fixed-width label + column so values align). Reuse existing tokens; add to `app.css` near `.mod-section`. + +**Editing:** each group's Edit swaps that group's rows for inputs (the existing edit-state +machinery in `ServiceDataCard`, extended to also carry the `fieldSpec` values that +`ModuleFieldsForm` edits today). Save = one audited `patchClientModule` / `setModuleFields` +call, then back to read view. Secret fields keep the `SecretField` reveal/set pattern. +`ModuleFieldsForm`'s always-on inputs + separate "Save fields" button are removed — folded into +the Details group's Edit. + +## 2 · Onboarding — horizontal status line, per-module templates + +Replace `MilestoneChecklist`'s checkbox grid with a **horizontal track** (`.track` / `.node` CSS, +already prototyped in the mockup): ordered points = the module's template steps, filled to the +current point, current point ringed, completed dates under nodes, `overflow-x:auto` for narrow +screens. Clicking a node toggles done / stamps the date (reuse `setMilestone`); "+ Add step" keeps +appending per-project custom steps (`addProjectMilestone`). The toggle POST still returns the fresh +list so the block re-renders in place. + +**Per-module templates (config-over-code):** +- `repos-milestones.ts` — `milestoneTemplate(db, moduleCode?)` resolves in order: + `project.milestone_template:` setting → global `project.milestone_template` → + `FALLBACK_TEMPLATE`. `ensureProjectMilestones(db, clientModuleId)` looks up the client_module's + module code and resolves the module-specific template. +- Seed settings (`seed.ts`, additive) for: + - **SMS:** Document collection · DLT registration · Account creation & registration · Installation + · Testing · Training · Go-live · Advance payment · Balance payment. + - **RTGS:** Document collection · Server checkup · Setup & configuration · Installation · Testing · + Training · Go-live · Advance payment · Balance payment. + - **Mobile App (draft, editable):** Requirement & setup · Configuration · Data import · + Installation · Testing · Training · Go-live · Advance payment · Balance payment. + + Every list ends with **Advance payment → Balance payment** (payment is part of onboarding for all + modules). Ticking a payment step stamps its date on the line — that is the payment date of record + for the onboarding view; the money itself is still recorded in the Payments tab / ledger. +- Owner-editable on the **Modules** page: a per-module template editor (list of `{key,label}` + rows). Minimum viable: edit the JSON list per module; every mutation audited. New/changed steps + reach existing projects additively via `ensureProjectMilestones` (which already only adds missing + keys). + +**Go-live → status:** when a step with key `go_live` is ticked, set `client_module.status='live'` +in the same transaction as the milestone write, with its own audit entry. Unticking does **not** +revert status (manual only). + +## 3 · Migration — swap seeded modules to their new lists, preserve ticks + +One-time, idempotent migration (in `migrate()` / a scripted pass like `d35`), for every +`client_module` whose module is SMS / RTGS / Mobile App: + +1. Materialise the module's new template (unticked). +2. Carry ticks from old generic keys by mapping, preserving `done` + `done_on`: + +| old generic key | → SMS | → RTGS / Mobile App | +|---|---|---| +| `installed` | `installation` | `installation` | +| `go_live` | `go_live` | `go_live` | +| `advance_paid` | `advance_payment` | `advance_payment` | +| `balance_paid` | `balance_payment` | `balance_payment` | +| `setup_done` | *(drop)* | `setup_configuration` (RTGS) | +| `amc_start` | *(drop — AMC tracked separately)* | *(drop)* | + +Every module list now ends with advance/balance payment steps, so no payment tick is ever dropped. + +3. Remove the unmapped old rows so a project shows exactly its new list (no doubling). + +Modules with **no** custom template keep the generic list untouched. The mapping and the +per-project result are covered by tests (below). Dropped ticks are logged in the migration summary +(no silent loss). + +## 4 · Per-module Documents + +Client-side only — the ledger already returns full `Doc`s and each `payload.lines[].itemId` **is** +the module id. In each module block add a **Documents** group listing +`ledger.documents.filter(d => d.payload.lines.some(l => l.itemId === cm.moduleId))`, newest first: +doc no · type badge · date · amount · status, row click → `/documents/:id`. Empty → nothing shown +(no empty card). + +### 4a · SMS bulk-credit purchase (a document, within the module) + +SMS has two distinct money events: the **annual subscription/renewal** and ad-hoc **bulk SMS +credit purchases** (top-ups). The bulk purchase is not onboarding and not the subscription — it is +its own transaction and must be **captured as a document**. + +- In the SMS module block's Documents group, a **"Bulk SMS purchase"** action raises a proforma for + a chosen SMS quantity — the existing usage-priced path (`generateModuleRenewalQuote` / + `resolveUsageRate` tiered SMS pricing), pre-filled with the SMS line. (This is the same mechanism + the SMS-balances top-up already uses; here it lives in the module block.) +- The resulting proforma/invoice appears in that block's Documents list (labelled so a bulk top-up + reads distinctly from a renewal), flows into the **Outstanding** panel, and the client's payment + settles it — so "what is this payment for?" resolves to a real document. +- Generalisation: the Documents group also offers **"New document for this module"** (quotation + pre-filled with the module line) so a block is where you start the sale — matching the + quote → conversation → start flow. Bulk SMS purchase is the SMS-specific flavour of this. + +## 5 · Payments & plans — Outstanding panel + Settled column + +Extend the ledger read (no money-math change): +- `repos-payments.ts` `clientLedger` → `ClientLedger` gains: + - `outstanding: { docId; docNo; docType; label; outstandingPaise }[]` — issued invoices with + `outstandingPaise > 0` plus open (sent/accepted) proformas, oldest first. `label` is derived + from the doc's line module names ("SMS annual renewal", "Bulk SMS top-up"). + - each `payment` gains `allocations: { docNo; amountPaise }[]` (from `payment_allocation`), empty + ⇒ shows "advance". +- `api.ts` types (`Ledger`, `Payment`) updated to match. +- **UI (Payments tab):** an **Outstanding** card above the record-payment row (open docs + amount + due + advance-on-account), and a **Settled** column on the payments table showing the doc no(s) + each payment landed on (or "advance"). Record-payment form and its auto-allocation are unchanged. + +## 6 · Active modules summary + assign reframe + +- **Summary table** (Modules tab): dates now live in each block's status line, so simplify the + table to **Module · Kind · Status · Billed · Settled** (drop Installed/Completed/Trained columns); + right-align the numeric columns. This is the "tidy alignment / give it life" item. +- **Assign form:** rename "Assign module" → **"Add a module"**; copy notes it starts the module at + `quoted` and seeds its onboarding line. Behaviour otherwise unchanged (module + kind + optional + creds). Coarse status stays editable via a small control in the block header (kept for reports). + +## 7 · Adopted refinements + +1. **Status line auto-drives coarse status.** A step→status map advances `client_module.status` when + a step is ticked (never downgrades on untick): `installation → installed`, `training → trained`, + `go_live → live` (intermediate `ordered`/`installing` remain manual). The line becomes the single + driver of status — the dropdown is no longer hand-set in normal use. Each auto-advance is audited + in the milestone-toggle transaction. This is the full resolution of the point-1 overlap. +2. **Payment steps ↔ real payments.** Ticking `advance_payment` / `balance_payment` opens the + record-payment form pre-filled (or links an existing payment); the step's `done_on` mirrors the + payment's `receivedOn`, so the line and the ledger never disagree on the payment date. The link + is stored on the milestone row (nullable `payment_id`). +3. **Document-type labels in the block.** Rows in a module's Documents list are tagged + **Quote / Renewal / Bulk top-up / Invoice / Credit note**, derived from `docType` + the + generator `source` (renewal vs bulk-purchase stamp distinct sources) so a bulk SMS top-up reads + distinctly from a subscription renewal. +4. **Flag stalled onboarding.** Dashboard/MIS surface active modules parked at the same pending step + for more than N days (config setting), reusing `milestoneBoard` / `projectsPendingMilestone`. + A read-only tile + drill-down; no new write path. + +--- + +## Files touched + +- `apps/hq-web/src/pages/ClientDetail.tsx` — module block (50/50 sheet, status line, documents), + payments tab (outstanding + settled), summary table. +- `apps/hq-web/src/pages/client-forms.tsx` — `AssignModuleForm` copy; any shared status control. +- `apps/hq-web/src/app.css` — `.split`, `.kv`, `.track`/`.node`, outstanding-panel styles. +- `apps/hq-web/src/api.ts` — `Ledger`/`Payment` types. +- `apps/hq/src/repos-milestones.ts` — per-module template resolution; go-live→status. +- `apps/hq/src/repos-payments.ts` — `clientLedger` outstanding + allocations. +- `apps/hq/src/seed.ts` — seed SMS/RTGS/Mobile App template settings. +- `apps/hq/src/db.ts` (`migrate()`) or a `scripts/` pass — the milestone migration; `project_milestone` + gains a nullable `payment_id`; step→status auto-advance in `setMilestone`. +- `apps/hq-web/src/pages/Modules.tsx` — per-module template editor. +- `apps/hq/src/repos-documents.ts` — bulk-SMS-purchase generator `source` tag (vs renewal). +- `apps/hq-web/src/pages/Dashboard.tsx` / `Mis.tsx` — stalled-onboarding tile + drill-down. + +## Testing + +- **Milestone resolution:** module-specific template wins; falls back to global then code default. +- **Migration:** an SMS project with `installed`+`go_live` ticked → new list with Installation + + Go-live ticked (dates carried), five new steps unticked, old `setup_done`/`amc_start` gone; a + module with no custom template is untouched. Idempotent on re-run. +- **Go-live status:** ticking `go_live` sets status `live` + writes audit; unticking leaves it. +- **Ledger:** `outstanding` lists exactly the open invoices/proformas with correct + `outstandingPaise`; a fully-paid client shows none; each payment's `allocations` match + `payment_allocation`. Money totals unchanged (existing tests stay green). +- Manual: exercise assign → walk the status line → go-live flips status → record payment against an + outstanding invoice, in light and dark. +- **Bulk SMS purchase:** from the SMS block, raise a bulk-credit proforma → it appears in the SMS + Documents list and in the Outstanding panel → a payment settles it. + +### Responsive / mobile + red-team alignment (explicit requirement) + +The redesign must be **pixel-tight on a full mobile view**, not just desktop. Every new surface +is verified at phone (~375px), tablet (~768px) and desktop widths, in **both themes**: + +- **50/50 sheet** collapses cleanly to one column < 720px; labels never clip their values; no row + runs together again on any width. +- **Status line** scrolls horizontally on narrow screens with no clipped nodes/labels; dots stay + aligned to the connector; the current-point ring isn't cut off. +- **Outstanding panel, payments table, summary table, per-module Documents** — tables scroll inside + their own `overflow-x:auto`; the page body never scrolls sideways; numeric columns stay + right-aligned with `tabular-nums`. +- **Red-team alignment pass:** a deliberate adversarial review (via the browser QA / design-review + tooling) that hunts for misalignment, overflow, overlap, collapsed spacing, and theme-contrast + breaks across the three widths — findings fixed before landing, re-checked after. Alignment is a + landing gate, not a nice-to-have. + +## Risks / open items + +- **Mobile App step list** is a draft — owner can edit post-seed. +- **Summary-table column drop** removes inline date editing from the table; dates are edited on the + status line instead. Confirm no report depends on the table columns (they read `client_module` + directly, not the table). +- **Bulk SMS pricing path:** the bulk-purchase proforma relies on the existing usage/tiered SMS + pricing (`resolveUsageRate`); the plan must confirm the SMS module is priced as a usage kind and + the quantity input maps to that rate. No new money math — reuses the vetted path.