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
main
Thomas Joise 1 day ago
commit d6faf0d5bd

@ -47,6 +47,21 @@ directory via a multi-agent web-lookup (D35). **Login history** (D36): every sig
owner **Login history** screen; an invited user is sent to Profile to change their default
password on first sign-in.
**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
| Check | Result |

@ -217,11 +217,13 @@ export interface Payment {
id: string; clientId: string; receivedOn: string; mode: PaymentMode
reference: string; amountPaise: number; tdsPaise: number
createdBy: string; createdAt: string
allocations: { docNo: string; amountPaise: number }[]
}
export interface Ledger {
documents: Doc[]; payments: Payment[]; advancePaise: number
modulePaid: { moduleId: string; billedPaise: number; settledPaise: number }[]
outstanding: { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number }[]
}
// ---------- typed calls ----------
@ -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) })
.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[]> =>
apiFetch<{ clientModules: ClientModule[] }>(`/clients/${clientId}/modules`).then((r) => r.clientModules)
export const assignClientModule = (clientId: string, body: Record<string, unknown>): Promise<ClientModule> =>
@ -360,18 +376,26 @@ export const revealModuleSecret = (id: string, key: string): Promise<string> =>
// ---------- onboarding milestones (D22) ----------
/** One checklist step on a project (client_module). Ticking stamps `doneOn`. */
export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number }
/** One checklist step on a project (client_module). Ticking stamps `doneOn`; the
* advance/balance payment steps additionally carry `paymentId`, linking a real
* payment from the client's ledger. */
export interface Milestone {
key: string; label: string; done: boolean; doneOn: string | null; sort: number; paymentId: string | null
}
/** The project's checklist; the server auto-seeds the standard template on first read. */
export const getMilestones = (clientModuleId: string): Promise<Milestone[]> =>
apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`).then((r) => r.milestones)
/** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it. */
/** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it.
* `paymentId` links a real payment to a step (advance_payment / balance_payment). */
export const setMilestone = (
clientModuleId: string, key: string, done: boolean, doneOn?: string,
clientModuleId: string, key: string, done: boolean, doneOn?: string, paymentId?: string,
): Promise<Milestone[]> =>
apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`, {
method: 'POST', body: JSON.stringify({ key, done, ...(doneOn !== undefined ? { doneOn } : {}) }),
method: 'POST',
body: JSON.stringify({
key, done, ...(doneOn !== undefined ? { doneOn } : {}), ...(paymentId !== undefined ? { paymentId } : {}),
}),
}).then((r) => r.milestones)
/** Add a project-specific step beyond the standard template. */
export const addMilestone = (clientModuleId: string, label: string): Promise<Milestone[]> =>
@ -955,6 +979,11 @@ export const getRenewals = (from?: string, to?: string): Promise<RenewalRow[]> =
}
export const generateModuleRenewalQuote = (clientModuleId: string): Promise<Doc> =>
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.
export interface MisCockpit {

@ -483,3 +483,41 @@ table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inse
}
.no-print { display: none !important; }
}
/* ---- Client Detail redesign (D-CDR) ---- */
/* 50/50 service sheet */
.cdr-split { display: grid; grid-template-columns: 1fr 1fr; gap: 0 30px; }
.cdr-split > .col { padding: 13px 0; min-width: 0; }
.cdr-split > .col + .col { border-left: 1px solid var(--border); padding-left: 30px; }
.cdr-kv { display: flex; align-items: baseline; gap: 10px; padding: 6px 0; min-width: 0; }
.cdr-kv + .cdr-kv { border-top: 1px dashed var(--border); }
.cdr-kv > .k { flex: none; width: 120px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-dim); font-weight: 600; padding-top: 2px; }
.cdr-kv > .v { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.cdr-kv > .v .txt { overflow: hidden; text-overflow: ellipsis; }
@media (max-width: 720px) {
.cdr-split { grid-template-columns: 1fr; }
.cdr-split > .col + .col { border-left: none; padding-left: 0; border-top: 1px solid var(--border); }
}
/* horizontal onboarding status line */
.cdr-track-wrap { overflow-x: auto; padding: 10px 2px 4px; }
.cdr-track { display: flex; min-width: min-content; }
.cdr-node { flex: 1 0 112px; display: flex; flex-direction: column; align-items: center; text-align: center; position: relative; padding: 0 4px; }
.cdr-node .bar { position: absolute; top: 11px; right: 50%; width: 100%; height: 2px; background: var(--border); }
.cdr-node.done .bar, .cdr-node.now .bar { background: var(--accent); }
.cdr-node:first-child .bar { display: none; }
.cdr-node .dot { position: relative; z-index: 1; width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--border-strong); background: var(--bg-raised); display: grid; place-items: center; font-size: 11px; color: transparent; cursor: pointer; }
.cdr-node.done .dot { background: var(--ok); border-color: var(--ok); color: #fff; }
.cdr-node.now .dot { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
.cdr-node .nm { font-size: 11px; margin-top: 7px; max-width: 104px; line-height: 1.35; }
.cdr-node.done .nm { color: var(--text-dim); }
.cdr-node.now .nm { font-weight: 600; }
.cdr-node .dt { font-size: 10px; color: var(--text-dim); font-family: var(--mono); margin-top: 3px; }
/* outstanding panel */
.cdr-out { border: 1px solid color-mix(in srgb, var(--warn) 30%, var(--border)); border-radius: var(--radius); overflow: hidden; }
.cdr-out .oh { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); }
.cdr-out .orow { display: flex; align-items: center; gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 12.5px; }
.cdr-out .orow:last-child { border-bottom: none; }
.cdr-out .orow .due { margin-left: auto; font-family: var(--mono); font-weight: 600; }
.cdr-out .ofoot { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--bg-inset); }
/* document-type pill */
.doc-pill { font-size: 10.5px; font-weight: 600; letter-spacing: .3px; border-radius: 999px; padding: 1px 8px; border: 1px solid var(--border); color: var(--text-dim); background: var(--bg-inset); }

