From 6bc92bace3da3358b172df1b19750d993eea6d34 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 22:38:48 +0530 Subject: [PATCH 01/25] docs(client-detail): redesign spec + backend/frontend implementation plans Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-07-19-client-detail-redesign-backend.md | 941 ++++++++++++++++++ ...6-07-19-client-detail-redesign-frontend.md | 572 +++++++++++ ...026-07-19-client-detail-redesign-design.md | 257 +++++ 3 files changed, 1770 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-19-client-detail-redesign-backend.md create mode 100644 docs/superpowers/plans/2026-07-19-client-detail-redesign-frontend.md create mode 100644 docs/superpowers/specs/2026-07-19-client-detail-redesign-design.md 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. From 1e77792bebccd9f52f49c37335be4861bc9488d1 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 22:43:04 +0530 Subject: [PATCH 02/25] feat(onboarding): resolve milestone template per module (config over code) Widens milestoneTemplate(db, moduleCode?) to resolve project.milestone_template: before the global project.milestone_template setting, falling back to FALLBACK_TEMPLATE. ensureProjectMilestones now looks up the client_module's module code and resolves the template through it, so each module can carry its own onboarding checklist as a dated setting row instead of one hardcoded global list. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq/src/repos-milestones.ts | 32 ++++++++++++++++------- apps/hq/test/milestones-templates.test.ts | 30 +++++++++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 apps/hq/test/milestones-templates.test.ts diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts index 5aba315..b2f2433 100644 --- a/apps/hq/src/repos-milestones.ts +++ b/apps/hq/src/repos-milestones.ts @@ -12,9 +12,9 @@ import type { DB } from './db' export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number } interface MilestoneRow { key: string; label: string; done: number; done_on: string | null; sort: number } -interface TemplateEntry { key: string; label: string } +export interface TemplateEntry { key: string; label: string } -const FALLBACK_TEMPLATE: TemplateEntry[] = [ +export const FALLBACK_TEMPLATE: TemplateEntry[] = [ { key: 'advance_paid', label: 'Advance payment received' }, { key: 'setup_done', label: 'Setup / configuration done' }, { key: 'installed', label: 'Installation done' }, @@ -23,14 +23,23 @@ const FALLBACK_TEMPLATE: TemplateEntry[] = [ { key: 'amc_start', label: 'AMC / renewal start' }, ] -/** The standard checklist (owner-editable via the setting; fallback to the code default). */ -export async function milestoneTemplate(db: DB): Promise { +/** 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'`) - if (row === undefined) return FALLBACK_TEMPLATE - try { - const parsed = JSON.parse(row.value) as TemplateEntry[] - return Array.isArray(parsed) && parsed.length > 0 ? parsed : FALLBACK_TEMPLATE - } catch { return FALLBACK_TEMPLATE } + const parsedGlobal = row !== undefined ? parse(row.value) : undefined + return parsedGlobal ?? FALLBACK_TEMPLATE } /** @@ -39,7 +48,10 @@ export async function milestoneTemplate(db: DB): Promise { * project's own custom milestones and any ticked state are never touched. */ export async function ensureProjectMilestones(db: DB, clientModuleId: string): Promise { - const template = await milestoneTemplate(db) + 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), diff --git a/apps/hq/test/milestones-templates.test.ts b/apps/hq/test/milestones-templates.test.ts new file mode 100644 index 0000000..eaaac0b --- /dev/null +++ b/apps/hq/test/milestones-templates.test.ts @@ -0,0 +1,30 @@ +// apps/hq/test/milestones-templates.test.ts — per-module onboarding template resolution. +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(() => { db = 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') + }) +}) From 9ac2ce9d50c5631416f647650d257521c21b997a Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 22:51:29 +0530 Subject: [PATCH 03/25] feat(onboarding): seed SMS/RTGS/MobileApp onboarding templates Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq/src/seed.ts | 47 +++++++++++++++++++++++++++++ apps/hq/test/seed-templates.test.ts | 25 +++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 apps/hq/test/seed-templates.test.ts diff --git a/apps/hq/src/seed.ts b/apps/hq/src/seed.ts index 8aac5b2..e8fa5ea 100644 --- a/apps/hq/src/seed.ts +++ b/apps/hq/src/seed.ts @@ -100,6 +100,53 @@ export async function seedIfEmpty(db: DB): Promise { const seededMilestone = (await db.run(INSERT_SETTING_SQL, 'project.milestone_template', MILESTONE_TEMPLATE)).changes if (seededMilestone > 0) await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template', undefined, { set: true }) + // 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) }) + } + // Dated reminder schedules (rule 3 / D-REMIND): one open-ended row per rule kind, // matching the code-constant fallback. A cadence change is a NEW dated row, not an edit. const scheduleKinds: ScheduleRuleKind[] = ['quote_followup', 'invoice_overdue'] diff --git a/apps/hq/test/seed-templates.test.ts b/apps/hq/test/seed-templates.test.ts new file mode 100644 index 0000000..a5f62b4 --- /dev/null +++ b/apps/hq/test/seed-templates.test.ts @@ -0,0 +1,25 @@ +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 = 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', + ]) + }) +}) From b2c49832d5b062c9d38b6bedd6b2b32e474ce7b3 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 22:59:17 +0530 Subject: [PATCH 04/25] feat(onboarding): status line auto-drives client_module.status (never downgrades) Ticking the installation/training/go_live onboarding milestone steps now advances the coarse client_module.status (installed/trained/live), audited in the same transaction. A STATUS_RANK guard blocks downgrades from unticking or ticking an earlier step after a later one already landed. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq/src/repos-milestones.ts | 20 ++++++++++++ apps/hq/test/milestone-status.test.ts | 45 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 apps/hq/test/milestone-status.test.ts diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts index b2f2433..0703a51 100644 --- a/apps/hq/src/repos-milestones.ts +++ b/apps/hq/src/repos-milestones.ts @@ -14,6 +14,25 @@ interface MilestoneRow { key: string; label: string; done: number; done_on: stri export interface TemplateEntry { key: string; label: string } +/** 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 }) +} + export const FALLBACK_TEMPLATE: TemplateEntry[] = [ { key: 'advance_paid', label: 'Advance payment received' }, { key: 'setup_done', label: 'Setup / configuration done' }, @@ -99,6 +118,7 @@ export async function setMilestone( done ? 1 : 0, stamp, clientModuleId, key, ) await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined, { key, done, doneOn: stamp }) + if (done) await advanceStatusForStep(db, userId, clientModuleId, key) return listProjectMilestones(db, clientModuleId) }) } diff --git a/apps/hq/test/milestone-status.test.ts b/apps/hq/test/milestone-status.test.ts new file mode 100644 index 0000000..924137b --- /dev/null +++ b/apps/hq/test/milestone-status.test.ts @@ -0,0 +1,45 @@ +// apps/hq/test/milestone-status.test.ts — milestone→status auto-drive (never downgrade). +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, assignModule, getClientModule } from '../src/repos-modules' +import { setMilestone } from '../src/repos-milestones' + +async function world() { + const db = openDb(':memory:') + await seedIfEmpty(db) + const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' }) + const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' }) + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + return { db, c, m, cmId: cm.id } +} + +describe('milestone → status auto-drive (D22 status line)', () => { + it('ticking installation advances status to installed', async () => { + const { db, cmId } = await world() + 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 () => { + const { db, cmId } = await world() + await setMilestone(db, 'u1', cmId, 'go_live', true) + expect((await getClientModule(db, cmId))!.status).toBe('live') + }) + + it('unticking never downgrades status', async () => { + const { db, cmId } = await world() + 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 () => { + const { db, cmId } = await world() + 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') + }) +}) From cd235b622b7dd7328d463c010fc173ff6713366c Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 23:08:13 +0530 Subject: [PATCH 05/25] feat(onboarding): link advance/balance payment steps to real payments Milestone.paymentId + project_milestone.payment_id (SQLite + Postgres, additive). setMilestone(..., paymentId?) resolves the payment's received_on to stamp done_on when a payment-step tick links a real payment; the linked payment must belong to the same client. api.ts threads paymentId from the milestone POST body. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq/src/api.ts | 5 +- apps/hq/src/db.ts | 5 ++ apps/hq/src/migrations-pg.ts | 5 ++ apps/hq/src/repos-milestones.ts | 46 +++++++++++++----- apps/hq/test/milestone-payment.test.ts | 66 ++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 apps/hq/test/milestone-payment.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 9c20d8e..9eee431 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -731,7 +731,7 @@ export function apiRouter( res.status(404).json({ ok: false, error: 'Client module not found' }); return } try { - const body = req.body as { key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown } + const body = req.body as { key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown; paymentId?: unknown } if (typeof body.label === 'string') { // Add a project-specific milestone. res.json({ ok: true, milestones: await addProjectMilestone(db, staffId(res), id, body.label) }) @@ -741,7 +741,8 @@ export function apiRouter( throw new Error('Provide { key, done } to tick a milestone, or { label } to add one') } const milestones = await setMilestone(db, staffId(res), id, body.key, body.done, - typeof body.doneOn === 'string' ? body.doneOn : undefined) + typeof body.doneOn === 'string' ? body.doneOn : undefined, + typeof body.paymentId === 'string' ? body.paymentId : undefined) res.json({ ok: true, milestones }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index cd0cef8..8ba8410 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -516,6 +516,11 @@ function migrate(db: SqliteRaw): void { if (!modCols2.some((c) => c.name === 'sort_order')) { db.exec(`ALTER TABLE module ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 100`) } + // Client Detail redesign task 4: link an advance/balance payment-step tick to the real payment. + 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`) + } rebuildStaffUserRoleCheck(db) rebuildReminderRuleKindCheck(db) } diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts index 36756e3..54abbd0 100644 --- a/apps/hq/src/migrations-pg.ts +++ b/apps/hq/src/migrations-pg.ts @@ -352,4 +352,9 @@ UPDATE client SET category = CASE END; `, }, + { + // Client Detail redesign task 4: link an advance/balance payment-step tick to the real payment. + id: '015-milestone-payment-id', + sql: `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS payment_id text;`, + }, ] diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts index 0703a51..93e5e52 100644 --- a/apps/hq/src/repos-milestones.ts +++ b/apps/hq/src/repos-milestones.ts @@ -9,8 +9,12 @@ import type { DB } from './db' * across the whole book: "who's pending go-live", "who hasn't paid the advance". */ -export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number } -interface MilestoneRow { key: string; label: string; done: number; done_on: string | null; sort: number } +export interface Milestone { + key: string; label: string; done: boolean; doneOn: string | null; sort: number; paymentId: string | null +} +interface MilestoneRow { + key: string; label: string; done: number; done_on: string | null; sort: number; payment_id: string | null +} export interface TemplateEntry { key: string; label: string } @@ -92,32 +96,52 @@ export async function ensureProjectMilestones(db: DB, clientModuleId: string): P export async function listProjectMilestones(db: DB, clientModuleId: string): Promise { const rows = await db.all( - `SELECT key, label, done, done_on, sort FROM project_milestone + `SELECT key, label, done, done_on, sort, payment_id FROM project_milestone WHERE client_module_id=? ORDER BY sort, label`, clientModuleId, ) - return rows.map((r) => ({ key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort })) + return rows.map((r) => ({ + key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort, paymentId: r.payment_id, + })) } -/** Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears it. Audited. */ +/** + * Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears + * it (and any linked payment). A payment-step tick (advance_payment / balance_payment) can + * pass `paymentId` to link a real payment — it must belong to the same client as this + * project, and its `received_on` mirrors into `done_on` unless an explicit `doneOn` is + * also given. Audited. + */ export async function setMilestone( - db: DB, userId: string, clientModuleId: string, key: string, done: boolean, doneOn?: string, + 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 FROM project_milestone WHERE client_module_id=? AND key=?`, + `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') } - const stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null + 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=? WHERE client_module_id=? AND key=?`, - done ? 1 : 0, stamp, clientModuleId, key, + `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 }) + 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) }) diff --git a/apps/hq/test/milestone-payment.test.ts b/apps/hq/test/milestone-payment.test.ts new file mode 100644 index 0000000..c2e6bd2 --- /dev/null +++ b/apps/hq/test/milestone-payment.test.ts @@ -0,0 +1,66 @@ +// apps/hq/test/milestone-payment.test.ts — payment_id on milestone + link on payment-step tick. +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, assignModule } from '../src/repos-modules' +import { setMilestone, listProjectMilestones } from '../src/repos-milestones' +import { recordPayment } from '../src/repos-payments' + +async function world() { + const db = openDb(':memory:') + await seedIfEmpty(db) + const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' }) + const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' }) + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + return { db, clientId: c.id, cmId: cm.id } +} + +describe('milestone payment_id link (payment-step tick)', () => { + it('linking a payment to advance_payment stores payment_id and mirrors received_on', async () => { + const { db, clientId, cmId } = await world() + 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) + }) + + it('rejects a payment that belongs to a different client', async () => { + const { db, cmId } = await world() + const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' }) + const pay = await recordPayment(db, 'u1', { + clientId: other.id, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 500_00, + }) + await expect( + setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id), + ).rejects.toThrow(/payment/i) + }) + + it('unticking clears payment_id along with done_on', async () => { + const { db, clientId, cmId } = await world() + 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) + await setMilestone(db, 'u1', cmId, 'advance_payment', false) + const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'advance_payment')! + expect(ms.done).toBe(false) + expect(ms.doneOn).toBeNull() + expect(ms.paymentId).toBeNull() + }) + + it('an explicit doneOn wins over the payment received_on', async () => { + const { db, clientId, cmId } = await world() + const pay = await recordPayment(db, 'u1', { + clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00, + }) + await setMilestone(db, 'u1', cmId, 'balance_payment', true, '2026-05-10', pay.payment.id) + const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'balance_payment')! + expect(ms.doneOn).toBe('2026-05-10') + expect(ms.paymentId).toBe(pay.payment.id) + }) +}) From 6bce248c2e342eb1426d7ce448de03665d40030d Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 23:18:02 +0530 Subject: [PATCH 06/25] feat(onboarding): one-time migration swapping seeded modules to new templates (ticks preserved) Idempotent migrateOnboardingTemplates(db): for every client_module whose module has a per-module onboarding template (task 2), rebuilds its milestone rows from that template, carrying mapped ticks (installed->installation, go_live->go_live, advance_paid->advance_payment, balance_paid->balance_payment, setup_done-> setup_configuration when present) with done_on and payment_id intact. Unmapped ticks (amc_start, or setup_done where the new template has no matching step) are dropped and counted. Audited per project; a second run is a no-op via an identical-key-set short-circuit. Adds the CLI wrapper (mirrors apply-name-fixes.ts's open-DB idiom, Postgres or SQLite) for the real one-time run against production data. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scripts/migrate-onboarding-templates.ts | 19 +++ apps/hq/src/migrate-onboarding-templates.ts | 81 ++++++++++ .../test/migrate-onboarding-templates.test.ts | 141 ++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 apps/hq/scripts/migrate-onboarding-templates.ts create mode 100644 apps/hq/src/migrate-onboarding-templates.ts create mode 100644 apps/hq/test/migrate-onboarding-templates.test.ts diff --git a/apps/hq/scripts/migrate-onboarding-templates.ts b/apps/hq/scripts/migrate-onboarding-templates.ts new file mode 100644 index 0000000..c255d8a --- /dev/null +++ b/apps/hq/scripts/migrate-onboarding-templates.ts @@ -0,0 +1,19 @@ +// apps/hq/scripts/migrate-onboarding-templates.ts — Client Detail redesign task 5: one-time +// migration swapping existing SMS/RTGS/MobileApp projects off the old generic onboarding +// checklist onto their new per-module template, carrying mapped ticks. Idempotent — safe +// to re-run (a second run does nothing). +// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/migrate-onboarding-templates.ts +import { openDb } from '../src/db' +import { openPgDb } from '../src/db-pg' +import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' + +async function main() { + const pgUrl = process.env['DATABASE_URL'] ?? '' + const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) + + const res = await migrateOnboardingTemplates(db) + // eslint-disable-next-line no-console + console.log(`Onboarding template migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ${res.dropped} dropped.`) +} + +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/apps/hq/src/migrate-onboarding-templates.ts b/apps/hq/src/migrate-onboarding-templates.ts new file mode 100644 index 0000000..8245c82 --- /dev/null +++ b/apps/hq/src/migrate-onboarding-templates.ts @@ -0,0 +1,81 @@ +// apps/hq/src/migrate-onboarding-templates.ts — Client Detail redesign task 5: one-time +// migration swapping seeded modules (SMS/RTGS/MobileApp) off the old generic onboarding +// checklist onto their new per-module template (task 2), carrying mapped ticks +// (done + done_on + payment_id) so onboarding history is not lost. +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 + * (a `project.milestone_template:` setting — task 2), rebuild its milestone rows + * from that template, carrying mapped ticks (done/done_on/payment_id) forward under their + * new key. Unmapped old ticks (e.g. `amc_start`, or `setup_done` for a module whose new + * template has no matching step) are dropped (and counted). Client_modules whose module + * has no per-module template are left completely 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 } +} diff --git a/apps/hq/test/migrate-onboarding-templates.test.ts b/apps/hq/test/migrate-onboarding-templates.test.ts new file mode 100644 index 0000000..6d34d15 --- /dev/null +++ b/apps/hq/test/migrate-onboarding-templates.test.ts @@ -0,0 +1,141 @@ +// apps/hq/test/migrate-onboarding-templates.test.ts — Client Detail redesign task 5: one-time +// migration swapping seeded modules (SMS/RTGS/MobileApp) from the old generic onboarding +// checklist onto their new per-module templates, carrying mapped ticks (done/done_on/payment_id). +import { describe, it, expect, beforeEach } from 'vitest' +import { openDb, type DB } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, assignModule } from '../src/repos-modules' +import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' +import { listProjectMilestones } from '../src/repos-milestones' + +describe('onboarding template migration', () => { + let db: DB + beforeEach(async () => { db = openDb(':memory:'); await seedIfEmpty(db) }) + + async function makeSmsClientModule(): Promise { + const client = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' }) + const mod = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' }) + const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' }) + // assignModule auto-seeds the NEW per-module template (task 2/4) — wipe it so the test + // can plant the OLD generic rows a legacy project would actually have on disk. + await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cm.id) + return cm.id + } + + async function seedOldGenericRows(cmId: string): Promise { + const rows: [string, number, string | null][] = [ + ['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], + ] + for (const [i, r] of rows.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, + ) + } + } + + it('swaps an SMS project to the 9-step list carrying installed+go_live ticks', async () => { + const cmId = await makeSmsClientModule() + await seedOldGenericRows(cmId) + + const res = await migrateOnboardingTemplates(db) + expect(res.projects).toBe(1) + expect(res.carried).toBe(2) // installed -> installation, go_live -> go_live + expect(res.dropped).toBe(0) // the two dropped keys (setup_done, amc_start) were both undone + + 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(installation.paymentId).toBeNull() + const goLive = ms.find((m) => m.key === 'go_live')! + expect(goLive.done).toBe(true) + expect(goLive.doneOn).toBe('2026-04-02') + // untouched (never ticked) steps stay pending + expect(ms.find((m) => m.key === 'document_collection')!.done).toBe(false) + expect(ms.find((m) => m.key === 'advance_payment')!.done).toBe(false) + expect(ms.find((m) => m.key === 'balance_payment')!.done).toBe(false) + // old setup_done / amc_start gone entirely + expect(ms.some((m) => m.key === 'setup_done' || m.key === 'amc_start')).toBe(false) + + // audited + const audits = await db.all<{ entity_id: string; after_json: string | null }>( + `SELECT entity_id, after_json FROM audit_log WHERE action='migrate_onboarding'`, + ) + expect(audits).toHaveLength(1) + expect(audits[0]!.entity_id).toBe(cmId) + expect(audits[0]!.after_json).toContain('installation') + }) + + it('carries a done payment_id along with the mapped tick', async () => { + const cmId = await makeSmsClientModule() + const client = await db.get<{ client_id: string }>( + `SELECT client_id FROM client_module WHERE id=?`, cmId, + ) + const paymentId = 'pay-1' + await db.run( + `INSERT INTO payment (id, client_id, received_on, mode, amount_paise, created_by, created_at) + VALUES (?, ?, '2026-03-01', 'bank', 500000, 'u1', '2026-03-01T00:00:00.000Z')`, + paymentId, client!.client_id, + ) + await db.run( + `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort) + VALUES (?,?,?,?,?,?,?,?)`, + 'm0', cmId, 'advance_paid', 'advance_paid', 1, '2026-03-01', paymentId, 0, + ) + + await migrateOnboardingTemplates(db) + const ms = await listProjectMilestones(db, cmId) + const advance = ms.find((m) => m.key === 'advance_payment')! + expect(advance.done).toBe(true) + expect(advance.doneOn).toBe('2026-03-01') + expect(advance.paymentId).toBe(paymentId) + }) + + it('is idempotent (second run changes nothing new)', async () => { + const cmId = await makeSmsClientModule() + await seedOldGenericRows(cmId) + + const first = await migrateOnboardingTemplates(db) + expect(first.projects).toBe(1) + const afterFirst = await listProjectMilestones(db, cmId) + + const second = await migrateOnboardingTemplates(db) + expect(second.projects).toBe(0) + expect(second.carried).toBe(0) + expect(second.dropped).toBe(0) + const afterSecond = await listProjectMilestones(db, cmId) + expect(afterSecond).toEqual(afterFirst) + + // no extra audit rows written on the no-op second pass + const audits = await db.all<{ n: number }>( + `SELECT COUNT(*) AS n FROM audit_log WHERE action='migrate_onboarding'`, + ) + expect((audits[0] as unknown as { n: number }).n).toBe(1) + }) + + it('leaves a module without a per-module template untouched', async () => { + const client = await createClient(db, 'u1', { name: 'Other Society', stateCode: '32' }) + const mod = await createModule(db, 'u1', { code: 'NOTEMPLATE', name: 'No Template Module' }) + const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' }) + // This module has no project.milestone_template:NOTEMPLATE setting, so ensureProjectMilestones + // fell back to the global generic template — the OLD generic rows themselves. + const before = await listProjectMilestones(db, cm.id) + expect(before.map((m) => m.key)).toEqual([ + 'advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start', + ]) + + const res = await migrateOnboardingTemplates(db) + expect(res.projects).toBe(0) + + const after = await listProjectMilestones(db, cm.id) + expect(after).toEqual(before) + }) +}) From 4b2e44c3ea706a56579cfb587fbeca2c6062b47b Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 23:27:32 +0530 Subject: [PATCH 07/25] test(onboarding-migration): add coverage for dropped ticked milestone keys Add one test case to cover the scenario where old milestone keys that don't map to the new template (amc_start, setup_done for SMS) are ticked and should be counted in the migration's dropped tally. Verifies both dropped and carried counts are accurate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/migrate-onboarding-templates.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/hq/test/migrate-onboarding-templates.test.ts b/apps/hq/test/migrate-onboarding-templates.test.ts index 6d34d15..8873af4 100644 --- a/apps/hq/test/migrate-onboarding-templates.test.ts +++ b/apps/hq/test/migrate-onboarding-templates.test.ts @@ -121,6 +121,47 @@ describe('onboarding template migration', () => { expect((audits[0] as unknown as { n: number }).n).toBe(1) }) + it('counts ticked dropped keys (unmapped or not in new template)', async () => { + const cmId = await makeSmsClientModule() + // Insert old generic rows with two ticked keys that will be dropped: + // - amc_start: unmapped (not in KEY_MAP) + // - setup_done: maps to 'setup_configuration', but SMS template doesn't have it + // Plus one carried key (installed -> installation) to verify carried count too. + const rows: [string, number, string | null][] = [ + ['amc_start', 1, '2026-02-15'], // ticked, unmapped -> dropped + ['setup_done', 1, '2026-03-01'], // ticked, maps to setup_configuration but SMS template lacks it -> dropped + ['installed', 1, '2026-03-25'], // ticked, maps to installation which IS in SMS template -> carried + ['advance_paid', 0, null], + ['go_live', 0, null], + ['balance_paid', 0, null], + ] + for (const [i, r] of rows.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) + expect(res.carried).toBe(1) // only installed -> installation + expect(res.dropped).toBe(2) // amc_start (unmapped) + setup_done (not in SMS template) + + // Verify the dropped keys are gone from the migrated template + 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', + ]) + expect(ms.some((m) => m.key === 'amc_start' || m.key === 'setup_done')).toBe(false) + + // Verify the carried installation tick is preserved + const installation = ms.find((m) => m.key === 'installation')! + expect(installation.done).toBe(true) + expect(installation.doneOn).toBe('2026-03-25') + }) + it('leaves a module without a per-module template untouched', async () => { const client = await createClient(db, 'u1', { name: 'Other Society', stateCode: '32' }) const mod = await createModule(db, 'u1', { code: 'NOTEMPLATE', name: 'No Template Module' }) From fdf31b4dbd921779bc0f17521375b18fc7625990 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 23:34:19 +0530 Subject: [PATCH 08/25] feat(payments): ledger exposes outstanding docs + per-payment allocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clientLedger now attaches allocations (docNo + amount) to each payment and returns an outstanding[] list — issued invoices with outstandingPaise > 0 plus open (draft/sent/accepted) proformas, oldest-first by docNo. Read-only extension over the existing settlement math (outstandingPaise, allocations); no money/allocation logic changed. Mirrors the widened Payment/Ledger shapes onto apps/hq-web/src/api.ts for the upcoming Payments tab. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq-web/src/api.ts | 2 + apps/hq/src/repos-payments.ts | 40 +++++++++++- apps/hq/test/ledger-outstanding.test.ts | 86 +++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 apps/hq/test/ledger-outstanding.test.ts diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 37f543c..8a311be 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -217,11 +217,13 @@ export interface Payment { id: string; clientId: string; receivedOn: string; mode: PaymentMode reference: string; amountPaise: number; tdsPaise: number createdBy: string; createdAt: string + allocations: { docNo: string; amountPaise: number }[] } export interface Ledger { documents: Doc[]; payments: Payment[]; advancePaise: number modulePaid: { moduleId: string; billedPaise: number; settledPaise: number }[] + outstanding: { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number }[] } // ---------- typed calls ---------- diff --git a/apps/hq/src/repos-payments.ts b/apps/hq/src/repos-payments.ts index 46a18b6..103cf84 100644 --- a/apps/hq/src/repos-payments.ts +++ b/apps/hq/src/repos-payments.ts @@ -231,7 +231,13 @@ export async function receiptForPayment(db: DB, userId: string, paymentId: strin // ---------- read views ---------- -export interface ClientLedger { documents: Doc[]; payments: Payment[]; advancePaise: number } +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[] +} export async function clientLedger(db: DB, clientId: string): Promise { const payments = await listPayments(db, clientId) @@ -249,10 +255,40 @@ export async function clientLedger(db: DB, clientId: string): Promise= batch.total) break } + + // 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, + payments: ledgerPayments, advancePaise: settling - row.total, + outstanding, } } diff --git a/apps/hq/test/ledger-outstanding.test.ts b/apps/hq/test/ledger-outstanding.test.ts new file mode 100644 index 0000000..92f57bb --- /dev/null +++ b/apps/hq/test/ledger-outstanding.test.ts @@ -0,0 +1,86 @@ +// apps/hq/test/ledger-outstanding.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { createClient } from '../src/repos-clients' +import { createModule, setPrice } from '../src/repos-modules' +import { createDraft, issueDocument, markStatus } from '../src/repos-documents' +import { recordPayment, clientLedger } from '../src/repos-payments' + +/** + * Fixture: one client, one module priced ₹10,000/yr (→ ₹11,800 with GST18). + * - INVOICE (qty 1): issued as INV/26-27-0001, payable ₹11,800. + * Partly paid ₹5,000 → outstanding ₹6,800. + * - PROFORMA (qty 2): issued as PI/26-27-0001, payable ₹23,600, marked 'sent' + * (open — nothing paid against a proforma). + */ +async function buildFixture(db: ReturnType) { + await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`) + await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`) + const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' }) + await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) + + const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { + docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], + })).id) // payable ₹11,800 (taxable ₹10,000 + GST18 ₹1,800) + + const pf = await markStatus(db, 'u1', (await issueDocument(db, 'u1', (await createDraft(db, 'u1', { + docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }], + })).id)).id, 'sent') // payable ₹23,600 (taxable ₹20,000 + GST18 ₹3,600) + + const { payment } = await recordPayment(db, 'u1', { + clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 5_000_00, + }) // ₹5,000 of ₹11,800 → outstanding ₹6,800 + + return { clientId: c.id, inv, pf, payment } +} + +describe('clientLedger — outstanding + per-payment allocations', () => { + it('reports outstanding open documents oldest-first with correct due amounts', async () => { + const db = openDb(':memory:') + const { clientId, inv, pf } = await buildFixture(db) + const led = await clientLedger(db, clientId) + // INV/26-27-0001 sorts before PI/26-27-0001 (docNo lexical order, both oldest). + expect(led.outstanding.map((o) => o.docNo)).toEqual([inv.docNo, pf.docNo]) + expect(led.outstanding[0]).toMatchObject({ + docId: inv.id, docNo: inv.docNo, docType: 'INVOICE', label: 'POS', + outstandingPaise: 6_800_00, // 11,800 payable − 5,000 allocated − 0 credited + }) + expect(led.outstanding[1]).toMatchObject({ + docId: pf.id, docNo: pf.docNo, docType: 'PROFORMA', label: 'POS', + outstandingPaise: 23_600_00, // open proforma: full payable, nothing settled against it + }) + }) + + it('excludes a fully-paid invoice and a cancelled/lost document from outstanding', async () => { + const db = openDb(':memory:') + const { clientId, inv } = await buildFixture(db) + // Settle the remaining 6,800 in full. + await recordPayment(db, 'u1', { clientId, receivedOn: '2026-07-11', mode: 'upi', amountPaise: 6_800_00 }) + const led = await clientLedger(db, clientId) + expect(led.outstanding.find((o) => o.docId === inv.id)).toBeUndefined() + }) + + it('attaches allocations to each payment', async () => { + const db = openDb(':memory:') + const { clientId, inv, payment } = await buildFixture(db) + const led = await clientLedger(db, clientId) + const p = led.payments.find((x) => x.id === payment.id)! + expect(p.allocations).toEqual([{ docNo: inv.docNo, amountPaise: 5_000_00 }]) + expect(p.allocations.reduce((s, a) => s + a.amountPaise, 0)).toBeLessThanOrEqual(p.amountPaise + p.tdsPaise) + }) + + it('a payment with no allocation (pure advance) reports an empty allocations array', async () => { + const db = openDb(':memory:') + await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`) + await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`) + const c = await createClient(db, 'u1', { name: 'NoInvoiceCo', stateCode: '32' }) + const { payment } = await recordPayment(db, 'u1', { + clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 1_000_00, + }) + const led = await clientLedger(db, c.id) + expect(led.payments[0]!.id).toBe(payment.id) + expect(led.payments[0]!.allocations).toEqual([]) + expect(led.outstanding).toEqual([]) + }) +}) From 195d821bef3b741c31265d47a1088fae38ecb4cf Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 23:46:04 +0530 Subject: [PATCH 09/25] feat(sms): bulk SMS purchase proforma + document source tagging (renewal vs top-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads an optional `source` through DraftInput/insertDocRow (default 'hq', preserving current behaviour when omitted), tags module renewals as 'module_renewal', and adds generateBulkSmsPurchaseQuote — an ad-hoc bulk SMS credit top-up priced via the existing usage rate card, tagged 'bulk_sms_topup' so the client-detail document list can distinguish it from a subscription renewal. New route: POST /client-modules/:id/bulk-sms-quote. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq/src/api.ts | 12 ++++- apps/hq/src/repos-documents.ts | 12 +++-- apps/hq/src/repos-renewals.ts | 21 +++++++- apps/hq/test/bulk-sms-purchase.test.ts | 74 ++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 apps/hq/test/bulk-sms-purchase.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 9eee431..d3f44a9 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -60,7 +60,7 @@ import { import { pullMonthlyCosts } from './aws-costs' import { clientProfitability, duesAging, gstSummary, moduleRevenue, type DateRange } from './repos-reports' import { listUsageRateCards, setUsageRateCard } from './repos-rate-card' -import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals' +import { renewalsDue, generateModuleRenewalQuote, generateBulkSmsPurchaseQuote } from './repos-renewals' import { misCockpit } from './repos-mis' import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms' import { globalSearch } from './repos-search' @@ -1665,6 +1665,16 @@ export function apiRouter( res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) + // Ad-hoc bulk SMS credit purchase (top-up) — distinct from a subscription renewal. + 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) }) + } + }) // ---------- bulk operations (D26 — cleanup at scale, owner-only) ---------- r.post('/clients/bulk', requireAuth, requireOwner, async (req, res) => { diff --git a/apps/hq/src/repos-documents.ts b/apps/hq/src/repos-documents.ts index ba02a91..7245131 100644 --- a/apps/hq/src/repos-documents.ts +++ b/apps/hq/src/repos-documents.ts @@ -152,6 +152,8 @@ export interface DraftInput { terms?: string /** INVOICE only (D18): payment due date; when absent, issue stamps doc_date + terms. */ dueDate?: string + /** Provenance tag for the frontend's Quote/Renewal/Bulk-top-up label; default: schema default ('hq'). */ + source?: string } const todayIso = (): string => new Date().toISOString().slice(0, 10) @@ -240,18 +242,18 @@ async function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warn /** Insert a draft row + created event + audit. Callers wrap in a transaction. */ async function insertDocRow(db: DB, userId: string, a: { docType: DocType; clientId: string; refDocId: string | null; docDate: string - totals: BillTotals; payload: DocPayload; dueDate?: string | null + totals: BillTotals; payload: DocPayload; dueDate?: string | null; source?: string }): Promise { const id = uuidv7() const t = a.totals await db.run( `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, created_by, created_at) - VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + payload, source, created_by, created_at) + VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, a.docType, fyOf(a.docDate), a.clientId, a.docDate, a.dueDate ?? null, a.refDocId, t.taxablePaise, t.cgstPaise, t.sgstPaise, t.igstPaise, t.roundOffPaise, t.payablePaise, - JSON.stringify(a.payload), userId, new Date().toISOString()) + JSON.stringify(a.payload), a.source ?? 'hq', userId, new Date().toISOString()) await addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {}) const doc = (await getDocument(db, id))! await writeAudit(db, userId, 'create', 'document', id, undefined, { @@ -334,7 +336,7 @@ export async function createDraft(db: DB, userId: string, input: DraftInput): Pr return db.transaction(() => insertDocRow(db, userId, { docType: prepared.docType, clientId: prepared.clientId, refDocId: null, docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload, - dueDate: prepared.dueDate ?? null, + dueDate: prepared.dueDate ?? null, source: input.source, })) } diff --git a/apps/hq/src/repos-renewals.ts b/apps/hq/src/repos-renewals.ts index a5edc42..e3e8180 100644 --- a/apps/hq/src/repos-renewals.ts +++ b/apps/hq/src/repos-renewals.ts @@ -72,7 +72,26 @@ export async function generateModuleRenewalQuote(db: DB, userId: string, clientM 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, + 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 (top-up) — a PROFORMA priced through the same + * createDraft path as any other document, so the usage rate card (D23) resolves the + * per-SMS rate for `smsQty` the same way a subscription line would. Tagged + * `source: 'bulk_sms_topup'` so it reads distinctly from a subscription renewal + * (`module_renewal`) on the client-detail document list. + */ +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 }], + }) +} diff --git a/apps/hq/test/bulk-sms-purchase.test.ts b/apps/hq/test/bulk-sms-purchase.test.ts new file mode 100644 index 0000000..fdee684 --- /dev/null +++ b/apps/hq/test/bulk-sms-purchase.test.ts @@ -0,0 +1,74 @@ +// apps/hq/test/bulk-sms-purchase.test.ts — Task 7: ad-hoc bulk-SMS top-up documents, +// tagged distinctly from subscription renewals via document.source. +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, assignModule, setPrice, updateClientModule } from '../src/repos-modules' +import { setUsageRateCard } from '../src/repos-rate-card' +import { generateModuleRenewalQuote, generateBulkSmsPurchaseQuote } from '../src/repos-renewals' +import { createDraft, getDocument } from '../src/repos-documents' + +// The founder's real card: ₹0.40 / 0.37 / 0.34 / 0.30 per SMS = 40/37/34/30 paise. +const BANDS = [ + { minQty: 50_000, ratePaise: 40 }, + { minQty: 100_000, ratePaise: 37 }, + { minQty: 200_000, ratePaise: 34 }, + { minQty: 300_000, ratePaise: 30 }, +] + +async function world() { + const db = openDb(':memory:') + await seedIfEmpty(db) // company state 32, GST18 + const c = await createClient(db, 'u1', { name: 'Karapuzha SCB', stateCode: '32' }) + const sms = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS', allowedKinds: ['usage'] }) + await setUsageRateCard(db, 'u1', sms.id, '2026-04-01', BANDS) + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: sms.id, kind: 'usage' }) + return { db, c, sms, cm } +} + +describe('generateBulkSmsPurchaseQuote', () => { + it('raises a proforma tagged bulk_sms_topup for the given SMS quantity', async () => { + const { db, cm } = await world() + const doc = await generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 100_000) + expect(doc.docType).toBe('PROFORMA') + expect(doc.source).toBe('bulk_sms_topup') + expect(doc.payload.lines[0]!.qty).toBe(100_000) + expect(doc.payablePaise).toBeGreaterThan(0) + // Persisted correctly — a re-fetch from the DB carries the same tag. + const full = (await getDocument(db, doc.id))! + expect(full.source).toBe('bulk_sms_topup') + }) + + it('rejects a non-positive or fractional quantity', async () => { + const { db, cm } = await world() + await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 0)).rejects.toThrow() + await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, -5)).rejects.toThrow() + await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 1.5)).rejects.toThrow() + }) + + it('rejects an unknown client module', async () => { + const { db } = await world() + await expect(generateBulkSmsPurchaseQuote(db, 'u1', 'nope', 100_000)).rejects.toThrow(/not found/) + }) +}) + +describe('document.source tagging', () => { + it('tags a renewal proforma module_renewal, and a manual draft keeps the default', async () => { + const { db, c } = await world() + // A separate yearly-priced module for the renewal path (usage modules enforce a + // minimum-order qty that a qty:1 renewal line would fail). + const yearly = await createModule(db, 'u1', { code: 'CORE', name: 'Core App', allowedKinds: ['yearly'] }) + await setPrice(db, 'u1', { moduleId: yearly.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) + const cm2 = await assignModule(db, 'u1', { clientId: c.id, moduleId: yearly.id, kind: 'yearly' }) + await updateClientModule(db, 'u1', cm2.id, { nextRenewal: '2026-08-15' }) + const renewal = await generateModuleRenewalQuote(db, 'u1', cm2.id) + expect(renewal.source).toBe('module_renewal') + + const manual = await createDraft(db, 'u1', { + docType: 'PROFORMA', clientId: c.id, + lines: [{ moduleId: yearly.id, qty: 1, kind: 'yearly' }], + }) + expect(manual.source).toBe('hq') // default preserved when source is omitted + }) +}) From 75fb41c366ec77b50ff1d544ceb2fd7a030961f0 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 23:57:07 +0530 Subject: [PATCH 10/25] feat(onboarding): stalled-project query for the dashboard/MIS tile Adds stalledProjects(db, olderThanDays, today) to repos-milestones.ts: active projects with a pending step whose last progress (MAX done_on, or module creation if nothing ticked) is older than N days. GET /projects/stalled reads ?days= with a project.stall_days setting fallback (default 30). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq/src/api.ts | 10 ++++- apps/hq/src/repos-milestones.ts | 27 ++++++++++++ apps/hq/test/stalled-onboarding.test.ts | 57 +++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 apps/hq/test/stalled-onboarding.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index d3f44a9..5d179d2 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -70,7 +70,7 @@ import { assertSafeGatewayUrl } from './sms-gateway' import { makeRateLimiter } from './rate-limit' import { addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard, - projectsPendingMilestone, setMilestone, + projectsPendingMilestone, setMilestone, stalledProjects, } from './repos-milestones' import { commitImport, stageCsv, verificationReport } from './import-apex' import { documentHtml, documentHtmlSample } from './templates' @@ -755,6 +755,14 @@ export function apiRouter( r.get('/projects/pending/:key', requireAuth, async (req, res) => { res.json({ ok: true, projects: await projectsPendingMilestone(db, String(req.params['key'] ?? '')) }) }) + // Parked onboarding: active projects whose next step hasn't moved in a while (dashboard/MIS tile). + r.get('/projects/stalled', requireAuth, async (req, res) => { + const q = req.query['days'] + const days = typeof q === 'string' && q !== '' && Number.isFinite(Number(q)) + ? Number(q) : await getNumberSetting(db, 'project.stall_days', 30) + const today = new Date().toISOString().slice(0, 10) + res.json({ ok: true, projects: await stalledProjects(db, days, today) }) + }) // ---------- client branches (D20) ---------- r.get('/clients/:id/branches', requireAuth, async (req, res) => { diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts index 93e5e52..4623571 100644 --- a/apps/hq/src/repos-milestones.ts +++ b/apps/hq/src/repos-milestones.ts @@ -236,3 +236,30 @@ export async function projectsPendingMilestone(db: DB, key: string): Promise { + const rows = await db.all( + `SELECT pm.client_module_id AS "clientModuleId", MIN(cm.client_id) AS "clientId", + MIN(c.name) AS "clientName", MIN(m.code) AS "moduleCode", MIN(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 * 86_400_000).toISOString().slice(0, 10) + return rows + .filter((r) => (r.last_done ?? '0000-00-00') < cutoff) + .map(({ last_done: _last_done, ...rest }) => rest) +} diff --git a/apps/hq/test/stalled-onboarding.test.ts b/apps/hq/test/stalled-onboarding.test.ts new file mode 100644 index 0000000..ee8eb08 --- /dev/null +++ b/apps/hq/test/stalled-onboarding.test.ts @@ -0,0 +1,57 @@ +// apps/hq/test/stalled-onboarding.test.ts — Task 8: stalled-onboarding query (dashboard/MIS tile). +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, assignModule } from '../src/repos-modules' +import { setMilestone, stalledProjects } from '../src/repos-milestones' + +async function world() { + const db = openDb(':memory:') + await seedIfEmpty(db) + const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' }) + // A module code with no per-module milestone template seeded (D22 seed.ts only carries + // SMS/RTGS/MOBILEAPP overrides), so the global template applies: advance_paid, setup_done, + // installed, go_live, balance_paid, amc_start. + const m = await createModule(db, 'u1', { code: 'MISC', name: 'Misc Module' }) + return { db, c, m } +} + +describe('stalledProjects (D22 — dashboard/MIS tile)', () => { + it('lists a project parked past the threshold', async () => { + const { db, c, m } = await world() + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + // Early steps done on an old date; later steps (incl. go_live) stay pending — project is + // still active (status stays 'quoted'/'installed', never 'live'). + await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01') + await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01') + // last done_on = 2026-03-01, today = 2026-07-19 → ~140 days parked. + const stalled = await stalledProjects(db, 30, '2026-07-19') + expect(stalled.map((p) => p.clientModuleId)).toContain(cm.id) + const row = stalled.find((p) => p.clientModuleId === cm.id)! + expect(row.clientName).toBe('Kothavara SCB') + expect(row.moduleCode).toBe('MISC') + }) + + it('excludes a recently-progressed project', async () => { + const { db, c, m } = await world() + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01') + await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01') + const stalled = await stalledProjects(db, 365, '2026-07-19') + expect(stalled).toHaveLength(0) + }) + + it('excludes projects with no pending steps (fully ticked) and inactive/live projects', async () => { + const { db, c, m } = await world() + // Fully ticked project: nothing pending, so it should never appear regardless of age. + const done = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + for (const key of ['advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start']) { + await setMilestone(db, 'u1', done.id, key, true, '2026-01-01') + } + const stalled = await stalledProjects(db, 1, '2026-07-19') + expect(stalled.map((p) => p.clientModuleId)).not.toContain(done.id) + // 'live' status (from ticking go_live) also excludes it, which the loop above proves. + expect((await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, done.id))!.status).toBe('live') + }) +}) From 67f69fc99921d9e2b6f9a2271ed24acbd16f35ee Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 00:04:57 +0530 Subject: [PATCH 11/25] style(client-detail): CSS for 50/50 sheet, status line, outstanding panel Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq-web/src/app.css | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/hq-web/src/app.css b/apps/hq-web/src/app.css index 5d313d6..d4349dc 100644 --- a/apps/hq-web/src/app.css +++ b/apps/hq-web/src/app.css @@ -483,3 +483,41 @@ table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inse } .no-print { display: none !important; } } + +/* ---- 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); } From d05c7ee31b3f7e39f6a838a9aaecdc0b12f0b3f5 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 00:13:46 +0530 Subject: [PATCH 12/25] docs: bring README/STATUS/CLAUDE/DECISIONS current (D34-D37) Records the work since the last doc pass: employee master import + invited/first-login flag (D34), client type PACS/Store + directory-sourced LTD numbers and the name-correction session (D35), login history with IP/device (D36), and the society category matching the Kerala co-op directory taxonomy (D37). Adds the login_event table, the Login history screen, and the client type/category fields; bumps the schema table count 31->32. Decisions D34-D37 appended to 06-DECISIONS.md. --- CLAUDE.md | 7 ++++++- README.md | 7 ++++++- STATUS.md | 20 +++++++++++++++++--- docs/06-DECISIONS.md | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 994f8ea..6f91a81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ dashboard, an append-only audit log, an APEX importer, and a scheduler. | Part | Where | Notes | |---|---|---| | Backend | `apps/hq/src` | Express over `better-sqlite3`, DB at `data/hq.db` (WAL) | -| Frontend | `apps/hq-web/src` | React + Vite. **Home** card launcher is the landing (`/`); Dashboard (My Day) is at `/dashboard` (D30). Pages: Home / Dashboard / Pipeline / Reminders / Tickets / Onboarding / Clients / ClientDetail / Documents / NewDocument / DocumentView / Renewals / Modules / Module data / Portals / Reports / MIS cockpit (owner-only) / SMS credits / Employees (owner-only) / Profile / APEX Import (owner-only) / Audit log (owner-only) / Settings. Theme = light/dark, 7 accents, 4 neutral palettes (Warm/Slate/Graphite/Zinc), Geist font -- all in Settings > Appearance (D29) | +| Frontend | `apps/hq-web/src` | React + Vite. **Home** card launcher is the landing (`/`); Dashboard (My Day) is at `/dashboard` (D30). Pages: Home / Dashboard / Pipeline / Reminders / Tickets / Onboarding / Clients / ClientDetail / Documents / NewDocument / DocumentView / Renewals / Modules / Module data / Portals / Reports / MIS cockpit (owner-only) / SMS credits / Employees (owner-only) / Profile / APEX Import (owner-only) / Audit log (owner-only) / Login history (owner-only) / Settings. Theme = light/dark, 7 accents, 4 neutral palettes (Warm/Slate/Graphite/Zinc), Geist font -- all in Settings > Appearance (D29) | | Repos | `apps/hq/src/repos-*.ts` | one file per domain; plain functions over a `DB` handle | | Schema | `apps/hq/src/db.ts` + `apps/hq/schema.sql` | `CREATE TABLE IF NOT EXISTS` + additive `migrate()` | | Shared packages | `packages/*` | trimmed forks — see below | @@ -70,6 +70,11 @@ cd apps/hq-web && npm run dev - **SMS gateway** (D28): the SMS module's per-client balance is pulled live from the provider panel (`sms.balance_api_url`, keyless) — manual "Refresh from gateway" + a daily scheduler pass; low balances surface on the SMS credits screen, cockpit tile and dashboard alert. +- **Clients carry a type + category** (D35/D37): `client_type` (PACS/Store) and `category` + (Kerala co-op taxonomy — Service Bank / Vanitha / Housing / Employees / …), both derived from + the name and filterable on the Clients screen. **Employees** log in by username; imported staff + get a default `@123` with a `must_change_password` "Invited" flag (D34). **Login history** + (D36): every sign-in is logged with IP + device (`login_event`), owner-viewable. - Postgres integration tests: `HQ_PG_TEST_URL=postgres://hq:hq_dev@localhost:5432/hq_test npm test` (wipes that database each run — never point it at real data). - Health check: `GET /api/health` → `{ ok: true }`. diff --git a/README.md b/README.md index d634ff0..bc44396 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,11 @@ document and conversation we've had with them. - **Renewals** command center, an owner **MIS cockpit**, an **audit-log viewer**, and **database backups** (`npm run backup` → compressed, restorable `pg_dump`). Credentials are stored in the clear (no `HQ_SECRET_KEY`), so the DB and its backups are the security boundary. +- **Employees** with per-user logins (imported from the master; an "invited" flag until they + change their default password), and a **login history** (every sign-in with IP + device). +- **Client classification** — each client is a **PACS** or **Store**, and carries a **society + category** matching the Kerala co-op directory (Service Bank / Vanitha / Housing / Employees / + …); both are filterable on the Clients screen. ## Build state (start here) @@ -58,7 +63,7 @@ runbook (server, HTTPS, backups, Gmail/AWS/import wiring, the Postgres switch) i - **Frontend — `apps/hq-web`**: React + Vite. Pages: Home, Dashboard, Pipeline, Reminders, Tickets, Onboarding, Clients, ClientDetail, Documents, NewDocument, DocumentView, Renewals, SMS credits, Modules, Module data, Portals, Reports, MIS cockpit (owner), Employees (owner), - Profile, APEX Import (owner), Audit log (owner), Settings. + Profile, APEX Import (owner), Audit log (owner), Login history (owner), Settings. - **Shared packages** (trimmed forks): `@sims/domain` (ids, money, business-day, doc-series, gstin, documents), `@sims/auth` (pin/scrypt), `@sims/billing-engine` (compute, tax — our invoices are GST documents, computed to the paisa), `@sims/ui` (design system). diff --git a/STATUS.md b/STATUS.md index cf79d5d..7841280 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,7 +1,7 @@ # SiMS HQ — Build Status _Internal ops console for running our software business. Not shipped to clients._ -_Last reviewed: 2026-07-19 · Version 0.1.0_ +_Last reviewed: 2026-07-20 · Version 0.1.0_ HQ is the back-office console we use to run ~300 Classic client relationships: the client book, our software module catalogue and dated price book, quotations / @@ -35,6 +35,18 @@ clear** — `HQ_SECRET_KEY` was removed, so portal/gateway passwords, modul passwords and the Gmail token are plaintext and keyless (login passwords stay scrypt-hashed); a DB dump exposes them, so the DB file and its backups are the security boundary. +**Since 2026-07-19 (D34–D37).** **Employee master imported** (11 staff; username = first name, +default password `@123`) with a `must_change_password` "Invited" flag that clears on the +first self password-change (D34). Clients gained a **type** (PACS vs Store) and a **society +category** matching the Kerala co-op directory taxonomy (Service Bank / Urban / Vanitha / +Housing / Employees / Agricultural / …), both derived from the name and filterable on the +Clients screen (D35/D37). A founder-approved **data-correction session** cleaned 187 client +names and appended **LTD/registration numbers to 141 PACS**, sourced from the Kerala govt co-op +directory via a multi-agent web-lookup (D35). **Login history** (D36): every sign-in attempt +(success/failed/locked) is logged with IP + device in a `login_event` table, viewable on a new +owner **Login history** screen; an invited user is sent to Profile to change their default +password on first sign-in. + ## Verification state | Check | Result | @@ -82,7 +94,7 @@ live under `apps/hq/test/`, `apps/hq-web/test/`, and ## Database schema (`apps/hq/src/db.ts`) -The schema creates 31 tables on open; `migrate()` additively backfills the +The schema creates 32 tables on open; `migrate()` additively backfills the post-launch columns on older DBs (`module.quote_content`, `email_log.bounced`, `document.due_date`, `staff_user.phone`/`title`, `client.owner_id` plus the D18 support fields `anydesk`/`os`/`district`/`sector`/`db_password_enc`, and @@ -95,7 +107,8 @@ and widens two CHECK constraints via a guarded, transactional table rebuild | --- | --- | | `staff_user` | Console logins / employees — owner · manager · staff role, scrypt salt+hash, active flag | | `session` | Bearer session tokens with expiry | -| `client` | Client registry — code, name, GSTIN, state, contacts (JSON), status, account owner (`owner_id` → `staff_user`), source | +| `login_event` | Login attempt log — staff_id/username, outcome (success/failed/locked), IP, user-agent (D36) | +| `client` | Client registry — code, name, GSTIN, state, contacts (JSON), status, account owner (`owner_id`), `client_type` (PACS/Store, D35), `category` (society type, D37), source | | `module` | Software module catalogue — SAC, allowed billing kinds, multi-sub flag, quote-content lines | | `module_price_book` | Dated prices per module/edition/kind (latest effective row wins) | | `client_module` | A module assigned to a client — lifecycle status, install/complete/train dates, next renewal, provider/username/password (plaintext, D32), module-defined `field_values` (D21) | @@ -296,6 +309,7 @@ command palette, Gmail status pill, theme/accent switcher and a user menu | `/portals` | `Portals` | Every stored login across all modules — Open ↗ links, 🔑 = password on file (reveal on Client 360) | | `/mis` | `Mis` | Owner cockpit: this-month billing / collections / outstanding / pipeline / renewals / tickets / SMS-low, each tile click-through | | `/audit` | `Audit` | Owner audit-log viewer: entity/action filters (from server facets), paginated before/after key-diff | +| `/login-history` | `LoginHistory` | Owner login history: every sign-in (success/failed/locked) with IP + parsed device, outcome filter, paginated (D36) | | `/settings` | `Settings` | Settings hub (Pinterest-style left rail, `?s=` deep-links): Company profile · Document template (live preview) · Reminders (dated cadence rows, append-only) · Sharing default expiry · Integrations status · Appearance. Owner-only except Appearance; `/settings/template` redirects here | | `/employees` | `Employees` | Owner-only staff management: add / edit / re-role / reset password / deactivate / reactivate, surfacing the last-owner and self-deactivation guards | | `/profile` | `Profile` | Self-service: edit display name/phone (email + role read-only), change own password (current password required; other sessions purged) | diff --git a/docs/06-DECISIONS.md b/docs/06-DECISIONS.md index 0fa6187..020dfc9 100644 --- a/docs/06-DECISIONS.md +++ b/docs/06-DECISIONS.md @@ -578,3 +578,41 @@ install. Restore: `pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" Priority rose with D32 — dumps now contain **plaintext credentials**, so the `backups/` dir must stay access-controlled and off shared drives. Still pending in the ops slice: schedule it (Task Scheduler / cron), off-box copy, and getting the DB password out of `server.ts`. + +## D34 — Employee master import + invited/first-login flag (2026-07-19) +Imported the 11-row employee master (`empmaster.xlsx`) into `staff_user` via +`scripts/import-employees.ts`: username = first-name slug, default password `@123` +(founder's explicit choice), role `staff`, active per `ACTIVE_STAT`. New `must_change_password` +column drives an "Invited · must change pw" badge on the Employees page and sends the user to +Profile to change it on first sign-in; the flag clears on that first self-change. A background +security review flagged the predictable default password — kept per the founder's explicit +instruction, with the invited flag + change-nudge as the mitigation (full first-login +enforcement offered, not yet enabled). + +## D35 — Client type (PACS/Store) + directory-sourced LTD numbers (2026-07-19) +`client.client_type` = **PACS** (co-op society) vs **Store** (retail), backfilled by name +(ends in STORE → Store) and filterable on the Clients screen. **Data-correction session** +(founder-approved, review-sheet-first): 187 client names cleaned mechanically (co-op style → +`CO-OPERATIVE`, run-on splits, `ARP00KARA`→`ARPOOKARA`, spacing), applied after a backup and +audited. **LTD/registration numbers** looked up from the **Kerala govt co-op directory** (the +source of truth) via a multi-agent web-lookup workflow — 259 PACS across two runs (the +first-wave batches needed a smaller-batch re-run to clear a transient safety-classifier block); +**141 now carry their LTD number in the name** (` LTD NO. `, guarded against duplicates). +The ~26 real banks not found online + non-bank societies were left untouched (no guessing). + +## D36 — Login history (IP + device) + first-login nudge (2026-07-19) +Every `/auth/login` attempt is recorded in a `login_event` table (staff_id or null, username +tried, outcome success/failed/locked, IP, user-agent) — security telemetry, separate from the +domain audit, best-effort so it never breaks login. Owner-only **Login history** page (Admin): +who signed in / failed, when, from which IP + parsed device, outcome filter, paginated. +`login()` now returns `mustChangePassword` (D34) so an invited user lands on Profile to change +their default password. + +## D37 — Society category (Kerala co-op directory taxonomy) (2026-07-20) +`client.category` classifies each client the way the official Kerala co-op directory does: +**Service Bank · Urban Bank · Vanitha · Housing · Employees · Agricultural · Marketing · +Bank (other) · Society (other) · Store · Other**. Derived from the name (most-specific-first; +a "VANITHA … SOCIETY" is Vanitha, not the generic Society), backfilled by a portable CASE and +applied to new/edited clients via `categoryFromName`. Clients screen gains a Category filter + +column. Live backfill: 135 Service Bank, 38 Employees, 15 Vanitha, 14 Store, 12 Agricultural, +11 Urban, … From 9d3087c1fec15d378c91c66cb2a21a4a017e2edf Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 00:15:39 +0530 Subject: [PATCH 13/25] feat(client-detail): module details as 50/50 report with single edit per group Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq-web/src/pages/ClientDetail.tsx | 281 +++++++++++-------------- 1 file changed, 124 insertions(+), 157 deletions(-) diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index 97b42cc..eca7ded 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState, type CSSProperties, type ReactNode } from 'react' +import { useState, type CSSProperties, type ReactNode } from 'react' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { formatINR } from '@sims/domain' import { @@ -959,23 +959,27 @@ function BranchList(props: { clientId: string; branches: Branch[]; onChanged: () } /** - * Per-service operational data (D20 — APEX sms_clients_list parity), following - * SupportCard's exact visual pattern: label/value cells, the portal password as - * •••• with managerial audited reveal (gated like the DB password), and Edit - * switching provider/username/remark + the labeled details into inputs — one - * audited PATCH saves the lot. + * Per-service operational data (D20 — APEX sms_clients_list parity) + D21 module-defined + * fields, rendered as a 50/50 report (Access | Details — D-CDR): read-only key/value rows, + * the portal password as •••• with managerial audited reveal (gated like the DB password), + * secret fieldSpec values as their own inline SecretField, and ONE Edit per column that + * swaps the whole card into a single form — provider/username/remark/password + the + * freeform details + the non-secret fieldSpec values all save through one Save (two + * audited calls in sequence: patchClientModule for D20, setModuleFields for D21). */ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: FieldDef[]; onChanged: () => void }) { const toast = useToast() const managerial = isManagerial() const cm = props.cm + const sortedFieldSpec = [...props.fieldSpec].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)) + const nonSecretFields = sortedFieldSpec.filter((fld) => fld.type !== 'secret') + const secretFields = sortedFieldSpec.filter((fld) => fld.type === 'secret') const [revealed, setRevealed] = useState() - const [settingPw, setSettingPw] = useState(false) - const [pw, setPw] = useState('') const [busy, setBusy] = useState(false) const [editing, setEditing] = useState(false) const [f, setF] = useState({ provider: '', username: '', remark: '', password: '' }) const [details, setDetails] = useState([]) + const [fieldDraft, setFieldDraft] = useState>({}) const copy = (text: string, what: string) => { navigator.clipboard.writeText(text).then(() => toast.ok(`${what} copied`)).catch(() => toast.err('Copy failed')) @@ -988,42 +992,68 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie .catch((e: Error) => toast.err(e.message)) .finally(() => setBusy(false)) } - const savePw = () => { - if (busy || pw === '') return - setBusy(true) - patchClientModule(cm.id, { password: pw }) - .then(() => { toast.ok('Portal password stored (encrypted)'); setSettingPw(false); setPw(''); setRevealed(undefined); props.onChanged() }) - .catch((e: Error) => toast.err(e.message)) - .finally(() => setBusy(false)) - } const startEdit = () => { setF({ provider: cm.provider ?? '', username: cm.username ?? '', remark: cm.remark ?? '', password: '' }) setDetails(cm.details.map((d) => ({ ...d }))) + setFieldDraft(Object.fromEntries(nonSecretFields.map((fld) => [fld.key, cm.fieldValues[fld.key] ?? '']))) setEditing(true) } const saveEdit = () => { if (busy) return const cleaned = details.filter((d) => d.label.trim() !== '' || d.value.trim() !== '') if (cleaned.some((d) => d.label.trim() === '')) { toast.err('Every detail needs a label'); return } + const missingRequired = nonSecretFields.find((fld) => fld.required === true && (fieldDraft[fld.key] ?? '').trim() === '') + if (missingRequired !== undefined) { toast.err(`${missingRequired.label} is required`); return } setBusy(true) + // Only the fieldSpec keys that actually changed — '' clears a value server-side. + const changedFields = Object.fromEntries( + nonSecretFields + .filter((fld) => (fieldDraft[fld.key] ?? '') !== (cm.fieldValues[fld.key] ?? '')) + .map((fld) => [fld.key, fieldDraft[fld.key] ?? '']), + ) patchClientModule(cm.id, { provider: f.provider, username: f.username, remark: f.remark, details: cleaned.map((d) => ({ label: d.label.trim(), value: d.value })), // Password only when the managerial user typed one (blank = keep current). ...(managerial && f.password.trim() !== '' ? { password: f.password } : {}), }) - .then(() => { toast.ok('Service data saved'); setEditing(false); props.onChanged() }) + .then(() => (Object.keys(changedFields).length > 0 ? setModuleFields(cm.id, changedFields) : undefined)) + .then(() => { toast.ok('Service data saved'); setEditing(false); setRevealed(undefined); props.onChanged() }) .catch((e: Error) => toast.err(e.message)) .finally(() => setBusy(false)) } - const cell = (label: string, value: string | undefined, mono = false, copyable = false) => ( - - {label} - {value ?? '—'} - {copyable && value !== undefined && } - - ) + // D21 non-secret fieldSpec input — moved here from the old always-on ModuleFieldsForm so + // it saves alongside D20 through this one Edit/Save. Secret fields keep their own inline + // SecretField widget (independent Set/Update/Reveal) and stay out of this form. + const fieldControl = (fld: FieldDef) => { + const v = fieldDraft[fld.key] ?? '' + const set = (val: string) => setFieldDraft((p) => ({ ...p, [fld.key]: val })) + if (fld.type === 'boolean') { + return ( + + ) + } + if (fld.type === 'select') { + return ( + + ) + } + if (fld.type === 'number') { + return set(e.target.value)} /> + } + if (fld.type === 'date') { + return set(e.target.value)} /> + } + return set(e.target.value)} /> + } if (editing) { return ( @@ -1040,6 +1070,29 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie )} setF((p) => ({ ...p, remark: e.target.value }))} /> + {nonSecretFields.length > 0 && ( + <> +
+
+ {nonSecretFields.map((fld) => { + // A field holding an http(s) value gets a direct "Open ↗" link (portal/console shortcut). + const val = fieldDraft[fld.key] ?? '' + const isUrl = /^https?:\/\//i.test(val.trim()) + return ( + + + {fieldControl(fld)} + {isUrl && ( + Open ↗ + )} + + + ) + })} +
+ + )} {details.map((d, i) => (
-
- {props.name} - {cell('Provider', cm.provider ?? undefined)} - {cell('Username', cm.username ?? undefined, true, true)} - {cm.details.map((d, i) => {cell(d.label, d.value)})} - {cell('Remark', cm.remark ?? undefined)} - - Portal password - {revealed !== undefined - ? <>{revealed} - : cm.hasPassword - ? <>••••••••{managerial && } - : not set} - {managerial && !settingPw && } - {managerial && settingPw && ( - <> - setPw(e.target.value)} /> - - - - )} - - - +
+
+
+ Access + + +
+ + copy(cm.username ?? '', 'Username')} /> +
+ Password + + {revealed !== undefined + ? <>{revealed} + : cm.hasPassword + ? <>••••••••{managerial && } + : not set} + +
+ {cm.remark !== null && cm.remark !== '' && } +
+
+
+ Details + + +
+ {nonSecretFields.map((fld) => ( + + ))} + {cm.details.map((d, i) => )} + {secretFields.map((fld) => ( +
+ {fld.label} + +
+ ))} +
- {/* D21: dynamic form from the owning module's fieldSpec, below the D20 block. */} - {props.fieldSpec.length > 0 && ( - <> -
- - - )}
) } -/** - * D21 module-defined fields (config over code): renders one input per field the owning - * module DECLARES in its fieldSpec. Non-secret edits collect into a single audited PUT - * (setModuleFields); secret fields never leave the server unrevealed — •••• with a - * managerial audited Reveal + Set/Update (setModuleSecret), exactly like the portal - * password. Values come from cm.fieldValues; secret set-state from cm.secretKeys. - */ -function ModuleFieldsForm(props: { cm: ClientModule; fieldSpec: FieldDef[]; onChanged: () => void }) { - const toast = useToast() - const managerial = isManagerial() - const cm = props.cm - const sorted = [...props.fieldSpec].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)) - const nonSecret = sorted.filter((f) => f.type !== 'secret') - const secrets = sorted.filter((f) => f.type === 'secret') - - const seed = (): Record => - Object.fromEntries(nonSecret.map((f) => [f.key, cm.fieldValues[f.key] ?? ''])) - const [draft, setDraft] = useState>(seed) - const [busy, setBusy] = useState(false) - - const dirty = nonSecret.some((f) => (draft[f.key] ?? '') !== (cm.fieldValues[f.key] ?? '')) - const missingRequired = nonSecret.find((f) => f.required === true && (draft[f.key] ?? '').trim() === '') - - const save = () => { - if (busy) return - if (missingRequired !== undefined) { toast.err(`${missingRequired.label} is required`); return } - setBusy(true) - // Send only the changed keys; '' clears server-side. - const values = Object.fromEntries( - nonSecret - .filter((f) => (draft[f.key] ?? '') !== (cm.fieldValues[f.key] ?? '')) - .map((f) => [f.key, draft[f.key] ?? '']), - ) - setModuleFields(cm.id, values) - .then((updated) => { - toast.ok('Fields saved') - setDraft(Object.fromEntries(nonSecret.map((f) => [f.key, updated.fieldValues[f.key] ?? '']))) - props.onChanged() - }) - .catch((e: Error) => toast.err(e.message)) - .finally(() => setBusy(false)) - } - - const control = (f: FieldDef) => { - const v = draft[f.key] ?? '' - const set = (val: string) => setDraft((p) => ({ ...p, [f.key]: val })) - if (f.type === 'boolean') { - return ( - - ) - } - if (f.type === 'select') { - return ( - - ) - } - if (f.type === 'number') { - return set(e.target.value)} /> - } - if (f.type === 'date') { - return set(e.target.value)} /> - } - return set(e.target.value)} /> - } - +/** One label/value row in the 50/50 service-data report; `—` for empty, optional mono + copy. */ +function Kv(props: { label: string; value: string | undefined; mono?: boolean; copyable?: boolean; onCopy?: () => void }) { + const v = props.value return ( -
- {nonSecret.length > 0 && ( - <> -
- {nonSecret.map((f) => { - // A field holding an http(s) value gets a direct "Open ↗" link (portal/console shortcut). - const val = draft[f.key] ?? '' - const isUrl = /^https?:\/\//i.test(val.trim()) - return ( - - - {control(f)} - {isUrl && ( - Open ↗ - )} - - - ) - })} -
- - - - - )} - {secrets.map((f) => ( - - ))} +
+ {props.label} + + {v !== undefined && v !== '' ? {v} : } + {props.copyable === true && v !== undefined && v !== '' && } +
) } From 8c653eb41aa4ad74cb2d230392a880a82ff945a3 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 00:22:32 +0530 Subject: [PATCH 14/25] fix(payments): exclude draft proformas from outstanding (owner decision) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq/src/repos-payments.ts | 2 +- apps/hq/test/ledger-outstanding.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/hq/src/repos-payments.ts b/apps/hq/src/repos-payments.ts index 103cf84..36455d8 100644 --- a/apps/hq/src/repos-payments.ts +++ b/apps/hq/src/repos-payments.ts @@ -278,7 +278,7 @@ export async function clientLedger(db: DB, clientId: string): Promise 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')) { + } else if (d.docType === 'PROFORMA' && (d.status === 'sent' || d.status === 'accepted')) { outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: d.payablePaise }) } } diff --git a/apps/hq/test/ledger-outstanding.test.ts b/apps/hq/test/ledger-outstanding.test.ts index 92f57bb..045bc9f 100644 --- a/apps/hq/test/ledger-outstanding.test.ts +++ b/apps/hq/test/ledger-outstanding.test.ts @@ -61,6 +61,17 @@ describe('clientLedger — outstanding + per-payment allocations', () => { expect(led.outstanding.find((o) => o.docId === inv.id)).toBeUndefined() }) + it('excludes a draft proforma from outstanding but still includes a sent one', async () => { + const db = openDb(':memory:') + const { clientId, inv, pf } = await buildFixture(db) + const draftPf = await createDraft(db, 'u1', { + docType: 'PROFORMA', clientId, lines: [{ moduleId: inv.payload.lines[0]!.itemId, qty: 1, kind: 'yearly' }], + }) // left as 'draft' — never issued/sent + const led = await clientLedger(db, clientId) + expect(led.outstanding.find((o) => o.docId === draftPf.id)).toBeUndefined() + expect(led.outstanding.find((o) => o.docId === pf.id)).toMatchObject({ docId: pf.id, docNo: pf.docNo }) + }) + it('attaches allocations to each payment', async () => { const db = openDb(':memory:') const { clientId, inv, payment } = await buildFixture(db) From 343fa94efd306171fbbc19ec1d220f60f2d98f12 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 00:28:23 +0530 Subject: [PATCH 15/25] docs(client-detail): D38 redesign decision + STATUS in-progress note Co-Authored-By: Claude Opus 4.8 (1M context) --- STATUS.md | 8 ++++++++ docs/06-DECISIONS.md | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/STATUS.md b/STATUS.md index 7841280..21ac693 100644 --- a/STATUS.md +++ b/STATUS.md @@ -47,6 +47,14 @@ directory via a multi-agent web-lookup (D35). **Login history** (D36): every sig owner **Login history** screen; an invited user is sent to Profile to change their default password on first sign-in. +**In progress (D38, branch `feat/client-detail-redesign`).** Client 360 Modules + Payments +redesign: **per-module onboarding templates** (SMS/RTGS/Mobile App), a **horizontal status line** +that auto-drives `client_module.status`, advance/balance steps **linked to real payments**, module +details as a read-only **50/50 sheet**, **per-module document lists** with a **bulk SMS purchase** +proforma, and a Payments **Outstanding** panel (issued invoices + sent/accepted proformas; drafts +excluded) with a settled-to-document column, plus a **stalled-onboarding** flag. Backend complete +(Tasks 1–8, 446 tests green); frontend building; responsive + red-team pass gates the merge (D38). + ## Verification state | Check | Result | diff --git a/docs/06-DECISIONS.md b/docs/06-DECISIONS.md index 020dfc9..15e0d7c 100644 --- a/docs/06-DECISIONS.md +++ b/docs/06-DECISIONS.md @@ -616,3 +616,24 @@ a "VANITHA … SOCIETY" is Vanitha, not the generic Society), backfilled by a po applied to new/edited clients via `categoryFromName`. Clients screen gains a Category filter + column. Live backfill: 135 Service Bank, 38 Employees, 15 Vanitha, 14 Store, 12 Agricultural, 11 Urban, … + +## D38 — Client Detail redesign: module lifecycle, status line & payments context (2026-07-20) +Reworks the Client 360 Modules + Payments tabs around how the business actually runs. **Per-module +onboarding templates** (config over code, `project.milestone_template:` resolved per module → +global → code fallback): SMS / RTGS / Mobile App each get their own step list, seeded and +owner-editable; a one-time migration swaps existing projects onto their new list preserving mapped +ticks. The onboarding checklist becomes a **horizontal status line** whose ticks **auto-drive the +coarse `client_module.status`** (Installation→installed, Training→trained, Go-live→live; never +downgrades), so the status dropdown stops being hand-set. Advance/Balance payment steps **link to a +real payment** (`project_milestone.payment_id`; step date mirrors `received_on`). Module details +render as a read-only **50/50 sheet** (Access | Details) with one Edit per group — no always-on +input boxes. Each module block lists **its own documents** (line-item match) with type labels +(Quote / Renewal / **Bulk top-up** / Invoice); SMS gains a **bulk-credit purchase** proforma +(`source='bulk_sms_topup'`, priced via the usage rate card) distinct from a subscription renewal +(`source='module_renewal'`). The Payments tab gains an **Outstanding** panel (issued invoices with a +balance + **sent/accepted** proformas — un-issued drafts excluded, owner decision — plus advance on +account) and a "Settled → document" column per payment; the money engine is unchanged (read-only +ledger extension). A **stalled-onboarding** query surfaces projects parked past a threshold for the +dashboard/MIS. Spec + plans under `docs/superpowers/`. **Status:** landing on branch +`feat/client-detail-redesign` — backend complete (Tasks 1–8, 446 tests green), frontend in progress; +a responsive + red-team alignment pass gates the merge. From e5ed3517dce05f03667ed00bef7466ac704e8ed1 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 00:34:46 +0530 Subject: [PATCH 16/25] feat(client-detail): onboarding as horizontal status line with payment-step linking Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq-web/src/api.ts | 18 ++- apps/hq-web/src/pages/ClientDetail.tsx | 186 ++++++++++++++++++------- 2 files changed, 150 insertions(+), 54 deletions(-) diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 8a311be..d4fd4db 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -362,18 +362,26 @@ export const revealModuleSecret = (id: string, key: string): Promise => // ---------- onboarding milestones (D22) ---------- -/** One checklist step on a project (client_module). Ticking stamps `doneOn`. */ -export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number } +/** One checklist step on a project (client_module). Ticking stamps `doneOn`; the + * advance/balance payment steps additionally carry `paymentId`, linking a real + * payment from the client's ledger. */ +export interface Milestone { + key: string; label: string; done: boolean; doneOn: string | null; sort: number; paymentId: string | null +} /** The project's checklist; the server auto-seeds the standard template on first read. */ export const getMilestones = (clientModuleId: string): Promise => apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`).then((r) => r.milestones) -/** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it. */ +/** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it. + * `paymentId` links a real payment to a step (advance_payment / balance_payment). */ export const setMilestone = ( - clientModuleId: string, key: string, done: boolean, doneOn?: string, + clientModuleId: string, key: string, done: boolean, doneOn?: string, paymentId?: string, ): Promise => apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`, { - method: 'POST', body: JSON.stringify({ key, done, ...(doneOn !== undefined ? { doneOn } : {}) }), + method: 'POST', + body: JSON.stringify({ + key, done, ...(doneOn !== undefined ? { doneOn } : {}), ...(paymentId !== undefined ? { paymentId } : {}), + }), }).then((r) => r.milestones) /** Add a project-specific step beyond the standard template. */ export const addMilestone = (clientModuleId: string, label: string): Promise => diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index eca7ded..e0aa996 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -2,7 +2,7 @@ import { useState, type CSSProperties, type ReactNode } from 'react' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { formatINR } from '@sims/domain' import { - Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, Field, RecordHeader, Skeleton, + Badge, Button, ConfirmDialog, DataTable, Dialog, EmptyState, ErrorState, Field, Notice, RecordHeader, Skeleton, StatCard, Stats, Tabs, Toolbar, useToast, } from '@sims/ui' import { @@ -18,7 +18,8 @@ import { getMilestones, setMilestone, addMilestone, CLIENT_STATUSES, KIND_LABEL, type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule, - type ClientStatus, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome, type RecurringPlan, type ServiceDetail, + type ClientStatus, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome, + type Payment, type RecurringPlan, type ServiceDetail, } from '../api' import { buildStatement } from '../statement' import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients' @@ -73,6 +74,13 @@ export function ClientDetail() { const [interactionDialog, setInteractionDialog] = useState<{ initial?: Interaction } | undefined>() const [confirm, setConfirm] = useState() const [statementOpen, setStatementOpen] = useState(false) + // Onboarding status line (D38): ticking the advance/balance payment step opens this + // picker instead of stamping a bare date. `milestonesVersion` bumps to force every + // StatusLine's milestone list to refetch after a pick lands (see `refreshToken` below). + const [paymentPicker, setPaymentPicker] = useState<{ clientModuleId: string; key: string; label: string } | undefined>() + const [linkingPayment, setLinkingPayment] = useState(false) + const [linkError, setLinkError] = useState() + const [milestonesVersion, setMilestonesVersion] = useState(0) const rawTab = sp.get('tab') ?? 'overview' const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview' @@ -313,7 +321,12 @@ export function ClientDetail() { fieldSpec={modules.data?.find((m) => m.id === cm.moduleId)?.fieldSpec ?? []} onChanged={() => cms.reload()} /> - + { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }} + />
))} @@ -607,6 +620,30 @@ export function ClientDetail() { {statementOpen && ( setStatementOpen(false)} /> )} + {paymentPicker !== undefined && ( + b.receivedOn.localeCompare(a.receivedOn))} + saving={linkingPayment} + error={linkError} + onClose={() => { if (!linkingPayment) setPaymentPicker(undefined) }} + onPick={(paymentId) => { + if (linkingPayment) return + setLinkingPayment(true) + setLinkError(undefined) + setMilestone(paymentPicker.clientModuleId, paymentPicker.key, true, undefined, paymentId) + .then(() => { + toast.ok('Payment linked') + setMilestonesVersion((v) => v + 1) + setPaymentPicker(undefined) + }) + .catch((e: Error) => setLinkError(e.message)) + .finally(() => setLinkingPayment(false)) + }} + onRecordNew={() => { setPaymentPicker(undefined); setTab('payments') }} + /> + )} ) } @@ -1229,14 +1266,23 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo } /** - * Onboarding checklist (D22 — per-project detail): the project's milestones as a row - * of toggles. Ticking one stamps the date (a compact date input adjusts it; toggling - * on with no date uses today); "+ Add step" appends a project-specific milestone. Every - * change is one audited POST that returns the fresh list, so the card re-renders in place. + * Onboarding as a horizontal status line (D38 redesign — per-project detail): the + * project's milestones as a point-by-point track (`.cdr-track`) — a connector line + * joins ordered dots, the current (first not-done) step is ringed ("now"), done steps + * show a check and their date. Ticking a plain step stamps today's date and drives the + * module status (installed/live) exactly as before; ticking the advance/balance payment + * steps instead calls `onTickPayment` so the parent can open a picker that links a real + * ledger payment (`ClientDetail`'s `PaymentPickerDialog`) rather than a bare date. + * "+ Add step" appends a project-specific milestone. Every change is one audited POST + * that returns the fresh list; `refreshToken` lets the parent force a refetch after a + * payment link lands from outside this component. */ -function MilestoneChecklist(props: { clientModuleId: string; name: string }) { +function StatusLine(props: { + clientModuleId: string; name: string; refreshToken: number + onTickPayment: (key: string, label: string) => void +}) { const toast = useToast() - const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId]) + const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId, props.refreshToken]) const [busyKey, setBusyKey] = useState('') const [adding, setAdding] = useState(false) const [label, setLabel] = useState('') @@ -1261,64 +1307,106 @@ function MilestoneChecklist(props: { clientModuleId: string; name: string }) { const ms = list.data const done = ms?.filter((m) => m.done).length ?? 0 + // current = first not-done step + const currentKey = ms?.find((m) => !m.done)?.key + const isPaymentStep = (key: string) => key === 'advance_payment' || key === 'balance_payment' + + // Ticking ON a payment step opens the picker (parent owns that dialog — it needs the + // client's ledger + the Payments tab); un-ticking and every other step toggle directly. + const onDotClick = (m: Milestone) => { + if (busyKey !== '') return + if (!m.done && isPaymentStep(m.key)) { props.onTickPayment(m.key, m.label); return } + toggle(m, !m.done) + } return (
-
- {props.name} — onboarding +
+ {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.done && ( - <> - on - toggle(m, true, e.target.value !== '' ? e.target.value : undefined)} - /> - - )} + + + {m.label} + {m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'}
))} -
+
+ )} +
+ ) +} + +/** + * Payment-step picker (D38): ticking the advance/balance payment status step opens this + * instead of stamping a bare date — it links a real payment row so the milestone and the + * ledger agree (`setMilestone(..., paymentId)`; the server mirrors that payment's + * `receivedOn` into `doneOn`). Lists the client's payments, newest first; "Record new + * payment" defers to the Payments tab rather than guessing an amount here. + */ +function PaymentPickerDialog(props: { + open: boolean; onClose: () => void; stepLabel: string + payments: Payment[]; saving: boolean; error?: string + onPick: (paymentId: string) => void; onRecordNew: () => void +}) { + return ( + { if (!props.saving) props.onClose() }} + title={`Link a payment — ${props.stepLabel}`} + size="sm" + footer={} + > + {props.payments.length === 0 + ? No payments recorded for this client yet. + : ( + ({ + on: p.receivedOn, + amount: inr(p.amountPaise), + act: , + }))} + /> )} - {!adding - ? - : ( - <> - setLabel(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') addStep() }} - /> - - - - )} + + -
+ {props.error !== undefined && {props.error}} + ) } From e8f00503a455569a7d2cfc1c0a5bde5d624b13fd Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 01:36:53 +0530 Subject: [PATCH 17/25] feat(client-detail): per-module documents list + bulk SMS purchase action Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq-web/src/api.ts | 5 ++ apps/hq-web/src/pages/ClientDetail.tsx | 64 +++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index d4fd4db..f78bfe5 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -965,6 +965,11 @@ export const getRenewals = (from?: string, to?: string): Promise = } export const generateModuleRenewalQuote = (clientModuleId: string): Promise => apiFetch<{ document: Doc }>(`/client-modules/${clientModuleId}/renewal-quote`, { method: 'POST', body: '{}' }).then((r) => r.document) +/** Raise a bulk SMS top-up proforma for a client's SMS module (client-detail Task 4). */ +export const generateBulkSmsPurchase = (clientModuleId: string, smsQty: number): Promise => + apiFetch<{ document: Doc }>(`/client-modules/${clientModuleId}/bulk-sms-quote`, { + method: 'POST', body: JSON.stringify({ smsQty }), + }).then((r) => r.document) // D27: owner MIS cockpit. export interface MisCockpit { diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index e0aa996..e3109fc 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -16,9 +16,10 @@ import { getClientAwsUsage, getCompanyProfile, getBranches, createBranch, patchBranch, getTickets, getMilestones, setMilestone, addMilestone, + generateBulkSmsPurchase, CLIENT_STATUSES, KIND_LABEL, type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule, - type ClientStatus, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome, + type ClientStatus, type Doc, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome, type Payment, type RecurringPlan, type ServiceDetail, } from '../api' import { buildStatement } from '../statement' @@ -327,6 +328,12 @@ export function ClientDetail() { refreshToken={milestonesVersion} onTickPayment={(key, label) => { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }} /> + m.id === cm.moduleId)?.code ?? ''} + onCreated={() => ledger.reload()} + /> ))} @@ -1367,6 +1374,61 @@ function StatusLine(props: { ) } +/** Doc.source values that don't map onto DOC_TYPE_LABEL get a friendlier, source-aware label. */ +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 +} + +/** + * Task 4: per-module documents — every doc whose line items include this module (quotes, + * renewals, bulk SMS top-ups, invoices, credit notes), newest first. SMS-coded modules get + * a "Bulk SMS purchase" action that raises a proforma and jumps straight to it. + */ +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'); props.onCreated(); 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} +
+ ))} +
+ ) +} + /** * Payment-step picker (D38): ticking the advance/balance payment status step opens this * instead of stamping a bare date — it links a real payment row so the milestone and the From f1a7eac9ddc26d265a7156bcf3970afae7f44a0f Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 03:49:48 +0530 Subject: [PATCH 18/25] feat(onboarding): composed milestone templates (shared front + per-module tail) Supersedes the flat per-module onboarding templates: milestoneTemplate(db, code) now returns [...front, ...tail]. front is a shared setting (project.milestone_template.front, FRONT_FALLBACK code default: enquiry -> visit/meeting -> quotation sent -> quotation approved) prepended to every module's checklist. tail resolves per-module setting -> global setting -> TAIL_FALLBACK (the old generic list), unchanged in precedence. - STEP_STATUS gains quotation_approved -> ordered (never-downgrade guard kept). - seed.ts seeds the front once plus SMS/RTGS/MOBILEAPP tails (advance/balance payment steps collapsed into a single payment_received tail step); no longer seeds a bare project.milestone_template default. - migrate-onboarding-templates.ts KEY_MAP updated so advance_paid, balance_paid, advance_payment and balance_payment all carry forward to payment_received; its idempotency check holds against the longer composed list. - Updated seed-templates, milestones-templates, milestone-status, migrate-onboarding-templates, milestones and milestone-payment tests to the composed content/mappings (TDD: reverted the implementation, confirmed the updated tests red, restored the implementation, confirmed green). Backend + tests only; the live-DB migration run is a separate step. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq/src/migrate-onboarding-templates.ts | 3 +- apps/hq/src/repos-milestones.ts | 50 ++++++++++++----- apps/hq/src/seed.ts | 38 ++++++------- .../test/migrate-onboarding-templates.test.ts | 56 +++++++++++++------ apps/hq/test/milestone-payment.test.ts | 18 +++--- apps/hq/test/milestone-status.test.ts | 7 +++ apps/hq/test/milestones-templates.test.ts | 52 +++++++++++++---- apps/hq/test/milestones.test.ts | 13 +++-- apps/hq/test/seed-templates.test.ts | 23 ++++++-- 9 files changed, 176 insertions(+), 84 deletions(-) diff --git a/apps/hq/src/migrate-onboarding-templates.ts b/apps/hq/src/migrate-onboarding-templates.ts index 8245c82..6c8ba27 100644 --- a/apps/hq/src/migrate-onboarding-templates.ts +++ b/apps/hq/src/migrate-onboarding-templates.ts @@ -10,7 +10,8 @@ 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', + advance_paid: 'payment_received', balance_paid: 'payment_received', + advance_payment: 'payment_received', balance_payment: 'payment_received', setup_done: 'setup_configuration', } diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts index 4623571..931d23b 100644 --- a/apps/hq/src/repos-milestones.ts +++ b/apps/hq/src/repos-milestones.ts @@ -24,7 +24,7 @@ const STATUS_RANK: Record = { 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', + quotation_approved: 'ordered', installation: 'installed', training: 'trained', go_live: 'live', } async function advanceStatusForStep(db: DB, userId: string, clientModuleId: string, key: string): Promise { @@ -37,7 +37,16 @@ async function advanceStatusForStep(db: DB, userId: string, clientModuleId: stri await writeAudit(db, userId, 'update', 'client_module', clientModuleId, { status: cm.status }, { status: target }) } -export const FALLBACK_TEMPLATE: TemplateEntry[] = [ +/** The shared lead-in every module's checklist starts with (code default for the "front"). */ +export const FRONT_FALLBACK: TemplateEntry[] = [ + { key: 'enquiry', label: 'Enquiry received' }, + { key: 'visit_meeting', label: 'Visit / meeting scheduled' }, + { key: 'quotation_sent', label: 'Quotation sent' }, + { key: 'quotation_approved', label: 'Quotation approved' }, +] + +/** Code default for the module-specific "tail" when nothing is configured. */ +export const TAIL_FALLBACK: TemplateEntry[] = [ { key: 'advance_paid', label: 'Advance payment received' }, { key: 'setup_done', label: 'Setup / configuration done' }, { key: 'installed', label: 'Installation done' }, @@ -46,23 +55,36 @@ export const FALLBACK_TEMPLATE: TemplateEntry[] = [ { key: 'amc_start', label: 'AMC / renewal start' }, ] -/** The checklist for a module (owner-editable per module via setting; global default; code fallback). */ +function parseTemplate(value: string): TemplateEntry[] | undefined { + try { + const p = JSON.parse(value) as TemplateEntry[] + return Array.isArray(p) && p.length > 0 ? p : undefined + } catch { return undefined } +} + +/** + * The checklist for a module: a shared "front" (same for every module — enquiry through + * quotation-approved) followed by a per-module "tail" (owner-editable per module via + * setting; global default; code fallback). Composed as [...front, ...tail]. + */ 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 } - } + const frontRow = await db.get<{ value: string }>( + `SELECT value FROM setting WHERE key='project.milestone_template.front'`) + const front = (frontRow !== undefined ? parseTemplate(frontRow.value) : undefined) ?? FRONT_FALLBACK + + let tail: TemplateEntry[] | 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 + tail = perModule !== undefined ? parseTemplate(perModule.value) : undefined } - 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 + if (tail === undefined) { + const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='project.milestone_template'`) + tail = row !== undefined ? parseTemplate(row.value) : undefined + } + tail ??= TAIL_FALLBACK + + return [...front, ...tail] } /** diff --git a/apps/hq/src/seed.ts b/apps/hq/src/seed.ts index e8fa5ea..24f7072 100644 --- a/apps/hq/src/seed.ts +++ b/apps/hq/src/seed.ts @@ -86,22 +86,23 @@ export async function seedIfEmpty(db: DB): Promise { } if (seededBilling > 0) await writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS) - // Onboarding milestone template (D22): the standard checklist every new project - // (client_module) starts with. Config over code — owner-editable; a project can also add - // its own milestones. Order here is the display order. - const MILESTONE_TEMPLATE = JSON.stringify([ - { key: 'advance_paid', label: 'Advance payment received' }, - { key: 'setup_done', label: 'Setup / configuration done' }, - { key: 'installed', label: 'Installation done' }, - { key: 'go_live', label: 'Go-live' }, - { key: 'balance_paid', label: 'Balance payment received' }, - { key: 'amc_start', label: 'AMC / renewal start' }, + // Onboarding milestone template (D22), composed model: a shared "front" (same for every + // module — enquiry through quotation-approved) plus a per-module "tail". Config over code + // — owner-editable; a project can also add its own milestones. Order here is display order. + const MILESTONE_TEMPLATE_FRONT = JSON.stringify([ + { key: 'enquiry', label: 'Enquiry received' }, + { key: 'visit_meeting', label: 'Visit / meeting scheduled' }, + { key: 'quotation_sent', label: 'Quotation sent' }, + { key: 'quotation_approved', label: 'Quotation approved' }, ]) - const seededMilestone = (await db.run(INSERT_SETTING_SQL, 'project.milestone_template', MILESTONE_TEMPLATE)).changes - if (seededMilestone > 0) await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template', undefined, { set: true }) + const seededFront = (await db.run( + INSERT_SETTING_SQL, 'project.milestone_template.front', MILESTONE_TEMPLATE_FRONT)).changes + if (seededFront > 0) { + await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template.front', undefined, { set: true }) + } - // 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. + // Per-module onboarding tails (config over code). SMS/RTGS/Mobile App differ; each ends + // with go-live + payment received so a payment tick maps cleanly on migration. const PER_MODULE_TEMPLATES: Record = { SMS: [ { key: 'document_collection', label: 'Document collection' }, @@ -111,8 +112,7 @@ export async function seedIfEmpty(db: DB): Promise { { 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' }, + { key: 'payment_received', label: 'Payment received' }, ], RTGS: [ { key: 'document_collection', label: 'Document collection' }, @@ -122,8 +122,7 @@ export async function seedIfEmpty(db: DB): Promise { { 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' }, + { key: 'payment_received', label: 'Payment received' }, ], MOBILEAPP: [ { key: 'requirement_setup', label: 'Requirement & setup' }, @@ -133,8 +132,7 @@ export async function seedIfEmpty(db: DB): Promise { { 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' }, + { key: 'payment_received', label: 'Payment received' }, ], } let seededPerModule = 0 diff --git a/apps/hq/test/migrate-onboarding-templates.test.ts b/apps/hq/test/migrate-onboarding-templates.test.ts index 8873af4..69500b0 100644 --- a/apps/hq/test/migrate-onboarding-templates.test.ts +++ b/apps/hq/test/migrate-onboarding-templates.test.ts @@ -1,13 +1,16 @@ // apps/hq/test/migrate-onboarding-templates.test.ts — Client Detail redesign task 5: one-time // migration swapping seeded modules (SMS/RTGS/MobileApp) from the old generic onboarding -// checklist onto their new per-module templates, carrying mapped ticks (done/done_on/payment_id). +// checklist onto their new composed (front + per-module tail) templates, carrying mapped +// ticks (done/done_on/payment_id). import { describe, it, expect, beforeEach } from 'vitest' import { openDb, type DB } from '../src/db' import { seedIfEmpty } from '../src/seed' import { createClient } from '../src/repos-clients' import { createModule, assignModule } from '../src/repos-modules' import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' -import { listProjectMilestones } from '../src/repos-milestones' +import { listProjectMilestones, TAIL_FALLBACK } from '../src/repos-milestones' + +const FRONT_KEYS = ['enquiry', 'visit_meeting', 'quotation_sent', 'quotation_approved'] describe('onboarding template migration', () => { let db: DB @@ -17,7 +20,7 @@ describe('onboarding template migration', () => { const client = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' }) const mod = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' }) const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' }) - // assignModule auto-seeds the NEW per-module template (task 2/4) — wipe it so the test + // assignModule auto-seeds the NEW composed template (task 2/4) — wipe it so the test // can plant the OLD generic rows a legacy project would actually have on disk. await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cm.id) return cm.id @@ -37,7 +40,7 @@ describe('onboarding template migration', () => { } } - it('swaps an SMS project to the 9-step list carrying installed+go_live ticks', async () => { + it('swaps an SMS project to the composed front+tail list carrying installed+go_live ticks', async () => { const cmId = await makeSmsClientModule() await seedOldGenericRows(cmId) @@ -48,8 +51,9 @@ describe('onboarding template migration', () => { const ms = await listProjectMilestones(db, cmId) expect(ms.map((m) => m.key)).toEqual([ + ...FRONT_KEYS, 'document_collection', 'dlt_registration', 'account_creation', 'installation', - 'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', + 'testing', 'training', 'go_live', 'payment_received', ]) const installation = ms.find((m) => m.key === 'installation')! expect(installation.done).toBe(true) @@ -58,10 +62,11 @@ describe('onboarding template migration', () => { const goLive = ms.find((m) => m.key === 'go_live')! expect(goLive.done).toBe(true) expect(goLive.doneOn).toBe('2026-04-02') - // untouched (never ticked) steps stay pending + // untouched (never ticked) steps stay pending, including the newly-prepended front + expect(ms.find((m) => m.key === 'enquiry')!.done).toBe(false) + expect(ms.find((m) => m.key === 'quotation_approved')!.done).toBe(false) expect(ms.find((m) => m.key === 'document_collection')!.done).toBe(false) - expect(ms.find((m) => m.key === 'advance_payment')!.done).toBe(false) - expect(ms.find((m) => m.key === 'balance_payment')!.done).toBe(false) + expect(ms.find((m) => m.key === 'payment_received')!.done).toBe(false) // old setup_done / amc_start gone entirely expect(ms.some((m) => m.key === 'setup_done' || m.key === 'amc_start')).toBe(false) @@ -74,7 +79,7 @@ describe('onboarding template migration', () => { expect(audits[0]!.after_json).toContain('installation') }) - it('carries a done payment_id along with the mapped tick', async () => { + it('carries a done payment_id along with the mapped tick (advance_paid -> payment_received)', async () => { const cmId = await makeSmsClientModule() const client = await db.get<{ client_id: string }>( `SELECT client_id FROM client_module WHERE id=?`, cmId, @@ -93,10 +98,25 @@ describe('onboarding template migration', () => { await migrateOnboardingTemplates(db) const ms = await listProjectMilestones(db, cmId) - const advance = ms.find((m) => m.key === 'advance_payment')! - expect(advance.done).toBe(true) - expect(advance.doneOn).toBe('2026-03-01') - expect(advance.paymentId).toBe(paymentId) + const paymentReceived = ms.find((m) => m.key === 'payment_received')! + expect(paymentReceived.done).toBe(true) + expect(paymentReceived.doneOn).toBe('2026-03-01') + expect(paymentReceived.paymentId).toBe(paymentId) + }) + + it('balance_paid also maps to payment_received', async () => { + const cmId = await makeSmsClientModule() + await db.run( + `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort) + VALUES (?,?,?,?,?,?,?)`, + 'm0', cmId, 'balance_paid', 'balance_paid', 1, '2026-04-10', 0, + ) + + await migrateOnboardingTemplates(db) + const ms = await listProjectMilestones(db, cmId) + const paymentReceived = ms.find((m) => m.key === 'payment_received')! + expect(paymentReceived.done).toBe(true) + expect(paymentReceived.doneOn).toBe('2026-04-10') }) it('is idempotent (second run changes nothing new)', async () => { @@ -151,8 +171,9 @@ describe('onboarding template migration', () => { // Verify the dropped keys are gone from the migrated template const ms = await listProjectMilestones(db, cmId) expect(ms.map((m) => m.key)).toEqual([ + ...FRONT_KEYS, 'document_collection', 'dlt_registration', 'account_creation', 'installation', - 'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', + 'testing', 'training', 'go_live', 'payment_received', ]) expect(ms.some((m) => m.key === 'amc_start' || m.key === 'setup_done')).toBe(false) @@ -167,11 +188,10 @@ describe('onboarding template migration', () => { const mod = await createModule(db, 'u1', { code: 'NOTEMPLATE', name: 'No Template Module' }) const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' }) // This module has no project.milestone_template:NOTEMPLATE setting, so ensureProjectMilestones - // fell back to the global generic template — the OLD generic rows themselves. + // composed the front with the code TAIL_FALLBACK (no global 'project.milestone_template' is + // seeded any more either). const before = await listProjectMilestones(db, cm.id) - expect(before.map((m) => m.key)).toEqual([ - 'advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start', - ]) + expect(before.map((m) => m.key)).toEqual([...FRONT_KEYS, ...TAIL_FALLBACK.map((t) => t.key)]) const res = await migrateOnboardingTemplates(db) expect(res.projects).toBe(0) diff --git a/apps/hq/test/milestone-payment.test.ts b/apps/hq/test/milestone-payment.test.ts index c2e6bd2..9ef8510 100644 --- a/apps/hq/test/milestone-payment.test.ts +++ b/apps/hq/test/milestone-payment.test.ts @@ -17,13 +17,13 @@ async function world() { } describe('milestone payment_id link (payment-step tick)', () => { - it('linking a payment to advance_payment stores payment_id and mirrors received_on', async () => { + it('linking a payment to payment_received stores payment_id and mirrors received_on', async () => { const { db, clientId, cmId } = await world() 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')! + await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id) + const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'payment_received')! expect(ms.done).toBe(true) expect(ms.doneOn).toBe('2026-05-01') expect(ms.paymentId).toBe(pay.payment.id) @@ -36,7 +36,7 @@ describe('milestone payment_id link (payment-step tick)', () => { clientId: other.id, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 500_00, }) await expect( - setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id), + setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id), ).rejects.toThrow(/payment/i) }) @@ -45,9 +45,9 @@ describe('milestone payment_id link (payment-step tick)', () => { 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) - await setMilestone(db, 'u1', cmId, 'advance_payment', false) - const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'advance_payment')! + await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id) + await setMilestone(db, 'u1', cmId, 'payment_received', false) + const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'payment_received')! expect(ms.done).toBe(false) expect(ms.doneOn).toBeNull() expect(ms.paymentId).toBeNull() @@ -58,8 +58,8 @@ describe('milestone payment_id link (payment-step tick)', () => { const pay = await recordPayment(db, 'u1', { clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00, }) - await setMilestone(db, 'u1', cmId, 'balance_payment', true, '2026-05-10', pay.payment.id) - const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'balance_payment')! + await setMilestone(db, 'u1', cmId, 'payment_received', true, '2026-05-10', pay.payment.id) + const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'payment_received')! expect(ms.doneOn).toBe('2026-05-10') expect(ms.paymentId).toBe(pay.payment.id) }) diff --git a/apps/hq/test/milestone-status.test.ts b/apps/hq/test/milestone-status.test.ts index 924137b..abeaca7 100644 --- a/apps/hq/test/milestone-status.test.ts +++ b/apps/hq/test/milestone-status.test.ts @@ -16,6 +16,13 @@ async function world() { } describe('milestone → status auto-drive (D22 status line)', () => { + it('ticking quotation_approved advances status to ordered', async () => { + const { db, cmId } = await world() + await setMilestone(db, 'u1', cmId, 'quotation_approved', true) + const cm = await getClientModule(db, cmId) + expect(cm!.status).toBe('ordered') + }) + it('ticking installation advances status to installed', async () => { const { db, cmId } = await world() await setMilestone(db, 'u1', cmId, 'installation', true) diff --git a/apps/hq/test/milestones-templates.test.ts b/apps/hq/test/milestones-templates.test.ts index eaaac0b..ab84f19 100644 --- a/apps/hq/test/milestones-templates.test.ts +++ b/apps/hq/test/milestones-templates.test.ts @@ -1,30 +1,58 @@ -// apps/hq/test/milestones-templates.test.ts — per-module onboarding template resolution. +// apps/hq/test/milestones-templates.test.ts — composed (front + per-module tail) onboarding +// template resolution. import { describe, it, expect, beforeEach } from 'vitest' import { openDb, type DB } from '../src/db' -import { milestoneTemplate } from '../src/repos-milestones' +import { milestoneTemplate, FRONT_FALLBACK, TAIL_FALLBACK } from '../src/repos-milestones' -describe('per-module milestone templates', () => { +describe('composed milestone templates (front + tail)', () => { let db: DB beforeEach(() => { db = openDb(':memory:') }) - it('falls back to the global template when no module-specific one exists', async () => { + it('falls back to the code FRONT_FALLBACK + TAIL_FALLBACK when nothing is configured', async () => { + expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK]) + expect(await milestoneTemplate(db)).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK]) + }) + + it('starts with the 4 front keys then the SMS tail', async () => { + const keys = (await milestoneTemplate(db, 'SMS')).map((e) => e.key) + expect(keys.slice(0, 4)).toEqual(FRONT_FALLBACK.map((f) => f.key)) + }) + + it('uses the configured front for every module and with no moduleCode', async () => { + await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template.front', ?)`, + JSON.stringify([{ key: 'f1', label: 'F1' }])) + expect((await milestoneTemplate(db, 'SMS'))[0]).toEqual({ key: 'f1', label: 'F1' }) + expect((await milestoneTemplate(db))[0]).toEqual({ key: 'f1', label: 'F1' }) + }) + + it('tail: falls back to the global tail 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' }]) + expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, { key: 'a', label: 'A' }]) }) - it('prefers the module-specific template over the global one', async () => { + it('tail: 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' }]) + expect(await milestoneTemplate(db, 'SMS')) + .toEqual([...FRONT_FALLBACK, { key: 'doc', label: 'Document collection' }]) + // A different module still gets the global tail. + expect(await milestoneTemplate(db, 'RTGS')).toEqual([...FRONT_FALLBACK, { key: 'a', label: 'A' }]) + }) + + it('with no moduleCode: front + global tail when set, else TAIL_FALLBACK', async () => { + expect(await milestoneTemplate(db)).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK]) + await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`, + JSON.stringify([{ key: 'a', label: 'A' }])) + expect(await milestoneTemplate(db)).toEqual([...FRONT_FALLBACK, { 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') + it('a malformed or empty per-module tail setting is treated as absent', async () => { + await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template:SMS', ?)`, 'not json') + expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK]) + await db.run(`UPDATE setting SET value=? WHERE key='project.milestone_template:SMS'`, '[]') + expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK]) }) }) diff --git a/apps/hq/test/milestones.test.ts b/apps/hq/test/milestones.test.ts index cf1a994..65c5d1b 100644 --- a/apps/hq/test/milestones.test.ts +++ b/apps/hq/test/milestones.test.ts @@ -21,10 +21,13 @@ async function world() { } describe('project milestones (D22)', () => { - it('seeds the standard checklist on assignment, in template order', async () => { + it('seeds the standard checklist on assignment, in template order (front + tail)', async () => { const { db, p1 } = await world() const ms = await listProjectMilestones(db, p1.id) - expect(ms.map((m) => m.key)).toEqual(['advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start']) + expect(ms.map((m) => m.key)).toEqual([ + 'enquiry', 'visit_meeting', 'quotation_sent', 'quotation_approved', + 'advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start', + ]) expect(ms.every((m) => !m.done && m.doneOn === null)).toBe(true) }) @@ -45,7 +48,7 @@ describe('project milestones (D22)', () => { it('adds a project-specific milestone after the standard set', async () => { const { db, p1 } = await world() const ms = await addProjectMilestone(db, 'u1', p1.id, 'AWS region confirmed') - expect(ms).toHaveLength(7) + expect(ms).toHaveLength(11) // 4 front + 6 tail + 1 custom expect(ms[ms.length - 1]!.label).toBe('AWS region confirmed') await expect(addProjectMilestone(db, 'u1', p1.id, ' ')).rejects.toThrow(/label/) }) @@ -59,9 +62,9 @@ describe('project milestones (D22)', () => { ) expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(0) await ensureProjectMilestones(db, 'imported-cm') - expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(6) + expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(10) // 4 front + 6 tail await ensureProjectMilestones(db, 'imported-cm') // second call adds nothing - expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(6) + expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(10) }) it('boards the whole book and drills into who is pending a milestone', async () => { diff --git a/apps/hq/test/seed-templates.test.ts b/apps/hq/test/seed-templates.test.ts index a5f62b4..a09fb93 100644 --- a/apps/hq/test/seed-templates.test.ts +++ b/apps/hq/test/seed-templates.test.ts @@ -3,23 +3,36 @@ import { openDb, type DB } from '../src/db' import { seedIfEmpty } from '../src/seed' import { milestoneTemplate } from '../src/repos-milestones' -describe('seed per-module onboarding templates', () => { +const FRONT_KEYS = ['enquiry', 'visit_meeting', 'quotation_sent', 'quotation_approved'] + +describe('seed onboarding templates (composed front + per-module tail)', () => { let db: DB beforeEach(async () => { db = openDb(':memory:'); await seedIfEmpty(db) }) - it('seeds the SMS 9-step list', async () => { + it('composes the shared front with the SMS tail', async () => { const keys = (await milestoneTemplate(db, 'SMS')).map((e) => e.key) expect(keys).toEqual([ + ...FRONT_KEYS, 'document_collection', 'dlt_registration', 'account_creation', 'installation', - 'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', + 'testing', 'training', 'go_live', 'payment_received', ]) }) - it('seeds the RTGS list ending in payment steps', async () => { + it('composes the shared front with the RTGS tail ending in go-live + payment received', async () => { const keys = (await milestoneTemplate(db, 'RTGS')).map((e) => e.key) expect(keys).toEqual([ + ...FRONT_KEYS, 'document_collection', 'server_checkup', 'setup_configuration', 'installation', - 'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', + 'testing', 'training', 'go_live', 'payment_received', + ]) + }) + + it('composes the shared front with the Mobile App tail', async () => { + const keys = (await milestoneTemplate(db, 'MOBILEAPP')).map((e) => e.key) + expect(keys).toEqual([ + ...FRONT_KEYS, + 'requirement_setup', 'configuration', 'data_import', 'installation', + 'testing', 'training', 'go_live', 'payment_received', ]) }) }) From 78ed947422d72e870a809286d13b31703ca1bac3 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 03:59:59 +0530 Subject: [PATCH 19/25] feat(payments): outstanding panel + settled-to-document column Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq-web/src/pages/ClientDetail.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index e3109fc..4c3b48f 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -422,12 +422,35 @@ export function ClientDetail() { )} + {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)}
+
+
+ )} { ledger.reload(); cms.reload(); amc.reload() }} /> {ledger.data !== undefined && ledger.data.payments.length > 0 && ( (a.receivedOn === b.receivedOn ? (a.id < b.id ? 1 : -1) : b.receivedOn.localeCompare(a.receivedOn))) .map((p) => ({ on: p.receivedOn, mode: p.mode, ref: p.reference !== '' ? p.reference : '—', + settled: p.allocations.length === 0 + ? advance + : → {p.allocations.map((a) => a.docNo).join(', ')}, amount: inr(p.amountPaise), tds: p.tdsPaise > 0 ? inr(p.tdsPaise) : '—', }))} /> From 025b334b092324a375abef920a21e6402b026a1c Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 04:08:37 +0530 Subject: [PATCH 20/25] feat(client-detail): drop mandatory assign creds, add per-module status control, trim summary table - AssignModuleForm: remove SMS-forced username/password gate at assign time (creds now captured later via module Access -> Edit); reword to "Add a module" with a one-line hint on what assigning starts. - Modules summary table: drop Installed/Completed/Trained columns (dates live in the per-module status line); keep Module/Kind/Status/Billed/Settled. - Each module's collapsible block now has an inline ClientModuleStatusSelect next to its status badge, so status is editable without opening the table. - Payments tab: remove the Outstanding panel's duplicate "Advance on account" footer line -- the pre-existing StatCard is the single source. - Remove RowDate, now unused after the summary-table trim. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq-web/src/pages/ClientDetail.tsx | 17 +++------ apps/hq-web/src/pages/client-forms.tsx | 50 ++++++-------------------- 2 files changed, 15 insertions(+), 52 deletions(-) diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index 4c3b48f..3be032d 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -29,7 +29,7 @@ import { } from './Tickets' import { AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect, - InteractionDialog, PaymentForm, PlanDialog, RowDate, + InteractionDialog, PaymentForm, PlanDialog, } from './client-forms' import { ClientFormDialog } from '../components/ClientFormDialog' import { PulseRibbon } from '../components/PulseRibbon' @@ -268,11 +268,8 @@ export function ClientDetail() { {modules.data !== undefined && ( { + onAssign={(moduleId, kind) => { assignClientModule(id, { moduleId, kind }) - .then((cm) => creds !== undefined - ? patchClientModule(cm.id, { username: creds.username, password: creds.password }) - : cm) .then(() => { toast.ok('Module assigned'); cms.reload(); ledger.reload() }) .catch((err: Error) => toast.err(err.message)) }} @@ -285,8 +282,6 @@ export function ClientDetail() { columns={[ { key: 'module', label: 'Module' }, { key: 'kind', label: 'Kind' }, { key: 'status', label: 'Status' }, - { key: 'installed', label: 'Installed' }, { key: 'completed', label: 'Completed' }, - { key: 'trained', label: 'Trained' }, { key: 'billed', label: 'Billed', numeric: true }, { key: 'settled', label: 'Settled', numeric: true }, ]} @@ -296,9 +291,6 @@ export function ClientDetail() { module: moduleName(cm.moduleId), kind: KIND_LABEL[cm.kind], status: cms.reload()} onError={toast.err} />, - installed: cms.reload()} onError={toast.err} />, - completed: cms.reload()} onError={toast.err} />, - trained: cms.reload()} onError={toast.err} />, billed: paid !== undefined ? inr(paid.billedPaise) : '—', settled: paid !== undefined ? inr(paid.settledPaise) : '—', } @@ -314,6 +306,9 @@ export function ClientDetail() { {moduleName(cm.moduleId)} {cm.status} {KIND_LABEL[cm.kind]} + e.stopPropagation()}> + cms.reload()} onError={toast.err} /> +
{inr(o.outstandingPaise)}
))} -
Advance on account - {formatINR(ledger.data.advancePaise)}
)} diff --git a/apps/hq-web/src/pages/client-forms.tsx b/apps/hq-web/src/pages/client-forms.tsx index a092b05..693b3c0 100644 --- a/apps/hq-web/src/pages/client-forms.tsx +++ b/apps/hq-web/src/pages/client-forms.tsx @@ -284,25 +284,21 @@ export function InteractionDialog(props: { export function AssignModuleForm(props: { modules: Module[] - onAssign: (moduleId: string, kind: Kind, creds?: { username: string; password: string }) => void + onAssign: (moduleId: string, kind: Kind) => void }) { const [moduleId, setModuleId] = useState('') const mod = props.modules.find((m) => m.id === moduleId) const [kind, setKind] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - // SMS is a gateway service — its login is required at assign time so the balance pull works. - const needsCreds = mod?.code === 'SMS' - const ready = moduleId !== '' && kind !== '' && (!needsCreds || (username.trim() !== '' && password.trim() !== '')) - const reset = () => { setModuleId(''); setKind(''); setUsername(''); setPassword('') } + const ready = moduleId !== '' && kind !== '' + const reset = () => { setModuleId(''); setKind('') } return (
{mod !== undefined && ( @@ -311,30 +307,20 @@ export function AssignModuleForm(props: { {mod.allowedKinds.map((k) => )} )} - {needsCreds && ( - <> - setUsername(e.target.value)} /> - setPassword(e.target.value)} /> - - )} - {needsCreds && ( - - SMS needs the provider gateway login — username and password are required so the balance can be pulled. - - )} + + Starts the module at "quoted" and opens its onboarding status line. +
) } @@ -355,22 +341,6 @@ export function ClientModuleStatusSelect(props: { ) } -export function RowDate(props: { - cm: ClientModule; field: 'installedOn' | 'completedOn' | 'trainedOn' - onPatched: () => void; onError: (msg: string) => void -}) { - return ( - { - patchClientModule(props.cm.id, { [props.field]: e.target.value !== '' ? e.target.value : null }) - .then(props.onPatched).catch((err: Error) => props.onError(err.message)) - }} - /> - ) -} - /** * Record a payment against the client — amounts entered in rupees, stored in * integer paise via fromRupees. Shared with DocumentView's "Record payment". From 0050f73a7077ab1a7fdb9a6bc3e28398563786b3 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 04:16:28 +0530 Subject: [PATCH 21/25] fix(ui): stack record-header actions and scroll wide tables on mobile RecordHeader's action-button cluster crowded/overlapped the client name on narrow phones, and DataTable (Tickets, etc.) ran wider than the viewport, clipping columns with no way to reach them. Add a <=640px media query: .wf-rec wraps so .wf-rec-actions drops to its own full-width row below the avatar/name/meta; desktop layout is untouched outside the query. Wrap DataTable's in a new .wf-table-wrap div that scrolls horizontally on overflow at that breakpoint (nowrap cells so the table can exceed 100% instead of being squeezed illegibly), keeping the page body itself from scrolling sideways. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ui/src/components.tsx | 90 ++++++++++++++++++---------------- packages/ui/src/tokens.css | 15 ++++++ 2 files changed, 62 insertions(+), 43 deletions(-) diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index c80162a..0c875da 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -134,52 +134,56 @@ export function DataTable(props: { } return ( -
- - - {props.columns.map((c) => { - const sortable = canSort(c) - const arrow = sort?.key === c.key ? (sort.dir === 'asc' ? ' ▲' : ' ▼') : '' + // Horizontal-scroll boundary: on narrow screens a wide table scrolls inside this box + // instead of pushing the page body wider than the viewport (see .wf-table-wrap CSS). +
+
+ + + {props.columns.map((c) => { + const sortable = canSort(c) + const arrow = sort?.key === c.key ? (sort.dir === 'asc' ? ' ▲' : ' ▼') : '' + return ( + + ) + })} + + + + {ordered.map(({ row, i }) => { + const tone = props.rowTone?.(i) + const clickable = props.onRowClick !== undefined return ( - + props.onRowClick?.(row, i) : undefined} + onKeyDown={clickable + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onRowClick?.(row, i) } + } + : undefined} + role={clickable ? 'button' : undefined} + tabIndex={clickable ? 0 : undefined} + style={clickable ? { cursor: 'pointer' } : undefined} + > + {props.columns.map((c) => ( + + ))} + ) })} - - - - {ordered.map(({ row, i }) => { - const tone = props.rowTone?.(i) - const clickable = props.onRowClick !== undefined - return ( - props.onRowClick?.(row, i) : undefined} - onKeyDown={clickable - ? (e) => { - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onRowClick?.(row, i) } - } - : undefined} - role={clickable ? 'button' : undefined} - tabIndex={clickable ? 0 : undefined} - style={clickable ? { cursor: 'pointer' } : undefined} - > - {props.columns.map((c) => ( - - ))} - - ) - })} - -
toggle(c.key) : undefined} + style={sortable ? { cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' } : undefined} + title={sortable ? 'Sort' : undefined}> + {c.label}{arrow} +
toggle(c.key) : undefined} - style={sortable ? { cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' } : undefined} - title={sortable ? 'Sort' : undefined}> - {c.label}{arrow} -
{row[c.key]}
{row[c.key]}
+ + + ) } diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 822785b..24bb41d 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -461,3 +461,18 @@ button.wf.pill { border-radius: 999px; padding: 8px 18px; } .wf-formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; } .wf-formgrid .wide { grid-column: 1 / -1; } @media (max-width: 560px) { .wf-formgrid { grid-template-columns: 1fr; } } + +/* ================= mobile (phone) layout ================= */ +@media (max-width: 640px) { + /* Record header: stack the action-button cluster below the avatar/name/meta instead of + crowding them on one row. Desktop layout (outside this query) is untouched. */ + .wf-rec { flex-wrap: wrap; } + .wf-rec-actions { width: 100%; } + + /* Wide tables (e.g. Tickets) scroll horizontally inside their own box rather than + clipping columns or pushing the page body wider than the viewport. `white-space: + nowrap` stops cells from being squeezed/wrapped illegibly, which is what actually + lets the table exceed 100% and become scrollable. */ + .wf-table-wrap { overflow-x: auto; max-width: 100%; -webkit-overflow-scrolling: touch; } + .wf-table-wrap table.wf th, .wf-table-wrap table.wf td { white-space: nowrap; } +} From db8ec9c5462ea408a5012cee12de5b86ba7ff7c6 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 04:22:21 +0530 Subject: [PATCH 22/25] feat(client-detail): prompt for module credentials on account_creation tick Ticking the "Account creation & registration" onboarding step now opens a small credentials dialog (mirrors the existing payment-step picker) instead of a bare toggle. Saving reuses ServiceDataCard's exact patchClientModule call and managerial password gate, then marks the milestone done; "Skip" marks it done without touching credentials so the step never hard-blocks. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq-web/src/pages/ClientDetail.tsx | 121 ++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index 3be032d..535fd5d 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -82,6 +82,12 @@ export function ClientDetail() { const [linkingPayment, setLinkingPayment] = useState(false) const [linkError, setLinkError] = useState() const [milestonesVersion, setMilestonesVersion] = useState(0) + // Ticking the account_creation step opens this credentials dialog instead of a bare + // toggle (mirrors the payment picker above) — `cm` is captured via closure at the JSX + // callback below, exactly like `onTickPayment` captures it for the payment picker. + const [credsDialog, setCredsDialog] = useState<{ cm: ClientModule; key: string; label: string } | undefined>() + const [savingCreds, setSavingCreds] = useState(false) + const [credsError, setCredsError] = useState() const rawTab = sp.get('tab') ?? 'overview' const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview' @@ -322,6 +328,7 @@ export function ClientDetail() { name={moduleName(cm.moduleId)} refreshToken={milestonesVersion} onTickPayment={(key, label) => { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }} + onTickCreds={(key, label) => { setCredsError(undefined); setCredsDialog({ cm, key, label }) }} /> { setPaymentPicker(undefined); setTab('payments') }} /> )} + {credsDialog !== undefined && ( + { if (!savingCreds) setCredsDialog(undefined) }} + onSave={(fields) => { + if (savingCreds) return + setSavingCreds(true) + setCredsError(undefined) + patchClientModule(credsDialog.cm.id, { + provider: fields.provider, username: fields.username, + ...(canRoute && fields.password.trim() !== '' ? { password: fields.password } : {}), + }) + .then(() => setMilestone(credsDialog.cm.id, credsDialog.key, true)) + .then(() => { + toast.ok('Credentials saved') + setMilestonesVersion((v) => v + 1) + cms.reload() + setCredsDialog(undefined) + }) + .catch((e: Error) => setCredsError(e.message)) + .finally(() => setSavingCreds(false)) + }} + onSkip={() => { + if (savingCreds) return + setSavingCreds(true) + setCredsError(undefined) + setMilestone(credsDialog.cm.id, credsDialog.key, true) + .then(() => { + toast.ok('Step marked done') + setMilestonesVersion((v) => v + 1) + setCredsDialog(undefined) + }) + .catch((e: Error) => setCredsError(e.message)) + .finally(() => setSavingCreds(false)) + }} + /> + )} ) } @@ -1306,6 +1354,7 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo function StatusLine(props: { clientModuleId: string; name: string; refreshToken: number onTickPayment: (key: string, label: string) => void + onTickCreds: (key: string, label: string) => void }) { const toast = useToast() const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId, props.refreshToken]) @@ -1336,12 +1385,16 @@ function StatusLine(props: { // current = first not-done step const currentKey = ms?.find((m) => !m.done)?.key const isPaymentStep = (key: string) => key === 'advance_payment' || key === 'balance_payment' + const isCredsStep = (key: string) => key === 'account_creation' // Ticking ON a payment step opens the picker (parent owns that dialog — it needs the - // client's ledger + the Payments tab); un-ticking and every other step toggle directly. + // client's ledger + the Payments tab); ticking ON account_creation opens the parent's + // credentials dialog (it needs the module's provider/username/password + managerial + // gate). Un-ticking and every other step toggle directly. const onDotClick = (m: Milestone) => { if (busyKey !== '') return if (!m.done && isPaymentStep(m.key)) { props.onTickPayment(m.key, m.label); return } + if (!m.done && isCredsStep(m.key)) { props.onTickCreds(m.key, m.label); return } toggle(m, !m.done) } @@ -1378,7 +1431,12 @@ function StatusLine(props: { + + + + } + > +
+ + setProvider(e.target.value)} /> + + + setUsername(e.target.value)} /> + + {managerial && ( + + setPassword(e.target.value)} + /> + + )} +
+ {props.error !== undefined && {props.error}} + + ) +} From 371cdb1c6973de2811a4eb8b3fc1974de2796da3 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 04:28:29 +0530 Subject: [PATCH 23/25] fix(ui): drop redundant table-scroll CSS, rely on existing app.css mechanism Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ui/src/components.tsx | 90 ++++++++++++++++------------------ packages/ui/src/tokens.css | 7 --- 2 files changed, 43 insertions(+), 54 deletions(-) diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index 0c875da..c80162a 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -134,56 +134,52 @@ export function DataTable(props: { } return ( - // Horizontal-scroll boundary: on narrow screens a wide table scrolls inside this box - // instead of pushing the page body wider than the viewport (see .wf-table-wrap CSS). -
- - - - {props.columns.map((c) => { - const sortable = canSort(c) - const arrow = sort?.key === c.key ? (sort.dir === 'asc' ? ' ▲' : ' ▼') : '' - return ( - - ) - })} - - - - {ordered.map(({ row, i }) => { - const tone = props.rowTone?.(i) - const clickable = props.onRowClick !== undefined +
toggle(c.key) : undefined} - style={sortable ? { cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' } : undefined} - title={sortable ? 'Sort' : undefined}> - {c.label}{arrow} -
+ + + {props.columns.map((c) => { + const sortable = canSort(c) + const arrow = sort?.key === c.key ? (sort.dir === 'asc' ? ' ▲' : ' ▼') : '' return ( - props.onRowClick?.(row, i) : undefined} - onKeyDown={clickable - ? (e) => { - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onRowClick?.(row, i) } - } - : undefined} - role={clickable ? 'button' : undefined} - tabIndex={clickable ? 0 : undefined} - style={clickable ? { cursor: 'pointer' } : undefined} - > - {props.columns.map((c) => ( - - ))} - + ) })} - -
{row[c.key]}
toggle(c.key) : undefined} + style={sortable ? { cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' } : undefined} + title={sortable ? 'Sort' : undefined}> + {c.label}{arrow} +
-
+ + + + {ordered.map(({ row, i }) => { + const tone = props.rowTone?.(i) + const clickable = props.onRowClick !== undefined + return ( + props.onRowClick?.(row, i) : undefined} + onKeyDown={clickable + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onRowClick?.(row, i) } + } + : undefined} + role={clickable ? 'button' : undefined} + tabIndex={clickable ? 0 : undefined} + style={clickable ? { cursor: 'pointer' } : undefined} + > + {props.columns.map((c) => ( + {row[c.key]} + ))} + + ) + })} + + ) } diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 24bb41d..e23fe61 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -468,11 +468,4 @@ button.wf.pill { border-radius: 999px; padding: 8px 18px; } crowding them on one row. Desktop layout (outside this query) is untouched. */ .wf-rec { flex-wrap: wrap; } .wf-rec-actions { width: 100%; } - - /* Wide tables (e.g. Tickets) scroll horizontally inside their own box rather than - clipping columns or pushing the page body wider than the viewport. `white-space: - nowrap` stops cells from being squeezed/wrapped illegibly, which is what actually - lets the table exceed 100% and become scrollable. */ - .wf-table-wrap { overflow-x: auto; max-width: 100%; -webkit-overflow-scrolling: touch; } - .wf-table-wrap table.wf th, .wf-table-wrap table.wf td { white-space: nowrap; } } From 45cfb83d031e2ae3e19474aea217905abed4e934 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 20:05:35 +0530 Subject: [PATCH 24/25] chore(deploy): apply prod DB URL + onboarding-pipeline deploy script Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq/scripts/apply-onboarding-pipeline.ts | 68 ++++++++++++++++++++ apps/hq/src/server.ts | 2 +- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 apps/hq/scripts/apply-onboarding-pipeline.ts diff --git a/apps/hq/scripts/apply-onboarding-pipeline.ts b/apps/hq/scripts/apply-onboarding-pipeline.ts new file mode 100644 index 0000000..fd49cc9 --- /dev/null +++ b/apps/hq/scripts/apply-onboarding-pipeline.ts @@ -0,0 +1,68 @@ +// One-off deploy step for the Client Detail redesign: overwrite the live onboarding-template +// settings with the composed quotation-led pipeline (shared front + per-module tail), then run +// the tick-preserving migration so existing SMS/RTGS/MobileApp projects adopt the new steps. +// Idempotent. Run from repo root: npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts +import { openDb } from '../src/db' +import { openPgDb } from '../src/db-pg' +import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' + +const FRONT = [ + { key: 'enquiry', label: 'Enquiry received' }, + { key: 'visit_meeting', label: 'Visit / meeting scheduled' }, + { key: 'quotation_sent', label: 'Quotation sent' }, + { key: 'quotation_approved', label: 'Quotation approved' }, +] +const TAILS: 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: 'payment_received', label: 'Payment received' }, + ], + 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: 'payment_received', label: 'Payment received' }, + ], + 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: 'payment_received', label: 'Payment received' }, + ], +} + +async function main(): Promise { + const pgUrl = process.env['DATABASE_URL'] ?? '' + const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) + + const upsert = async (key: string, list: unknown): Promise => { + await db.run( + `INSERT INTO setting (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`, + key, JSON.stringify(list), + ) + } + await upsert('project.milestone_template.front', FRONT) + for (const [code, list] of Object.entries(TAILS)) await upsert(`project.milestone_template:${code}`, list) + // eslint-disable-next-line no-console + console.log('Template settings updated: front + SMS/RTGS/MOBILEAPP tails.') + + const res = await migrateOnboardingTemplates(db) + // eslint-disable-next-line no-console + 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) }) diff --git a/apps/hq/src/server.ts b/apps/hq/src/server.ts index 0ab3a2d..382c338 100644 --- a/apps/hq/src/server.ts +++ b/apps/hq/src/server.ts @@ -150,7 +150,7 @@ export async function startServer(port: number, opts: { scheduler?: boolean } = // and the test suite keep selecting SQLite (an unset/empty DATABASE_URL) and stay green. // ⚠️ Once the real password is filled in, this file holds a secret — do not push the // filled-in value to a repo others can read. - const HARDCODED_DATABASE_URL = 'postgres://postgres:CHANGE_ME_PASSWORD@host.docker.internal:5432/hq' + const HARDCODED_DATABASE_URL = 'postgres://postgres:inv123@host.docker.internal:5432/hq' // D19 engine selection: DATABASE_URL (or the hardcoded prod value) → Postgres; else SQLite. const pgUrl = process.env['DATABASE_URL'] || (process.env['NODE_ENV'] === 'production' ? HARDCODED_DATABASE_URL : '') const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) From 4e747427d2d8c9d66494885e928fe946ff398d7f Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 20:42:54 +0530 Subject: [PATCH 25/25] feat(onboarding): owner editor for shared front + per-module tail templates Adds owner-only, audited routes for the two config settings milestoneTemplate composes (project.milestone_template.front and project.milestone_template:), plus a Modules page section to edit them: staged label rows with add/remove/ reorder/Save, keys auto-derived from labels when blank, uniqueness enforced. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq-web/src/api.ts | 14 ++++ apps/hq-web/src/pages/Modules.tsx | 132 +++++++++++++++++++++++++++++- apps/hq/src/api.ts | 45 +++++++++- apps/hq/src/repos-milestones.ts | 76 +++++++++++++++-- 4 files changed, 252 insertions(+), 15 deletions(-) diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index f78bfe5..5562f66 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -328,6 +328,20 @@ export const addPrice = (moduleId: string, body: Record): Promi apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`, { method: 'POST', body: JSON.stringify(body) }) .then((r) => r.prices) +// Onboarding step templates (owner editor): shared "front" (every module) + per-module "tail". +export interface TemplateStep { key: string; label: string } +export const getFrontTemplate = (): Promise => + apiFetch<{ steps: TemplateStep[] }>('/onboarding-template/front').then((r) => r.steps) +export const setFrontTemplate = (steps: TemplateStep[]): Promise => + apiFetch<{ steps: TemplateStep[] }>('/onboarding-template/front', { method: 'PUT', body: JSON.stringify({ steps }) }) + .then((r) => r.steps) +export const getModuleTemplate = (code: string): Promise => + apiFetch<{ steps: TemplateStep[] }>(`/modules/${encodeURIComponent(code)}/onboarding-template`).then((r) => r.steps) +export const setModuleTemplate = (code: string, steps: TemplateStep[]): Promise => + apiFetch<{ steps: TemplateStep[] }>(`/modules/${encodeURIComponent(code)}/onboarding-template`, { + method: 'PUT', body: JSON.stringify({ steps }), + }).then((r) => r.steps) + export const getClientModules = (clientId: string): Promise => apiFetch<{ clientModules: ClientModule[] }>(`/clients/${clientId}/modules`).then((r) => r.clientModules) export const assignClientModule = (clientId: string, body: Record): Promise => diff --git a/apps/hq-web/src/pages/Modules.tsx b/apps/hq-web/src/pages/Modules.tsx index 631e8b2..38c90a7 100644 --- a/apps/hq-web/src/pages/Modules.tsx +++ b/apps/hq-web/src/pages/Modules.tsx @@ -5,11 +5,11 @@ import { FormGrid, Notice, PageHeader, Skeleton, Tabs, Toolbar, useToast, } from '@sims/ui' import { - addPrice, assignClientModule, createModule, downloadModuleClientsCsv, getClients, - getModuleClients, getModules, getPrices, notifyModuleClients, patchClientModule, patchModule, - previewSample, role, + addPrice, assignClientModule, createModule, downloadModuleClientsCsv, getClients, getFrontTemplate, + getModuleClients, getModuleTemplate, getModules, getPrices, notifyModuleClients, patchClientModule, patchModule, + previewSample, role, setFrontTemplate, setModuleTemplate, CLIENT_MODULE_STATUSES, KIND_LABEL, - type Client, type ClientModuleStatus, type FieldDef, type FieldType, type Kind, type Module, + type Client, type ClientModuleStatus, type FieldDef, type FieldType, type Kind, type Module, type TemplateStep, } from '../api' import { LivePreview } from '../components/LivePreview' import { useData } from './Clients' @@ -121,6 +121,7 @@ export function Modules() { onCreated={() => { setCreating(false); modules.reload() }} /> )} + {isOwner && modules.data !== undefined && } ) } @@ -832,3 +833,126 @@ function AddPriceDialog(props: { open: boolean; onClose: () => void; module: Mod ) } + +/** + * Owner-only editor for the COMPOSED onboarding checklist template: one shared "front" (same + * lead-in steps for every module) plus each module's own "tail". Both are config (`setting` + * rows) resolved by `milestoneTemplate` — this just edits the two halves. Staged local edits; + * nothing is written until Save. + */ +function OnboardingTemplatesSection(props: { modules: Module[] }) { + const front = useData(getFrontTemplate, []) + const [expanded, setExpanded] = useState() + + return ( +
+

