Compare commits

..

36 Commits

Author SHA1 Message Date
Thomas Joise b146de990f fix(scripts): audit + guard apply-onboarding-pipeline's setting writes
Final whole-branch review FIX 2.

The live-deploy script upserted the onboarding template settings with raw
SQL (ON CONFLICT DO UPDATE), bypassing setSetting -> no audit row, violating
the append-only-audit rule -- and it unconditionally overwrote templates an
owner may have already edited in the Modules onboarding-template editor,
making the "Idempotent" header comment false and dangerous for a script that
runs against ~167 real projects.

Upserts now go through setSetting(db, 'system', key, value) (audited), and
only write a key that is still absent, unless run with an explicit --force
argument (documented). Header comment corrected to say idempotency holds
only while the templates haven't been owner-edited, and that --force
overwrites unconditionally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 4ee8f17b37 fix(onboarding-migration): preserve ad-hoc custom steps across the template swap
Final whole-branch review FIX 3.

migrateOnboardingTemplates previously DELETEd every project_milestone row and
reinserted only template keys, so a project-specific step added via
addProjectMilestone ("+ Add step", custom_* keys) vanished with no trace if
un-ticked, or was miscounted as `dropped` if ticked.

Old rows are now partitioned before the DELETE into template-mapped vs
ad-hoc custom (not a template key, not in KEY_MAP, and not a known old
generic FRONT_FALLBACK/TAIL_FALLBACK key like amc_start — those keep their
existing drop behavior). Custom rows are re-inserted unchanged after the
template block, sort continuing past it, preserving
label/done/done_on/payment_id/document_id. The return type and the audit
`after` payload both gained a `preserved` count/list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise e8e2a202c1 fix(milestones): reject front/tail key collisions; dedupe on collision; stamp installed/trained dates
Final whole-branch review FIX 1 + FIX 5.

- setFrontTemplate / setModuleTail now reject a key that collides across the
  front/tail halves (checked against the default tail, every configured
  per-module tail, and the shared front respectively) instead of letting
  milestoneTemplate silently compose a duplicate-key list.
- ensureProjectMilestones adds each inserted key to its own `existing` set
  inside the loop, so a template that still ends up with a duplicate key
  (a pre-existing bad setting) is deduped instead of hitting the
  UNIQUE (client_module_id, key) violation a second time.
- GET /client-modules/:id/milestones now wraps ensureProjectMilestones +
  listProjectMilestones in try/catch -> 400, matching its POST sibling,
  instead of an unhandled async rejection that hung the request.
- advanceStatusForStep now stamps client_module.installed_on / trained_on
  from the triggering milestone's own done_on the first time status
  advances into 'installed' / 'trained' (never overwriting an existing
  date), replacing the UI date-writers the Client Detail redesign removed
  without a replacement. Reflected in the same audit row as the status
  change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 7494f53e2f fix(client-detail): payment step gate matches shipped key payment_received
The status-line payment picker only fired for advance_payment/balance_payment,
which no shipped template contains (the composed tails and the migration both
standardise on payment_received) — so payment linking was unreachable and
project_milestone.payment_id was never written by the UI. Keeps the legacy keys
for projects not yet migrated. Also refreshes the stale STATUS test count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise ac16d8c896 merge: frontend — quotation-step dialog, ticket types + deep-links, cleanup
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 3ac4e2a47d merge: backend — quotation-step document link + ticket classification
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 7838ced6c2 feat(client-detail): quotation-step document link + ticket classification (backend)
Backend half of the Client Detail redesign follow-ups (#7, #6, #4).

#7 — quotation step links its document
- project_milestone gains a nullable `document_id`, landed on BOTH engines:
  a PRAGMA-guarded ALTER in db.ts migrate() and pg migration 016.
- setMilestone(..., doneOn?, paymentId?, documentId?) validates the document
  belongs to the same client as the project (mirroring the paymentId check),
  stores it on tick and clears it on untick; Milestone gains `documentId`.
- POST /client-modules/:id/milestones accepts `documentId`.
- migrateOnboardingTemplates carries document_id across the template swap so a
  linked quotation is not lost.

#6 — ticket classification
- ticket gains `type TEXT NOT NULL DEFAULT 'service'` (db.ts SCHEMA + guarded
  ALTER, pg migration 017); imported APEX rows fall to the default.
- Config over code: the list is the `ticket.types` setting (seeded on first
  boot) resolved by ticketTypes(), with TICKET_TYPE_FALLBACK in code.
- GET /tickets/types; create/update accept + validate `type`; list rows carry
  it and GET /tickets?type= filters (blank/omitted = every type).

#4 — cleanup
- Refreshed the stale file comment atop repos-milestones.ts and the
  project_milestone comment in db.ts: both now describe the composed
  front + per-module tail model (they still described the old flat template).
- Migration tests for the interim keys advance_payment / balance_payment ->
  payment_received, plus the document-link carry.

npm run typecheck clean; npm test 473 passed / 5 skipped (was 453/5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 357735e04d feat(client-detail): quotation step links a document; tickets get a type
Frontend half of two Client-Detail follow-ups, plus the StatusLine doc refresh.

Quotation step -> document link:
- Milestone gains documentId; setMilestone threads an optional trailing
  documentId (contract: doneOn, paymentId, documentId).
- Ticking `quotation_sent` on the status line now opens DocumentPickerDialog
  instead of stamping a bare date. It mirrors PaymentPickerDialog exactly —
  same state shape in ClientDetail, same Dialog props — and lists the client's
  QUOTATION/PROFORMA documents straight from the already-loaded ledger (no
  extra fetch), with "Create new document…" handing off to the composer.

Ticket classification:
- Ticket gains `type`; getTicketTypes() reads the `ticket.types` setting
  (config over code) with a code fallback only for the load window.
- New-ticket dialog gets a Type select; Tickets page gets Type filter chips
  wired to the ?type= list param and a Type column.
- Every ticket's module cell deep-links to /clients/<id>?tab=modules, and each
  Client 360 module block gets "Raise ticket for this module", which opens the
  new-ticket dialog pre-filled with that client + module code.

Also refreshes the now-stale StatusLine JSDoc so it describes all three
hand-off dialogs — payment, credentials (account_creation) and document.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 88a19f154e chore(client-detail): drop the dead .cdr-out .ofoot CSS rule
The outstanding panel never rendered a footer row — the rule has had no
matching markup since the panel landed. Removing it keeps app.css honest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 83633c86ee feat(dashboard): add Onboarding stalled tile
Surfaces stalledProjects() on the Dashboard bento grid, listing active
projects parked past the stall threshold; each row jumps to the
client's Modules tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise d6faf0d5bd merge: Client Detail redesign (D38) — composed onboarding pipeline, status line, payments context, mobile fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

# Conflicts:
#	STATUS.md
#	docs/06-DECISIONS.md
1 day ago
Thomas Joise 4e747427d2 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:<CODE>),
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) <noreply@anthropic.com>
1 day ago
Thomas Joise 45cfb83d03 chore(deploy): apply prod DB URL + onboarding-pipeline deploy script
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 371cdb1c69 fix(ui): drop redundant table-scroll CSS, rely on existing app.css mechanism
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise db8ec9c546 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) <noreply@anthropic.com>
2 days ago
Thomas Joise 0050f73a70 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 <table> 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) <noreply@anthropic.com>
2 days ago
Thomas Joise 025b334b09 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) <noreply@anthropic.com>
2 days ago
Thomas Joise 78ed947422 feat(payments): outstanding panel + settled-to-document column
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise f1a7eac9dd 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) <noreply@anthropic.com>
2 days ago
Thomas Joise e8f00503a4 feat(client-detail): per-module documents list + bulk SMS purchase action
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise e5ed3517dc feat(client-detail): onboarding as horizontal status line with payment-step linking
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise 343fa94efd docs(client-detail): D38 redesign decision + STATUS in-progress note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise 8c653eb41a fix(payments): exclude draft proformas from outstanding (owner decision)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise 9d3087c1fe feat(client-detail): module details as 50/50 report with single edit per group
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise d05c7ee31b 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.
2 days ago
Thomas Joise 67f69fc999 style(client-detail): CSS for 50/50 sheet, status line, outstanding panel
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise 75fb41c366 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) <noreply@anthropic.com>
2 days ago
Thomas Joise 195d821bef feat(sms): bulk SMS purchase proforma + document source tagging (renewal vs top-up)
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) <noreply@anthropic.com>
2 days ago
Thomas Joise fdf31b4dbd feat(payments): ledger exposes outstanding docs + per-payment allocations
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) <noreply@anthropic.com>
2 days ago
Thomas Joise 4b2e44c3ea 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) <noreply@anthropic.com>
2 days ago
Thomas Joise 6bce248c2e 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) <noreply@anthropic.com>
2 days ago
Thomas Joise cd235b622b 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) <noreply@anthropic.com>
2 days ago
Thomas Joise b2c49832d5 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) <noreply@anthropic.com>
2 days ago
Thomas Joise 9ac2ce9d50 feat(onboarding): seed SMS/RTGS/MobileApp onboarding templates
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise 1e77792beb feat(onboarding): resolve milestone template per module (config over code)
Widens milestoneTemplate(db, moduleCode?) to resolve project.milestone_template:<CODE>
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) <noreply@anthropic.com>
2 days ago
Thomas Joise 6bc92bace3 docs(client-detail): redesign spec + backend/frontend implementation plans
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago

@ -47,13 +47,28 @@ 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 owner **Login history** screen; an invited user is sent to Profile to change their default
password on first sign-in. password on first sign-in.
**Since 2026-07-20 (D38).** Client 360 **Modules + Payments redesign**. Onboarding is now a
**composed status line** — a shared lead front (Enquiry → Visit/meeting scheduled → Quotation sent
→ Quotation approved) plus a per-module tail (SMS / RTGS / Mobile App), both owner-editable config —
whose ticks **auto-drive `client_module.status`** (quotation approved→ordered, installation→installed,
training→trained, go-live→live; never downgrades). A **Payment received** step links to a real
payment, and the **Account creation** step prompts for the module credentials — which are no longer
demanded when a module is added. Module details render as a read-only **50/50 sheet** (Access |
Details) with one Edit per group; each module block lists **its own documents** (Quote / Renewal /
**Bulk top-up** / Invoice) and SMS can raise a **bulk-credit purchase** proforma. The Payments tab
gains an **Outstanding** panel (issued invoices with a balance + sent/accepted proformas; drafts
excluded) and a *settled → document* column — the money engine is unchanged. Also: a
**stalled-onboarding** query, an owner **onboarding-template editor**, and mobile fixes (the record
header stacks instead of overlapping the title).
## Verification state ## Verification state
| Check | Result | | Check | Result |
| --- | --- | | --- | --- |
| `npm install` | clean | | `npm install` | clean |
| `npm run typecheck` (root + workspaces) | clean, no errors | | `npm run typecheck` (root + workspaces) | clean, no errors |
| `npm test` (`vitest run`) | **416 tests pass across 85 files** (5 Postgres-gated skipped) | | `npm test` (`vitest run`) | **473 tests pass across 94 files** (5 Postgres-gated skipped) |
The 416 figure is the live `vitest run` count measured 2026-07-19; the suite has The 416 figure is the live `vitest run` count measured 2026-07-19; the suite has
grown from the D18 go-live cluster through the D20–D33 work (tickets, module fields, grown from the D18 go-live cluster through the D20–D33 work (tickets, module fields,

@ -217,11 +217,13 @@ export interface Payment {
id: string; clientId: string; receivedOn: string; mode: PaymentMode id: string; clientId: string; receivedOn: string; mode: PaymentMode
reference: string; amountPaise: number; tdsPaise: number reference: string; amountPaise: number; tdsPaise: number
createdBy: string; createdAt: string createdBy: string; createdAt: string
allocations: { docNo: string; amountPaise: number }[]
} }
export interface Ledger { export interface Ledger {
documents: Doc[]; payments: Payment[]; advancePaise: number documents: Doc[]; payments: Payment[]; advancePaise: number
modulePaid: { moduleId: string; billedPaise: number; settledPaise: number }[] modulePaid: { moduleId: string; billedPaise: number; settledPaise: number }[]
outstanding: { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number }[]
} }
// ---------- typed calls ---------- // ---------- typed calls ----------
@ -326,6 +328,20 @@ export const addPrice = (moduleId: string, body: Record<string, unknown>): Promi
apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`, { method: 'POST', body: JSON.stringify(body) }) apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`, { method: 'POST', body: JSON.stringify(body) })
.then((r) => r.prices) .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<TemplateStep[]> =>
apiFetch<{ steps: TemplateStep[] }>('/onboarding-template/front').then((r) => r.steps)
export const setFrontTemplate = (steps: TemplateStep[]): Promise<TemplateStep[]> =>
apiFetch<{ steps: TemplateStep[] }>('/onboarding-template/front', { method: 'PUT', body: JSON.stringify({ steps }) })
.then((r) => r.steps)
export const getModuleTemplate = (code: string): Promise<TemplateStep[]> =>
apiFetch<{ steps: TemplateStep[] }>(`/modules/${encodeURIComponent(code)}/onboarding-template`).then((r) => r.steps)
export const setModuleTemplate = (code: string, steps: TemplateStep[]): Promise<TemplateStep[]> =>
apiFetch<{ steps: TemplateStep[] }>(`/modules/${encodeURIComponent(code)}/onboarding-template`, {
method: 'PUT', body: JSON.stringify({ steps }),
}).then((r) => r.steps)
export const getClientModules = (clientId: string): Promise<ClientModule[]> => export const getClientModules = (clientId: string): Promise<ClientModule[]> =>
apiFetch<{ clientModules: ClientModule[] }>(`/clients/${clientId}/modules`).then((r) => r.clientModules) apiFetch<{ clientModules: ClientModule[] }>(`/clients/${clientId}/modules`).then((r) => r.clientModules)
export const assignClientModule = (clientId: string, body: Record<string, unknown>): Promise<ClientModule> => export const assignClientModule = (clientId: string, body: Record<string, unknown>): Promise<ClientModule> =>
@ -360,18 +376,32 @@ export const revealModuleSecret = (id: string, key: string): Promise<string> =>
// ---------- onboarding milestones (D22) ---------- // ---------- onboarding milestones (D22) ----------
/** One checklist step on a project (client_module). Ticking stamps `doneOn`. */ /** One checklist step on a project (client_module). Ticking stamps `doneOn`; the
export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number } * advance/balance payment steps additionally carry `paymentId`, linking a real
* payment from the client's ledger, and the quotation step carries `documentId`,
* linking the quotation/proforma that was actually sent. */
export interface Milestone {
key: string; label: string; done: boolean; doneOn: string | null; sort: number
paymentId: string | null; documentId: string | null
}
/** The project's checklist; the server auto-seeds the standard template on first read. */ /** The project's checklist; the server auto-seeds the standard template on first read. */
export const getMilestones = (clientModuleId: string): Promise<Milestone[]> => export const getMilestones = (clientModuleId: string): Promise<Milestone[]> =>
apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`).then((r) => r.milestones) 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);
* `documentId` links a real document to a step (quotation_sent). Un-ticking clears both. */
export const setMilestone = ( export const setMilestone = (
clientModuleId: string, key: string, done: boolean, doneOn?: string, clientModuleId: string, key: string, done: boolean, doneOn?: string, paymentId?: string, documentId?: string,
): Promise<Milestone[]> => ): Promise<Milestone[]> =>
apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`, { 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 } : {}),
...(documentId !== undefined ? { documentId } : {}),
}),
}).then((r) => r.milestones) }).then((r) => r.milestones)
/** Add a project-specific step beyond the standard template. */ /** Add a project-specific step beyond the standard template. */
export const addMilestone = (clientModuleId: string, label: string): Promise<Milestone[]> => export const addMilestone = (clientModuleId: string, label: string): Promise<Milestone[]> =>
@ -403,6 +433,11 @@ export const bulkSetMilestone = (
body: JSON.stringify({ clientModuleIds, key, done, ...(doneOn !== undefined ? { doneOn } : {}) }), body: JSON.stringify({ clientModuleIds, key, done, ...(doneOn !== undefined ? { doneOn } : {}) }),
}).then((r) => ({ updated: r.updated })) }).then((r) => ({ updated: r.updated }))
/** Active projects parked past the stall threshold (dashboard "Onboarding stalled" tile)
* same row shape as PendingProject; server defaults the threshold from `project.stall_days`. */
export const getStalledProjects = (): Promise<PendingProject[]> =>
apiFetch<{ projects: PendingProject[] }>('/projects/stalled').then((r) => r.projects)
// ---------- ticket desk + client branches (D20 APEX parity) ---------- // ---------- ticket desk + client branches (D20 APEX parity) ----------
export type TicketStatus = 'open' | 'in_progress' | 'waiting' | 'closed' | 'dropped' export type TicketStatus = 'open' | 'in_progress' | 'waiting' | 'closed' | 'dropped'
@ -412,6 +447,8 @@ export interface Ticket {
id: string; clientId: string; clientName: string id: string; clientId: string; clientName: string
branchId: string | null; branchName: string | null branchId: string | null; branchName: string | null
moduleCode: string | null; kind: string; description: string moduleCode: string | null; kind: string; description: string
/** Classification key from the `ticket.types` setting (service / module / …). */
type: string
status: TicketStatus status: TicketStatus
assignedTo: string | null; assignedName: string | null assignedTo: string | null; assignedName: string | null
onlineOffline: string | null onlineOffline: string | null
@ -419,6 +456,20 @@ export interface Ticket {
createdBy: string; createdAt: string; source: string createdBy: string; createdAt: string; source: string
} }
/** One ticket classification (config over code — the `ticket.types` setting row). */
export interface TicketType { key: string; label: string }
/** Fallback used only while the setting-backed list is loading / unreachable
* the server's `ticket.types` setting row stays the source of truth. */
export const TICKET_TYPE_FALLBACK: TicketType[] = [
{ key: 'service', label: 'Service' }, { key: 'module', label: 'Module issue' },
{ key: 'modification', label: 'Modification' }, { key: 'amc', label: 'AMC' },
{ key: 'other', label: 'Other' },
]
export const getTicketTypes = (): Promise<TicketType[]> =>
apiFetch<{ ok: boolean; types: TicketType[] }>('/tickets/types').then((r) => r.types)
export interface TicketsPage { export interface TicketsPage {
tickets: Ticket[]; total: number; page: number; pageSize: number tickets: Ticket[]; total: number; page: number; pageSize: number
/** Whole-desk per-status counts (chips) + every kind seen so far (datalist suggestions). */ /** Whole-desk per-status counts (chips) + every kind seen so far (datalist suggestions). */
@ -431,11 +482,14 @@ export interface TicketsPage {
export const getTickets = ( export const getTickets = (
opts: { opts: {
status?: TicketStatus; mine?: boolean; clientId?: string; module?: string status?: TicketStatus; mine?: boolean; clientId?: string; module?: string
/** Classification filter — omit / blank means every type. */
type?: string
overdue?: boolean; q?: string; page?: number; pageSize?: number overdue?: boolean; q?: string; page?: number; pageSize?: number
} = {}, } = {},
): Promise<TicketsPage> => { ): Promise<TicketsPage> => {
const p = new URLSearchParams() const p = new URLSearchParams()
if (opts.status !== undefined) p.set('status', opts.status) if (opts.status !== undefined) p.set('status', opts.status)
if (opts.type !== undefined && opts.type !== '') p.set('type', opts.type)
if (opts.mine === true) p.set('mine', '1') if (opts.mine === true) p.set('mine', '1')
if (opts.clientId !== undefined && opts.clientId !== '') p.set('clientId', opts.clientId) if (opts.clientId !== undefined && opts.clientId !== '') p.set('clientId', opts.clientId)
if (opts.module !== undefined && opts.module !== '') p.set('module', opts.module) if (opts.module !== undefined && opts.module !== '') p.set('module', opts.module)
@ -447,13 +501,13 @@ export const getTickets = (
return apiFetch<TicketsPage>(`/tickets${q === '' ? '' : `?${q}`}`) return apiFetch<TicketsPage>(`/tickets${q === '' ? '' : `?${q}`}`)
} }
export const createTicket = (body: { export const createTicket = (body: {
clientId: string; branchId?: string; moduleCode?: string; kind?: string clientId: string; branchId?: string; moduleCode?: string; kind?: string; type?: string
description?: string; onlineOffline?: string; assignedTo?: string description?: string; onlineOffline?: string; assignedTo?: string
}): Promise<Ticket> => }): Promise<Ticket> =>
apiFetch<{ ticket: Ticket }>('/tickets', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.ticket) apiFetch<{ ticket: Ticket }>('/tickets', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.ticket)
/** assignedTo: null clears the assignment; closing stamps closedOn server-side. */ /** assignedTo: null clears the assignment; closing stamps closedOn server-side. */
export const patchTicket = (id: string, body: { export const patchTicket = (id: string, body: {
status?: TicketStatus; assignedTo?: string | null; kind?: string; description?: string status?: TicketStatus; assignedTo?: string | null; kind?: string; type?: string; description?: string
moduleCode?: string | null; branchId?: string | null; onlineOffline?: string | null moduleCode?: string | null; branchId?: string | null; onlineOffline?: string | null
}): Promise<Ticket> => }): Promise<Ticket> =>
apiFetch<{ ticket: Ticket }>(`/tickets/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.ticket) apiFetch<{ ticket: Ticket }>(`/tickets/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.ticket)
@ -955,6 +1009,11 @@ export const getRenewals = (from?: string, to?: string): Promise<RenewalRow[]> =
} }
export const generateModuleRenewalQuote = (clientModuleId: string): Promise<Doc> => export const generateModuleRenewalQuote = (clientModuleId: string): Promise<Doc> =>
apiFetch<{ document: Doc }>(`/client-modules/${clientModuleId}/renewal-quote`, { method: 'POST', body: '{}' }).then((r) => r.document) 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<Doc> =>
apiFetch<{ document: Doc }>(`/client-modules/${clientModuleId}/bulk-sms-quote`, {
method: 'POST', body: JSON.stringify({ smsQty }),
}).then((r) => r.document)
// D27: owner MIS cockpit. // D27: owner MIS cockpit.
export interface MisCockpit { export interface MisCockpit {

@ -483,3 +483,40 @@ table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inse
} }
.no-print { display: none !important; } .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; }
/* 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); }

File diff suppressed because it is too large Load Diff

@ -2,7 +2,7 @@ import { useState, type ReactNode } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { formatINR } from '@sims/domain' import { formatINR } from '@sims/domain'
import { Badge, DataTable, EmptyState, ErrorState, Notice, Skeleton, StatCard, Stats } from '@sims/ui' import { Badge, DataTable, EmptyState, ErrorState, Notice, Skeleton, StatCard, Stats } from '@sims/ui'
import { displayName, getDashboard, getSmsBalances, isManagerial } from '../api' import { displayName, getDashboard, getSmsBalances, getStalledProjects, isManagerial } from '../api'
import { QueueActions, RULE_LABEL, reminderTone } from '../components/ReminderQueue' import { QueueActions, RULE_LABEL, reminderTone } from '../components/ReminderQueue'
import { useData } from './Clients' import { useData } from './Clients'
@ -34,6 +34,7 @@ export function Dashboard() {
const [mine, setMine] = useState(false) const [mine, setMine] = useState(false)
const dash = useData(() => getDashboard(mine), [mine]) const dash = useData(() => getDashboard(mine), [mine])
const smsLow = useData(getSmsBalances, []) // D28: low-balance alert, seen even without opening the SMS screen const smsLow = useData(getSmsBalances, []) // D28: low-balance alert, seen even without opening the SMS screen
const stalled = useData(getStalledProjects, []) // parked onboarding — active projects with no recent progress
const [err, setErr] = useState('') const [err, setErr] = useState('')
const v = dash.data const v = dash.data
const name = displayName() const name = displayName()
@ -144,6 +145,13 @@ export function Dashboard() {
))} ))}
</Card> </Card>
<Card title="Onboarding stalled" count={stalled.data?.length ?? 0} empty="Nothing stalled." isEmpty={(stalled.data ?? []).length === 0}>
{(stalled.data ?? []).map((p) => (
<Row key={p.clientModuleId} onClick={() => nav(`/clients/${p.clientId}?tab=modules`)}
title={p.clientName} sub={p.moduleName} />
))}
</Card>
<Card title="Follow-ups today" count={v.followUpsToday.length} empty="No follow-ups due." isEmpty={v.followUpsToday.length === 0}> <Card title="Follow-ups today" count={v.followUpsToday.length} empty="No follow-ups due." isEmpty={v.followUpsToday.length === 0}>
{v.followUpsToday.map((f) => ( {v.followUpsToday.map((f) => (
<Row key={f.id} onClick={() => nav(`/clients/${f.clientId}`)} <Row key={f.id} onClick={() => nav(`/clients/${f.clientId}`)}

@ -5,11 +5,11 @@ import {
FormGrid, Notice, PageHeader, Skeleton, Tabs, Toolbar, useToast, FormGrid, Notice, PageHeader, Skeleton, Tabs, Toolbar, useToast,
} from '@sims/ui' } from '@sims/ui'
import { import {
addPrice, assignClientModule, createModule, downloadModuleClientsCsv, getClients, addPrice, assignClientModule, createModule, downloadModuleClientsCsv, getClients, getFrontTemplate,
getModuleClients, getModules, getPrices, notifyModuleClients, patchClientModule, patchModule, getModuleClients, getModuleTemplate, getModules, getPrices, notifyModuleClients, patchClientModule, patchModule,
previewSample, role, previewSample, role, setFrontTemplate, setModuleTemplate,
CLIENT_MODULE_STATUSES, KIND_LABEL, 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' } from '../api'
import { LivePreview } from '../components/LivePreview' import { LivePreview } from '../components/LivePreview'
import { useData } from './Clients' import { useData } from './Clients'
@ -121,6 +121,7 @@ export function Modules() {
onCreated={() => { setCreating(false); modules.reload() }} onCreated={() => { setCreating(false); modules.reload() }}
/> />
)} )}
{isOwner && modules.data !== undefined && <OnboardingTemplatesSection modules={modules.data} />}
</div> </div>
) )
} }
@ -832,3 +833,126 @@ function AddPriceDialog(props: { open: boolean; onClose: () => void; module: Mod
</Dialog> </Dialog>
) )
} }
/**
* 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<string | undefined>()
return (
<section style={{ marginTop: 28 }}>
<h3>Onboarding steps</h3>
<div className="wf-card" style={{ padding: '10px 14px', marginBottom: 14, fontSize: 13, color: 'var(--text-dim)', lineHeight: 1.5 }}>
Every project's onboarding checklist is this shared <strong style={{ color: 'var(--text)' }}>front</strong> followed
by that module's own <strong style={{ color: 'var(--text)' }}>tail</strong> 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.
</div>
<h4>Shared front <span style={{ fontWeight: 400, fontSize: 12, color: 'var(--text-dim)' }}> applies to every module</span></h4>
{front.error !== undefined ? <ErrorState message={front.error} onRetry={front.reload} />
: front.data === undefined ? <Skeleton rows={3} /> : (
<div className="wf-card" style={{ padding: 14, marginBottom: 20 }}>
<StepListEditor
steps={front.data}
onSave={(steps) => setFrontTemplate(steps).then((saved) => { front.reload(); return saved })}
/>
</div>
)}
<h4>Per-module tail</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{props.modules.map((m) => (
<div key={m.id} className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<span><strong>{m.name}</strong> <span className="mono" style={{ fontSize: 12, color: 'var(--text-dim)' }}>{m.code}</span></span>
<Button onClick={() => setExpanded((v) => (v === m.id ? undefined : m.id))}>
{expanded === m.id ? 'Close' : 'Edit onboarding steps'}
</Button>
</div>
{expanded === m.id && <ModuleTailEditor code={m.code} />}
</div>
))}
</div>
</section>
)
}
/** 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 (
<div style={{ marginTop: 10 }}>
{tail.error !== undefined ? <ErrorState message={tail.error} onRetry={tail.reload} />
: tail.data === undefined ? <Skeleton rows={3} /> : (
<StepListEditor
steps={tail.data}
onSave={(steps) => setModuleTemplate(props.code, steps).then((saved) => { tail.reload(); return saved })}
/>
)}
</div>
)
}
/**
* 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<TemplateStep[]> }) {
const toast = useToast()
const [rows, setRows] = useState<TemplateStep[]>(() => props.steps.map((s) => ({ ...s })))
const [dirty, setDirty] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | undefined>()
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{rows.map((r, i) => (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input className="wf" style={{ flex: 1 }} placeholder="Step label…"
value={r.label} onChange={(e) => setLabel(i, e.target.value)} />
<Button disabled={busy || i === 0} aria-label="Move step up" onClick={() => move(i, -1)}></Button>
<Button disabled={busy || i === rows.length - 1} aria-label="Move step down" onClick={() => move(i, 1)}></Button>
<Button tone="danger" disabled={busy} aria-label="Remove step" onClick={() => removeRow(i)}></Button>
</div>
))}
<Toolbar>
<Button disabled={busy} onClick={addRow}>+ Add step</Button>
<Button tone="primary" disabled={busy || !dirty} onClick={save}>{busy ? 'Saving…' : 'Save'}</Button>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}

@ -5,9 +5,9 @@ import {
FormField, FormGrid, Notice, PageHeader, Skeleton, Toolbar, useToast, FormField, FormGrid, Notice, PageHeader, Skeleton, Toolbar, useToast,
} from '@sims/ui' } from '@sims/ui'
import { import {
createTicket, getBranches, getClients, getEmployees, getModules, getTickets, patchTicket, staffId, createTicket, getBranches, getClients, getEmployees, getModules, getTicketTypes, getTickets, patchTicket, staffId,
TICKET_STATUSES, TICKET_STATUSES, TICKET_TYPE_FALLBACK,
type Branch, type Client, type Employee, type Ticket, type TicketStatus, type Branch, type Client, type Employee, type Ticket, type TicketStatus, type TicketType,
} from '../api' } from '../api'
import { useData, useDebounced } from './Clients' import { useData, useDebounced } from './Clients'
@ -25,6 +25,14 @@ export const TICKET_TONE: Record<TicketStatus, 'ok' | 'warn' | 'err' | 'accent'
open: 'accent', in_progress: 'warn', waiting: undefined, closed: 'ok', dropped: 'err', open: 'accent', in_progress: 'warn', waiting: undefined, closed: 'ok', dropped: 'err',
} }
/** Classification key label, resolved against the setting-backed list (config over
* code); an unknown key shows verbatim rather than disappearing. */
export function ticketTypeLabel(types: TicketType[], key: string): string {
const k = key ?? ''
if (k === '') return '—'
return types.find((t) => t.key === k)?.label ?? k
}
/** Whole days between an opened-on date (YYYY-MM-DD) and today. */ /** Whole days between an opened-on date (YYYY-MM-DD) and today. */
function ageDays(openedOn: string): number { function ageDays(openedOn: string): number {
const opened = Date.parse(`${openedOn}T00:00:00Z`) const opened = Date.parse(`${openedOn}T00:00:00Z`)
@ -124,32 +132,44 @@ export function TicketAssignSelect(props: {
* New-ticket dialog. Client via type-ahead (Modules assign pattern) unless the * New-ticket dialog. Client via type-ahead (Modules assign pattern) unless the
* caller pre-picks one (Client 360 tab); branches load once a client is picked; * caller pre-picks one (Client 360 tab); branches load once a client is picked;
* module and kind are free text with suggestions (config over code kinds come * module and kind are free text with suggestions (config over code kinds come
* from history via the list response). * from history via the list response). The Type select is the classification
* (`ticket.types` setting row, code fallback while it loads); `initialModuleCode`
* pre-fills the module so Client 360's per-module "Raise ticket" lands here ready.
*/ */
export function NewTicketDialog(props: { export function NewTicketDialog(props: {
open: boolean; onClose: () => void; onCreated: () => void open: boolean; onClose: () => void; onCreated: () => void
kinds: string[] kinds: string[]
/** Pre-picked client (Client 360) — hides the type-ahead. */ /** Pre-picked client (Client 360) — hides the type-ahead. */
initialClient?: { id: string; code: string; name: string } initialClient?: { id: string; code: string; name: string }
/** Pre-filled module code (Client 360 per-module "Raise ticket for this module"). */
initialModuleCode?: string
}) { }) {
const toast = useToast() const toast = useToast()
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [hits, setHits] = useState<Client[]>([]) const [hits, setHits] = useState<Client[]>([])
const [pick, setPick] = useState<{ id: string; code: string; name: string } | undefined>() const [pick, setPick] = useState<{ id: string; code: string; name: string } | undefined>()
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
const [f, setF] = useState({ branchId: '', moduleCode: '', kind: '', description: '', onlineOffline: '' }) const [f, setF] = useState({ branchId: '', moduleCode: '', kind: '', type: '', description: '', onlineOffline: '' })
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
// Live catalog feeds the suggestions — future modules appear with zero code changes. // Live catalog feeds the suggestions — future modules appear with zero code changes.
const catalog = useData(getModules, []) const catalog = useData(getModules, [])
// Classification list is a DB setting; the code fallback only covers the load window.
const types = useData(getTicketTypes, [])
const typeOptions = types.data ?? TICKET_TYPE_FALLBACK
// Blank state means "whatever the list offers first" — never a hardcoded key.
const typeValue = f.type !== '' ? f.type : (typeOptions[0]?.key ?? '')
useEffect(() => { useEffect(() => {
if (!props.open) return if (!props.open) return
setError(undefined) setError(undefined)
setQ(''); setHits([]); setPick(props.initialClient) setQ(''); setHits([]); setPick(props.initialClient)
setF({ branchId: '', moduleCode: '', kind: '', description: '', onlineOffline: '' }) setF({
branchId: '', moduleCode: props.initialModuleCode ?? '', kind: '', type: '',
description: '', onlineOffline: '',
})
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.open, props.initialClient?.id]) }, [props.open, props.initialClient?.id, props.initialModuleCode])
useEffect(() => { useEffect(() => {
if (pick === undefined) { setBranches([]); return } if (pick === undefined) { setBranches([]); return }
@ -174,6 +194,7 @@ export function NewTicketDialog(props: {
...(f.branchId !== '' ? { branchId: f.branchId } : {}), ...(f.branchId !== '' ? { branchId: f.branchId } : {}),
...(f.moduleCode.trim() !== '' ? { moduleCode: f.moduleCode.trim() } : {}), ...(f.moduleCode.trim() !== '' ? { moduleCode: f.moduleCode.trim() } : {}),
...(f.kind.trim() !== '' ? { kind: f.kind.trim() } : {}), ...(f.kind.trim() !== '' ? { kind: f.kind.trim() } : {}),
...(typeValue !== '' ? { type: typeValue } : {}),
...(f.description.trim() !== '' ? { description: f.description.trim() } : {}), ...(f.description.trim() !== '' ? { description: f.description.trim() } : {}),
...(f.onlineOffline !== '' ? { onlineOffline: f.onlineOffline } : {}), ...(f.onlineOffline !== '' ? { onlineOffline: f.onlineOffline } : {}),
}) })
@ -234,6 +255,11 @@ export function NewTicketDialog(props: {
value={f.moduleCode} onChange={set('moduleCode')} value={f.moduleCode} onChange={set('moduleCode')}
/> />
</FormField> </FormField>
<FormField label="Type">
<select className="wf" value={typeValue} onChange={set('type')}>
{typeOptions.map((t) => <option key={t.key} value={t.key}>{t.label}</option>)}
</select>
</FormField>
<FormField label="Kind"> <FormField label="Kind">
<input <input
className="wf" list="hq-ticket-kinds" placeholder="e.g. NEW WORK" className="wf" list="hq-ticket-kinds" placeholder="e.g. NEW WORK"
@ -273,6 +299,7 @@ export function NewTicketDialog(props: {
export function Tickets() { export function Tickets() {
const [status, setStatus] = useState('open') const [status, setStatus] = useState('open')
const [scope, setScope] = useState<'all' | 'mine'>('all') const [scope, setScope] = useState<'all' | 'mine'>('all')
const [type, setType] = useState('all')
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [err, setErr] = useState('') const [err, setErr] = useState('')
@ -280,6 +307,9 @@ export function Tickets() {
const me = staffId() const me = staffId()
const nav = useNavigate() const nav = useNavigate()
const employees = useData(() => getEmployees(), []) const employees = useData(() => getEmployees(), [])
// Classification chips come from the `ticket.types` setting (config over code).
const types = useData(getTicketTypes, [])
const typeOptions = types.data ?? TICKET_TYPE_FALLBACK
// Debounced so typing in the search box doesn't fire a server request per keystroke. // Debounced so typing in the search box doesn't fire a server request per keystroke.
const qDebounced = useDebounced(q) const qDebounced = useDebounced(q)
@ -287,10 +317,11 @@ export function Tickets() {
() => getTickets({ () => getTickets({
...(status === 'overdue' ? { overdue: true } : status !== 'all' ? { status: status as TicketStatus } : {}), ...(status === 'overdue' ? { overdue: true } : status !== 'all' ? { status: status as TicketStatus } : {}),
...(scope === 'mine' ? { mine: true } : {}), ...(scope === 'mine' ? { mine: true } : {}),
...(type !== 'all' ? { type } : {}),
...(qDebounced !== '' ? { q: qDebounced } : {}), ...(qDebounced !== '' ? { q: qDebounced } : {}),
page, pageSize: PAGE_SIZE, page, pageSize: PAGE_SIZE,
}), }),
[status, scope, qDebounced, page], [status, scope, type, qDebounced, page],
) )
const data = list.data const data = list.data
@ -320,6 +351,14 @@ export function Tickets() {
active={status} active={status}
onChange={(k) => { setStatus(k); setPage(1) }} onChange={(k) => { setStatus(k); setPage(1) }}
/> />
<FilterChips
chips={[
{ key: 'all', label: 'All types' },
...typeOptions.map((t) => ({ key: t.key, label: t.label })),
]}
active={type}
onChange={(k) => { setType(k); setPage(1) }}
/>
{me !== '' && ( {me !== '' && (
<FilterChips <FilterChips
chips={[{ key: 'all', label: 'All' }, { key: 'mine', label: 'Mine' }]} chips={[{ key: 'all', label: 'All' }, { key: 'mine', label: 'Mine' }]}
@ -353,6 +392,7 @@ export function Tickets() {
{ key: 'client', label: 'Client' }, { key: 'client', label: 'Client' },
{ key: 'branch', label: 'Branch' }, { key: 'branch', label: 'Branch' },
{ key: 'module', label: 'Module', mono: true }, { key: 'module', label: 'Module', mono: true },
{ key: 'type', label: 'Type' },
{ key: 'kind', label: 'Kind' }, { key: 'kind', label: 'Kind' },
{ key: 'desc', label: 'Description' }, { key: 'desc', label: 'Description' },
{ key: 'assigned', label: 'Assigned' }, { key: 'assigned', label: 'Assigned' },
@ -364,7 +404,12 @@ export function Tickets() {
age: <TicketAge openedOn={t.openedOn} status={t.status} slaDays={data?.slaDays ?? 7} />, age: <TicketAge openedOn={t.openedOn} status={t.status} slaDays={data?.slaDays ?? 7} />,
client: <Link to={`/clients/${t.clientId}`}>{t.clientName}</Link>, client: <Link to={`/clients/${t.clientId}`}>{t.clientName}</Link>,
branch: t.branchName ?? '—', branch: t.branchName ?? '—',
module: t.moduleCode ?? '—', // The module cell jumps straight to that client's Modules tab, where the
// project's status line, service data and documents all live.
module: t.moduleCode !== null && t.moduleCode !== ''
? <Link to={`/clients/${t.clientId}?tab=modules`}>{t.moduleCode}</Link>
: '—',
type: ticketTypeLabel(typeOptions, t.type),
kind: t.kind !== '' ? t.kind : '—', kind: t.kind !== '' ? t.kind : '—',
desc: <TicketDescription text={t.description} />, desc: <TicketDescription text={t.description} />,
assigned: employees.data !== undefined assigned: employees.data !== undefined

@ -284,25 +284,21 @@ export function InteractionDialog(props: {
export function AssignModuleForm(props: { export function AssignModuleForm(props: {
modules: Module[] modules: Module[]
onAssign: (moduleId: string, kind: Kind, creds?: { username: string; password: string }) => void onAssign: (moduleId: string, kind: Kind) => void
}) { }) {
const [moduleId, setModuleId] = useState('') const [moduleId, setModuleId] = useState('')
const mod = props.modules.find((m) => m.id === moduleId) const mod = props.modules.find((m) => m.id === moduleId)
const [kind, setKind] = useState<Kind | ''>('') const [kind, setKind] = useState<Kind | ''>('')
const [username, setUsername] = useState('') const ready = moduleId !== '' && kind !== ''
const [password, setPassword] = useState('') const reset = () => { setModuleId(''); setKind('') }
// 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('') }
return ( return (
<div className="wf-card" style={{ padding: '10px 14px', marginBottom: 10 }}> <div className="wf-card" style={{ padding: '10px 14px', marginBottom: 10 }}>
<Toolbar> <Toolbar>
<select <select
className="wf" value={moduleId} className="wf" value={moduleId}
onChange={(e) => { setModuleId(e.target.value); setKind(''); setUsername(''); setPassword('') }} onChange={(e) => { setModuleId(e.target.value); setKind('') }}
> >
<option value="">Assign module</option> <option value="">Add a module</option>
{props.modules.filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)} {props.modules.filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select> </select>
{mod !== undefined && ( {mod !== undefined && (
@ -311,30 +307,20 @@ export function AssignModuleForm(props: {
{mod.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)} {mod.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select> </select>
)} )}
{needsCreds && (
<>
<input className="wf mono" style={{ width: 150 }} placeholder="SMS username *" aria-label="SMS username"
value={username} onChange={(e) => setUsername(e.target.value)} />
<input className="wf mono" style={{ width: 150 }} placeholder="SMS password *" aria-label="SMS password"
value={password} onChange={(e) => setPassword(e.target.value)} />
</>
)}
<Button <Button
tone="primary" disabled={!ready} tone="primary" disabled={!ready}
onClick={() => { onClick={() => {
if (!ready) return if (!ready) return
props.onAssign(moduleId, kind as Kind, needsCreds ? { username: username.trim(), password: password.trim() } : undefined) props.onAssign(moduleId, kind as Kind)
reset() reset()
}} }}
> >
Assign Add a module
</Button> </Button>
</Toolbar> </Toolbar>
{needsCreds && ( <span style={{ fontSize: 12, color: 'var(--text-dim)' }}>
<span style={{ fontSize: 12, color: 'var(--text-dim)' }}> Starts the module at "quoted" and opens its onboarding status line.
SMS needs the provider gateway login username and password are required so the balance can be pulled. </span>
</span>
)}
</div> </div>
) )
} }
@ -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 (
<input
type="date" className="wf" style={{ width: 140 }}
value={props.cm[props.field] ?? ''}
onChange={(e) => {
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 * Record a payment against the client amounts entered in rupees, stored in
* integer paise via fromRupees. Shared with DocumentView's "Record payment". * integer paise via fromRupees. Shared with DocumentView's "Record payment".

@ -0,0 +1,80 @@
// One-off deploy step for the Client Detail redesign: writes the live onboarding-template
// settings with the composed quotation-led pipeline (shared front + per-module tail), then runs
// the tick-preserving migration so existing SMS/RTGS/MobileApp projects adopt the new steps.
// Idempotent ONLY as long as the templates below haven't been owner-edited since: each setting
// is written only if it is still ABSENT, so a plain re-run never clobbers an owner's edit made
// in the Modules onboarding-template editor. Pass --force to unconditionally overwrite every
// template with the settings below — this DOES clobber owner edits, use with care.
// Run from repo root:
// npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts [--force]
import { openDb } from '../src/db'
import { openPgDb } from '../src/db-pg'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
import { getSetting, setSetting } from '../src/repos-reminders'
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<string, { key: string; label: string }[]> = {
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<void> {
const pgUrl = process.env['DATABASE_URL'] ?? ''
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
const force = process.argv.includes('--force')
// Audited via setSetting (append-only-audit rule) and, unless --force, skips any key
// that's already set — so an owner's edit in the Modules onboarding-template editor is
// left alone on a re-run instead of being silently clobbered.
const upsert = async (key: string, list: unknown): Promise<void> => {
if (!force && (await getSetting(db, key)) !== null) {
// eslint-disable-next-line no-console
console.log(`Skipped ${key} — already set (pass --force to overwrite an owner edit).`)
return
}
await setSetting(db, 'system', 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 step done: 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, ${res.preserved} custom step(s) preserved.`)
}
main().catch((e) => { console.error(e); process.exit(1) })

@ -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, ${res.preserved} custom step(s) preserved.`)
}
main().catch((e) => { console.error(e); process.exit(1) })

@ -21,7 +21,7 @@ import {
} from './repos-modules' } from './repos-modules'
import { createBranch, listBranches, updateBranch } from './repos-branches' import { createBranch, listBranches, updateBranch } from './repos-branches'
import { import {
createTicket, getTicket, listTickets, ticketCounts, ticketKinds, overdueCount, updateTicket, createTicket, getTicket, listTickets, ticketCounts, ticketKinds, ticketTypes, overdueCount, updateTicket,
TICKET_STATUSES, type CreateTicketInput, type TicketPatch, type TicketStatus, TICKET_STATUSES, type CreateTicketInput, type TicketPatch, type TicketStatus,
} from './repos-tickets' } from './repos-tickets'
import { import {
@ -60,7 +60,7 @@ import {
import { pullMonthlyCosts } from './aws-costs' import { pullMonthlyCosts } from './aws-costs'
import { clientProfitability, duesAging, gstSummary, moduleRevenue, type DateRange } from './repos-reports' import { clientProfitability, duesAging, gstSummary, moduleRevenue, type DateRange } from './repos-reports'
import { listUsageRateCards, setUsageRateCard } from './repos-rate-card' 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 { misCockpit } from './repos-mis'
import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms' import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms'
import { globalSearch } from './repos-search' import { globalSearch } from './repos-search'
@ -69,8 +69,9 @@ import { clearLegacyCiphertext } from './migrate-plaintext'
import { assertSafeGatewayUrl } from './sms-gateway' import { assertSafeGatewayUrl } from './sms-gateway'
import { makeRateLimiter } from './rate-limit' import { makeRateLimiter } from './rate-limit'
import { import {
addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard, addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, getFrontTemplate, getModuleTail,
projectsPendingMilestone, setMilestone, listProjectMilestones, milestoneBoard, projectsPendingMilestone, setFrontTemplate, setMilestone,
setModuleTail, stalledProjects,
} from './repos-milestones' } from './repos-milestones'
import { commitImport, stageCsv, verificationReport } from './import-apex' import { commitImport, stageCsv, verificationReport } from './import-apex'
import { documentHtml, documentHtmlSample } from './templates' 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 → client roster (spec §10, D-ROSTER) ----------
// Module directory (D28): every client on a module with its service fields, for review. // Module directory (D28): every client on a module with its service fields, for review.
r.get('/modules/:id/directory', requireAuth, async (req, res) => { r.get('/modules/:id/directory', requireAuth, async (req, res) => {
@ -722,8 +746,12 @@ export function apiRouter(
if ((await getClientModule(db, id)) === null) { if ((await getClientModule(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Client module not found' }); return res.status(404).json({ ok: false, error: 'Client module not found' }); return
} }
await ensureProjectMilestones(db, id) // lazily seed for projects created before D22 (imports) try {
res.json({ ok: true, milestones: await listProjectMilestones(db, id) }) await ensureProjectMilestones(db, id) // lazily seed for projects created before D22 (imports)
res.json({ ok: true, milestones: await listProjectMilestones(db, id) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
}) })
r.post('/client-modules/:id/milestones', requireAuth, async (req, res) => { r.post('/client-modules/:id/milestones', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '') const id = String(req.params['id'] ?? '')
@ -731,7 +759,10 @@ export function apiRouter(
res.status(404).json({ ok: false, error: 'Client module not found' }); return res.status(404).json({ ok: false, error: 'Client module not found' }); return
} }
try { 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; documentId?: unknown
}
if (typeof body.label === 'string') { if (typeof body.label === 'string') {
// Add a project-specific milestone. // Add a project-specific milestone.
res.json({ ok: true, milestones: await addProjectMilestone(db, staffId(res), id, body.label) }) res.json({ ok: true, milestones: await addProjectMilestone(db, staffId(res), id, body.label) })
@ -741,7 +772,9 @@ export function apiRouter(
throw new Error('Provide { key, done } to tick a milestone, or { label } to add one') 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, 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,
typeof body.documentId === 'string' ? body.documentId : undefined)
res.json({ ok: true, milestones }) res.json({ ok: true, milestones })
} catch (err) { } catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
@ -754,6 +787,31 @@ export function apiRouter(
r.get('/projects/pending/:key', requireAuth, async (req, res) => { r.get('/projects/pending/:key', requireAuth, async (req, res) => {
res.json({ ok: true, projects: await projectsPendingMilestone(db, String(req.params['key'] ?? '')) }) 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) })
})
// 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) ---------- // ---------- client branches (D20) ----------
r.get('/clients/:id/branches', requireAuth, async (req, res) => { r.get('/clients/:id/branches', requireAuth, async (req, res) => {
@ -795,6 +853,10 @@ export function apiRouter(
}) })
// ---------- ticket desk (D20 — team-visible workbench) ---------- // ---------- ticket desk (D20 — team-visible workbench) ----------
// The classification list (config over code: the `ticket.types` setting, code fallback).
r.get('/tickets/types', requireAuth, async (_req, res) => {
res.json({ ok: true, types: await ticketTypes(db) })
})
r.get('/tickets', requireAuth, async (req, res) => { r.get('/tickets', requireAuth, async (req, res) => {
try { try {
const q = req.query const q = req.query
@ -810,6 +872,7 @@ export function apiRouter(
? { assignedTo: staffId(res) } ? { assignedTo: staffId(res) }
: typeof q['assignedTo'] === 'string' && q['assignedTo'] !== '' ? { assignedTo: q['assignedTo'] } : {}), : typeof q['assignedTo'] === 'string' && q['assignedTo'] !== '' ? { assignedTo: q['assignedTo'] } : {}),
...(typeof q['module'] === 'string' && q['module'] !== '' ? { moduleCode: q['module'] } : {}), ...(typeof q['module'] === 'string' && q['module'] !== '' ? { moduleCode: q['module'] } : {}),
...(typeof q['type'] === 'string' && q['type'] !== '' ? { type: q['type'] } : {}),
...(q['overdue'] === '1' ? { overdueBefore: cutoff } : {}), ...(q['overdue'] === '1' ? { overdueBefore: cutoff } : {}),
...(typeof q['q'] === 'string' && q['q'] !== '' ? { q: q['q'] } : {}), ...(typeof q['q'] === 'string' && q['q'] !== '' ? { q: q['q'] } : {}),
page: Number(q['page'] ?? 1), pageSize: Number(q['pageSize'] ?? 50), page: Number(q['page'] ?? 1), pageSize: Number(q['pageSize'] ?? 50),
@ -1664,6 +1727,16 @@ export function apiRouter(
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) 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) ---------- // ---------- bulk operations (D26 — cleanup at scale, owner-only) ----------
r.post('/clients/bulk', requireAuth, requireOwner, async (req, res) => { r.post('/clients/bulk', requireAuth, requireOwner, async (req, res) => {

@ -253,9 +253,13 @@ CREATE TABLE IF NOT EXISTS client_branch (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, name TEXT NOT NULL, id TEXT PRIMARY KEY, client_id TEXT NOT NULL, name TEXT NOT NULL,
code TEXT, active INTEGER NOT NULL DEFAULT 1 code TEXT, active INTEGER NOT NULL DEFAULT 1
); );
-- D22 onboarding tracker: each client_module (a "project") gets a checklist of milestones -- D22 onboarding tracker: each client_module (a "project") gets a COMPOSED checklist
-- seeded from the dated project.milestone_template setting on assignment. done_on stamps -- a shared "front" (enquiry quotation approved, from the project.milestone_template.front
-- when a milestone is ticked. Reportable across all projects (who is pending go-live). -- setting) followed by a per-module "tail" (project.milestone_template:<CODE>, else the
-- global project.milestone_template, else the code fallback) materialized on assignment.
-- done_on stamps when a step is ticked; payment_id links a payment step to the real payment
-- row and document_id links the quotation step to the quotation/proforma that was sent (both
-- added by migrate(), below). Reportable across all projects (who is pending go-live).
CREATE TABLE IF NOT EXISTS project_milestone ( CREATE TABLE IF NOT EXISTS project_milestone (
id TEXT PRIMARY KEY, client_module_id TEXT NOT NULL, id TEXT PRIMARY KEY, client_module_id TEXT NOT NULL,
key TEXT NOT NULL, label TEXT NOT NULL, key TEXT NOT NULL, label TEXT NOT NULL,
@ -266,6 +270,7 @@ CREATE TABLE IF NOT EXISTS ticket (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, branch_id TEXT, id TEXT PRIMARY KEY, client_id TEXT NOT NULL, branch_id TEXT,
module_code TEXT, -- 'SMS' | 'RTGS' | ... (free text; module codes by convention) module_code TEXT, -- 'SMS' | 'RTGS' | ... (free text; module codes by convention)
kind TEXT NOT NULL DEFAULT '', -- MODIFICATION / SOFTWARE ADDONS / ... (config over code: free text + history suggestions) kind TEXT NOT NULL DEFAULT '', -- MODIFICATION / SOFTWARE ADDONS / ... (config over code: free text + history suggestions)
type TEXT NOT NULL DEFAULT 'service', -- classification key from the ticket.types setting (config over code)
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','in_progress','waiting','closed','dropped')), status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','in_progress','waiting','closed','dropped')),
assigned_to TEXT, -- staff_user.id; NULL = unassigned (team-visible workbench) assigned_to TEXT, -- staff_user.id; NULL = unassigned (team-visible workbench)
@ -516,6 +521,20 @@ function migrate(db: SqliteRaw): void {
if (!modCols2.some((c) => c.name === 'sort_order')) { if (!modCols2.some((c) => c.name === 'sort_order')) {
db.exec(`ALTER TABLE module ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 100`) 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`)
}
// Client Detail redesign task 7: link the quotation step to the quotation/proforma sent.
if (!milestoneCols.some((c) => c.name === 'document_id')) {
db.exec(`ALTER TABLE project_milestone ADD COLUMN document_id TEXT`)
}
// Client Detail redesign task 6: ticket classification (key from the `ticket.types` setting).
const ticketCols = db.prepare(`PRAGMA table_info(ticket)`).all() as { name: string }[]
if (!ticketCols.some((c) => c.name === 'type')) {
db.exec(`ALTER TABLE ticket ADD COLUMN type TEXT NOT NULL DEFAULT 'service'`)
}
rebuildStaffUserRoleCheck(db) rebuildStaffUserRoleCheck(db)
rebuildReminderRuleKindCheck(db) rebuildReminderRuleKindCheck(db)
} }

@ -0,0 +1,117 @@
// 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 + document_id) so onboarding history is not lost. Ad-hoc
// project-specific steps ("+ Add step" / `addProjectMilestone`, `custom_*` keys) are never
// template-mapped — they are preserved verbatim, appended after the template block, so a
// staffer's own checklist entries survive the swap too (see `preserved`, below).
import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit'
import { FRONT_FALLBACK, TAIL_FALLBACK, 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<string, string> = {
installed: 'installation', go_live: 'go_live',
advance_paid: 'payment_received', balance_paid: 'payment_received',
advance_payment: 'payment_received', balance_payment: 'payment_received',
setup_done: 'setup_configuration',
}
/**
* Old generic (pre-redesign) keys that intentionally have NO forward mapping e.g.
* `amc_start` (AMC/renewal is tracked elsewhere now). These are code-level defaults from
* FRONT_FALLBACK/TAIL_FALLBACK, not ad-hoc project-specific steps (those always use
* `addProjectMilestone`'s `custom_*` key pattern), so a ticked one still counts as `dropped`
* never `preserved` same as before this fix.
*/
const KNOWN_LEGACY_KEYS = new Set([...FRONT_FALLBACK, ...TAIL_FALLBACK].map((t) => t.key))
interface OldRow {
key: string; label: string; done: number; done_on: string | null
payment_id: string | null; document_id: string | null
}
/**
* Idempotent: for every client_module whose module has a per-module onboarding template
* (a `project.milestone_template:<CODE>` setting task 2), rebuild its milestone rows
* from that template, carrying mapped ticks (done/done_on/payment_id/document_id) forward under their
* new key. Unmapped old GENERIC ticks (e.g. `amc_start`, or `setup_done` for a module whose
* new template has no matching step) are dropped (and counted). Ad-hoc project-specific rows
* (any key that isn't a template key, isn't in KEY_MAP, and isn't a known old generic key) are
* preserved unchanged and re-appended after the template block (counted in `preserved`).
* 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; preserved: 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, preserved = 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<OldRow>(
`SELECT key, label, done, done_on, payment_id, document_id FROM project_milestone
WHERE client_module_id=? ORDER BY sort`, cmId)
// Build the carried tick-state keyed by NEW key, and set aside ad-hoc custom rows.
const carriedState = new Map<string, OldRow>()
const customRows: OldRow[] = []
let alreadyNew = true
for (const r of oldRows) {
if (!templateKeys.has(r.key)) alreadyNew = false
const isCustom = !templateKeys.has(r.key) && KEY_MAP[r.key] === undefined && !KNOWN_LEGACY_KEYS.has(r.key)
if (isCustom) { customRows.push(r); continue }
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, document_id, sort)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
uuidv7(), cmId, t.key, t.label,
c !== undefined ? 1 : 0, c?.done_on ?? null, c?.payment_id ?? null, c?.document_id ?? null, sort,
)
if (c !== undefined) carried++
sort++
}
// Ad-hoc project-specific steps survive the swap unchanged, appended after the
// template (sort continues past it) so this project's own checklist entries — and
// whatever tick state they carry — are never silently lost.
for (const c of customRows) {
await db.run(
`INSERT INTO project_milestone
(id, client_module_id, key, label, done, done_on, payment_id, document_id, sort)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
uuidv7(), cmId, c.key, c.label, c.done, c.done_on, c.payment_id, c.document_id, sort,
)
sort++
}
preserved += customRows.length
await writeAudit(db, 'system', 'migrate_onboarding', 'client_module', cmId, undefined,
{ module: code, carried: [...carriedState.keys()], preserved: customRows.map((c) => c.key) })
})
}
}
return { projects, carried, dropped, preserved }
}

@ -352,4 +352,19 @@ UPDATE client SET category = CASE
END; 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;`,
},
{
// Client Detail redesign task 7: link the quotation step to the quotation/proforma sent.
id: '016-milestone-document-id',
sql: `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS document_id text;`,
},
{
// Client Detail redesign task 6: ticket classification (key from the `ticket.types` setting).
id: '017-ticket-type',
sql: `ALTER TABLE ticket ADD COLUMN IF NOT EXISTS type text NOT NULL DEFAULT 'service';`,
},
] ]

@ -152,6 +152,8 @@ export interface DraftInput {
terms?: string terms?: string
/** INVOICE only (D18): payment due date; when absent, issue stamps doc_date + terms. */ /** INVOICE only (D18): payment due date; when absent, issue stamps doc_date + terms. */
dueDate?: string 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) 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. */ /** Insert a draft row + created event + audit. Callers wrap in a transaction. */
async function insertDocRow(db: DB, userId: string, a: { async function insertDocRow(db: DB, userId: string, a: {
docType: DocType; clientId: string; refDocId: string | null; docDate: string 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<Doc> { }): Promise<Doc> {
const id = uuidv7() const id = uuidv7()
const t = a.totals const t = a.totals
await db.run( await db.run(
`INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, due_date, status, ref_doc_id, `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, taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise,
payload, created_by, created_at) payload, source, created_by, created_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, a.docType, fyOf(a.docDate), a.clientId, a.docDate, a.dueDate ?? null, a.refDocId, 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, 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 } : {}) await addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {})
const doc = (await getDocument(db, id))! const doc = (await getDocument(db, id))!
await writeAudit(db, userId, 'create', 'document', id, undefined, { 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, { return db.transaction(() => insertDocRow(db, userId, {
docType: prepared.docType, clientId: prepared.clientId, refDocId: null, docType: prepared.docType, clientId: prepared.clientId, refDocId: null,
docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload, docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload,
dueDate: prepared.dueDate ?? null, dueDate: prepared.dueDate ?? null, source: input.source,
})) }))
} }

@ -1,20 +1,85 @@
import { uuidv7 } from '@sims/domain' import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit' import { writeAudit } from './audit'
import { setSetting } from './repos-reminders'
import type { DB } from './db' import type { DB } from './db'
/** /**
* Onboarding tracker (D22). Each client_module (a "project") carries a checklist of * Onboarding tracker (D22), COMPOSED front + tail model. Each client_module (a "project")
* milestones advance paid, setup, install, go-live, balance, AMC seeded from the * carries a checklist built as [...front, ...tail]:
* dated `project.milestone_template` setting. Ticking one stamps the date. Reportable * - the **front** is shared by every module enquiry visit quotation sent quotation
* across the whole book: "who's pending go-live", "who hasn't paid the advance". * approved from the `project.milestone_template.front` setting (code: FRONT_FALLBACK);
* - the **tail** is module-specific the `project.milestone_template:<CODE>` setting, else
* the global `project.milestone_template`, else the code TAIL_FALLBACK.
* Both are owner-editable settings (config over code); a project may also add its own steps.
* Ticking a step stamps the date, can advance the coarse client_module.status (never
* downgrades), and can carry a link: a payment step to the real `payment` row, the quotation
* step to the `document` (quotation / proforma) that was sent. Reportable across the whole
* book: "who's pending go-live", "who hasn't paid".
*/ */
export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number } export interface Milestone {
interface MilestoneRow { key: string; label: string; done: number; done_on: string | null; sort: number } key: string; label: string; done: boolean; doneOn: string | null; sort: number
paymentId: string | null; documentId: string | null
}
interface MilestoneRow {
key: string; label: string; done: number; done_on: string | null; sort: number
payment_id: string | null; document_id: string | null
}
interface TemplateEntry { key: string; label: string } 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<ordered<installing<installed<trained<live<expired<cancelled. */
const STATUS_RANK: Record<string, number> = {
quoted: 0, ordered: 1, installing: 2, installed: 3, trained: 4, live: 5, expired: 6, cancelled: 7,
}
const STEP_STATUS: Record<string, string> = {
quotation_approved: 'ordered', installation: 'installed', training: 'trained', go_live: 'live',
}
const FALLBACK_TEMPLATE: TemplateEntry[] = [ /**
* Also replaces the removed UI date-writers (Client Detail redesign dropped `RowDate` /
* the summary-table date columns without a replacement): the first time status crosses
* into 'installed' / 'trained', stamp client_module.installed_on / trained_on from the
* triggering milestone's own done_on never overwriting a date already on file (e.g. one
* set by the APEX importer or a direct edit).
*/
async function advanceStatusForStep(
db: DB, userId: string, clientModuleId: string, key: string, doneOn: string | null,
): Promise<void> {
const target = STEP_STATUS[key]
if (target === undefined) return
const cm = await db.get<{ status: string; installed_on: string | null; trained_on: string | null }>(
`SELECT status, installed_on, trained_on FROM client_module WHERE id=?`, clientModuleId)
if (cm === undefined) return
if ((STATUS_RANK[cm.status] ?? 0) >= (STATUS_RANK[target] ?? 0)) return // never downgrade
const sets = ['status=?']
const args: unknown[] = [target]
const before: Record<string, unknown> = { status: cm.status }
const after: Record<string, unknown> = { status: target }
if (target === 'installed' && cm.installed_on === null && doneOn !== null) {
sets.push('installed_on=?'); args.push(doneOn)
before['installedOn'] = null; after['installedOn'] = doneOn
}
if (target === 'trained' && cm.trained_on === null && doneOn !== null) {
sets.push('trained_on=?'); args.push(doneOn)
before['trainedOn'] = null; after['trainedOn'] = doneOn
}
args.push(clientModuleId)
await db.run(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`, ...args)
await writeAudit(db, userId, 'update', 'client_module', clientModuleId, before, after)
}
/** 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: 'advance_paid', label: 'Advance payment received' },
{ key: 'setup_done', label: 'Setup / configuration done' }, { key: 'setup_done', label: 'Setup / configuration done' },
{ key: 'installed', label: 'Installation done' }, { key: 'installed', label: 'Installation done' },
@ -23,14 +88,123 @@ const FALLBACK_TEMPLATE: TemplateEntry[] = [
{ key: 'amc_start', label: 'AMC / renewal start' }, { key: 'amc_start', label: 'AMC / renewal start' },
] ]
/** The standard checklist (owner-editable via the setting; fallback to the code default). */ function parseTemplate(value: string): TemplateEntry[] | undefined {
export async function milestoneTemplate(db: DB): Promise<TemplateEntry[]> {
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='project.milestone_template'`)
if (row === undefined) return FALLBACK_TEMPLATE
try { try {
const parsed = JSON.parse(row.value) as TemplateEntry[] const p = JSON.parse(value) as TemplateEntry[]
return Array.isArray(parsed) && parsed.length > 0 ? parsed : FALLBACK_TEMPLATE return Array.isArray(p) && p.length > 0 ? p : undefined
} catch { return FALLBACK_TEMPLATE } } catch { return undefined }
}
/** The shared "front" alone — setting row, else the code fallback. */
export async function getFrontTemplate(db: DB): Promise<TemplateEntry[]> {
const frontRow = await db.get<{ value: string }>(
`SELECT value FROM setting WHERE key='project.milestone_template.front'`)
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<TemplateEntry[]> {
let tail: TemplateEntry[] | undefined
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
}
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
}
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<TemplateEntry[]> {
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<string>()
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
}
/** Every module code with its OWN configured tail setting (not the global fallback). */
async function configuredTailCodes(db: DB): Promise<string[]> {
const rows = await db.all<{ key: string }>(`SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`)
return rows.map((r) => r.key.slice('project.milestone_template:'.length))
}
/** Throws a clear 400-able error if any of `frontKeys` collides with a key in `tail`.
* `tailLabel` identifies the tail in the message (a module code, or 'default'). */
function rejectCollision(frontKeys: Set<string>, tail: TemplateEntry[], tailLabel: string): void {
const hit = tail.find((t) => frontKeys.has(t.key))
if (hit !== undefined) {
throw new Error(`Step key "${hit.key}" is used by both the front and the ${tailLabel} tail template — keys must be unique across the whole checklist`)
}
}
/**
* Owner editor: replace the shared front template. Config-over-code, audited via `setSetting`.
* The front is composed with EVERY module's tail ([...front, ...tail]), so a front key must
* not collide with any tail currently in play: the global/default tail, and every module that
* has its own configured tail.
*/
export async function setFrontTemplate(db: DB, userId: string, rawSteps: unknown): Promise<TemplateEntry[]> {
const steps = normalizeTemplateSteps(rawSteps)
const frontKeys = new Set(steps.map((s) => s.key))
rejectCollision(frontKeys, await getModuleTail(db, ''), 'default')
for (const code of await configuredTailCodes(db)) {
rejectCollision(frontKeys, await getModuleTail(db, code), code)
}
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`.
* This tail is always composed after the shared front, so its keys must not collide with it.
*/
export async function setModuleTail(db: DB, userId: string, moduleCode: string, rawSteps: unknown): Promise<TemplateEntry[]> {
const steps = normalizeTemplateSteps(rawSteps)
const frontKeys = new Set((await getFrontTemplate(db)).map((f) => f.key))
rejectCollision(frontKeys, steps, moduleCode || 'default')
await setSetting(db, userId, `project.milestone_template:${moduleCode}`, JSON.stringify(steps))
return steps
} }
/** /**
@ -39,7 +213,10 @@ export async function milestoneTemplate(db: DB): Promise<TemplateEntry[]> {
* project's own custom milestones and any ticked state are never touched. * project's own custom milestones and any ticked state are never touched.
*/ */
export async function ensureProjectMilestones(db: DB, clientModuleId: string): Promise<void> { export async function ensureProjectMilestones(db: DB, clientModuleId: string): Promise<void> {
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( const existing = new Set(
(await db.all<{ key: string }>(`SELECT key FROM project_milestone WHERE client_module_id=?`, clientModuleId)) (await db.all<{ key: string }>(`SELECT key FROM project_milestone WHERE client_module_id=?`, clientModuleId))
.map((r) => r.key), .map((r) => r.key),
@ -53,6 +230,10 @@ export async function ensureProjectMilestones(db: DB, clientModuleId: string): P
VALUES (?, ?, ?, ?, 0, NULL, ?)`, VALUES (?, ?, ?, ?, 0, NULL, ?)`,
uuidv7(), clientModuleId, t.key, t.label, sort, uuidv7(), clientModuleId, t.key, t.label, sort,
) )
// A template with a front/tail key collision (owner-editor bug, or a legacy setting
// written before that was validated) would otherwise INSERT the same
// (client_module_id, key) twice and violate the UNIQUE constraint mid-loop.
existing.add(t.key)
} }
sort++ sort++
} }
@ -61,32 +242,66 @@ export async function ensureProjectMilestones(db: DB, clientModuleId: string): P
export async function listProjectMilestones(db: DB, clientModuleId: string): Promise<Milestone[]> { export async function listProjectMilestones(db: DB, clientModuleId: string): Promise<Milestone[]> {
const rows = await db.all<MilestoneRow>( const rows = await db.all<MilestoneRow>(
`SELECT key, label, done, done_on, sort FROM project_milestone `SELECT key, label, done, done_on, sort, payment_id, document_id FROM project_milestone
WHERE client_module_id=? ORDER BY sort, label`, WHERE client_module_id=? ORDER BY sort, label`,
clientModuleId, 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, documentId: r.document_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 / document). A payment-step tick (`payment_received`) 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.
* A quotation-step tick (`quotation_sent`) can likewise pass `documentId` to link the
* quotation / proforma that was sent it must belong to the same client too. Audited.
*/
export async function setMilestone( 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, documentId?: string,
): Promise<Milestone[]> { ): Promise<Milestone[]> {
return db.transaction(async () => { return db.transaction(async () => {
const before = await db.get<MilestoneRow>( const before = await db.get<MilestoneRow>(
`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, document_id FROM project_milestone
WHERE client_module_id=? AND key=?`,
clientModuleId, key, clientModuleId, key,
) )
if (before === undefined) throw new Error('Milestone not found on this project') if (before === undefined) throw new Error('Milestone not found on this project')
if (done && doneOn !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(doneOn)) { if (done && doneOn !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(doneOn)) {
throw new Error('doneOn must be YYYY-MM-DD') 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
}
let linkedDocument: string | null = null
if (done && documentId !== undefined && documentId !== '') {
const doc = await db.get<{ id: string }>(
`SELECT id FROM document WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`,
documentId, clientModuleId,
)
if (doc === undefined) throw new Error('Document not found for this client')
linkedDocument = documentId
}
await db.run( await db.run(
`UPDATE project_milestone SET done=?, done_on=? WHERE client_module_id=? AND key=?`, `UPDATE project_milestone SET done=?, done_on=?, payment_id=?, document_id=?
done ? 1 : 0, stamp, clientModuleId, key, WHERE client_module_id=? AND key=?`,
done ? 1 : 0, stamp, done ? linkedPayment : null, done ? linkedDocument : 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, documentId: linkedDocument })
if (done) await advanceStatusForStep(db, userId, clientModuleId, key, stamp)
return listProjectMilestones(db, clientModuleId) return listProjectMilestones(db, clientModuleId)
}) })
} }
@ -180,3 +395,30 @@ export async function projectsPendingMilestone(db: DB, key: string): Promise<Pen
key, key,
) )
} }
/**
* Active projects with at least one pending step whose progress last moved more than
* `olderThanDays` ago (measured against `today`, passed in never `new Date()` here, so this
* stays testable). "Last moved" is the most recent `done_on` across the project's ticked
* steps; a project with nothing ticked yet counts as parked since creation (no lower bound).
* Non-aggregated columns are wrapped in MIN() grouping by `pm.client_module_id` alone isn't
* enough for Postgres to infer they're single-valued per group (D15 portability).
*/
export async function stalledProjects(db: DB, olderThanDays: number, today: string): Promise<PendingProject[]> {
const rows = await db.all<PendingProject & { last_done: string | null }>(
`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)
}

@ -231,7 +231,13 @@ export async function receiptForPayment(db: DB, userId: string, paymentId: strin
// ---------- read views ---------- // ---------- 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<ClientLedger> { export async function clientLedger(db: DB, clientId: string): Promise<ClientLedger> {
const payments = await listPayments(db, clientId) const payments = await listPayments(db, clientId)
@ -249,10 +255,40 @@ export async function clientLedger(db: DB, clientId: string): Promise<ClientLedg
documents.push(...batch.documents) documents.push(...batch.documents)
if (page * batch.pageSize >= batch.total) break if (page * batch.pageSize >= 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<string, { docNo: string; amountPaise: number }[]>()
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<string> =>
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')) {
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 { return {
documents, documents,
payments, payments: ledgerPayments,
advancePaise: settling - row.total, advancePaise: settling - row.total,
outstanding,
} }
} }

@ -72,7 +72,26 @@ export async function generateModuleRenewalQuote(db: DB, userId: string, clientM
const cm = await getClientModule(db, clientModuleId) const cm = await getClientModule(db, clientModuleId)
if (cm === null) throw new Error('Client module not found') if (cm === null) throw new Error('Client module not found')
return createDraft(db, userId, { 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 }], 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<Doc> {
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 }],
})
}

@ -12,10 +12,51 @@ import type { DB } from './db'
export const TICKET_STATUSES = ['open', 'in_progress', 'waiting', 'closed', 'dropped'] as const export const TICKET_STATUSES = ['open', 'in_progress', 'waiting', 'closed', 'dropped'] as const
export type TicketStatus = (typeof TICKET_STATUSES)[number] export type TicketStatus = (typeof TICKET_STATUSES)[number]
export interface TicketType { key: string; label: string }
/**
* Code fallback for the ticket classification list. The live list is the `ticket.types`
* setting (config over code seeded on first boot, owner-editable); this is only what
* `ticketTypes` falls back to when the row is missing or unreadable.
*/
export const TICKET_TYPE_FALLBACK: TicketType[] = [
{ key: 'service', label: 'Service' },
{ key: 'module', label: 'Module issue' },
{ key: 'modification', label: 'Modification' },
{ key: 'amc', label: 'AMC' },
{ key: 'other', label: 'Other' },
]
/** The classification list: the `ticket.types` setting when present and usable, else the code fallback. */
export async function ticketTypes(db: DB): Promise<TicketType[]> {
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='ticket.types'`)
if (row === undefined) return TICKET_TYPE_FALLBACK
try {
const parsed = JSON.parse(row.value) as unknown
if (!Array.isArray(parsed) || parsed.length === 0) return TICKET_TYPE_FALLBACK
const out: TicketType[] = []
for (const raw of parsed) {
const t = (typeof raw === 'object' && raw !== null ? raw : {}) as { key?: unknown; label?: unknown }
if (typeof t.key !== 'string' || t.key.trim() === '') return TICKET_TYPE_FALLBACK
out.push({ key: t.key.trim(), label: typeof t.label === 'string' && t.label.trim() !== '' ? t.label.trim() : t.key.trim() })
}
return out
} catch { return TICKET_TYPE_FALLBACK }
}
/** Normalize a caller-supplied type: blank → the first configured type; unknown → rejected. */
async function resolveType(db: DB, raw: unknown): Promise<string> {
const types = await ticketTypes(db)
const wanted = typeof raw === 'string' ? raw.trim() : ''
if (wanted === '') return types[0]!.key
if (!types.some((t) => t.key === wanted)) throw new Error(`Invalid ticket type: ${wanted}`)
return wanted
}
export interface Ticket { export interface Ticket {
id: string; clientId: string; clientName: string id: string; clientId: string; clientName: string
branchId: string | null; branchName: string | null branchId: string | null; branchName: string | null
moduleCode: string | null; kind: string; description: string moduleCode: string | null; kind: string; type: string; description: string
status: TicketStatus status: TicketStatus
assignedTo: string | null; assignedName: string | null assignedTo: string | null; assignedName: string | null
onlineOffline: string | null onlineOffline: string | null
@ -26,7 +67,7 @@ export interface Ticket {
interface TicketRow { interface TicketRow {
id: string; client_id: string; client_name: string id: string; client_id: string; client_name: string
branch_id: string | null; branch_name: string | null branch_id: string | null; branch_name: string | null
module_code: string | null; kind: string; description: string module_code: string | null; kind: string; type: string; description: string
status: string; assigned_to: string | null; assigned_name: string | null status: string; assigned_to: string | null; assigned_name: string | null
online_offline: string | null online_offline: string | null
opened_on: string; closed_on: string | null opened_on: string; closed_on: string | null
@ -44,7 +85,7 @@ function toTicket(r: TicketRow): Ticket {
return { return {
id: r.id, clientId: r.client_id, clientName: r.client_name, id: r.id, clientId: r.client_id, clientName: r.client_name,
branchId: r.branch_id, branchName: r.branch_name, branchId: r.branch_id, branchName: r.branch_name,
moduleCode: r.module_code, kind: r.kind, description: r.description, moduleCode: r.module_code, kind: r.kind, type: r.type, description: r.description,
status: r.status as TicketStatus, status: r.status as TicketStatus,
assignedTo: r.assigned_to, assignedName: r.assigned_name, assignedTo: r.assigned_to, assignedName: r.assigned_name,
onlineOffline: r.online_offline, onlineOffline: r.online_offline,
@ -60,6 +101,8 @@ export async function getTicket(db: DB, id: string): Promise<Ticket | null> {
export interface ListTicketsOpts { export interface ListTicketsOpts {
status?: TicketStatus; clientId?: string; assignedTo?: string; moduleCode?: string status?: TicketStatus; clientId?: string; assignedTo?: string; moduleCode?: string
/** Classification key from `ticket.types`; omitted/blank = every type. */
type?: string
/** Overdue = still-open (open/in_progress/waiting) and opened on or before this date. */ /** Overdue = still-open (open/in_progress/waiting) and opened on or before this date. */
overdueBefore?: string overdueBefore?: string
q?: string; page?: number; pageSize?: number q?: string; page?: number; pageSize?: number
@ -75,6 +118,7 @@ export async function listTickets(
if (opts.clientId !== undefined) { where += ` AND t.client_id=?`; args.push(opts.clientId) } if (opts.clientId !== undefined) { where += ` AND t.client_id=?`; args.push(opts.clientId) }
if (opts.assignedTo !== undefined) { where += ` AND t.assigned_to=?`; args.push(opts.assignedTo) } if (opts.assignedTo !== undefined) { where += ` AND t.assigned_to=?`; args.push(opts.assignedTo) }
if (opts.moduleCode !== undefined) { where += ` AND t.module_code=?`; args.push(opts.moduleCode) } if (opts.moduleCode !== undefined) { where += ` AND t.module_code=?`; args.push(opts.moduleCode) }
if (opts.type !== undefined && opts.type !== '') { where += ` AND t.type=?`; args.push(opts.type) }
if (opts.overdueBefore !== undefined) { if (opts.overdueBefore !== undefined) {
where += ` AND t.status IN ('open','in_progress','waiting') AND t.opened_on<=?` where += ` AND t.status IN ('open','in_progress','waiting') AND t.opened_on<=?`
args.push(opts.overdueBefore) args.push(opts.overdueBefore)
@ -123,6 +167,8 @@ export async function ticketKinds(db: DB): Promise<string[]> {
export interface CreateTicketInput { export interface CreateTicketInput {
clientId: string; branchId?: string | null; moduleCode?: string | null clientId: string; branchId?: string | null; moduleCode?: string | null
kind?: string; description?: string; onlineOffline?: string | null kind?: string; description?: string; onlineOffline?: string | null
/** Classification key from `ticket.types`; omitted = the first configured type ('service'). */
type?: string
assignedTo?: string | null; openedOn?: string assignedTo?: string | null; openedOn?: string
} }
@ -137,14 +183,15 @@ export async function createTicket(db: DB, userId: string, input: CreateTicketIn
if (branch === undefined) throw new Error('Branch not found for this client') if (branch === undefined) throw new Error('Branch not found for this client')
} }
if (input.assignedTo != null) await assertActiveStaff(db, input.assignedTo) if (input.assignedTo != null) await assertActiveStaff(db, input.assignedTo)
const type = await resolveType(db, input.type)
const id = uuidv7() const id = uuidv7()
const now = new Date().toISOString() const now = new Date().toISOString()
await db.run( await db.run(
`INSERT INTO ticket (id, client_id, branch_id, module_code, kind, description, status, `INSERT INTO ticket (id, client_id, branch_id, module_code, kind, type, description, status,
assigned_to, online_offline, opened_on, closed_on, created_by, created_at, source) assigned_to, online_offline, opened_on, closed_on, created_by, created_at, source)
VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, NULL, ?, ?, 'hq')`, VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, NULL, ?, ?, 'hq')`,
id, input.clientId, input.branchId ?? null, input.moduleCode ?? null, id, input.clientId, input.branchId ?? null, input.moduleCode ?? null,
(input.kind ?? '').trim(), (input.description ?? '').trim(), (input.kind ?? '').trim(), type, (input.description ?? '').trim(),
input.assignedTo ?? null, input.onlineOffline ?? null, input.assignedTo ?? null, input.onlineOffline ?? null,
input.openedOn ?? now.slice(0, 10), userId, now, input.openedOn ?? now.slice(0, 10), userId, now,
) )
@ -159,6 +206,8 @@ export interface TicketPatch {
/** null clears the assignment. */ /** null clears the assignment. */
assignedTo?: string | null assignedTo?: string | null
kind?: string; description?: string kind?: string; description?: string
/** Classification key from `ticket.types` — must be one of the configured keys. */
type?: string
moduleCode?: string | null; branchId?: string | null; onlineOffline?: string | null moduleCode?: string | null; branchId?: string | null; onlineOffline?: string | null
} }
@ -186,6 +235,7 @@ export async function updateTicket(db: DB, userId: string, id: string, patch: Ti
} }
if (patch.assignedTo !== undefined) { sets.push('assigned_to=?'); args.push(patch.assignedTo) } if (patch.assignedTo !== undefined) { sets.push('assigned_to=?'); args.push(patch.assignedTo) }
if (patch.kind !== undefined) { sets.push('kind=?'); args.push(patch.kind.trim()) } if (patch.kind !== undefined) { sets.push('kind=?'); args.push(patch.kind.trim()) }
if (patch.type !== undefined) { sets.push('type=?'); args.push(await resolveType(db, patch.type)) }
if (patch.description !== undefined) { sets.push('description=?'); args.push(patch.description.trim()) } if (patch.description !== undefined) { sets.push('description=?'); args.push(patch.description.trim()) }
if (patch.moduleCode !== undefined) { sets.push('module_code=?'); args.push(patch.moduleCode) } if (patch.moduleCode !== undefined) { sets.push('module_code=?'); args.push(patch.moduleCode) }
if (patch.branchId !== undefined) { sets.push('branch_id=?'); args.push(patch.branchId) } if (patch.branchId !== undefined) { sets.push('branch_id=?'); args.push(patch.branchId) }

@ -5,6 +5,7 @@ import { writeAudit } from './audit'
import type { DB } from './db' import type { DB } from './db'
import { createModule } from './repos-modules' import { createModule } from './repos-modules'
import { SCHEDULE_DEFAULTS, type ScheduleRuleKind } from './repos-reminders' import { SCHEDULE_DEFAULTS, type ScheduleRuleKind } from './repos-reminders'
import { TICKET_TYPE_FALLBACK } from './repos-tickets'
/** First-boot seed: owner login, company settings placeholders, GST18 tax class. */ /** First-boot seed: owner login, company settings placeholders, GST18 tax class. */
@ -86,19 +87,74 @@ export async function seedIfEmpty(db: DB): Promise<void> {
} }
if (seededBilling > 0) await writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS) if (seededBilling > 0) await writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS)
// Onboarding milestone template (D22): the standard checklist every new project // Ticket classification list (config over code): the Tickets desk reads `ticket.types`,
// (client_module) starts with. Config over code — owner-editable; a project can also add // falling back to TICKET_TYPE_FALLBACK in repos-tickets.ts when the row is missing.
// its own milestones. Order here is the display order. // Order here is display order; the first entry is the default for a new ticket.
const MILESTONE_TEMPLATE = JSON.stringify([ const seededTicketTypes = (await db.run(
{ key: 'advance_paid', label: 'Advance payment received' }, INSERT_SETTING_SQL, 'ticket.types', JSON.stringify(TICKET_TYPE_FALLBACK))).changes
{ key: 'setup_done', label: 'Setup / configuration done' }, if (seededTicketTypes > 0) {
{ key: 'installed', label: 'Installation done' }, await writeAudit(db, 'system', 'seed', 'setting', 'ticket.types', undefined,
{ key: 'go_live', label: 'Go-live' }, { types: TICKET_TYPE_FALLBACK.map((t) => t.key) })
{ 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 const seededFront = (await db.run(
if (seededMilestone > 0) await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template', undefined, { set: true }) 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 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<string, { key: string; label: string }[]> = {
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' },
],
}
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, // 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. // matching the code-constant fallback. A cadence change is a NEW dated row, not an edit.

@ -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. // 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 // ⚠️ 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. // 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. // 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 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']) const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])

@ -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
})
})

@ -0,0 +1,97 @@
// 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<typeof openDb>) {
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('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)
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([])
})
})

@ -0,0 +1,324 @@
// 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 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, TAIL_FALLBACK } from '../src/repos-milestones'
const FRONT_KEYS = ['enquiry', 'visit_meeting', 'quotation_sent', 'quotation_approved']
describe('onboarding template migration', () => {
let db: DB
beforeEach(async () => { db = openDb(':memory:'); await seedIfEmpty(db) })
async function makeSmsClientModule(): Promise<string> {
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 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
}
async function seedOldGenericRows(cmId: string): Promise<void> {
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 composed front+tail 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([
...FRONT_KEYS,
'document_collection', 'dlt_registration', 'account_creation', 'installation',
'testing', 'training', 'go_live', 'payment_received',
])
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, 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 === '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)
// 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 (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,
)
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 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')
})
// The interim shape shipped mid-redesign split the payment step in two
// (advance_payment / balance_payment) before it was collapsed into one
// `payment_received` step. A DB written by that build must migrate too.
it('maps the interim advance_payment key (with its payment link) to payment_received', 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-interim'
await db.run(
`INSERT INTO payment (id, client_id, received_on, mode, amount_paise, created_by, created_at)
VALUES (?, ?, '2026-06-01', 'bank', 250000, 'u1', '2026-06-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_payment', 'Advance payment', 1, '2026-06-01', paymentId, 0,
)
const res = await migrateOnboardingTemplates(db)
expect(res.carried).toBe(1)
expect(res.dropped).toBe(0)
const ms = await listProjectMilestones(db, cmId)
expect(ms.some((m) => m.key === 'advance_payment')).toBe(false)
const paymentReceived = ms.find((m) => m.key === 'payment_received')!
expect(paymentReceived.done).toBe(true)
expect(paymentReceived.doneOn).toBe('2026-06-01')
expect(paymentReceived.paymentId).toBe(paymentId)
})
it('maps the interim balance_payment key 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_payment', 'Balance payment', 1, '2026-06-20', 0,
)
const res = await migrateOnboardingTemplates(db)
expect(res.dropped).toBe(0)
const ms = await listProjectMilestones(db, cmId)
expect(ms.some((m) => m.key === 'balance_payment')).toBe(false)
const paymentReceived = ms.find((m) => m.key === 'payment_received')!
expect(paymentReceived.done).toBe(true)
expect(paymentReceived.doneOn).toBe('2026-06-20')
})
it('carries a quotation-step document link across the swap', async () => {
const cmId = await makeSmsClientModule()
const client = await db.get<{ client_id: string }>(
`SELECT client_id FROM client_module WHERE id=?`, cmId,
)
const documentId = 'doc-1'
await db.run(
`INSERT INTO document (id, doc_type, fy, client_id, doc_date, taxable_paise, cgst_paise,
sgst_paise, igst_paise, round_off_paise, payable_paise, payload, created_by, created_at)
VALUES (?, 'QUOTATION', '26-27', ?, '2026-05-01', 100000, 9000, 9000, 0, 0, 118000,
'{"lines":[]}', 'u1', '2026-05-01T00:00:00.000Z')`,
documentId, client!.client_id,
)
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, document_id, sort)
VALUES (?,?,?,?,?,?,?,?)`,
'm0', cmId, 'quotation_sent', 'Quotation sent', 1, '2026-05-01', documentId, 0,
)
await migrateOnboardingTemplates(db)
const quotationSent = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'quotation_sent')!
expect(quotationSent.done).toBe(true)
expect(quotationSent.documentId).toBe(documentId)
})
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('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([
...FRONT_KEYS,
'document_collection', 'dlt_registration', 'account_creation', 'installation',
'testing', 'training', 'go_live', 'payment_received',
])
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' })
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
// 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([...FRONT_KEYS, ...TAIL_FALLBACK.map((t) => t.key)])
const res = await migrateOnboardingTemplates(db)
expect(res.projects).toBe(0)
const after = await listProjectMilestones(db, cm.id)
expect(after).toEqual(before)
})
// Final whole-branch review FIX 3: an ad-hoc project-specific step (`+ Add step` /
// addProjectMilestone, `custom_*` key) must survive the swap — ticked or not — instead of
// being silently deleted because it's in neither templateKeys nor KEY_MAP.
it('preserves ad-hoc custom steps (both ticked and un-ticked), appended after the template, with state intact', async () => {
const cmId = await makeSmsClientModule()
await seedOldGenericRows(cmId) // baseline so the project isn't already "identical" (forces a rebuild)
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort)
VALUES (?,?,?,?,?,?,?)`,
'custom-ticked', cmId, 'custom_ab12cd34', 'AWS region confirmed', 1, '2026-05-01', 10,
)
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort)
VALUES (?,?,?,?,?,?,?)`,
'custom-unticked', cmId, 'custom_ef56gh78', 'Client-specific step', 0, null, 11,
)
const res = await migrateOnboardingTemplates(db)
expect(res.preserved).toBe(2)
// Neither custom row counts as dropped, even though the ticked one is done=1.
expect(res.dropped).toBe(0)
const ms = await listProjectMilestones(db, cmId)
const ticked = ms.find((m) => m.key === 'custom_ab12cd34')
expect(ticked).toMatchObject({ label: 'AWS region confirmed', done: true, doneOn: '2026-05-01' })
const unticked = ms.find((m) => m.key === 'custom_ef56gh78')
expect(unticked).toMatchObject({ label: 'Client-specific step', done: false, doneOn: null })
// Appended after the whole template block, sort continuing past it (deterministic order:
// the ticked one was inserted before the un-ticked one).
expect(ms.slice(-2).map((m) => m.key)).toEqual(['custom_ab12cd34', 'custom_ef56gh78'])
// Reflected in the audit's `after` payload.
const audits = await db.all<{ after_json: string | null }>(
`SELECT after_json FROM audit_log WHERE action='migrate_onboarding' AND entity_id=?`, cmId,
)
expect(audits).toHaveLength(1)
expect(audits[0]!.after_json).toContain('custom_ab12cd34')
expect(audits[0]!.after_json).toContain('custom_ef56gh78')
})
it('a known old-generic key with no forward mapping (amc_start) is still dropped, not treated as custom', async () => {
const cmId = await makeSmsClientModule()
await seedOldGenericRows(cmId) // includes ticked 'installed'/'go_live' and un-ticked 'amc_start'
const res = await migrateOnboardingTemplates(db)
expect(res.preserved).toBe(0)
const ms = await listProjectMilestones(db, cmId)
expect(ms.some((m) => m.key === 'amc_start')).toBe(false)
})
})

@ -0,0 +1,122 @@
// apps/hq/test/milestone-document.test.ts — Client Detail redesign task 7: the quotation
// step (`quotation_sent`) can link the quotation / proforma document that was actually sent.
import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { apiRouter } from '../src/api'
import { createStaff } from '../src/auth'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice, assignModule } from '../src/repos-modules'
import { createDraft } from '../src/repos-documents'
import { setMilestone, listProjectMilestones } from '../src/repos-milestones'
const KEY = '11'.repeat(32)
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' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2020-01-01' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const quote = async (clientId: string) => createDraft(db, 'u1', {
docType: 'QUOTATION', clientId, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})
return { db, clientId: c.id, cmId: cm.id, quote }
}
const step = async (db: Awaited<ReturnType<typeof world>>['db'], cmId: string) =>
(await listProjectMilestones(db, cmId)).find((s) => s.key === 'quotation_sent')!
describe('milestone document_id link (quotation step)', () => {
it('linking a quotation stores document_id on the ticked step', async () => {
const { db, clientId, cmId, quote } = await world()
const doc = await quote(clientId)
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, '2026-05-01', undefined, doc.id)
const s = await step(db, cmId)
expect(s.done).toBe(true)
expect(s.doneOn).toBe('2026-05-01')
expect(s.documentId).toBe(doc.id)
})
it('rejects a document that belongs to a different client', async () => {
const { db, cmId, quote } = await world()
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
const foreign = await quote(other.id)
await expect(
setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, foreign.id),
).rejects.toThrow(/document/i)
// …and nothing was written: the step is still pending.
const s = await step(db, cmId)
expect(s.done).toBe(false)
expect(s.documentId).toBeNull()
})
it('unticking clears document_id along with done_on', async () => {
const { db, clientId, cmId, quote } = await world()
const doc = await quote(clientId)
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, doc.id)
expect((await step(db, cmId)).documentId).toBe(doc.id)
await setMilestone(db, 'u1', cmId, 'quotation_sent', false)
const s = await step(db, cmId)
expect(s.done).toBe(false)
expect(s.doneOn).toBeNull()
expect(s.documentId).toBeNull()
})
it('ticking without a documentId leaves the link null (opt-in)', async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'quotation_sent', true)
const s = await step(db, cmId)
expect(s.done).toBe(true)
expect(s.documentId).toBeNull()
})
it('audits the link in the same transaction as the tick', async () => {
const { db, clientId, cmId, quote } = await world()
const doc = await quote(clientId)
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, doc.id)
const audits = await db.all<{ after_json: string | null }>(
`SELECT after_json FROM audit_log WHERE action='set_milestone'`,
)
expect(audits).toHaveLength(1)
expect(audits[0]!.after_json).toContain(doc.id)
})
})
describe('milestone route accepts documentId', () => {
const servers: { close: () => void }[] = []
afterAll(() => { for (const s of servers) s.close() })
it('POST /client-modules/:id/milestones threads documentId through', async () => {
const { db, clientId, cmId, quote } = await world()
await createStaff(db, { email: 't@t.in', displayName: 'T', role: 'staff', password: 'password-1' })
const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, { keyHex: KEY }))
const server = app.listen(0); servers.push(server)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
const token = ((await (await fetch(`${base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 't@t.in', password: 'password-1' }),
})).json()) as { token: string }).token
const H = { 'content-type': 'application/json', authorization: `Bearer ${token}` }
const doc = await quote(clientId)
const out = await (await fetch(`${base}/client-modules/${cmId}/milestones`, {
method: 'POST', headers: H,
body: JSON.stringify({ key: 'quotation_sent', done: true, documentId: doc.id }),
})).json() as { ok: boolean; milestones: { key: string; documentId: string | null }[] }
expect(out.ok).toBe(true)
expect(out.milestones.find((m) => m.key === 'quotation_sent')!.documentId).toBe(doc.id)
// A foreign document is a 400, not a silent link.
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
const foreign = await quote(other.id)
const bad = await fetch(`${base}/client-modules/${cmId}/milestones`, {
method: 'POST', headers: H,
body: JSON.stringify({ key: 'quotation_sent', done: true, documentId: foreign.id }),
})
expect(bad.status).toBe(400)
})
})

@ -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 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, '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)
})
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, 'payment_received', 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, '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()
})
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, '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)
})
})

@ -0,0 +1,111 @@
// 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, updateClientModule } 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 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)
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')
})
})
// Final whole-branch review FIX 5: installedOn / trainedOn became write-only once the UI's
// RowDate / summary-table date writers were removed with nothing to replace them — the
// milestone-driven status advance is now that replacement.
describe('milestone-driven installed_on / trained_on stamping (FIX 5)', () => {
it("ticking 'installation' (which advances status to installed) stamps installed_on from the milestone's done_on", async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const cm = await getClientModule(db, cmId)
expect(cm!.status).toBe('installed')
expect(cm!.installedOn).toBe('2026-03-25')
})
it("ticking 'training' (which advances status to trained) stamps trained_on from the milestone's done_on", async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'training', true, '2026-05-02')
const cm = await getClientModule(db, cmId)
expect(cm!.status).toBe('trained')
expect(cm!.trainedOn).toBe('2026-05-02')
})
it('never overwrites an installed_on already on file (e.g. set by import / direct edit)', async () => {
const { db, cmId } = await world()
await updateClientModule(db, 'u1', cmId, { installedOn: '2025-01-01' })
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const cm = await getClientModule(db, cmId)
expect(cm!.status).toBe('installed') // status still advances
expect(cm!.installedOn).toBe('2025-01-01') // date untouched
})
it('re-ticking after an untick does not move an already-stamped installed_on', async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
await setMilestone(db, 'u1', cmId, 'installation', false)
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-04-01')
expect((await getClientModule(db, cmId))!.installedOn).toBe('2026-03-25')
})
it('leaves installed_on / trained_on untouched for a step with no date-column mapping (e.g. go_live)', async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'go_live', true, '2026-06-01')
const cm = await getClientModule(db, cmId)
expect(cm!.status).toBe('live')
expect(cm!.installedOn).toBeNull()
expect(cm!.trainedOn).toBeNull()
})
it('the status-advance audit payload reflects the installed_on stamp', async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const audits = await db.all<{ after_json: string | null }>(
`SELECT after_json FROM audit_log WHERE entity='client_module' AND entity_id=? AND action='update' ORDER BY id`,
cmId,
)
const stamped = audits.find((a) => (a.after_json ?? '').includes('installedOn'))
expect(stamped).toBeDefined()
expect(JSON.parse(stamped!.after_json!)).toMatchObject({ status: 'installed', installedOn: '2026-03-25' })
})
})

@ -0,0 +1,58 @@
// 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, FRONT_FALLBACK, TAIL_FALLBACK } from '../src/repos-milestones'
describe('composed milestone templates (front + tail)', () => {
let db: DB
beforeEach(() => { db = openDb(':memory:') })
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([...FRONT_FALLBACK, { key: 'a', label: 'A' }])
})
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([...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('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])
})
})

@ -21,10 +21,13 @@ async function world() {
} }
describe('project milestones (D22)', () => { 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 { db, p1 } = await world()
const ms = await listProjectMilestones(db, p1.id) 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) 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 () => { it('adds a project-specific milestone after the standard set', async () => {
const { db, p1 } = await world() const { db, p1 } = await world()
const ms = await addProjectMilestone(db, 'u1', p1.id, 'AWS region confirmed') 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') expect(ms[ms.length - 1]!.label).toBe('AWS region confirmed')
await expect(addProjectMilestone(db, 'u1', p1.id, ' ')).rejects.toThrow(/label/) await expect(addProjectMilestone(db, 'u1', p1.id, ' ')).rejects.toThrow(/label/)
}) })
@ -59,9 +62,37 @@ describe('project milestones (D22)', () => {
) )
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(0) expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(0)
await ensureProjectMilestones(db, 'imported-cm') 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 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)
})
// Final whole-branch review FIX 1(b): setFrontTemplate/setModuleTail now reject a
// cross-half key collision, but a template can still end up with a duplicate key some
// other way (e.g. a setting row written directly, or one that predates that guard) —
// ensureProjectMilestones must not crash with a UNIQUE (client_module_id, key) violation
// or insert the same key twice; it must dedupe within its own insert loop.
it('ensureProjectMilestones tolerates a front/tail key collision in the resolved template', async () => {
const { db, c1, m } = await world()
// Force a front/tail collision by writing the settings directly (bypassing the
// now-guarded setFrontTemplate/setModuleTail). world() ran seedIfEmpty, which already
// seeded the front row, so UPDATE it rather than INSERT.
await db.run(`UPDATE setting SET value=? WHERE key='project.milestone_template.front'`,
JSON.stringify([{ key: 'enquiry', label: 'Enquiry received' }, { key: 'installed', label: 'Front installed' }]))
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
JSON.stringify([{ key: 'installed', label: 'Tail installed' }, { key: 'go_live', label: 'Go-live' }]))
await db.run(
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition) VALUES (?, ?, ?, 'quoted', 'yearly', 'standard')`,
'colliding-cm', c1.id, m.id,
)
await expect(ensureProjectMilestones(db, 'colliding-cm')).resolves.toBeUndefined()
const ms = await listProjectMilestones(db, 'colliding-cm')
// Only ONE row for the colliding key ('installed'), not two.
expect(ms.filter((mm) => mm.key === 'installed')).toHaveLength(1)
expect(ms.map((mm) => mm.key)).toEqual(['enquiry', 'installed', 'go_live'])
// Re-running stays safe and adds nothing new.
await ensureProjectMilestones(db, 'colliding-cm')
expect(await listProjectMilestones(db, 'colliding-cm')).toHaveLength(3)
}) })
it('boards the whole book and drills into who is pending a milestone', async () => { it('boards the whole book and drills into who is pending a milestone', async () => {

@ -0,0 +1,224 @@
// apps/hq/test/onboarding-template-routes.test.ts — final whole-branch review FIX 4: the
// owner onboarding-template editor surface (normalizeTemplateSteps / setFrontTemplate /
// setModuleTail / getFrontTemplate / getModuleTail + the four routes) had no direct test
// coverage. Also exercises the cross-half (front <-> tail) key collision rejection added by
// FIX 1, at both the repo level and over HTTP.
import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { apiRouter } from '../src/api'
import { createStaff } from '../src/auth'
import { createModule } from '../src/repos-modules'
import {
normalizeTemplateSteps, setFrontTemplate, setModuleTail, getFrontTemplate, getModuleTail,
FRONT_FALLBACK, TAIL_FALLBACK,
} from '../src/repos-milestones'
const KEY = '33'.repeat(32)
describe('normalizeTemplateSteps (pure validation)', () => {
it('rejects a non-array or empty body', () => {
expect(() => normalizeTemplateSteps(undefined)).toThrow(/at least one/i)
expect(() => normalizeTemplateSteps('nope')).toThrow(/at least one/i)
expect(() => normalizeTemplateSteps([])).toThrow(/at least one/i)
})
it('rejects a blank label', () => {
expect(() => normalizeTemplateSteps([{ label: ' ' }])).toThrow(/label/i)
expect(() => normalizeTemplateSteps([{ key: 'a', label: 'A' }, { label: '' }])).toThrow(/label/i)
})
it('rejects a duplicate explicit key', () => {
expect(() => normalizeTemplateSteps([
{ key: 'dup', label: 'First' }, { key: 'dup', label: 'Second' },
])).toThrow(/Duplicate step key/)
})
it('derives a key by slugifying the label when none is given, bumping on collision', () => {
const out = normalizeTemplateSteps([
{ label: 'Site Visit!' }, { label: 'site visit' }, { label: 'Site -- Visit' },
])
expect(out).toEqual([
{ key: 'site_visit', label: 'Site Visit!' },
{ key: 'site_visit_2', label: 'site visit' },
{ key: 'site_visit_3', label: 'Site -- Visit' },
])
})
it('a key derived from a label collides with a later EXPLICIT key of the same value -> rejected', () => {
expect(() => normalizeTemplateSteps([{ label: 'Go Live' }, { key: 'go_live', label: 'Go-live (explicit)' }]))
.toThrow(/Duplicate step key/)
})
})
describe('setFrontTemplate / setModuleTail / getFrontTemplate / getModuleTail (repo level)', () => {
it('getFrontTemplate / getModuleTail fall back to the code defaults on an empty DB', async () => {
const db = openDb(':memory:')
expect(await getFrontTemplate(db)).toEqual(FRONT_FALLBACK)
expect(await getModuleTail(db, 'SMS')).toEqual(TAIL_FALLBACK)
})
it('setFrontTemplate rejects a key colliding with the default tail', async () => {
const db = openDb(':memory:')
await expect(setFrontTemplate(db, 'u1', [{ key: 'installed', label: 'Site installed' }]))
.rejects.toThrow(/used by both the front/i)
})
it('setFrontTemplate rejects a key colliding with an already-configured per-module tail', async () => {
const db = openDb(':memory:')
await setModuleTail(db, 'u1', 'SMS', [{ key: 'kickoff', label: 'Kickoff call' }])
await expect(setFrontTemplate(db, 'u1', [{ key: 'kickoff', label: 'Kickoff' }]))
.rejects.toThrow(/SMS/)
// The front setting was never written — still the fallback.
expect(await getFrontTemplate(db)).toEqual(FRONT_FALLBACK)
})
it('setModuleTail rejects a key colliding with the shared front', async () => {
const db = openDb(':memory:')
await expect(setModuleTail(db, 'u1', 'SMS', [{ key: 'enquiry', label: 'Enquiry (dup)' }]))
.rejects.toThrow(/front/i)
})
it('setModuleTail rejects a key colliding with a CUSTOM front (not just the code fallback)', async () => {
const db = openDb(':memory:')
await setFrontTemplate(db, 'u1', [{ key: 'lead_in', label: 'Lead in' }])
await expect(setModuleTail(db, 'u1', 'SMS', [{ key: 'lead_in', label: 'Also lead in' }]))
.rejects.toThrow(/front/i)
})
it('a non-colliding edit round-trips through the getters and is audited', async () => {
const db = openDb(':memory:')
const front = [{ key: 'lead_in', label: 'Lead in' }]
const tail = [{ key: 'kickoff', label: 'Kickoff call' }, { key: 'wrapup', label: 'Wrap up' }]
await setFrontTemplate(db, 'u1', front)
await setModuleTail(db, 'u1', 'SMS', tail)
expect(await getFrontTemplate(db)).toEqual(front)
expect(await getModuleTail(db, 'SMS')).toEqual(tail)
// A different, unconfigured module still gets the code fallback tail.
expect(await getModuleTail(db, 'RTGS')).toEqual(TAIL_FALLBACK)
const audited = await db.all<{ n: number }>(
`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'project.milestone_template%'`,
)
expect((audited[0] as unknown as { n: number }).n).toBe(2)
})
})
describe('onboarding-template routes (owner-only editor)', () => {
const servers: { close: () => void }[] = []
afterAll(() => { for (const s of servers) s.close() })
async function httpWorld(): Promise<{ db: DB; base: string; ownerH: Record<string, string>; staffH: Record<string, string> }> {
const db = openDb(':memory:')
await seedIfEmpty(db)
await createStaff(db, { email: 'owner2@t.in', displayName: 'Owner2', role: 'owner', password: 'owner-pass-1' })
await createStaff(db, { email: 'staff@t.in', displayName: 'Staff', role: 'staff', password: 'staff-pass-1' })
await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
// seedIfEmpty pre-seeds a `project.milestone_template:SMS` setting (the shipped SMS
// pipeline), so use an UNconfigured module for tests that assert the code TAIL_FALLBACK.
await createModule(db, 'u1', { code: 'WIDGET', name: 'Unconfigured Widget' })
const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, { keyHex: KEY }))
const server = app.listen(0); servers.push(server)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
const login = async (email: string, password: string): Promise<Record<string, string>> => {
const token = ((await (await fetch(`${base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
})).json()) as { token: string }).token
return { 'content-type': 'application/json', authorization: `Bearer ${token}` }
}
return { db, base, ownerH: await login('owner2@t.in', 'owner-pass-1'), staffH: await login('staff@t.in', 'staff-pass-1') }
}
it('GET /onboarding-template/front: owner only, serves the fallback by default', async () => {
const { base, ownerH, staffH } = await httpWorld()
const denied = await fetch(`${base}/onboarding-template/front`, { headers: staffH })
expect(denied.status).toBe(403)
const out = await (await fetch(`${base}/onboarding-template/front`, { headers: ownerH })).json() as
{ ok: boolean; steps: { key: string; label: string }[] }
expect(out.ok).toBe(true)
expect(out.steps).toEqual(FRONT_FALLBACK)
})
it('PUT /onboarding-template/front: owner only; rejects blank label / duplicate key; round-trips on GET', async () => {
const { base, ownerH, staffH } = await httpWorld()
const denied = await fetch(`${base}/onboarding-template/front`, {
method: 'PUT', headers: staffH, body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }] }),
})
expect(denied.status).toBe(403)
const blank = await fetch(`${base}/onboarding-template/front`, {
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'a', label: ' ' }] }),
})
expect(blank.status).toBe(400)
const dup = await fetch(`${base}/onboarding-template/front`, {
method: 'PUT', headers: ownerH,
body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }, { key: 'a', label: 'A2' }] }),
})
expect(dup.status).toBe(400)
const newFront = [{ label: 'Lead In!' }, { key: 'confirmed', label: 'Confirmed' }]
const put = await fetch(`${base}/onboarding-template/front`, {
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: newFront }),
})
expect(put.status).toBe(200)
const putBody = await put.json() as { steps: { key: string; label: string }[] }
expect(putBody.steps[0]).toEqual({ key: 'lead_in', label: 'Lead In!' })
const reGet = await (await fetch(`${base}/onboarding-template/front`, { headers: ownerH })).json() as
{ steps: { key: string; label: string }[] }
expect(reGet.steps).toEqual(putBody.steps)
})
it('GET/PUT /modules/:code/onboarding-template: 404 on an unknown module code', async () => {
const { base, ownerH } = await httpWorld()
const getMissing = await fetch(`${base}/modules/NOPE/onboarding-template`, { headers: ownerH })
expect(getMissing.status).toBe(404)
const putMissing = await fetch(`${base}/modules/NOPE/onboarding-template`, {
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }] }),
})
expect(putMissing.status).toBe(404)
})
it('GET/PUT /modules/:code/onboarding-template: owner only, round-trips a valid edit', async () => {
const { base, ownerH, staffH } = await httpWorld()
const denied = await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: staffH })
expect(denied.status).toBe(403)
const before = await (await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: ownerH })).json() as
{ steps: { key: string; label: string }[] }
expect(before.steps).toEqual(TAIL_FALLBACK)
const newTail = [{ key: 'kickoff', label: 'Kickoff call' }, { key: 'wrapup', label: 'Wrap up' }]
const put = await fetch(`${base}/modules/WIDGET/onboarding-template`, {
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: newTail }),
})
expect(put.status).toBe(200)
const after = await (await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: ownerH })).json() as
{ steps: { key: string; label: string }[] }
expect(after.steps).toEqual(newTail)
})
it('cross-half collision (FIX 1) is rejected with 400 over HTTP, both directions', async () => {
const { base, ownerH } = await httpWorld()
// Front step whose key collides with the (fallback) SMS tail's 'installed'... use the
// module tail directly since SMS starts from TAIL_FALLBACK (has 'installed').
const frontCollide = await fetch(`${base}/onboarding-template/front`, {
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'installed', label: 'Installed' }] }),
})
expect(frontCollide.status).toBe(400)
const frontCollideBody = await frontCollide.json() as { ok: boolean; error: string }
expect(frontCollideBody.error).toMatch(/used by both the front/i)
// Tail step whose key collides with the shared front's 'enquiry'.
const tailCollide = await fetch(`${base}/modules/SMS/onboarding-template`, {
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'enquiry', label: 'Enquiry (dup)' }] }),
})
expect(tailCollide.status).toBe(400)
const tailCollideBody = await tailCollide.json() as { ok: boolean; error: string }
expect(tailCollideBody.error).toMatch(/front/i)
})
})

@ -0,0 +1,38 @@
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'
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('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', 'payment_received',
])
})
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', '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',
])
})
})

@ -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')
})
})

@ -0,0 +1,161 @@
// apps/hq/test/ticket-types.test.ts — Client Detail redesign task 6: ticket classification.
// The list is config over code (the `ticket.types` setting, code fallback); tickets carry
// a `type`, the desk filters by it.
import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { apiRouter } from '../src/api'
import { createStaff } from '../src/auth'
import { createClient } from '../src/repos-clients'
import { setSetting } from '../src/repos-reminders'
import {
createTicket, updateTicket, listTickets, ticketTypes, TICKET_TYPE_FALLBACK,
} from '../src/repos-tickets'
const KEY = '11'.repeat(32)
async function world() {
const db = openDb(':memory:')
await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
return { db, c }
}
describe('ticket type list (config over code)', () => {
it('seeds `ticket.types` on first boot, matching the code fallback', async () => {
const { db } = await world()
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='ticket.types'`)
expect(row).toBeDefined()
expect(JSON.parse(row!.value)).toEqual(TICKET_TYPE_FALLBACK)
})
it('the resolver prefers the setting over the code fallback', async () => {
const { db } = await world()
await setSetting(db, 'u1', 'ticket.types', JSON.stringify([
{ key: 'service', label: 'Service call' }, { key: 'audit', label: 'Audit visit' },
]))
expect(await ticketTypes(db)).toEqual([
{ key: 'service', label: 'Service call' }, { key: 'audit', label: 'Audit visit' },
])
})
it('falls back to the code list when the setting is missing, empty or unparseable', async () => {
const { db } = await world()
await db.run(`DELETE FROM setting WHERE key='ticket.types'`)
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
await setSetting(db, 'u1', 'ticket.types', '[]')
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
await setSetting(db, 'u1', 'ticket.types', 'not json at all')
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
await setSetting(db, 'u1', 'ticket.types', JSON.stringify([{ label: 'no key' }]))
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
})
})
describe('ticket.type on create / update / list', () => {
it("defaults to 'service' when the caller sends no type", async () => {
const { db, c } = await world()
const t = await createTicket(db, 'u1', { clientId: c.id, description: 'no type given' })
expect(t.type).toBe('service')
})
it('stores a configured type and rejects an unknown one', async () => {
const { db, c } = await world()
const t = await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'renewal' })
expect(t.type).toBe('amc')
await expect(createTicket(db, 'u1', { clientId: c.id, type: 'nonsense' })).rejects.toThrow(/type/i)
})
it('updates the type (audited) and rejects an unknown one', async () => {
const { db, c } = await world()
const t = await createTicket(db, 'u1', { clientId: c.id, description: 'reclassify me' })
const after = await updateTicket(db, 'u1', t.id, { type: 'modification' })
expect(after.type).toBe('modification')
await expect(updateTicket(db, 'u1', t.id, { type: 'nonsense' })).rejects.toThrow(/type/i)
// still 'modification' — the rejected patch wrote nothing
expect((await listTickets(db, { clientId: c.id })).tickets[0]!.type).toBe('modification')
})
it('filters the list by type; a blank/omitted filter returns every type', async () => {
const { db, c } = await world()
await createTicket(db, 'u1', { clientId: c.id, type: 'service', description: 'a' })
await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'b' })
await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'c' })
expect((await listTickets(db, { type: 'amc' })).total).toBe(2)
expect((await listTickets(db, { type: 'service' })).total).toBe(1)
expect((await listTickets(db, { type: 'modification' })).total).toBe(0)
expect((await listTickets(db, { type: '' })).total).toBe(3)
expect((await listTickets(db, {})).total).toBe(3)
})
it('an existing (pre-migration) row reads back as the default type', async () => {
const { db, c } = await world()
// Insert the way the APEX importer does — without naming the new column.
await db.run(
`INSERT INTO ticket (id, client_id, kind, description, opened_on, created_by, created_at, source)
VALUES ('legacy-1', ?, 'MODIFICATION', 'imported', '2025-01-01', 'u1', '2025-01-01T00:00:00.000Z', 'apex')`,
c.id,
)
const listed = await listTickets(db, { clientId: c.id })
expect(listed.tickets[0]!.type).toBe('service')
})
})
describe('ticket type routes', () => {
const servers: { close: () => void }[] = []
afterAll(() => { for (const s of servers) s.close() })
async function httpWorld(): Promise<{ db: DB; clientId: string; base: string; H: Record<string, string> }> {
const { db, c } = await world()
await createStaff(db, { email: 't@t.in', displayName: 'T', role: 'staff', password: 'password-1' })
const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, { keyHex: KEY }))
const server = app.listen(0); servers.push(server)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
const token = ((await (await fetch(`${base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 't@t.in', password: 'password-1' }),
})).json()) as { token: string }).token
return { db, clientId: c.id, base, H: { 'content-type': 'application/json', authorization: `Bearer ${token}` } }
}
it('GET /tickets/types serves the configured list', async () => {
const { base, H } = await httpWorld()
const out = await (await fetch(`${base}/tickets/types`, { headers: H })).json() as
{ ok: boolean; types: { key: string; label: string }[] }
expect(out.ok).toBe(true)
expect(out.types).toEqual(TICKET_TYPE_FALLBACK)
})
it('POST /tickets accepts a type, and GET /tickets?type= filters on it', async () => {
const { clientId, base, H } = await httpWorld()
for (const type of ['service', 'amc', 'amc']) {
const created = await (await fetch(`${base}/tickets`, {
method: 'POST', headers: H, body: JSON.stringify({ clientId, type, description: type }),
})).json() as { ok: boolean; ticket: { type: string } }
expect(created.ticket.type).toBe(type)
}
const amc = await (await fetch(`${base}/tickets?type=amc`, { headers: H })).json() as
{ total: number; tickets: { type: string }[] }
expect(amc.total).toBe(2)
expect(amc.tickets.every((t) => t.type === 'amc')).toBe(true)
const all = await (await fetch(`${base}/tickets?type=`, { headers: H })).json() as { total: number }
expect(all.total).toBe(3)
})
it('PATCH /tickets/:id reclassifies; an unknown type is a 400', async () => {
const { clientId, base, H } = await httpWorld()
const created = await (await fetch(`${base}/tickets`, {
method: 'POST', headers: H, body: JSON.stringify({ clientId, description: 'x' }),
})).json() as { ticket: { id: string } }
const patched = await (await fetch(`${base}/tickets/${created.ticket.id}`, {
method: 'PATCH', headers: H, body: JSON.stringify({ type: 'module' }),
})).json() as { ticket: { type: string } }
expect(patched.ticket.type).toBe('module')
const bad = await fetch(`${base}/tickets/${created.ticket.id}`, {
method: 'PATCH', headers: H, body: JSON.stringify({ type: 'nonsense' }),
})
expect(bad.status).toBe(400)
})
})

@ -616,3 +616,37 @@ 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 + 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, column. Live backfill: 135 Service Bank, 38 Employees, 15 Vanitha, 14 Store, 12 Agricultural,
11 Urban, … 11 Urban, …
## D38 — Client Detail redesign: composed onboarding pipeline, status line & payments context (2026-07-20)
Reworks the Client 360 Modules + Payments tabs around how the business actually runs — a module is
the *end* of a sales process, not a one-click assignment.
**Composed onboarding templates (config over code).** The step list is resolved as a **shared lead
front + a per-module tail**: `project.milestone_template.front` (Enquiry received · Visit / meeting
scheduled · Quotation sent · Quotation approved) applies to **every** module, and
`project.milestone_template:<CODE>` supplies that module's delivery steps (SMS: Document collection ·
DLT registration · Account creation · Installation · Testing · Training · Go-live · Payment received;
RTGS and Mobile App have their own). Edit the front once and every module follows. Both are
owner-editable in a new **Modules → Onboarding steps** editor. A one-time, idempotent migration
(`scripts/apply-onboarding-pipeline.ts`) moves existing projects onto the composed list, carrying
mapped ticks.
**The status line drives the record.** The checklist became a **horizontal status line** whose ticks
**auto-advance `client_module.status`** (quotation approved→ordered, installation→installed,
training→trained, go-live→live; never downgrades), so the status dropdown stops being hand-set. The
**Payment received** step links to a real payment (`project_milestone.payment_id`, step date mirrors
`received_on`). The **Account creation** step prompts for the module credentials — and credentials
are **no longer required when adding a module** (they don't exist yet at that point); they stay
editable anytime via the module's Access panel.
**Module + payments surfaces.** 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) labelled Quote / Renewal / **Bulk top-up** / Invoice; SMS can raise a
**bulk-credit purchase** proforma (`source='bulk_sms_topup'`) distinct from a subscription renewal
(`source='module_renewal'`). The Payments tab gains an **Outstanding** panel — issued invoices with a
balance plus **sent/accepted** proformas (un-issued drafts excluded, owner decision) — and a
*Settled → document* column per payment. The money engine is untouched (read-only ledger extension).
Also: a **stalled-onboarding** query for the dashboard/MIS, and a mobile fix so the record header
stacks instead of overlapping the client name.
Spec + plans under `docs/superpowers/`.

@ -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<TemplateEntry[]>` — resolves `project.milestone_template:<CODE>``project.milestone_template``FALLBACK_TEMPLATE`.
- Produces: `ensureProjectMilestones(db: DB, clientModuleId: string): Promise<void>` — 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<TemplateEntry[]> {
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<void> {
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<string, { key: string; label: string }[]> = {
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<ordered<installing<installed<trained<live<expired<cancelled. */
const STATUS_RANK: Record<string, number> = {
quoted: 0, ordered: 1, installing: 2, installed: 3, trained: 4, live: 5, expired: 6, cancelled: 7,
}
const STEP_STATUS: Record<string, string> = {
installation: 'installed', training: 'trained', go_live: 'live',
}
async function advanceStatusForStep(db: DB, userId: string, clientModuleId: string, key: string): Promise<void> {
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<Milestone[]> {
return db.transaction(async () => {
const before = await db.get<MilestoneRow>(
`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<string, string> = {
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<OldRow>(
`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<string, OldRow>()
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<void> {
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<string, { docNo: string; amountPaise: number }[]>()
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<string> =>
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<Doc>` — 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<Doc> {
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<Doc> {
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<PendingProject[]>` — 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<PendingProject[]> {
const rows = await db.all<PendingProject & { last_done: string | null }>(
`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 (T3T8) 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.

@ -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 25 references.
- [ ] **Step 1: Append the styles**
Add to `apps/hq-web/src/app.css` (values ported from `scratchpad/client-detail-mockup.html`, swapping the mockup's literal colours for tokens):
```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 ~9611103) and fold `ModuleFieldsForm` (lines ~11121209) presentation into it.
**Interfaces:**
- Consumes: existing `patchClientModule`, `setModuleFields`, `revealModulePassword`, `SecretField`, `cm.details`, `cm.fieldValues`, `props.fieldSpec`.
- Produces: read-only 50/50 report; each group (`Access`, `Details`) has one **Edit** toggling that group's inputs; save = one audited call.
- [ ] **Step 1: Replace the read view of `ServiceDataCard`**
Rewrite the returned read view (the non-editing `return (...)` at ~1066) as the 50/50 report. Keep the existing edit-state machinery; change only what renders. The read view:
```tsx
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div className="cdr-split">
<div className="col">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span className="lbl" style={{ fontWeight: 700 }}>Access</span>
<span style={{ flex: 1, height: 1, background: 'var(--border)' }} />
<Button onClick={startEdit}>Edit</Button>
</div>
<Kv label="Provider" value={cm.provider ?? undefined} />
<Kv label="Login" value={cm.username ?? undefined} mono copyable onCopy={() => copy(cm.username ?? '', 'Username')} />
<div className="cdr-kv">
<span className="k">Password</span>
<span className="v">
{revealed !== undefined
? <><span className="mono">{revealed}</span><Button onClick={() => copy(revealed, 'Portal password')}>Copy</Button><Button onClick={() => setRevealed(undefined)}>Hide</Button></>
: cm.hasPassword
? <><span className="mono">••••••••</span>{managerial && <Button onClick={reveal}>{busy ? '…' : 'Reveal'}</Button>}</>
: <span style={{ opacity: 0.6 }}>not set</span>}
</span>
</div>
{cm.remark !== null && cm.remark !== '' && <Kv label="Remark" value={cm.remark} />}
</div>
<div className="col">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span className="lbl" style={{ fontWeight: 700 }}>Details</span>
<span style={{ flex: 1, height: 1, background: 'var(--border)' }} />
<Button onClick={startEdit}>Edit</Button>
</div>
{props.fieldSpec.filter((f) => f.type !== 'secret').map((f) => (
<Kv key={f.key} label={f.label} value={cm.fieldValues[f.key]} />
))}
{cm.details.map((d, i) => <Kv key={`d${i}`} label={d.label} value={d.value} />)}
{props.fieldSpec.filter((f) => f.type === 'secret').map((f) => (
<div className="cdr-kv" key={f.key}>
<span className="k">{f.label}</span>
<span className="v"><SecretField cm={cm} field={f} managerial={managerial} onChanged={props.onChanged} /></span>
</div>
))}
</div>
</div>
</div>
)
```
Add a small `Kv` helper in the same file:
```tsx
function Kv(props: { label: string; value: string | undefined; mono?: boolean; copyable?: boolean; onCopy?: () => void }) {
const v = props.value
return (
<div className="cdr-kv">
<span className="k">{props.label}</span>
<span className="v">
{v !== undefined && v !== '' ? <span className={`txt${props.mono ? ' mono' : ''}`}>{v}</span> : <span style={{ opacity: 0.5 }}></span>}
{props.copyable === true && v !== undefined && v !== '' && <Button onClick={props.onCopy}>Copy</Button>}
</span>
</div>
)
}
```
- [ ] **Step 2: Fold the fieldSpec edit into the Details Edit**
In the editing branch of `ServiceDataCard` (~1028), extend the edit form to also render the non-secret `fieldSpec` inputs (move the control logic from `ModuleFieldsForm`), so one **Save** persists provider/username/password/remark + details + fieldValues. Remove the separate always-on `ModuleFieldsForm` render at line ~10951100 (secret fields stay as `SecretField` in the read view). Keep `saveEdit` calling `patchClientModule` for the D20 fields and `setModuleFields` for the fieldSpec values (two audited calls in sequence is acceptable; or combine if `patchClientModule` accepts field values — check its signature).
- [ ] **Step 3: Verify**
Run: `npm run typecheck && npm run build --workspace @sims/hq-web`
Then run the app and open a client with SMS + RTGS: confirm the 50/50 read view, one Edit per group, save round-trips, secret reveal still audited, `—` for empty fields, no `*` markers.
- [ ] **Step 4: Commit**
```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` (~12701357) with `StatusLine`.
- Modify: `apps/hq-web/src/api.ts` — thread `paymentId` into `setMilestone`.
**Interfaces:**
- Consumes: `getMilestones`, `setMilestone(clientModuleId, key, done, doneOn?, paymentId?)`, `addMilestone`, `Milestone` (now with `paymentId`).
- Produces: `StatusLine` component rendering the horizontal `.cdr-track`.
- [ ] **Step 1: Extend the web `setMilestone` call**
In `apps/hq-web/src/api.ts`, update the milestone POST helper to accept an optional `paymentId` and include it in the body. Confirm the `Milestone` type includes `paymentId: string | null`.
- [ ] **Step 2: Write `StatusLine`**
Replace `MilestoneChecklist` with:
```tsx
function StatusLine(props: { clientModuleId: string; name: string }) {
const toast = useToast()
const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId])
const [busyKey, setBusyKey] = useState('')
const [adding, setAdding] = useState(false)
const [label, setLabel] = useState('')
const toggle = (m: Milestone, done: boolean, doneOn?: string) => {
if (busyKey !== '') return
setBusyKey(m.key)
setMilestone(props.clientModuleId, m.key, done, doneOn)
.then(() => list.reload()).catch((e: Error) => toast.err(e.message)).finally(() => setBusyKey(''))
}
const addStep = () => {
if (label.trim() === '') return
addMilestone(props.clientModuleId, label.trim())
.then(() => { setLabel(''); setAdding(false); list.reload() }).catch((e: Error) => toast.err(e.message))
}
const ms = list.data
const done = ms?.filter((m) => m.done).length ?? 0
// current = first not-done
const currentKey = ms?.find((m) => !m.done)?.key
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontWeight: 600 }}>{props.name} — status line</span>
{ms !== undefined && ms.length > 0 && <Badge tone={done === ms.length ? 'ok' : undefined}>{done}/{ms.length}</Badge>}
<span style={{ flex: 1 }} />
{!adding ? <Button onClick={() => setAdding(true)}>+ Add step</Button> : (
<>
<input className="wf" style={{ maxWidth: 200 }} placeholder="Step label" value={label}
onChange={(e) => setLabel(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') addStep() }} />
<Button tone="primary" onClick={addStep}>Add</Button>
<Button onClick={() => { setAdding(false); setLabel('') }}>Cancel</Button>
</>
)}
</div>
{list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
: ms === undefined ? <Skeleton rows={2} />
: ms.length === 0 ? <EmptyState>No steps yet.</EmptyState> : (
<div className="cdr-track-wrap"><div className="cdr-track">
{ms.map((m) => (
<div key={m.key} className={`cdr-node ${m.done ? 'done' : m.key === currentKey ? 'now' : ''}`}
style={{ opacity: busyKey === m.key ? 0.5 : 1 }}>
<span className="bar" />
<button className="dot" title={m.done ? 'Click to un-tick' : 'Click to mark done (today)'}
onClick={() => toggle(m, !m.done)}>{m.done ? '✓' : m.key === currentKey ? '●' : ''}</button>
<span className="nm">{m.label}</span>
<span className="dt">{m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'}</span>
</div>
))}
</div></div>
)}
</div>
)
}
```
Update the modules-tab render (line ~316) to use `<StatusLine clientModuleId={cm.id} name={moduleName(cm.moduleId)} />` and rename the heading if present.
- [ ] **Step 3: Payment-step linking (advance/balance)**
When a node whose key is `advance_payment` or `balance_payment` is ticked, open a small dialog to pick an existing payment (from the client ledger) or record a new one, then call `setMilestone(cmId, key, true, undefined, paymentId)`. Minimum viable: a dialog listing `ledger.payments` (date + amount) to pick from, plus a "Record new payment" shortcut that switches to the Payments tab. Wire the dialog state in `ClientDetail` and pass an `onTickPayment(key)` callback into `StatusLine` for those two keys. (If a lighter first cut is preferred, tick normally with today's date and leave linking as a follow-up — but the spec calls for the link; implement the picker.)
- [ ] **Step 4: Verify**
Run: `npm run typecheck && npm run build --workspace @sims/hq-web`. In-app: tick along the SMS line → dots fill, current ring moves, ticking Installation flips the header status to `installed`, ticking Go-live → `live` (backend Task 3); line scrolls on a narrow window; ticking Advance payment prompts to link a payment.
- [ ] **Step 5: Commit**
```bash
git add apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/api.ts
git commit -m "feat(client-detail): onboarding as horizontal status line with payment-step linking"
```
---
## Task 4: Per-module Documents + Bulk SMS purchase
**Files:**
- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` — add `ModuleDocuments`; render inside each module block after `StatusLine`.
- Modify: `apps/hq-web/src/api.ts` — add `generateBulkSmsPurchase(cmId, smsQty)`.
**Interfaces:**
- Consumes: `ledger.data.documents` (each `Doc.payload.lines[].itemId === moduleId`), `doc.source` (`module_renewal`/`bulk_sms_topup`), `nav`.
- Produces: `ModuleDocuments` component; a Bulk SMS action for SMS-coded modules.
- [ ] **Step 1: Add the web API call**
In `apps/hq-web/src/api.ts`:
```ts
export const generateBulkSmsPurchase = (clientModuleId: string, smsQty: number) =>
post<{ document: Doc }>(`/client-modules/${clientModuleId}/bulk-sms-quote`, { smsQty }).then((r) => r.document)
```
(Match the existing `post`/typed-call idiom in the file.)
- [ ] **Step 2: Write `ModuleDocuments`**
```tsx
const DOC_SOURCE_LABEL: Record<string, string> = {
module_renewal: 'Renewal', bulk_sms_topup: 'Bulk top-up',
}
function docLabel(d: Doc): string {
if (DOC_SOURCE_LABEL[d.source] !== undefined) return DOC_SOURCE_LABEL[d.source]!
return DOC_TYPE_LABEL[d.docType] ?? d.docType
}
function ModuleDocuments(props: { cm: ClientModule; docs: Doc[]; moduleCode: string; onCreated: () => void }) {
const nav = useNavigate()
const toast = useToast()
const [busy, setBusy] = useState(false)
const mine = props.docs
.filter((d) => d.payload.lines.some((l) => l.itemId === props.cm.moduleId))
.sort((a, b) => b.docDate.localeCompare(a.docDate))
const isSms = props.moduleCode.toUpperCase() === 'SMS'
const bulk = () => {
const qty = Number(window.prompt('Bulk SMS quantity to purchase?', '100000') ?? '')
if (!Number.isInteger(qty) || qty <= 0) return
setBusy(true)
generateBulkSmsPurchase(props.cm.id, qty)
.then((doc) => { toast.ok('Bulk SMS proforma created'); nav(`/documents/${doc.id}`) })
.catch((e: Error) => { toast.err(e.message); setBusy(false) })
}
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span className="lbl" style={{ fontWeight: 700 }}>Documents</span>
<Badge>{mine.length}</Badge>
<span style={{ flex: 1 }} />
{isSms && <Button disabled={busy} onClick={bulk}>Bulk SMS purchase</Button>}
</div>
{mine.length === 0 ? <div style={{ color: 'var(--text-dim)', fontSize: 12.5 }}>No documents for this module yet.</div>
: mine.map((d) => (
<div key={d.id} onClick={() => nav(`/documents/${d.id}`)}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '7px 0', borderTop: '1px solid var(--border)', cursor: 'pointer', fontSize: 12.5 }}>
<span className="mono" style={{ minWidth: 74 }}>{d.docNo ?? 'draft'}</span>
<span className="doc-pill">{docLabel(d)}</span>
<span className="mono" style={{ color: 'var(--text-dim)' }}>{d.docDate}</span>
<span className="mono" style={{ marginLeft: 'auto' }}>{inr(d.payablePaise)}</span>
<Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>
</div>
))}
</div>
)
}
```
- [ ] **Step 3: Render it in each module block**
In the modules-tab `.map` (~300319), inside `.mod-section-body`, after `<StatusLine … />` add:
```tsx
<ModuleDocuments cm={cm} docs={ledger.data?.documents ?? []} moduleCode={modules.data?.find((m) => m.id === cm.moduleId)?.code ?? ''} onCreated={() => ledger.reload()} />
```
- [ ] **Step 4: Verify**
Run: `npm run typecheck && npm run build --workspace @sims/hq-web`. In-app: SMS block lists its quotes/proformas with correct pills (Renewal / Bulk top-up / Quote); "Bulk SMS purchase" creates a proforma and opens it; it then appears in the list and (Task 5) in Outstanding.
- [ ] **Step 5: Commit**
```bash
git add apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/api.ts
git commit -m "feat(client-detail): per-module documents list + bulk SMS purchase action"
```
---
## Task 5: Payments tab — Outstanding panel + Settled column
**Files:**
- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` — Payments tab (~398475).
**Interfaces:**
- Consumes: `ledger.data.outstanding`, `ledger.data.payments[].allocations` (backend Task 6).
- [ ] **Step 1: Add the Outstanding panel**
At the top of the `tab === 'payments'` block, before `<PaymentForm …>`, render:
```tsx
{ledger.data !== undefined && ledger.data.outstanding.length > 0 && (
<div className="wf-card">
<h3 style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 10px' }}>
Outstanding
<Badge tone="warn">{inr(ledger.data.outstanding.reduce((s, o) => s + o.outstandingPaise, 0))} due</Badge>
</h3>
<div className="cdr-out">
<div className="oh"><span style={{ fontWeight: 700, fontSize: 13 }}>Open documents</span>
<span style={{ color: 'var(--text-dim)', fontSize: 12 }}>— oldest settles first</span></div>
{ledger.data.outstanding.map((o) => (
<div key={o.docId} className="orow" onClick={() => nav(`/documents/${o.docId}`)} style={{ cursor: 'pointer' }}>
<span className="mono" style={{ minWidth: 78 }}>{o.docNo ?? 'draft'}</span>
<span className="doc-pill">{DOC_TYPE_LABEL[o.docType as keyof typeof DOC_TYPE_LABEL] ?? o.docType}</span>
<span style={{ color: 'var(--text-dim)' }}>{o.label}</span>
<span className="due">{inr(o.outstandingPaise)}</span>
</div>
))}
<div className="ofoot"><span style={{ color: 'var(--text-dim)', fontSize: 12 }}>Advance on account</span>
<span className="mono" style={{ marginLeft: 'auto', fontWeight: 600 }}>{formatINR(ledger.data.advancePaise)}</span></div>
</div>
</div>
)}
```
- [ ] **Step 2: Add the Settled column to the payments table**
In the payments `DataTable` (~407), add a `settled` column between `ref` and `amount`:
```tsx
{ key: 'settled', label: 'Settled' },
// in the row map:
settled: p.allocations.length === 0
? <span style={{ color: 'var(--text-dim)' }}>advance</span>
: <span style={{ color: 'var(--text-dim)', fontSize: 12 }}>→ {p.allocations.map((a) => a.docNo).join(', ')}</span>,
```
- [ ] **Step 3: Verify**
Run: `npm run typecheck && npm run build --workspace @sims/hq-web`. In-app: Payments tab shows Outstanding with correct due totals + advance; each payment shows the doc(s) it settled or "advance"; recording a payment updates both.
- [ ] **Step 4: Commit**
```bash
git add apps/hq-web/src/pages/ClientDetail.tsx
git commit -m "feat(payments): outstanding panel + settled-to-document column"
```
---
## Task 6: Summary-table trim + "Add a module"
**Files:**
- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` (modules summary table ~275298)
- Modify: `apps/hq-web/src/pages/client-forms.tsx` (`AssignModuleForm` labels/copy ~285+)
**Interfaces:** none new.
- [ ] **Step 1: Trim the summary table**
Change the summary `DataTable` columns to `Module · Kind · Status · Billed · Settled` (drop `installed`/`completed`/`trained`; dates now live in the status line). Keep `status` as the `ClientModuleStatusSelect` (still editable), keep Billed/Settled numeric/right-aligned.
- [ ] **Step 2: Reword the assign form**
In `client-forms.tsx` `AssignModuleForm`: change the button/heading text from "Assign module" to **"Add a module"**, and add a one-line hint: `Starts the module at "quoted" and opens its onboarding status line.`
- [ ] **Step 3: Verify + commit**
Run: `npm run typecheck && npm run build --workspace @sims/hq-web`. In-app: summary table is tighter and aligned; "Add a module" reads right.
```bash
git add apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/pages/client-forms.tsx
git commit -m "feat(client-detail): trim module summary table; reword assign as 'Add a module'"
```
---
## Task 7: Per-module onboarding template editor (Modules page)
**Files:**
- Modify: `apps/hq-web/src/pages/Modules.tsx`
- Modify: `apps/hq-web/src/api.ts``getModuleTemplate(code)`, `setModuleTemplate(code, steps)`.
- Modify: `apps/hq/src/api.ts` — GET/PUT routes for `project.milestone_template:<CODE>` (audited).
**Interfaces:**
- Produces: `getModuleTemplate(code): Promise<{ key: string; label: string }[]>`, `setModuleTemplate(code, steps): Promise<void>`.
- [ ] **Step 1: Backend routes**
In `apps/hq/src/api.ts` add owner-only routes:
- `GET /modules/:code/onboarding-template``milestoneTemplate(db, code)`.
- `PUT /modules/:code/onboarding-template` → validate `steps: {key,label}[]` (non-empty labels; unique keys; slugify missing keys), write the `setting` row `project.milestone_template:<CODE>`, audited.
- [ ] **Step 2: Web calls + editor UI**
Add the two api calls, then in `Modules.tsx` add an "Onboarding steps" editor per module row (owner-only): a list of `{label}` rows with add/remove/reorder, Save calls `setModuleTemplate`. Keys auto-derive from labels on save (server slugifies). Note in the UI that changes reach existing projects additively (new steps appear; existing ticks untouched) and that a full re-order/rename for existing projects needs the migration script.
- [ ] **Step 3: Verify + commit**
Run: `npm run typecheck && npm test --workspace @sims/hq && npm run build --workspace @sims/hq-web`. In-app (owner): edit SMS steps, save, reload a client's SMS block → new step appears.
```bash
git add apps/hq-web/src/pages/Modules.tsx apps/hq-web/src/api.ts apps/hq/src/api.ts
git commit -m "feat(modules): owner editor for per-module onboarding templates"
```
---
## Task 8: Stalled-onboarding dashboard tile
**Files:**
- Modify: `apps/hq-web/src/api.ts``getStalledProjects()`.
- Modify: `apps/hq-web/src/pages/Dashboard.tsx` — a tile listing stalled projects.
**Interfaces:**
- Consumes: `GET /projects/stalled` (backend Task 8) → `{ clientModuleId, clientId, clientName, moduleCode, moduleName }[]`.
- [ ] **Step 1: Web call + tile**
Add `getStalledProjects`. In `Dashboard.tsx`, add a read-only card "Onboarding stalled" listing each stalled project as a link to `/clients/:clientId?tab=modules`, matching the existing dashboard card pattern (`.dash-card`/`.dash-row`). Empty → hide the card or show a positive empty state.
- [ ] **Step 2: Verify + commit**
Run: `npm run typecheck && npm run build --workspace @sims/hq-web`. In-app: a project parked past the threshold appears; clicking opens its Modules tab.
```bash
git add apps/hq-web/src/api.ts apps/hq-web/src/pages/Dashboard.tsx
git commit -m "feat(dashboard): stalled-onboarding tile"
```
---
## Task 9: Responsive + red-team alignment pass (landing gate)
**Files:** any of the above, as fixes require.
This task does not land new features — it hardens what Tasks 18 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 F2F5; `generateBulkSmsPurchase`, `getStalledProjects`, `getModuleTemplate`/`setModuleTemplate`, `setMilestone(..., paymentId?)`, `ledger.outstanding`, `payment.allocations` match the backend plan's exports.
- **Placeholder scan:** F3 Step 3 and F7 Step 2 describe interactive UI at component level with the exact calls to make; the payment-picker dialog and the template-editor rows follow the file's existing dialog/list idioms (e.g. `BranchList`, `PlanDialog`) — the implementer mirrors those concrete patterns rather than inventing new ones.
- **Ordering:** F1 (CSS) first so F2F5 render correctly; F9 last as the gate. Each task is independently reviewable and ends green.

@ -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 `<details class="mod-section">`):**
- 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:<MODULE_CODE>` 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.

@ -461,3 +461,11 @@ button.wf.pill { border-radius: 999px; padding: 8px 18px; }
.wf-formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; } .wf-formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; }
.wf-formgrid .wide { grid-column: 1 / -1; } .wf-formgrid .wide { grid-column: 1 / -1; }
@media (max-width: 560px) { .wf-formgrid { grid-template-columns: 1fr; } } @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%; }
}

Loading…
Cancel
Save