@ -1,8 +1,8 @@
import { Fragment, useState, type CSSProperties, type ReactNode } from 'react'
import { useState, type CSSProperties, type ReactNode } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import {
Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, Field, RecordHeader, Skeleton,
Badge, Button, ConfirmDialog, DataTable, Dialog, EmptyState, ErrorState, Field, Notice, RecordHeader, Skeleton,
StatCard, Stats, Tabs, Toolbar, useToast,
} from '@sims/ui'
import {
@ -16,9 +16,11 @@ import {
getClientAwsUsage, getCompanyProfile,
getBranches, createBranch, patchBranch, getTickets,
getMilestones, setMilestone, addMilestone,
generateBulkSmsPurchase,
CLIENT_STATUSES, KIND_LABEL,
type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule,
type ClientStatus, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome, type RecurringPlan, type ServiceDetail,
type ClientStatus, type Doc, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome,
type Payment, type RecurringPlan, type ServiceDetail,
} from '../api'
import { buildStatement } from '../statement'
import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients'
@ -27,7 +29,7 @@ import {
} from './Tickets'
import {
AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect,
InteractionDialog, PaymentForm, PlanDialog, RowDate,
InteractionDialog, PaymentForm, PlanDialog,
} from './client-forms'
import { ClientFormDialog } from '../components/ClientFormDialog'
import { PulseRibbon } from '../components/PulseRibbon'
@ -73,6 +75,19 @@ export function ClientDetail() {
const [interactionDialog, setInteractionDialog] = useState<{ initial?: Interaction } | undefined>()
const [confirm, setConfirm] = useState<Confirm | undefined>()
const [statementOpen, setStatementOpen] = useState(false)
// Onboarding status line (D38): ticking the advance/balance payment step opens this
// picker instead of stamping a bare date. `milestonesVersion` bumps to force every
// StatusLine's milestone list to refetch after a pick lands (see `refreshToken` below).
const [paymentPicker, setPaymentPicker] = useState<{ clientModuleId: string; key: string; label: string } | undefined>()
const [linkingPayment, setLinkingPayment] = useState(false)
const [linkError, setLinkError] = useState<string | undefined>()
const [milestonesVersion, setMilestonesVersion] = useState(0)
// Ticking the account_creation step opens this credentials dialog instead of a bare
// toggle (mirrors the payment picker above) — `cm` is captured via closure at the JSX
// callback below, exactly like `onTickPayment` captures it for the payment picker.
const [credsDialog, setCredsDialog] = useState<{ cm: ClientModule; key: string; label: string } | undefined>()
const [savingCreds, setSavingCreds] = useState(false)
const [credsError, setCredsError] = useState<string | undefined>()
const rawTab = sp.get('tab') ?? 'overview'
const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview'
@ -259,11 +274,8 @@ export function ClientDetail() {
{modules.data !== undefined && (
<AssignModuleForm
modules={modules.data}
onAssign={(moduleId, kind, creds) => {
onAssign={(moduleId, kind) => {
assignClientModule(id, { moduleId, kind })
.then((cm) => creds !== undefined
? patchClientModule(cm.id, { username: creds.username, password: creds.password })
: cm)
.then(() => { toast.ok('Module assigned'); cms.reload(); ledger.reload() })
.catch((err: Error) => toast.err(err.message))
}}
@ -276,8 +288,6 @@ export function ClientDetail() {
columns={[
{ key: 'module', label: 'Module' }, { key: 'kind', label: 'Kind' },
{ key: 'status', label: 'Status' },
{ key: 'installed', label: 'Installed' }, { key: 'completed', label: 'Completed' },
{ key: 'trained', label: 'Trained' },
{ key: 'billed', label: 'Billed', numeric: true },
{ key: 'settled', label: 'Settled', numeric: true },
]}
@ -287,9 +297,6 @@ export function ClientDetail() {
module: moduleName(cm.moduleId),
kind: KIND_LABEL[cm.kind],
status: <ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={toast.err} />,
installed: <RowDate cm={cm} field="installedOn" onPatched={() => cms.reload()} onError={toast.err} />,
completed: <RowDate cm={cm} field="completedOn" onPatched={() => cms.reload()} onError={toast.err} />,
trained: <RowDate cm={cm} field="trainedOn" onPatched={() => cms.reload()} onError={toast.err} />,
billed: paid !== undefined ? inr(paid.billedPaise) : '—',
settled: paid !== undefined ? inr(paid.settledPaise) : '—',
}
@ -305,6 +312,9 @@ export function ClientDetail() {
<span className="mod-name">{moduleName(cm.moduleId)}</span>
<Badge tone={cm.status === 'live' ? 'ok' : undefined}>{cm.status}</Badge>
<Badge>{KIND_LABEL[cm.kind]}</Badge>
<span onClick={(e) => e.stopPropagation()}>
<ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={toast.err} />
</span>
</summary>
<div className="mod-section-body">
<ServiceDataCard
@ -313,7 +323,19 @@ export function ClientDetail() {
fieldSpec={modules.data?.find((m) => m.id === cm.moduleId)?.fieldSpec ?? []}
onChanged={() => cms.reload()}
/>
<MilestoneChecklist clientModuleId={cm.id} name={moduleName(cm.moduleId)} />
<StatusLine
clientModuleId={cm.id}
name={moduleName(cm.moduleId)}
refreshToken={milestonesVersion}
onTickPayment={(key, label) => { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }}
onTickCreds={(key, label) => { setCredsError(undefined); setCredsDialog({ cm, key, label }) }}
/>
<ModuleDocuments
cm={cm}
docs={ledger.data?.documents ?? []}
moduleCode={modules.data?.find((m) => m.id === cm.moduleId)?.code ?? ''}
onCreated={() => ledger.reload()}
/>
</div>
</details>
))}
@ -402,12 +424,33 @@ export function ClientDetail() {
<StatCard label="Advance on account" value={formatINR(ledger.data.advancePaise)} hint="unallocated payments" />
</Stats>
)}
{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>
</div>
)}
<PaymentForm clientId={id} onDone={() => { ledger.reload(); cms.reload(); amc.reload() }} />
{ledger.data !== undefined && ledger.data.payments.length > 0 && (
<DataTable
columns={[
{ key: 'on', label: 'Received' }, { key: 'mode', label: 'Mode' },
{ key: 'ref', label: 'Reference', mono: true },
{ key: 'settled', label: 'Settled' },
{ key: 'amount', label: 'Amount', numeric: true },
{ key: 'tds', label: 'TDS', numeric: true },
]}
@ -415,6 +458,9 @@ export function ClientDetail() {
.sort((a, b) => (a.receivedOn === b.receivedOn ? (a.id < b.id ? 1 : -1) : b.receivedOn.localeCompare(a.receivedOn)))
.map((p) => ({
on: p.receivedOn, mode: p.mode, ref: p.reference !== '' ? p.reference : '—',
settled: p.allocations.length === 0
? <span style={{ color: 'var(--text-dim)' }}>advance</span>
: <span style={{ color: 'var(--text-dim)', fontSize: 12 }}> {p.allocations.map((a) => a.docNo).join(', ')}</span>,
amount: inr(p.amountPaise), tds: p.tdsPaise > 0 ? inr(p.tdsPaise) : '—',
}))}
/>
@ -607,6 +653,71 @@ export function ClientDetail() {
{statementOpen && (
<ClientStatement client={c} ledger={ledger.data} onClose={() => setStatementOpen(false)} />
)}
{paymentPicker !== undefined && (
<PaymentPickerDialog
open
stepLabel={paymentPicker.label}
payments={[...(ledger.data?.payments ?? [])].sort((a, b) => b.receivedOn.localeCompare(a.receivedOn))}
saving={linkingPayment}
error={linkError}
onClose={() => { if (!linkingPayment) setPaymentPicker(undefined) }}
onPick={(paymentId) => {
if (linkingPayment) return
setLinkingPayment(true)
setLinkError(undefined)
setMilestone(paymentPicker.clientModuleId, paymentPicker.key, true, undefined, paymentId)
.then(() => {
toast.ok('Payment linked')
setMilestonesVersion((v) => v + 1)
setPaymentPicker(undefined)
})
.catch((e: Error) => setLinkError(e.message))
.finally(() => setLinkingPayment(false))
}}
onRecordNew={() => { setPaymentPicker(undefined); setTab('payments') }}
/>
)}
{credsDialog !== undefined && (
<CredentialsDialog
open
stepLabel={credsDialog.label}
cm={credsDialog.cm}
saving={savingCreds}
error={credsError}
onClose={() => { if (!savingCreds) setCredsDialog(undefined) }}
onSave={(fields) => {
if (savingCreds) return
setSavingCreds(true)
setCredsError(undefined)
patchClientModule(credsDialog.cm.id, {
provider: fields.provider, username: fields.username,
...(canRoute && fields.password.trim() !== '' ? { password: fields.password } : {}),
})
.then(() => setMilestone(credsDialog.cm.id, credsDialog.key, true))
.then(() => {
toast.ok('Credentials saved')
setMilestonesVersion((v) => v + 1)
cms.reload()
setCredsDialog(undefined)
})
.catch((e: Error) => setCredsError(e.message))
.finally(() => setSavingCreds(false))
}}
onSkip={() => {
if (savingCreds) return
setSavingCreds(true)
setCredsError(undefined)
setMilestone(credsDialog.cm.id, credsDialog.key, true)
.then(() => {
toast.ok('Step marked done')
setMilestonesVersion((v) => v + 1)
setCredsDialog(undefined)
})
.catch((e: Error) => setCredsError(e.message))
.finally(() => setSavingCreds(false))
}}
/>
)}
</div>
)
}
@ -959,23 +1070,27 @@ function BranchList(props: { clientId: string; branches: Branch[]; onChanged: ()
}
/**
* Per-service operational data (D20 APEX sms_clients_list parity), following
* SupportCard's exact visual pattern: label/value cells, the portal password as
* with managerial audited reveal (gated like the DB password), and Edit
* switching provider/username/remark + the labeled details into inputs one
* audited PATCH saves the lot.
* Per-service operational data (D20 APEX sms_clients_list parity) + D21 module-defined
* fields, rendered as a 50/50 report (Access | Details D-CDR): read-only key/value rows,
* the portal password as with managerial audited reveal (gated like the DB password),
* secret fieldSpec values as their own inline SecretField, and ONE Edit per column that
* swaps the whole card into a single form provider/username/remark/password + the
* freeform details + the non-secret fieldSpec values all save through one Save (two
* audited calls in sequence: patchClientModule for D20, setModuleFields for D21).
*/
function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: FieldDef[]; onChanged: () => void }) {
const toast = useToast()
const managerial = isManagerial()
const cm = props.cm
const sortedFieldSpec = [...props.fieldSpec].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
const nonSecretFields = sortedFieldSpec.filter((fld) => fld.type !== 'secret')
const secretFields = sortedFieldSpec.filter((fld) => fld.type === 'secret')
const [revealed, setRevealed] = useState<string | undefined>()
const [settingPw, setSettingPw] = useState(false)
const [pw, setPw] = useState('')
const [busy, setBusy] = useState(false)
const [editing, setEditing] = useState(false)
const [f, setF] = useState({ provider: '', username: '', remark: '', password: '' })
const [details, setDetails] = useState<ServiceDetail[]>([])
const [fieldDraft, setFieldDraft] = useState<Record<string, string>>({})
const copy = (text: string, what: string) => {
navigator.clipboard.writeText(text).then(() => toast.ok(`${what} copied`)).catch(() => toast.err('Copy failed'))
@ -988,42 +1103,68 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const savePw = () => {
if (busy || pw === '') return
setBusy(true)
patchClientModule(cm.id, { password: pw })
.then(() => { toast.ok('Portal password stored (encrypted)'); setSettingPw(false); setPw(''); setRevealed(undefined); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const startEdit = () => {
setF({ provider: cm.provider ?? '', username: cm.username ?? '', remark: cm.remark ?? '', password: '' })
setDetails(cm.details.map((d) => ({ ...d })))
setFieldDraft(Object.fromEntries(nonSecretFields.map((fld) => [fld.key, cm.fieldValues[fld.key] ?? ''])))
setEditing(true)
}
const saveEdit = () => {
if (busy) return
const cleaned = details.filter((d) => d.label.trim() !== '' || d.value.trim() !== '')
if (cleaned.some((d) => d.label.trim() === '')) { toast.err('Every detail needs a label'); return }
const missingRequired = nonSecretFields.find((fld) => fld.required === true && (fieldDraft[fld.key] ?? '').trim() === '')
if (missingRequired !== undefined) { toast.err(`${missingRequired.label} is required`); return }
setBusy(true)
// Only the fieldSpec keys that actually changed — '' clears a value server-side.
const changedFields = Object.fromEntries(
nonSecretFields
.filter((fld) => (fieldDraft[fld.key] ?? '') !== (cm.fieldValues[fld.key] ?? ''))
.map((fld) => [fld.key, fieldDraft[fld.key] ?? '']),
)
patchClientModule(cm.id, {
provider: f.provider, username: f.username, remark: f.remark,
details: cleaned.map((d) => ({ label: d.label.trim(), value: d.value })),
// Password only when the managerial user typed one (blank = keep current).
...(managerial && f.password.trim() !== '' ? { password: f.password } : {}),
})
.then(() => { toast.ok('Service data saved'); setEditing(false); props.onChanged() })
.then(() => (Object.keys(changedFields).length > 0 ? setModuleFields(cm.id, changedFields) : undefined))
.then(() => { toast.ok('Service data saved'); setEditing(false); setRevealed(undefined); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const cell = (label: string, value: string | undefined, mono = false, copyable = false) => (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6, marginRight: 18 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>{label}</span>
<span className={mono ? 'mono' : undefined}>{value ?? '—'}</span>
{copyable && value !== undefined && <Button onClick={() => copy(value, label)}>Copy</Button>}
</span>
)
// D21 non-secret fieldSpec input — moved here from the old always-on ModuleFieldsForm so
// it saves alongside D20 through this one Edit/Save. Secret fields keep their own inline
// SecretField widget (independent Set/Update/Reveal) and stay out of this form.
const fieldControl = (fld: FieldDef) => {
const v = fieldDraft[fld.key] ?? ''
const set = (val: string) => setFieldDraft((p) => ({ ...p, [fld.key]: val }))
if (fld.type === 'boolean') {
return (
<select className="wf" style={{ width: 120 }} value={v} onChange={(e) => set(e.target.value)}>
<option value=""></option>
<option value="true">true</option>
<option value="false">false</option>
</select>
)
}
if (fld.type === 'select') {
return (
<select className="wf" style={{ width: 180 }} value={v} onChange={(e) => set(e.target.value)}>
<option value=""></option>
{(fld.options ?? []).map((o) => <option key={o} value={o}>{o}</option>)}
</select>
)
}
if (fld.type === 'number') {
return <input className="wf num" type="number" style={{ width: 140 }} value={v} onChange={(e) => set(e.target.value)} />
}
if (fld.type === 'date') {
return <input className="wf" type="date" style={{ width: 160 }} value={v} onChange={(e) => set(e.target.value)} />
}
return <input className="wf" style={{ width: 200 }} value={v} onChange={(e) => set(e.target.value)} />
}
if (editing) {
return (
@ -1040,6 +1181,29 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
)}
<Field label="Remark"><input className="wf" style={{ width: 220 }} value={f.remark} onChange={(e) => setF((p) => ({ ...p, remark: e.target.value }))} /></Field>
</div>
{nonSecretFields.length > 0 && (
<>
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0 10px' }} />
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 10 }}>
{nonSecretFields.map((fld) => {
// A field holding an http(s) value gets a direct "Open ↗" link (portal/console shortcut).
const val = fieldDraft[fld.key] ?? ''
const isUrl = /^https?:\/\//i.test(val.trim())
return (
<Field key={fld.key} label={fld.required === true ? `${fld.label} *` : fld.label}>
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
{fieldControl(fld)}
{isUrl && (
<a href={val.trim()} target="_blank" rel="noopener noreferrer"
title="Open in a new tab" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>Open </a>
)}
</span>
</Field>
)
})}
</div>
</>
)}
{details.map((d, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<input
@ -1065,145 +1229,59 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<span style={{ fontWeight: 600, marginRight: 10 }}>{props.name}</span>
{cell('Provider', cm.provider ?? undefined)}
{cell('Username', cm.username ?? undefined, true, true)}
{cm.details.map((d, i) => <Fragment key={i}>{cell(d.label, d.value)}</Fragment>)}
{cell('Remark', cm.remark ?? undefined)}
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>Portal password</span>
{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>}
{managerial && !settingPw && <Button onClick={() => setSettingPw(true)}>{cm.hasPassword ? 'Update' : 'Set…'}</Button>}
{managerial && settingPw && (
<>
<input className="wf mono" type="password" style={{ width: 160 }} placeholder="Portal password" aria-label="Portal password"
value={pw} onChange={(e) => setPw(e.target.value)} />
<Button tone="primary" onClick={savePw}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => { setSettingPw(false); setPw('') }}>Cancel</Button>
</>
)}
</span>
<span style={{ flex: 1 }} />
<Button onClick={startEdit}>Edit</Button>
<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>
{nonSecretFields.map((fld) => (
<Kv key={fld.key} label={fld.label} value={cm.fieldValues[fld.key]} />
))}
{cm.details.map((d, i) => <Kv key={`d${i}`} label={d.label} value={d.value} />)}
{secretFields.map((fld) => (
<div className="cdr-kv" key={fld.key}>
<span className="k">{fld.label}</span>
<span className="v"><SecretField cm={cm} field={fld} managerial={managerial} onChanged={props.onChanged} /></span>
</div>
))}
</div>
</div>
{/* D21: dynamic form from the owning module's fieldSpec, below the D20 block. */}
{props.fieldSpec.length > 0 && (
<>
<div style={{ borderTop: '1px solid var(--border)', margin: '12px 0 10px' }} />
<ModuleFieldsForm cm={cm} fieldSpec={props.fieldSpec} onChanged={props.onChanged} />
</>
)}
</div>
)
}
/**
* D21 module-defined fields (config over code): renders one input per field the owning
* module DECLARES in its fieldSpec. Non-secret edits collect into a single audited PUT
* (setModuleFields); secret fields never leave the server unrevealed with a
* managerial audited Reveal + Set/Update (setModuleSecret), exactly like the portal
* password. Values come from cm.fieldValues; secret set-state from cm.secretKeys.
*/
function ModuleFieldsForm(props: { cm: ClientModule; fieldSpec: FieldDef[]; onChanged: () => void }) {
const toast = useToast()
const managerial = isManagerial()
const cm = props.cm
const sorted = [...props.fieldSpec].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
const nonSecret = sorted.filter((f) => f.type !== 'secret')
const secrets = sorted.filter((f) => f.type === 'secret')
const seed = (): Record<string, string> =>
Object.fromEntries(nonSecret.map((f) => [f.key, cm.fieldValues[f.key] ?? '']))
const [draft, setDraft] = useState<Record<string, string>>(seed)
const [busy, setBusy] = useState(false)
const dirty = nonSecret.some((f) => (draft[f.key] ?? '') !== (cm.fieldValues[f.key] ?? ''))
const missingRequired = nonSecret.find((f) => f.required === true && (draft[f.key] ?? '').trim() === '')
const save = () => {
if (busy) return
if (missingRequired !== undefined) { toast.err(`${missingRequired.label} is required`); return }
setBusy(true)
// Send only the changed keys; '' clears server-side.
const values = Object.fromEntries(
nonSecret
.filter((f) => (draft[f.key] ?? '') !== (cm.fieldValues[f.key] ?? ''))
.map((f) => [f.key, draft[f.key] ?? '']),
)
setModuleFields(cm.id, values)
.then((updated) => {
toast.ok('Fields saved')
setDraft(Object.fromEntries(nonSecret.map((f) => [f.key, updated.fieldValues[f.key] ?? ''])))
props.onChanged()
})
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const control = (f: FieldDef) => {
const v = draft[f.key] ?? ''
const set = (val: string) => setDraft((p) => ({ ...p, [f.key]: val }))
if (f.type === 'boolean') {
return (
<select className="wf" style={{ width: 120 }} value={v} onChange={(e) => set(e.target.value)}>
<option value=""></option>
<option value="true">true</option>
<option value="false">false</option>
</select>
)
}
if (f.type === 'select') {
return (
<select className="wf" style={{ width: 180 }} value={v} onChange={(e) => set(e.target.value)}>
<option value=""></option>
{(f.options ?? []).map((o) => <option key={o} value={o}>{o}</option>)}
</select>
)
}
if (f.type === 'number') {
return <input className="wf num" type="number" style={{ width: 140 }} value={v} onChange={(e) => set(e.target.value)} />
}
if (f.type === 'date') {
return <input className="wf" type="date" style={{ width: 160 }} value={v} onChange={(e) => set(e.target.value)} />
}
return <input className="wf" style={{ width: 200 }} value={v} onChange={(e) => set(e.target.value)} />
}
/** One label/value row in the 50/50 service-data report; `—` for empty, optional mono + copy. */
function Kv(props: { label: string; value: string | undefined; mono?: boolean; copyable?: boolean; onCopy?: () => void }) {
const v = props.value
return (
<div>
{nonSecret.length > 0 && (
<>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'flex-end' }}>
{nonSecret.map((f) => {
// A field holding an http(s) value gets a direct "Open ↗" link (portal/console shortcut).
const val = draft[f.key] ?? ''
const isUrl = /^https?:\/\//i.test(val.trim())
return (
<Field key={f.key} label={f.required === true ? `${f.label} *` : f.label}>
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
{control(f)}
{isUrl && (
<a href={val.trim()} target="_blank" rel="noopener noreferrer"
title="Open in a new tab" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>Open </a>
)}
</span>
</Field>
)
})}
</div>
<Toolbar>
<Button tone="primary" disabled={busy || !dirty} onClick={save}>{busy ? 'Saving…' : 'Save fields'}</Button>
</Toolbar>
</>
)}
{secrets.map((f) => (
<SecretField key={f.key} cm={cm} field={f} managerial={managerial} onChanged={props.onChanged} />
))}
<div className="cdr-kv">
<span className="k">{props.label}</span>
<span className="v">
{v !== undefined && v !== '' ? <span className={`txt${props.mono === true ? ' mono' : ''}`}>{v}</span> : <span style={{ opacity: 0.5 }}></span>}
{props.copyable === true && v !== undefined && v !== '' && <Button onClick={props.onCopy}>Copy</Button>}
</span>
</div>
)
}
@ -1262,14 +1340,24 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
}
/**
* Onboarding checklist (D22 per-project detail): the project's milestones as a row
* of toggles. Ticking one stamps the date (a compact date input adjusts it; toggling
* on with no date uses today); "+ Add step" appends a project-specific milestone. Every
* change is one audited POST that returns the fresh list, so the card re-renders in place.
* Onboarding as a horizontal status line (D38 redesign per-project detail): the
* project's milestones as a point-by-point track (`.cdr-track`) a connector line
* joins ordered dots, the current (first not-done) step is ringed ("now"), done steps
* show a check and their date. Ticking a plain step stamps today's date and drives the
* module status (installed/live) exactly as before; ticking the advance/balance payment
* steps instead calls `onTickPayment` so the parent can open a picker that links a real
* ledger payment (`ClientDetail`'s `PaymentPickerDialog`) rather than a bare date.
* "+ Add step" appends a project-specific milestone. Every change is one audited POST
* that returns the fresh list; `refreshToken` lets the parent force a refetch after a
* payment link lands from outside this component.
*/
function MilestoneChecklist(props: { clientModuleId: string; name: string }) {
function StatusLine(props: {
clientModuleId: string; name: string; refreshToken: number
onTickPayment: (key: string, label: string) => void
onTickCreds: (key: string, label: string) => void
}) {
const toast = useToast()
const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId])
const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId, props.refreshToken])
const [busyKey, setBusyKey] = useState('')
const [adding, setAdding] = useState(false)
const [label, setLabel] = useState('')
@ -1294,64 +1382,229 @@ function MilestoneChecklist(props: { clientModuleId: string; name: string }) {
const ms = list.data
const done = ms?.filter((m) => m.done).length ?? 0
// current = first not-done step
const currentKey = ms?.find((m) => !m.done)?.key
const isPaymentStep = (key: string) => key === 'advance_payment' || key === 'balance_payment'
const isCredsStep = (key: string) => key === 'account_creation'
// Ticking ON a payment step opens the picker (parent owns that dialog — it needs the
// client's ledger + the Payments tab); ticking ON account_creation opens the parent's
// credentials dialog (it needs the module's provider/username/password + managerial
// gate). Un-ticking and every other step toggle directly.
const onDotClick = (m: Milestone) => {
if (busyKey !== '') return
if (!m.done && isPaymentStep(m.key)) { props.onTickPayment(m.key, m.label); return }
if (!m.done && isCredsStep(m.key)) { props.onTickCreds(m.key, m.label); return }
toggle(m, !m.done)
}
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{ fontWeight: 600 }}>{props.name} onboarding</span>
<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" aria-label="New step label"
value={label} onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addStep() }}
/>
<Button tone="primary" disabled={addBusy || label.trim() === ''} onClick={addStep}>{addBusy ? 'Adding…' : '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 style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
<div className="cdr-track-wrap"><div className="cdr-track">
{ms.map((m) => (
<div
key={m.key}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
border: '1px solid var(--border)', borderRadius: 6, padding: '4px 10px',
opacity: busyKey === m.key ? 0.6 : 1,
}}
className={`cdr-node ${m.done ? 'done' : m.key === currentKey ? 'now' : ''}`}
style={{ opacity: busyKey === m.key ? 0.5 : 1 }}
>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="checkbox" checked={m.done} disabled={busyKey === m.key}
onChange={(e) => toggle(m, e.target.checked)}
/>
<span style={m.done ? { textDecoration: 'line-through', opacity: 0.7 } : undefined}>{m.label}</span>
</label>
{m.done && (
<>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>on</span>
<input
type="date" className="wf" style={{ width: 140 }} value={m.doneOn ?? ''}
disabled={busyKey === m.key}
onChange={(e) => toggle(m, true, e.target.value !== '' ? e.target.value : undefined)}
/>
</>
)}
<span className="bar" />
<button
className="dot" disabled={busyKey === m.key}
title={
m.done ? 'Click to un-tick'
: isPaymentStep(m.key) ? 'Click to link a payment'
: isCredsStep(m.key) ? 'Click to set account credentials'
: 'Click to mark done (today)'
}
onClick={() => onDotClick(m)}
>
{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>
)
}
/** Doc.source values that don't map onto DOC_TYPE_LABEL get a friendlier, source-aware label. */
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
}
/**
* Task 4: per-module documents every doc whose line items include this module (quotes,
* renewals, bulk SMS top-ups, invoices, credit notes), newest first. SMS-coded modules get
* a "Bulk SMS purchase" action that raises a proforma and jumps straight to it.
*/
function ModuleDocuments(props: { cm: ClientModule; docs: Doc[]; moduleCode: string; onCreated: () => void }) {
const nav = useNavigate()
const toast = useToast()
const [busy, setBusy] = useState(false)
const mine = props.docs
.filter((d) => d.payload.lines.some((l) => l.itemId === props.cm.moduleId))
.sort((a, b) => b.docDate.localeCompare(a.docDate))
const isSms = props.moduleCode.toUpperCase() === 'SMS'
const bulk = () => {
const qty = Number(window.prompt('Bulk SMS quantity to purchase?', '100000') ?? '')
if (!Number.isInteger(qty) || qty <= 0) return
setBusy(true)
generateBulkSmsPurchase(props.cm.id, qty)
.then((doc) => { toast.ok('Bulk SMS proforma created'); props.onCreated(); nav(`/documents/${doc.id}`) })
.catch((e: Error) => { toast.err(e.message); setBusy(false) })
}
return (
<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>
)
}
/**
* Payment-step picker (D38): ticking the advance/balance payment status step opens this
* instead of stamping a bare date it links a real payment row so the milestone and the
* ledger agree (`setMilestone(..., paymentId)`; the server mirrors that payment's
* `receivedOn` into `doneOn`). Lists the client's payments, newest first; "Record new
* payment" defers to the Payments tab rather than guessing an amount here.
*/
function PaymentPickerDialog(props: {
open: boolean; onClose: () => void; stepLabel: string
payments: Payment[]; saving: boolean; error?: string
onPick: (paymentId: string) => void; onRecordNew: () => void
}) {
return (
<Dialog
open={props.open}
onClose={() => { if (!props.saving) props.onClose() }}
title={`Link a payment — ${props.stepLabel}`}
size="sm"
footer={<Button onClick={props.onClose} disabled={props.saving}>Cancel</Button>}
>
{props.payments.length === 0
? <EmptyState>No payments recorded for this client yet.</EmptyState>
: (
<DataTable
columns={[
{ key: 'on', label: 'Received' }, { key: 'amount', label: 'Amount', numeric: true },
{ key: 'act', label: '' },
]}
rows={props.payments.map((p) => ({
on: p.receivedOn,
amount: inr(p.amountPaise),
act: <Button tone="primary" disabled={props.saving} onClick={() => props.onPick(p.id)}>Use</Button>,
}))}
/>
)}
<Toolbar>
{!adding
? <Button onClick={() => setAdding(true)}>+ Add step</Button>
: (
<>
<input
className="wf" style={{ maxWidth: 240 }} placeholder="Step label" aria-label="New step label"
value={label} onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addStep() }}
/>
<Button tone="primary" disabled={addBusy || label.trim() === ''} onClick={addStep}>{addBusy ? 'Adding…' : 'Add'}</Button>
<Button onClick={() => { setAdding(false); setLabel('') }}>Cancel</Button>
</>
)}
<span style={{ flex: 1 }} />
<Button disabled={props.saving} onClick={props.onRecordNew}>Record new payment</Button>
</Toolbar>
</div>
{props.error !== undefined && <Notice tone="err">{props.error}</Notice>}
</Dialog>
)
}
/**
* Account-creation credentials dialog (D38 follow-up): ticking the "Account creation &
* registration" status-line step opens this instead of a bare toggle mirrors
* `PaymentPickerDialog`'s structure/props exactly. Captures the module's Access fields
* right at the point the account actually exists, reusing `ServiceDataCard`'s exact save
* call (`patchClientModule`) and its managerial gate on the password field (blank keeps
* whatever is already stored). Fields pre-fill from the client_module so re-ticking after
* creds already exist is never blocked "Skip" marks the step done without touching
* credentials at all, for modules that don't need one.
*/
function CredentialsDialog(props: {
open: boolean; onClose: () => void; stepLabel: string; cm: ClientModule
saving: boolean; error?: string
onSave: (fields: { provider: string; username: string; password: string }) => void
onSkip: () => void
}) {
const managerial = isManagerial()
const [provider, setProvider] = useState(props.cm.provider ?? '')
const [username, setUsername] = useState(props.cm.username ?? '')
const [password, setPassword] = useState('')
return (
<Dialog
open={props.open}
onClose={() => { if (!props.saving) props.onClose() }}
title={`Account credentials — ${props.stepLabel}`}
size="sm"
footer={
<>
<Button onClick={props.onClose} disabled={props.saving}>Cancel</Button>
<Button disabled={props.saving} onClick={props.onSkip}>Skip mark done</Button>
<Button tone="primary" disabled={props.saving} onClick={() => props.onSave({ provider, username, password })}>
{props.saving ? 'Saving…' : 'Save & mark done'}
</Button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Field label="Provider">
<input className="wf" value={provider} onChange={(e) => setProvider(e.target.value)} />
</Field>
<Field label="Username">
<input className="wf mono" value={username} onChange={(e) => setUsername(e.target.value)} />
</Field>
{managerial && (
<Field label="Password">
<input
className="wf mono" type="password"
placeholder={props.cm.hasPassword ? 'leave blank to keep' : 'set password'}
value={password} onChange={(e) => setPassword(e.target.value)}
/>
</Field>
)}
</div>
{props.error !== undefined && <Notice tone="err">{props.error}</Notice>}
</Dialog>
)
}

@ -5,11 +5,11 @@ import {
FormGrid, Notice, PageHeader, Skeleton, Tabs, Toolbar, useToast,
} from '@sims/ui'
import {
addPrice, assignClientModule, createModule, downloadModuleClientsCsv, getClients,
getModuleClients, getModules, getPrices, notifyModuleClients, patchClientModule, patchModule,
previewSample, role,
addPrice, assignClientModule, createModule, downloadModuleClientsCsv, getClients, getFrontTemplate,
getModuleClients, getModuleTemplate, getModules, getPrices, notifyModuleClients, patchClientModule, patchModule,
previewSample, role, setFrontTemplate, setModuleTemplate,
CLIENT_MODULE_STATUSES, KIND_LABEL,
type Client, type ClientModuleStatus, type FieldDef, type FieldType, type Kind, type Module,
type Client, type ClientModuleStatus, type FieldDef, type FieldType, type Kind, type Module, type TemplateStep,
} from '../api'
import { LivePreview } from '../components/LivePreview'
import { useData } from './Clients'
@ -121,6 +121,7 @@ export function Modules() {
onCreated={() => { setCreating(false); modules.reload() }}
/>
)}
{isOwner && modules.data !== undefined && <OnboardingTemplatesSection modules={modules.data} />}
</div>
)
}
@ -832,3 +833,126 @@ function AddPriceDialog(props: { open: boolean; onClose: () => void; module: Mod
</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>
)
}

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

@ -0,0 +1,68 @@
// One-off deploy step for the Client Detail redesign: overwrite the live onboarding-template
// settings with the composed quotation-led pipeline (shared front + per-module tail), then run
// the tick-preserving migration so existing SMS/RTGS/MobileApp projects adopt the new steps.
// Idempotent. Run from repo root: npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts
import { openDb } from '../src/db'
import { openPgDb } from '../src/db-pg'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
const FRONT = [
{ key: 'enquiry', label: 'Enquiry received' },
{ key: 'visit_meeting', label: 'Visit / meeting scheduled' },
{ key: 'quotation_sent', label: 'Quotation sent' },
{ key: 'quotation_approved', label: 'Quotation approved' },
]
const TAILS: Record<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 upsert = async (key: string, list: unknown): Promise<void> => {
await db.run(
`INSERT INTO setting (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`,
key, JSON.stringify(list),
)
}
await upsert('project.milestone_template.front', FRONT)
for (const [code, list] of Object.entries(TAILS)) await upsert(`project.milestone_template:${code}`, list)
// eslint-disable-next-line no-console
console.log('Template settings updated: front + SMS/RTGS/MOBILEAPP tails.')
const res = await migrateOnboardingTemplates(db)
// eslint-disable-next-line no-console
console.log(`Onboarding migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ${res.dropped} dropped.`)
}
main().catch((e) => { console.error(e); process.exit(1) })

@ -0,0 +1,19 @@
// apps/hq/scripts/migrate-onboarding-templates.ts — Client Detail redesign task 5: one-time
// migration swapping existing SMS/RTGS/MobileApp projects off the old generic onboarding
// checklist onto their new per-module template, carrying mapped ticks. Idempotent — safe
// to re-run (a second run does nothing).
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/migrate-onboarding-templates.ts
import { openDb } from '../src/db'
import { openPgDb } from '../src/db-pg'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
async function main() {
const pgUrl = process.env['DATABASE_URL'] ?? ''
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
const res = await migrateOnboardingTemplates(db)
// eslint-disable-next-line no-console
console.log(`Onboarding template migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ${res.dropped} dropped.`)
}
main().catch((e) => { console.error(e); process.exit(1) })

@ -60,7 +60,7 @@ import {
import { pullMonthlyCosts } from './aws-costs'
import { clientProfitability, duesAging, gstSummary, moduleRevenue, type DateRange } from './repos-reports'
import { listUsageRateCards, setUsageRateCard } from './repos-rate-card'
import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals'
import { renewalsDue, generateModuleRenewalQuote, generateBulkSmsPurchaseQuote } from './repos-renewals'
import { misCockpit } from './repos-mis'
import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms'
import { globalSearch } from './repos-search'
@ -69,8 +69,9 @@ import { clearLegacyCiphertext } from './migrate-plaintext'
import { assertSafeGatewayUrl } from './sms-gateway'
import { makeRateLimiter } from './rate-limit'
import {
addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard,
projectsPendingMilestone, setMilestone,
addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, getFrontTemplate, getModuleTail,
listProjectMilestones, milestoneBoard, projectsPendingMilestone, setFrontTemplate, setMilestone,
setModuleTail, stalledProjects,
} from './repos-milestones'
import { commitImport, stageCsv, verificationReport } from './import-apex'
import { documentHtml, documentHtmlSample } from './templates'
@ -481,6 +482,29 @@ export function apiRouter(
}
})
// ---------- onboarding step templates (owner editor): shared front + per-module tail ----------
r.get('/modules/:code/onboarding-template', requireAuth, requireOwner, async (req, res) => {
try {
const code = String(req.params['code'] ?? '')
const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code=?`, code)
if (mod === undefined) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
res.json({ ok: true, steps: await getModuleTail(db, code) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.put('/modules/:code/onboarding-template', requireAuth, requireOwner, async (req, res) => {
try {
const code = String(req.params['code'] ?? '')
const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code=?`, code)
if (mod === undefined) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
const body = req.body as { steps?: unknown }
res.json({ ok: true, steps: await setModuleTail(db, staffId(res), code, body.steps) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- module → client roster (spec §10, D-ROSTER) ----------
// Module directory (D28): every client on a module with its service fields, for review.
r.get('/modules/:id/directory', requireAuth, async (req, res) => {
@ -731,7 +755,7 @@ export function apiRouter(
res.status(404).json({ ok: false, error: 'Client module not found' }); return
}
try {
const body = req.body as { key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown }
const body = req.body as { key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown; paymentId?: unknown }
if (typeof body.label === 'string') {
// Add a project-specific milestone.
res.json({ ok: true, milestones: await addProjectMilestone(db, staffId(res), id, body.label) })
@ -741,7 +765,8 @@ export function apiRouter(
throw new Error('Provide { key, done } to tick a milestone, or { label } to add one')
}
const milestones = await setMilestone(db, staffId(res), id, body.key, body.done,
typeof body.doneOn === 'string' ? body.doneOn : undefined)
typeof body.doneOn === 'string' ? body.doneOn : undefined,
typeof body.paymentId === 'string' ? body.paymentId : undefined)
res.json({ ok: true, milestones })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
@ -754,6 +779,31 @@ export function apiRouter(
r.get('/projects/pending/:key', requireAuth, async (req, res) => {
res.json({ ok: true, projects: await projectsPendingMilestone(db, String(req.params['key'] ?? '')) })
})
// Parked onboarding: active projects whose next step hasn't moved in a while (dashboard/MIS tile).
r.get('/projects/stalled', requireAuth, async (req, res) => {
const q = req.query['days']
const days = typeof q === 'string' && q !== '' && Number.isFinite(Number(q))
? Number(q) : await getNumberSetting(db, 'project.stall_days', 30)
const today = new Date().toISOString().slice(0, 10)
res.json({ ok: true, projects: await stalledProjects(db, days, today) })
})
// The shared onboarding "front" — same lead-in steps for every module's checklist (owner editor).
r.get('/onboarding-template/front', requireAuth, requireOwner, async (_req, res) => {
try {
res.json({ ok: true, steps: await getFrontTemplate(db) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.put('/onboarding-template/front', requireAuth, requireOwner, async (req, res) => {
try {
const body = req.body as { steps?: unknown }
res.json({ ok: true, steps: await setFrontTemplate(db, staffId(res), body.steps) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- client branches (D20) ----------
r.get('/clients/:id/branches', requireAuth, async (req, res) => {
@ -1664,6 +1714,16 @@ export function apiRouter(
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// Ad-hoc bulk SMS credit purchase (top-up) — distinct from a subscription renewal.
r.post('/client-modules/:id/bulk-sms-quote', requireAuth, async (req, res) => {
try {
const qty = Number((req.body as { smsQty?: unknown }).smsQty)
const doc = await generateBulkSmsPurchaseQuote(db, staffId(res), String(req.params['id'] ?? ''), qty)
res.json({ ok: true, document: doc })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- bulk operations (D26 — cleanup at scale, owner-only) ----------
r.post('/clients/bulk', requireAuth, requireOwner, async (req, res) => {

@ -516,6 +516,11 @@ function migrate(db: SqliteRaw): void {
if (!modCols2.some((c) => c.name === 'sort_order')) {
db.exec(`ALTER TABLE module ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 100`)
}
// Client Detail redesign task 4: link an advance/balance payment-step tick to the real payment.
const milestoneCols = db.prepare(`PRAGMA table_info(project_milestone)`).all() as { name: string }[]
if (!milestoneCols.some((c) => c.name === 'payment_id')) {
db.exec(`ALTER TABLE project_milestone ADD COLUMN payment_id TEXT`)
}
rebuildStaffUserRoleCheck(db)
rebuildReminderRuleKindCheck(db)
}

@ -0,0 +1,82 @@
// apps/hq/src/migrate-onboarding-templates.ts — Client Detail redesign task 5: one-time
// migration swapping seeded modules (SMS/RTGS/MobileApp) off the old generic onboarding
// checklist onto their new per-module template (task 2), carrying mapped ticks
// (done + done_on + payment_id) so onboarding history is not lost.
import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit'
import { milestoneTemplate } from './repos-milestones'
import type { DB } from './db'
/** old generic key -> new template key (only applied if the new template has that key). */
const KEY_MAP: Record<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',
}
interface OldRow { key: string; done: number; done_on: string | null; payment_id: string | null }
/**
* Idempotent: for every client_module whose module has a per-module onboarding template
* (a `project.milestone_template:<CODE>` setting task 2), rebuild its milestone rows
* from that template, carrying mapped ticks (done/done_on/payment_id) forward under their
* new key. Unmapped old ticks (e.g. `amc_start`, or `setup_done` for a module whose new
* template has no matching step) are dropped (and counted). Client_modules whose module
* has no per-module template are left completely untouched.
*/
export async function migrateOnboardingTemplates(
db: DB,
): Promise<{ projects: number; carried: number; dropped: number }> {
// Which module codes have a per-module template setting?
const codes = (await db.all<{ key: string }>(
`SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`))
.map((r) => r.key.slice('project.milestone_template:'.length))
let projects = 0, carried = 0, dropped = 0
for (const code of codes) {
const template = await milestoneTemplate(db, code)
const templateKeys = new Set(template.map((t) => t.key))
const cms = await db.all<{ id: string }>(
`SELECT cm.id FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE m.code=?`, code)
for (const { id: cmId } of cms) {
const oldRows = await db.all<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 }
}

@ -352,4 +352,9 @@ UPDATE client SET category = CASE
END;
`,
},
{
// Client Detail redesign task 4: link an advance/balance payment-step tick to the real payment.
id: '015-milestone-payment-id',
sql: `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS payment_id text;`,
},
]

@ -152,6 +152,8 @@ export interface DraftInput {
terms?: string
/** INVOICE only (D18): payment due date; when absent, issue stamps doc_date + terms. */
dueDate?: string
/** Provenance tag for the frontend's Quote/Renewal/Bulk-top-up label; default: schema default ('hq'). */
source?: string
}
const todayIso = (): string => new Date().toISOString().slice(0, 10)
@ -240,18 +242,18 @@ async function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warn
/** Insert a draft row + created event + audit. Callers wrap in a transaction. */
async function insertDocRow(db: DB, userId: string, a: {
docType: DocType; clientId: string; refDocId: string | null; docDate: string
totals: BillTotals; payload: DocPayload; dueDate?: string | null
totals: BillTotals; payload: DocPayload; dueDate?: string | null; source?: string
}): Promise<Doc> {
const id = uuidv7()
const t = a.totals
await db.run(
`INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, due_date, status, ref_doc_id,
taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise,
payload, created_by, created_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
payload, source, created_by, created_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, a.docType, fyOf(a.docDate), a.clientId, a.docDate, a.dueDate ?? null, a.refDocId,
t.taxablePaise, t.cgstPaise, t.sgstPaise, t.igstPaise, t.roundOffPaise, t.payablePaise,
JSON.stringify(a.payload), userId, new Date().toISOString())
JSON.stringify(a.payload), a.source ?? 'hq', userId, new Date().toISOString())
await addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {})
const doc = (await getDocument(db, id))!
await writeAudit(db, userId, 'create', 'document', id, undefined, {
@ -334,7 +336,7 @@ export async function createDraft(db: DB, userId: string, input: DraftInput): Pr
return db.transaction(() => insertDocRow(db, userId, {
docType: prepared.docType, clientId: prepared.clientId, refDocId: null,
docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload,
dueDate: prepared.dueDate ?? null,
dueDate: prepared.dueDate ?? null, source: input.source,
}))
}

@ -1,5 +1,6 @@
import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit'
import { setSetting } from './repos-reminders'
import type { DB } from './db'
/**
@ -9,12 +10,44 @@ import type { DB } from './db'
* across the whole book: "who's pending go-live", "who hasn't paid the advance".
*/
export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number }
interface MilestoneRow { key: string; label: string; done: number; done_on: string | null; sort: number }
export interface Milestone {
key: string; label: string; done: boolean; doneOn: string | null; sort: number; paymentId: string | null
}
interface MilestoneRow {
key: string; label: string; done: number; done_on: string | null; sort: number; payment_id: string | null
}
export interface TemplateEntry { key: string; label: string }
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[] = [
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 })
}
/** The shared lead-in every module's checklist starts with (code default for the "front"). */
export const FRONT_FALLBACK: TemplateEntry[] = [
{ key: 'enquiry', label: 'Enquiry received' },
{ key: 'visit_meeting', label: 'Visit / meeting scheduled' },
{ key: 'quotation_sent', label: 'Quotation sent' },
{ key: 'quotation_approved', label: 'Quotation approved' },
]
/** Code default for the module-specific "tail" when nothing is configured. */
export const TAIL_FALLBACK: TemplateEntry[] = [
{ key: 'advance_paid', label: 'Advance payment received' },
{ key: 'setup_done', label: 'Setup / configuration done' },
{ key: 'installed', label: 'Installation done' },
@ -23,14 +56,93 @@ const FALLBACK_TEMPLATE: TemplateEntry[] = [
{ key: 'amc_start', label: 'AMC / renewal start' },
]
/** The standard checklist (owner-editable via the setting; fallback to the code default). */
export async function milestoneTemplate(db: DB): Promise<TemplateEntry[]> {
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='project.milestone_template'`)
if (row === undefined) return FALLBACK_TEMPLATE
function parseTemplate(value: string): TemplateEntry[] | undefined {
try {
const parsed = JSON.parse(row.value) as TemplateEntry[]
return Array.isArray(parsed) && parsed.length > 0 ? parsed : FALLBACK_TEMPLATE
} catch { return FALLBACK_TEMPLATE }
const p = JSON.parse(value) as TemplateEntry[]
return Array.isArray(p) && p.length > 0 ? p : undefined
} 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
}
/** Owner editor: replace the shared front template. Config-over-code, audited via `setSetting`. */
export async function setFrontTemplate(db: DB, userId: string, rawSteps: unknown): Promise<TemplateEntry[]> {
const steps = normalizeTemplateSteps(rawSteps)
await setSetting(db, userId, 'project.milestone_template.front', JSON.stringify(steps))
return steps
}
/** Owner editor: replace one module's tail template. Config-over-code, audited via `setSetting`. */
export async function setModuleTail(db: DB, userId: string, moduleCode: string, rawSteps: unknown): Promise<TemplateEntry[]> {
const steps = normalizeTemplateSteps(rawSteps)
await setSetting(db, userId, `project.milestone_template:${moduleCode}`, JSON.stringify(steps))
return steps
}
/**
@ -39,7 +151,10 @@ export async function milestoneTemplate(db: DB): Promise<TemplateEntry[]> {
* project's own custom milestones and any ticked state are never touched.
*/
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(
(await db.all<{ key: string }>(`SELECT key FROM project_milestone WHERE client_module_id=?`, clientModuleId))
.map((r) => r.key),
@ -61,32 +176,53 @@ export async function ensureProjectMilestones(db: DB, clientModuleId: string): P
export async function listProjectMilestones(db: DB, clientModuleId: string): Promise<Milestone[]> {
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 FROM project_milestone
WHERE client_module_id=? ORDER BY sort, label`,
clientModuleId,
)
return rows.map((r) => ({ key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort }))
return rows.map((r) => ({
key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort, paymentId: r.payment_id,
}))
}
/** Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears it. Audited. */
/**
* Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears
* it (and any linked payment). A payment-step tick (advance_payment / balance_payment) can
* pass `paymentId` to link a real payment it must belong to the same client as this
* project, and its `received_on` mirrors into `done_on` unless an explicit `doneOn` is
* also given. Audited.
*/
export async function setMilestone(
db: DB, userId: string, clientModuleId: string, key: string, done: boolean, doneOn?: string,
db: DB, userId: string, clientModuleId: string, key: string, done: boolean,
doneOn?: string, paymentId?: string,
): Promise<Milestone[]> {
return db.transaction(async () => {
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 FROM project_milestone WHERE client_module_id=? AND key=?`,
clientModuleId, key,
)
if (before === undefined) throw new Error('Milestone not found on this project')
if (done && doneOn !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(doneOn)) {
throw new Error('doneOn must be YYYY-MM-DD')
}
const stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null
let stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null
let linkedPayment: string | null = null
if (done && paymentId !== undefined && paymentId !== '') {
const pay = await db.get<{ received_on: string }>(
`SELECT received_on FROM payment WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`,
paymentId, clientModuleId,
)
if (pay === undefined) throw new Error('Payment not found for this client')
linkedPayment = paymentId
if (doneOn === undefined) stamp = pay.received_on // link date wins unless caller forced one
}
await db.run(
`UPDATE project_milestone SET done=?, done_on=? WHERE client_module_id=? AND key=?`,
done ? 1 : 0, stamp, clientModuleId, key,
`UPDATE project_milestone SET done=?, done_on=?, payment_id=? WHERE client_module_id=? AND key=?`,
done ? 1 : 0, stamp, done ? linkedPayment : null, clientModuleId, key,
)
await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined, { key, done, doneOn: stamp })
await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined,
{ key, done, doneOn: stamp, paymentId: linkedPayment })
if (done) await advanceStatusForStep(db, userId, clientModuleId, key)
return listProjectMilestones(db, clientModuleId)
})
}
@ -180,3 +316,30 @@ export async function projectsPendingMilestone(db: DB, key: string): Promise<Pen
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 ----------
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> {
const payments = await listPayments(db, clientId)
@ -249,10 +255,40 @@ export async function clientLedger(db: DB, clientId: string): Promise<ClientLedg
documents.push(...batch.documents)
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 {
documents,
payments,
payments: ledgerPayments,
advancePaise: settling - row.total,
outstanding,
}
}

@ -72,7 +72,26 @@ export async function generateModuleRenewalQuote(db: DB, userId: string, clientM
const cm = await getClientModule(db, clientModuleId)
if (cm === null) throw new Error('Client module not found')
return createDraft(db, userId, {
docType: 'PROFORMA', clientId: cm.clientId,
docType: 'PROFORMA', clientId: cm.clientId, source: 'module_renewal',
lines: [{ moduleId: cm.moduleId, qty: 1, kind: cm.kind, edition: cm.edition }],
})
}
/**
* Ad-hoc bulk SMS credit purchase (top-up) a PROFORMA priced through the same
* createDraft path as any other document, so the usage rate card (D23) resolves the
* per-SMS rate for `smsQty` the same way a subscription line would. Tagged
* `source: 'bulk_sms_topup'` so it reads distinctly from a subscription renewal
* (`module_renewal`) on the client-detail document list.
*/
export async function generateBulkSmsPurchaseQuote(
db: DB, userId: string, clientModuleId: string, smsQty: number,
): Promise<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 }],
})
}

@ -86,19 +86,64 @@ export async function seedIfEmpty(db: DB): Promise<void> {
}
if (seededBilling > 0) await writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS)
// Onboarding milestone template (D22): the standard checklist every new project
// (client_module) starts with. Config over code — owner-editable; a project can also add
// its own milestones. Order here is the display order.
const MILESTONE_TEMPLATE = JSON.stringify([
{ key: 'advance_paid', label: 'Advance payment received' },
{ key: 'setup_done', label: 'Setup / configuration done' },
{ key: 'installed', label: 'Installation done' },
{ key: 'go_live', label: 'Go-live' },
{ key: 'balance_paid', label: 'Balance payment received' },
{ key: 'amc_start', label: 'AMC / renewal start' },
// Onboarding milestone template (D22), composed model: a shared "front" (same for every
// module — enquiry through quotation-approved) plus a per-module "tail". Config over code
// — owner-editable; a project can also add its own milestones. Order here is display order.
const MILESTONE_TEMPLATE_FRONT = JSON.stringify([
{ key: 'enquiry', label: 'Enquiry received' },
{ key: 'visit_meeting', label: 'Visit / meeting scheduled' },
{ key: 'quotation_sent', label: 'Quotation sent' },
{ key: 'quotation_approved', label: 'Quotation approved' },
])
const seededMilestone = (await db.run(INSERT_SETTING_SQL, 'project.milestone_template', MILESTONE_TEMPLATE)).changes
if (seededMilestone > 0) await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template', undefined, { set: true })
const seededFront = (await db.run(
INSERT_SETTING_SQL, 'project.milestone_template.front', MILESTONE_TEMPLATE_FRONT)).changes
if (seededFront > 0) {
await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template.front', undefined, { set: true })
}
// Per-module onboarding 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,
// 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.
// ⚠️ Once the real password is filled in, this file holds a secret — do not push the
// filled-in value to a repo others can read.
const HARDCODED_DATABASE_URL = 'postgres://postgres:CHANGE_ME_PASSWORD@host.docker.internal:5432/hq'
const HARDCODED_DATABASE_URL = 'postgres://postgres:inv123@host.docker.internal:5432/hq'
// D19 engine selection: DATABASE_URL (or the hardcoded prod value) → Postgres; else SQLite.
const pgUrl = process.env['DATABASE_URL'] || (process.env['NODE_ENV'] === 'production' ? HARDCODED_DATABASE_URL : '')
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])

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

@ -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,52 @@
// apps/hq/test/milestone-status.test.ts — milestone→status auto-drive (never downgrade).
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, assignModule, getClientModule } from '../src/repos-modules'
import { setMilestone } from '../src/repos-milestones'
async function world() {
const db = openDb(':memory:')
await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
return { db, c, m, cmId: cm.id }
}
describe('milestone → status auto-drive (D22 status line)', () => {
it('ticking 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')
})
})

@ -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)', () => {
it('seeds the standard checklist on assignment, in template order', async () => {
it('seeds the standard checklist on assignment, in template order (front + tail)', async () => {
const { db, p1 } = await world()
const ms = await listProjectMilestones(db, p1.id)
expect(ms.map((m) => m.key)).toEqual(['advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start'])
expect(ms.map((m) => m.key)).toEqual([
'enquiry', 'visit_meeting', 'quotation_sent', 'quotation_approved',
'advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start',
])
expect(ms.every((m) => !m.done && m.doneOn === null)).toBe(true)
})
@ -45,7 +48,7 @@ describe('project milestones (D22)', () => {
it('adds a project-specific milestone after the standard set', async () => {
const { db, p1 } = await world()
const ms = await addProjectMilestone(db, 'u1', p1.id, 'AWS region confirmed')
expect(ms).toHaveLength(7)
expect(ms).toHaveLength(11) // 4 front + 6 tail + 1 custom
expect(ms[ms.length - 1]!.label).toBe('AWS region confirmed')
await expect(addProjectMilestone(db, 'u1', p1.id, ' ')).rejects.toThrow(/label/)
})
@ -59,9 +62,9 @@ describe('project milestones (D22)', () => {
)
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(0)
await ensureProjectMilestones(db, 'imported-cm')
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(6)
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(10) // 4 front + 6 tail
await ensureProjectMilestones(db, 'imported-cm') // second call adds nothing
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(6)
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(10)
})
it('boards the whole book and drills into who is pending a milestone', async () => {

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

@ -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 +
column. Live backfill: 135 Service Bank, 38 Employees, 15 Vanitha, 14 Store, 12 Agricultural,
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 .wide { grid-column: 1 / -1; }
@media (max-width: 560px) { .wf-formgrid { grid-template-columns: 1fr; } }
/* ================= mobile (phone) layout ================= */
@media (max-width: 640px) {
/* Record header: stack the action-button cluster below the avatar/name/meta instead of
crowding them on one row. Desktop layout (outside this query) is untouched. */
.wf-rec { flex-wrap: wrap; }
.wf-rec-actions { width: 100%; }
}

Loading…
Cancel
Save