Onboarding steps

+
+ Every project's onboarding checklist is this shared front followed + by that module's own tail below. Changes reach existing projects + additively — new steps appear on projects already in progress, and any step already ticked stays ticked as-is. + Re-ordering or renaming steps that existing projects already have doesn't retro-fit those projects — that needs + the onboarding-template migration script. +
+ +

Shared front — applies to every module

+ {front.error !== undefined ? + : front.data === undefined ? : ( +
+ setFrontTemplate(steps).then((saved) => { front.reload(); return saved })} + /> +
+ )} + +

Per-module tail

+
+ {props.modules.map((m) => ( +
+
+ {m.name} {m.code} + +
+ {expanded === m.id && } +
+ ))} +
+
+ ) +} + +/** Lazily loads and edits one module's tail — only fetched once its row is expanded. */ +function ModuleTailEditor(props: { code: string }) { + const tail = useData(() => getModuleTemplate(props.code), [props.code]) + return ( +
+ {tail.error !== undefined ? + : tail.data === undefined ? : ( + setModuleTemplate(props.code, steps).then((saved) => { tail.reload(); return saved })} + /> + )} +
+ ) +} + +/** + * One editable ordered list of template steps — label-only rows (the key is auto-derived + * server-side from the label when blank); add / remove / reorder, staged locally until Save. + * Shared by the front editor and every per-module tail editor above. + */ +function StepListEditor(props: { steps: TemplateStep[]; onSave: (steps: TemplateStep[]) => Promise }) { + const toast = useToast() + const [rows, setRows] = useState(() => props.steps.map((s) => ({ ...s }))) + const [dirty, setDirty] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState() + + useEffect(() => { setRows(props.steps.map((s) => ({ ...s }))); setDirty(false) }, [props.steps]) + + const setLabel = (i: number, label: string) => { + setRows((rs) => rs.map((r, j) => (j === i ? { ...r, label } : r))) + setDirty(true) + } + const addRow = () => { setRows((rs) => [...rs, { key: '', label: '' }]); setDirty(true) } + const removeRow = (i: number) => { setRows((rs) => rs.filter((_r, j) => j !== i)); setDirty(true) } + const move = (i: number, dir: -1 | 1) => { + const j = i + dir + if (j < 0 || j >= rows.length) return + setRows((rs) => { const next = [...rs]; [next[i], next[j]] = [next[j]!, next[i]!]; return next }) + setDirty(true) + } + const save = () => { + if (busy) return + if (rows.length === 0) { setError('Add at least one step'); return } + if (rows.some((r) => r.label.trim() === '')) { setError('Every step needs a label'); return } + setError(undefined); setBusy(true) + props.onSave(rows.map((r) => ({ key: r.key, label: r.label.trim() }))) + .then((saved) => { setRows(saved.map((s) => ({ ...s }))); setDirty(false); toast.ok('Onboarding steps saved') }) + .catch((e: Error) => { setError(e.message); toast.err(e.message) }) + .finally(() => setBusy(false)) + } + + return ( +
+ {rows.map((r, i) => ( +
+ setLabel(i, e.target.value)} /> + + + +
+ ))} + + + + + {error !== undefined && {error}} +
+ ) +} diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 5d179d2..283ff24 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -69,8 +69,9 @@ import { clearLegacyCiphertext } from './migrate-plaintext' import { assertSafeGatewayUrl } from './sms-gateway' import { makeRateLimiter } from './rate-limit' import { - addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard, - projectsPendingMilestone, setMilestone, stalledProjects, + addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, getFrontTemplate, getModuleTail, + listProjectMilestones, milestoneBoard, projectsPendingMilestone, setFrontTemplate, setMilestone, + setModuleTail, stalledProjects, } from './repos-milestones' import { commitImport, stageCsv, verificationReport } from './import-apex' import { documentHtml, documentHtmlSample } from './templates' @@ -481,6 +482,29 @@ export function apiRouter( } }) + // ---------- onboarding step templates (owner editor): shared front + per-module tail ---------- + r.get('/modules/:code/onboarding-template', requireAuth, requireOwner, async (req, res) => { + try { + const code = String(req.params['code'] ?? '') + const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code=?`, code) + if (mod === undefined) { res.status(404).json({ ok: false, error: 'Module not found' }); return } + res.json({ ok: true, steps: await getModuleTail(db, code) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.put('/modules/:code/onboarding-template', requireAuth, requireOwner, async (req, res) => { + try { + const code = String(req.params['code'] ?? '') + const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code=?`, code) + if (mod === undefined) { res.status(404).json({ ok: false, error: 'Module not found' }); return } + const body = req.body as { steps?: unknown } + res.json({ ok: true, steps: await setModuleTail(db, staffId(res), code, body.steps) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // ---------- module → client roster (spec §10, D-ROSTER) ---------- // Module directory (D28): every client on a module with its service fields, for review. r.get('/modules/:id/directory', requireAuth, async (req, res) => { @@ -764,6 +788,23 @@ export function apiRouter( res.json({ ok: true, projects: await stalledProjects(db, days, today) }) }) + // The shared onboarding "front" — same lead-in steps for every module's checklist (owner editor). + r.get('/onboarding-template/front', requireAuth, requireOwner, async (_req, res) => { + try { + res.json({ ok: true, steps: await getFrontTemplate(db) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.put('/onboarding-template/front', requireAuth, requireOwner, async (req, res) => { + try { + const body = req.body as { steps?: unknown } + res.json({ ok: true, steps: await setFrontTemplate(db, staffId(res), body.steps) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // ---------- client branches (D20) ---------- r.get('/clients/:id/branches', requireAuth, async (req, res) => { try { diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts index 931d23b..be5303d 100644 --- a/apps/hq/src/repos-milestones.ts +++ b/apps/hq/src/repos-milestones.ts @@ -1,5 +1,6 @@ import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' +import { setSetting } from './repos-reminders' import type { DB } from './db' /** @@ -62,18 +63,19 @@ function parseTemplate(value: string): TemplateEntry[] | undefined { } catch { return undefined } } -/** - * The checklist for a module: a shared "front" (same for every module — enquiry through - * quotation-approved) followed by a per-module "tail" (owner-editable per module via - * setting; global default; code fallback). Composed as [...front, ...tail]. - */ -export async function milestoneTemplate(db: DB, moduleCode?: string): Promise { +/** The shared "front" alone — setting row, else the code fallback. */ +export async function getFrontTemplate(db: DB): Promise { const frontRow = await db.get<{ value: string }>( `SELECT value FROM setting WHERE key='project.milestone_template.front'`) - const front = (frontRow !== undefined ? parseTemplate(frontRow.value) : undefined) ?? FRONT_FALLBACK + return (frontRow !== undefined ? parseTemplate(frontRow.value) : undefined) ?? FRONT_FALLBACK +} +/** One module's "tail" alone (not composed with the front): its own per-module setting, + * else the global tail setting, else the code fallback. `moduleCode` may be '' (no module + * context) — resolves straight to the global/fallback tail. */ +export async function getModuleTail(db: DB, moduleCode: string): Promise { let tail: TemplateEntry[] | undefined - if (moduleCode !== undefined && moduleCode !== '') { + if (moduleCode !== '') { const perModule = await db.get<{ value: string }>( `SELECT value FROM setting WHERE key=?`, `project.milestone_template:${moduleCode}`) tail = perModule !== undefined ? parseTemplate(perModule.value) : undefined @@ -82,11 +84,67 @@ export async function milestoneTemplate(db: DB, moduleCode?: string): Promise(`SELECT value FROM setting WHERE key='project.milestone_template'`) tail = row !== undefined ? parseTemplate(row.value) : undefined } - tail ??= TAIL_FALLBACK + return tail ?? TAIL_FALLBACK +} +/** + * The checklist for a module: a shared "front" (same for every module — enquiry through + * quotation-approved) followed by a per-module "tail" (owner-editable per module via + * setting; global default; code fallback). Composed as [...front, ...tail]. + */ +export async function milestoneTemplate(db: DB, moduleCode?: string): Promise { + const front = await getFrontTemplate(db) + const tail = await getModuleTail(db, moduleCode ?? '') return [...front, ...tail] } +/** Make a valid step key from a label: lowercase, non-alphanumeric -> `_`, collapse repeats, trim `_`. */ +function slugifyStepLabel(label: string): string { + return label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '') +} + +/** + * Validate + normalize a template-editor PUT body's `steps` into `TemplateEntry[]`: a + * non-empty array, every label non-blank after trim, every key unique. A missing/blank key + * is derived by slugifying its label (bumping a numeric suffix on collision). Throws with a + * clear message otherwise — the caller turns that into a 400. + */ +export function normalizeTemplateSteps(rawSteps: unknown): TemplateEntry[] { + if (!Array.isArray(rawSteps) || rawSteps.length === 0) throw new Error('Provide at least one step') + const used = new Set() + const out: TemplateEntry[] = [] + for (const raw of rawSteps) { + const r = (typeof raw === 'object' && raw !== null ? raw : {}) as { key?: unknown; label?: unknown } + const label = typeof r.label === 'string' ? r.label.trim() : '' + if (label === '') throw new Error('Every step needs a non-blank label') + let key = typeof r.key === 'string' ? r.key.trim() : '' + if (key === '') { + const base = slugifyStepLabel(label) || 'step' + key = base + let n = 2 + while (used.has(key)) key = `${base}_${n++}` + } + if (used.has(key)) throw new Error(`Duplicate step key: "${key}"`) + used.add(key) + out.push({ key, label }) + } + return out +} + +/** Owner editor: replace the shared front template. Config-over-code, audited via `setSetting`. */ +export async function setFrontTemplate(db: DB, userId: string, rawSteps: unknown): Promise { + const steps = normalizeTemplateSteps(rawSteps) + await setSetting(db, userId, 'project.milestone_template.front', JSON.stringify(steps)) + return steps +} + +/** Owner editor: replace one module's tail template. Config-over-code, audited via `setSetting`. */ +export async function setModuleTail(db: DB, userId: string, moduleCode: string, rawSteps: unknown): Promise { + const steps = normalizeTemplateSteps(rawSteps) + await setSetting(db, userId, `project.milestone_template:${moduleCode}`, JSON.stringify(steps)) + return steps +} + /** * Idempotently materialize the template's milestones for a project. New template rows are * added on later calls (so extending the standard set reaches existing projects); a