diff --git a/STATUS.md b/STATUS.md index 7841280..8aed106 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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 | diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 37f543c..5562f66 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -217,11 +217,13 @@ export interface Payment { id: string; clientId: string; receivedOn: string; mode: PaymentMode reference: string; amountPaise: number; tdsPaise: number createdBy: string; createdAt: string + allocations: { docNo: string; amountPaise: number }[] } export interface Ledger { documents: Doc[]; payments: Payment[]; advancePaise: number modulePaid: { moduleId: string; billedPaise: number; settledPaise: number }[] + outstanding: { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number }[] } // ---------- typed calls ---------- @@ -326,6 +328,20 @@ export const addPrice = (moduleId: string, body: Record): Promi apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`, { method: 'POST', body: JSON.stringify(body) }) .then((r) => r.prices) +// Onboarding step templates (owner editor): shared "front" (every module) + per-module "tail". +export interface TemplateStep { key: string; label: string } +export const getFrontTemplate = (): Promise => + apiFetch<{ steps: TemplateStep[] }>('/onboarding-template/front').then((r) => r.steps) +export const setFrontTemplate = (steps: TemplateStep[]): Promise => + apiFetch<{ steps: TemplateStep[] }>('/onboarding-template/front', { method: 'PUT', body: JSON.stringify({ steps }) }) + .then((r) => r.steps) +export const getModuleTemplate = (code: string): Promise => + apiFetch<{ steps: TemplateStep[] }>(`/modules/${encodeURIComponent(code)}/onboarding-template`).then((r) => r.steps) +export const setModuleTemplate = (code: string, steps: TemplateStep[]): Promise => + apiFetch<{ steps: TemplateStep[] }>(`/modules/${encodeURIComponent(code)}/onboarding-template`, { + method: 'PUT', body: JSON.stringify({ steps }), + }).then((r) => r.steps) + export const getClientModules = (clientId: string): Promise => apiFetch<{ clientModules: ClientModule[] }>(`/clients/${clientId}/modules`).then((r) => r.clientModules) export const assignClientModule = (clientId: string, body: Record): Promise => @@ -360,18 +376,26 @@ export const revealModuleSecret = (id: string, key: string): Promise => // ---------- onboarding milestones (D22) ---------- -/** One checklist step on a project (client_module). Ticking stamps `doneOn`. */ -export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number } +/** One checklist step on a project (client_module). Ticking stamps `doneOn`; the + * advance/balance payment steps additionally carry `paymentId`, linking a real + * payment from the client's ledger. */ +export interface Milestone { + key: string; label: string; done: boolean; doneOn: string | null; sort: number; paymentId: string | null +} /** The project's checklist; the server auto-seeds the standard template on first read. */ export const getMilestones = (clientModuleId: string): Promise => apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`).then((r) => r.milestones) -/** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it. */ +/** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it. + * `paymentId` links a real payment to a step (advance_payment / balance_payment). */ export const setMilestone = ( - clientModuleId: string, key: string, done: boolean, doneOn?: string, + clientModuleId: string, key: string, done: boolean, doneOn?: string, paymentId?: string, ): Promise => apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`, { - method: 'POST', body: JSON.stringify({ key, done, ...(doneOn !== undefined ? { doneOn } : {}) }), + method: 'POST', + body: JSON.stringify({ + key, done, ...(doneOn !== undefined ? { doneOn } : {}), ...(paymentId !== undefined ? { paymentId } : {}), + }), }).then((r) => r.milestones) /** Add a project-specific step beyond the standard template. */ export const addMilestone = (clientModuleId: string, label: string): Promise => @@ -955,6 +979,11 @@ export const getRenewals = (from?: string, to?: string): Promise = } export const generateModuleRenewalQuote = (clientModuleId: string): Promise => apiFetch<{ document: Doc }>(`/client-modules/${clientModuleId}/renewal-quote`, { method: 'POST', body: '{}' }).then((r) => r.document) +/** Raise a bulk SMS top-up proforma for a client's SMS module (client-detail Task 4). */ +export const generateBulkSmsPurchase = (clientModuleId: string, smsQty: number): Promise => + apiFetch<{ document: Doc }>(`/client-modules/${clientModuleId}/bulk-sms-quote`, { + method: 'POST', body: JSON.stringify({ smsQty }), + }).then((r) => r.document) // D27: owner MIS cockpit. export interface MisCockpit { diff --git a/apps/hq-web/src/app.css b/apps/hq-web/src/app.css index 5d313d6..d4349dc 100644 --- a/apps/hq-web/src/app.css +++ b/apps/hq-web/src/app.css @@ -483,3 +483,41 @@ table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inse } .no-print { display: none !important; } } + +/* ---- Client Detail redesign (D-CDR) ---- */ +/* 50/50 service sheet */ +.cdr-split { display: grid; grid-template-columns: 1fr 1fr; gap: 0 30px; } +.cdr-split > .col { padding: 13px 0; min-width: 0; } +.cdr-split > .col + .col { border-left: 1px solid var(--border); padding-left: 30px; } +.cdr-kv { display: flex; align-items: baseline; gap: 10px; padding: 6px 0; min-width: 0; } +.cdr-kv + .cdr-kv { border-top: 1px dashed var(--border); } +.cdr-kv > .k { flex: none; width: 120px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-dim); font-weight: 600; padding-top: 2px; } +.cdr-kv > .v { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } +.cdr-kv > .v .txt { overflow: hidden; text-overflow: ellipsis; } +@media (max-width: 720px) { + .cdr-split { grid-template-columns: 1fr; } + .cdr-split > .col + .col { border-left: none; padding-left: 0; border-top: 1px solid var(--border); } +} +/* horizontal onboarding status line */ +.cdr-track-wrap { overflow-x: auto; padding: 10px 2px 4px; } +.cdr-track { display: flex; min-width: min-content; } +.cdr-node { flex: 1 0 112px; display: flex; flex-direction: column; align-items: center; text-align: center; position: relative; padding: 0 4px; } +.cdr-node .bar { position: absolute; top: 11px; right: 50%; width: 100%; height: 2px; background: var(--border); } +.cdr-node.done .bar, .cdr-node.now .bar { background: var(--accent); } +.cdr-node:first-child .bar { display: none; } +.cdr-node .dot { position: relative; z-index: 1; width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--border-strong); background: var(--bg-raised); display: grid; place-items: center; font-size: 11px; color: transparent; cursor: pointer; } +.cdr-node.done .dot { background: var(--ok); border-color: var(--ok); color: #fff; } +.cdr-node.now .dot { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); } +.cdr-node .nm { font-size: 11px; margin-top: 7px; max-width: 104px; line-height: 1.35; } +.cdr-node.done .nm { color: var(--text-dim); } +.cdr-node.now .nm { font-weight: 600; } +.cdr-node .dt { font-size: 10px; color: var(--text-dim); font-family: var(--mono); margin-top: 3px; } +/* outstanding panel */ +.cdr-out { border: 1px solid color-mix(in srgb, var(--warn) 30%, var(--border)); border-radius: var(--radius); overflow: hidden; } +.cdr-out .oh { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); } +.cdr-out .orow { display: flex; align-items: center; gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 12.5px; } +.cdr-out .orow:last-child { border-bottom: none; } +.cdr-out .orow .due { margin-left: auto; font-family: var(--mono); font-weight: 600; } +.cdr-out .ofoot { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--bg-inset); } +/* document-type pill */ +.doc-pill { font-size: 10.5px; font-weight: 600; letter-spacing: .3px; border-radius: 999px; padding: 1px 8px; border: 1px solid var(--border); color: var(--text-dim); background: var(--bg-inset); } diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index 97b42cc..535fd5d 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -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() const [statementOpen, setStatementOpen] = useState(false) + // Onboarding status line (D38): ticking the advance/balance payment step opens this + // picker instead of stamping a bare date. `milestonesVersion` bumps to force every + // StatusLine's milestone list to refetch after a pick lands (see `refreshToken` below). + const [paymentPicker, setPaymentPicker] = useState<{ clientModuleId: string; key: string; label: string } | undefined>() + const [linkingPayment, setLinkingPayment] = useState(false) + const [linkError, setLinkError] = useState() + const [milestonesVersion, setMilestonesVersion] = useState(0) + // Ticking the account_creation step opens this credentials dialog instead of a bare + // toggle (mirrors the payment picker above) — `cm` is captured via closure at the JSX + // callback below, exactly like `onTickPayment` captures it for the payment picker. + const [credsDialog, setCredsDialog] = useState<{ cm: ClientModule; key: string; label: string } | undefined>() + const [savingCreds, setSavingCreds] = useState(false) + const [credsError, setCredsError] = useState() const rawTab = sp.get('tab') ?? 'overview' const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview' @@ -259,11 +274,8 @@ export function ClientDetail() { {modules.data !== undefined && ( { + onAssign={(moduleId, kind) => { assignClientModule(id, { moduleId, kind }) - .then((cm) => creds !== undefined - ? patchClientModule(cm.id, { username: creds.username, password: creds.password }) - : cm) .then(() => { toast.ok('Module assigned'); cms.reload(); ledger.reload() }) .catch((err: Error) => toast.err(err.message)) }} @@ -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: cms.reload()} onError={toast.err} />, - installed: cms.reload()} onError={toast.err} />, - completed: cms.reload()} onError={toast.err} />, - trained: cms.reload()} onError={toast.err} />, billed: paid !== undefined ? inr(paid.billedPaise) : '—', settled: paid !== undefined ? inr(paid.settledPaise) : '—', } @@ -305,6 +312,9 @@ export function ClientDetail() { {moduleName(cm.moduleId)} {cm.status} {KIND_LABEL[cm.kind]} + e.stopPropagation()}> + cms.reload()} onError={toast.err} /> +
m.id === cm.moduleId)?.fieldSpec ?? []} onChanged={() => cms.reload()} /> - + { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }} + onTickCreds={(key, label) => { setCredsError(undefined); setCredsDialog({ cm, key, label }) }} + /> + m.id === cm.moduleId)?.code ?? ''} + onCreated={() => ledger.reload()} + />
))} @@ -402,12 +424,33 @@ export function ClientDetail() { )} + {ledger.data !== undefined && ledger.data.outstanding.length > 0 && ( +
+

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

+
+
Open documents + — oldest settles first
+ {ledger.data.outstanding.map((o) => ( +
nav(`/documents/${o.docId}`)} style={{ cursor: 'pointer' }}> + {o.docNo ?? 'draft'} + {DOC_TYPE_LABEL[o.docType as keyof typeof DOC_TYPE_LABEL] ?? o.docType} + {o.label} + {inr(o.outstandingPaise)} +
+ ))} +
+
+ )} { ledger.reload(); cms.reload(); amc.reload() }} /> {ledger.data !== undefined && ledger.data.payments.length > 0 && ( (a.receivedOn === b.receivedOn ? (a.id < b.id ? 1 : -1) : b.receivedOn.localeCompare(a.receivedOn))) .map((p) => ({ on: p.receivedOn, mode: p.mode, ref: p.reference !== '' ? p.reference : '—', + settled: p.allocations.length === 0 + ? advance + : → {p.allocations.map((a) => a.docNo).join(', ')}, amount: inr(p.amountPaise), tds: p.tdsPaise > 0 ? inr(p.tdsPaise) : '—', }))} /> @@ -607,6 +653,71 @@ export function ClientDetail() { {statementOpen && ( setStatementOpen(false)} /> )} + {paymentPicker !== undefined && ( + b.receivedOn.localeCompare(a.receivedOn))} + saving={linkingPayment} + error={linkError} + onClose={() => { if (!linkingPayment) setPaymentPicker(undefined) }} + onPick={(paymentId) => { + if (linkingPayment) return + setLinkingPayment(true) + setLinkError(undefined) + setMilestone(paymentPicker.clientModuleId, paymentPicker.key, true, undefined, paymentId) + .then(() => { + toast.ok('Payment linked') + setMilestonesVersion((v) => v + 1) + setPaymentPicker(undefined) + }) + .catch((e: Error) => setLinkError(e.message)) + .finally(() => setLinkingPayment(false)) + }} + onRecordNew={() => { setPaymentPicker(undefined); setTab('payments') }} + /> + )} + {credsDialog !== undefined && ( + { if (!savingCreds) setCredsDialog(undefined) }} + onSave={(fields) => { + if (savingCreds) return + setSavingCreds(true) + setCredsError(undefined) + patchClientModule(credsDialog.cm.id, { + provider: fields.provider, username: fields.username, + ...(canRoute && fields.password.trim() !== '' ? { password: fields.password } : {}), + }) + .then(() => setMilestone(credsDialog.cm.id, credsDialog.key, true)) + .then(() => { + toast.ok('Credentials saved') + setMilestonesVersion((v) => v + 1) + cms.reload() + setCredsDialog(undefined) + }) + .catch((e: Error) => setCredsError(e.message)) + .finally(() => setSavingCreds(false)) + }} + onSkip={() => { + if (savingCreds) return + setSavingCreds(true) + setCredsError(undefined) + setMilestone(credsDialog.cm.id, credsDialog.key, true) + .then(() => { + toast.ok('Step marked done') + setMilestonesVersion((v) => v + 1) + setCredsDialog(undefined) + }) + .catch((e: Error) => setCredsError(e.message)) + .finally(() => setSavingCreds(false)) + }} + /> + )} ) } @@ -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() - const [settingPw, setSettingPw] = useState(false) - const [pw, setPw] = useState('') const [busy, setBusy] = useState(false) const [editing, setEditing] = useState(false) const [f, setF] = useState({ provider: '', username: '', remark: '', password: '' }) const [details, setDetails] = useState([]) + const [fieldDraft, setFieldDraft] = useState>({}) const copy = (text: string, what: string) => { navigator.clipboard.writeText(text).then(() => toast.ok(`${what} copied`)).catch(() => toast.err('Copy failed')) @@ -988,42 +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) => ( - - {label} - {value ?? '—'} - {copyable && value !== undefined && } - - ) + // D21 non-secret fieldSpec input — moved here from the old always-on ModuleFieldsForm so + // it saves alongside D20 through this one Edit/Save. Secret fields keep their own inline + // SecretField widget (independent Set/Update/Reveal) and stay out of this form. + const fieldControl = (fld: FieldDef) => { + const v = fieldDraft[fld.key] ?? '' + const set = (val: string) => setFieldDraft((p) => ({ ...p, [fld.key]: val })) + if (fld.type === 'boolean') { + return ( + + ) + } + if (fld.type === 'select') { + return ( + + ) + } + if (fld.type === 'number') { + return set(e.target.value)} /> + } + if (fld.type === 'date') { + return set(e.target.value)} /> + } + return set(e.target.value)} /> + } if (editing) { return ( @@ -1040,6 +1181,29 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie )} setF((p) => ({ ...p, remark: e.target.value }))} /> + {nonSecretFields.length > 0 && ( + <> +
+
+ {nonSecretFields.map((fld) => { + // A field holding an http(s) value gets a direct "Open ↗" link (portal/console shortcut). + const val = fieldDraft[fld.key] ?? '' + const isUrl = /^https?:\/\//i.test(val.trim()) + return ( + + + {fieldControl(fld)} + {isUrl && ( + Open ↗ + )} + + + ) + })} +
+ + )} {details.map((d, i) => (
-
- {props.name} - {cell('Provider', cm.provider ?? undefined)} - {cell('Username', cm.username ?? undefined, true, true)} - {cm.details.map((d, i) => {cell(d.label, d.value)})} - {cell('Remark', cm.remark ?? undefined)} - - Portal password - {revealed !== undefined - ? <>{revealed} - : cm.hasPassword - ? <>••••••••{managerial && } - : not set} - {managerial && !settingPw && } - {managerial && settingPw && ( - <> - setPw(e.target.value)} /> - - - - )} - - - +
+
+
+ Access + + +
+ + copy(cm.username ?? '', 'Username')} /> +
+ Password + + {revealed !== undefined + ? <>{revealed} + : cm.hasPassword + ? <>••••••••{managerial && } + : not set} + +
+ {cm.remark !== null && cm.remark !== '' && } +
+
+
+ Details + + +
+ {nonSecretFields.map((fld) => ( + + ))} + {cm.details.map((d, i) => )} + {secretFields.map((fld) => ( +
+ {fld.label} + +
+ ))} +
- {/* D21: dynamic form from the owning module's fieldSpec, below the D20 block. */} - {props.fieldSpec.length > 0 && ( - <> -
- - - )}
) } -/** - * D21 module-defined fields (config over code): renders one input per field the owning - * module DECLARES in its fieldSpec. Non-secret edits collect into a single audited PUT - * (setModuleFields); secret fields never leave the server unrevealed — •••• with a - * managerial audited Reveal + Set/Update (setModuleSecret), exactly like the portal - * password. Values come from cm.fieldValues; secret set-state from cm.secretKeys. - */ -function ModuleFieldsForm(props: { cm: ClientModule; fieldSpec: FieldDef[]; onChanged: () => void }) { - const toast = useToast() - const managerial = isManagerial() - const cm = props.cm - const sorted = [...props.fieldSpec].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)) - const nonSecret = sorted.filter((f) => f.type !== 'secret') - const secrets = sorted.filter((f) => f.type === 'secret') - - const seed = (): Record => - Object.fromEntries(nonSecret.map((f) => [f.key, cm.fieldValues[f.key] ?? ''])) - const [draft, setDraft] = useState>(seed) - const [busy, setBusy] = useState(false) - - const dirty = nonSecret.some((f) => (draft[f.key] ?? '') !== (cm.fieldValues[f.key] ?? '')) - const missingRequired = nonSecret.find((f) => f.required === true && (draft[f.key] ?? '').trim() === '') - - const save = () => { - if (busy) return - if (missingRequired !== undefined) { toast.err(`${missingRequired.label} is required`); return } - setBusy(true) - // Send only the changed keys; '' clears server-side. - const values = Object.fromEntries( - nonSecret - .filter((f) => (draft[f.key] ?? '') !== (cm.fieldValues[f.key] ?? '')) - .map((f) => [f.key, draft[f.key] ?? '']), - ) - setModuleFields(cm.id, values) - .then((updated) => { - toast.ok('Fields saved') - setDraft(Object.fromEntries(nonSecret.map((f) => [f.key, updated.fieldValues[f.key] ?? '']))) - props.onChanged() - }) - .catch((e: Error) => toast.err(e.message)) - .finally(() => setBusy(false)) - } - - const control = (f: FieldDef) => { - const v = draft[f.key] ?? '' - const set = (val: string) => setDraft((p) => ({ ...p, [f.key]: val })) - if (f.type === 'boolean') { - return ( - - ) - } - if (f.type === 'select') { - return ( - - ) - } - if (f.type === 'number') { - return set(e.target.value)} /> - } - if (f.type === 'date') { - return set(e.target.value)} /> - } - return set(e.target.value)} /> - } - +/** One label/value row in the 50/50 service-data report; `—` for empty, optional mono + copy. */ +function Kv(props: { label: string; value: string | undefined; mono?: boolean; copyable?: boolean; onCopy?: () => void }) { + const v = props.value return ( -
- {nonSecret.length > 0 && ( - <> -
- {nonSecret.map((f) => { - // A field holding an http(s) value gets a direct "Open ↗" link (portal/console shortcut). - const val = draft[f.key] ?? '' - const isUrl = /^https?:\/\//i.test(val.trim()) - return ( - - - {control(f)} - {isUrl && ( - Open ↗ - )} - - - ) - })} -
- - - - - )} - {secrets.map((f) => ( - - ))} +
+ {props.label} + + {v !== undefined && v !== '' ? {v} : } + {props.copyable === true && v !== undefined && v !== '' && } +
) } @@ -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 (
-
- {props.name} — onboarding +
+ {props.name} — status line {ms !== undefined && ms.length > 0 && ( {done}/{ms.length} )} + + {!adding ? : ( + <> + setLabel(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') addStep() }} + /> + + + + )}
{list.error !== undefined ? : ms === undefined ? : ms.length === 0 ? No steps yet. : ( -
+
{ms.map((m) => (
- - {m.done && ( - <> - on - toggle(m, true, e.target.value !== '' ? e.target.value : undefined)} - /> - - )} + + + {m.label} + {m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'}
))} +
+ )} +
+ ) +} + +/** Doc.source values that don't map onto DOC_TYPE_LABEL get a friendlier, source-aware label. */ +const DOC_SOURCE_LABEL: Record = { + module_renewal: 'Renewal', bulk_sms_topup: 'Bulk top-up', +} +function docLabel(d: Doc): string { + if (DOC_SOURCE_LABEL[d.source] !== undefined) return DOC_SOURCE_LABEL[d.source]! + return DOC_TYPE_LABEL[d.docType] ?? d.docType +} + +/** + * Task 4: per-module documents — every doc whose line items include this module (quotes, + * renewals, bulk SMS top-ups, invoices, credit notes), newest first. SMS-coded modules get + * a "Bulk SMS purchase" action that raises a proforma and jumps straight to it. + */ +function ModuleDocuments(props: { cm: ClientModule; docs: Doc[]; moduleCode: string; onCreated: () => void }) { + const nav = useNavigate() + const toast = useToast() + const [busy, setBusy] = useState(false) + const mine = props.docs + .filter((d) => d.payload.lines.some((l) => l.itemId === props.cm.moduleId)) + .sort((a, b) => b.docDate.localeCompare(a.docDate)) + const isSms = props.moduleCode.toUpperCase() === 'SMS' + + const bulk = () => { + const qty = Number(window.prompt('Bulk SMS quantity to purchase?', '100000') ?? '') + if (!Number.isInteger(qty) || qty <= 0) return + setBusy(true) + generateBulkSmsPurchase(props.cm.id, qty) + .then((doc) => { toast.ok('Bulk SMS proforma created'); props.onCreated(); nav(`/documents/${doc.id}`) }) + .catch((e: Error) => { toast.err(e.message); setBusy(false) }) + } + + return ( +
+
+ Documents + {mine.length} + + {isSms && } +
+ {mine.length === 0 ?
No documents for this module yet.
+ : mine.map((d) => ( +
nav(`/documents/${d.id}`)} + style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '7px 0', borderTop: '1px solid var(--border)', cursor: 'pointer', fontSize: 12.5 }}> + {d.docNo ?? 'draft'} + {docLabel(d)} + {d.docDate} + {inr(d.payablePaise)} + {d.status}
+ ))} +
+ ) +} + +/** + * Payment-step picker (D38): ticking the advance/balance payment status step opens this + * instead of stamping a bare date — it links a real payment row so the milestone and the + * ledger agree (`setMilestone(..., paymentId)`; the server mirrors that payment's + * `receivedOn` into `doneOn`). Lists the client's payments, newest first; "Record new + * payment" defers to the Payments tab rather than guessing an amount here. + */ +function PaymentPickerDialog(props: { + open: boolean; onClose: () => void; stepLabel: string + payments: Payment[]; saving: boolean; error?: string + onPick: (paymentId: string) => void; onRecordNew: () => void +}) { + return ( + { if (!props.saving) props.onClose() }} + title={`Link a payment — ${props.stepLabel}`} + size="sm" + footer={} + > + {props.payments.length === 0 + ? No payments recorded for this client yet. + : ( + ({ + on: p.receivedOn, + amount: inr(p.amountPaise), + act: , + }))} + /> )} - {!adding - ? - : ( - <> - setLabel(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') addStep() }} - /> - - - - )} + + -
+ {props.error !== undefined && {props.error}} + + ) +} + +/** + * 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 ( + { if (!props.saving) props.onClose() }} + title={`Account credentials — ${props.stepLabel}`} + size="sm" + footer={ + <> + + + + + } + > +
+ + setProvider(e.target.value)} /> + + + setUsername(e.target.value)} /> + + {managerial && ( + + setPassword(e.target.value)} + /> + + )} +
+ {props.error !== undefined && {props.error}} +
) } diff --git a/apps/hq-web/src/pages/Modules.tsx b/apps/hq-web/src/pages/Modules.tsx index 631e8b2..38c90a7 100644 --- a/apps/hq-web/src/pages/Modules.tsx +++ b/apps/hq-web/src/pages/Modules.tsx @@ -5,11 +5,11 @@ import { FormGrid, Notice, PageHeader, Skeleton, Tabs, Toolbar, useToast, } from '@sims/ui' import { - addPrice, assignClientModule, createModule, downloadModuleClientsCsv, getClients, - getModuleClients, getModules, getPrices, notifyModuleClients, patchClientModule, patchModule, - previewSample, role, + addPrice, assignClientModule, createModule, downloadModuleClientsCsv, getClients, getFrontTemplate, + getModuleClients, getModuleTemplate, getModules, getPrices, notifyModuleClients, patchClientModule, patchModule, + previewSample, role, setFrontTemplate, setModuleTemplate, CLIENT_MODULE_STATUSES, KIND_LABEL, - type Client, type ClientModuleStatus, type FieldDef, type FieldType, type Kind, type Module, + type Client, type ClientModuleStatus, type FieldDef, type FieldType, type Kind, type Module, type TemplateStep, } from '../api' import { LivePreview } from '../components/LivePreview' import { useData } from './Clients' @@ -121,6 +121,7 @@ export function Modules() { onCreated={() => { setCreating(false); modules.reload() }} /> )} + {isOwner && modules.data !== undefined && }
) } @@ -832,3 +833,126 @@ function AddPriceDialog(props: { open: boolean; onClose: () => void; module: Mod ) } + +/** + * Owner-only editor for the COMPOSED onboarding checklist template: one shared "front" (same + * lead-in steps for every module) plus each module's own "tail". Both are config (`setting` + * rows) resolved by `milestoneTemplate` — this just edits the two halves. Staged local edits; + * nothing is written until Save. + */ +function OnboardingTemplatesSection(props: { modules: Module[] }) { + const front = useData(getFrontTemplate, []) + const [expanded, setExpanded] = useState() + + return ( +
+

Onboarding steps

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

Shared front — applies to every module

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

Per-module tail

+
+ {props.modules.map((m) => ( +
+
+ {m.name} {m.code} + +
+ {expanded === m.id && } +
+ ))} +
+
+ ) +} + +/** Lazily loads and edits one module's tail — only fetched once its row is expanded. */ +function ModuleTailEditor(props: { code: string }) { + const tail = useData(() => getModuleTemplate(props.code), [props.code]) + return ( +
+ {tail.error !== undefined ? + : tail.data === undefined ? : ( + setModuleTemplate(props.code, steps).then((saved) => { tail.reload(); return saved })} + /> + )} +
+ ) +} + +/** + * One editable ordered list of template steps — label-only rows (the key is auto-derived + * server-side from the label when blank); add / remove / reorder, staged locally until Save. + * Shared by the front editor and every per-module tail editor above. + */ +function StepListEditor(props: { steps: TemplateStep[]; onSave: (steps: TemplateStep[]) => Promise }) { + const toast = useToast() + const [rows, setRows] = useState(() => props.steps.map((s) => ({ ...s }))) + const [dirty, setDirty] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState() + + useEffect(() => { setRows(props.steps.map((s) => ({ ...s }))); setDirty(false) }, [props.steps]) + + const setLabel = (i: number, label: string) => { + setRows((rs) => rs.map((r, j) => (j === i ? { ...r, label } : r))) + setDirty(true) + } + const addRow = () => { setRows((rs) => [...rs, { key: '', label: '' }]); setDirty(true) } + const removeRow = (i: number) => { setRows((rs) => rs.filter((_r, j) => j !== i)); setDirty(true) } + const move = (i: number, dir: -1 | 1) => { + const j = i + dir + if (j < 0 || j >= rows.length) return + setRows((rs) => { const next = [...rs]; [next[i], next[j]] = [next[j]!, next[i]!]; return next }) + setDirty(true) + } + const save = () => { + if (busy) return + if (rows.length === 0) { setError('Add at least one step'); return } + if (rows.some((r) => r.label.trim() === '')) { setError('Every step needs a label'); return } + setError(undefined); setBusy(true) + props.onSave(rows.map((r) => ({ key: r.key, label: r.label.trim() }))) + .then((saved) => { setRows(saved.map((s) => ({ ...s }))); setDirty(false); toast.ok('Onboarding steps saved') }) + .catch((e: Error) => { setError(e.message); toast.err(e.message) }) + .finally(() => setBusy(false)) + } + + return ( +
+ {rows.map((r, i) => ( +
+ setLabel(i, e.target.value)} /> + + + +
+ ))} + + + + + {error !== undefined && {error}} +
+ ) +} diff --git a/apps/hq-web/src/pages/client-forms.tsx b/apps/hq-web/src/pages/client-forms.tsx index a092b05..693b3c0 100644 --- a/apps/hq-web/src/pages/client-forms.tsx +++ b/apps/hq-web/src/pages/client-forms.tsx @@ -284,25 +284,21 @@ export function InteractionDialog(props: { export function AssignModuleForm(props: { modules: Module[] - onAssign: (moduleId: string, kind: Kind, creds?: { username: string; password: string }) => void + onAssign: (moduleId: string, kind: Kind) => void }) { const [moduleId, setModuleId] = useState('') const mod = props.modules.find((m) => m.id === moduleId) const [kind, setKind] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - // SMS is a gateway service — its login is required at assign time so the balance pull works. - const needsCreds = mod?.code === 'SMS' - const ready = moduleId !== '' && kind !== '' && (!needsCreds || (username.trim() !== '' && password.trim() !== '')) - const reset = () => { setModuleId(''); setKind(''); setUsername(''); setPassword('') } + const ready = moduleId !== '' && kind !== '' + const reset = () => { setModuleId(''); setKind('') } return (
{mod !== undefined && ( @@ -311,30 +307,20 @@ export function AssignModuleForm(props: { {mod.allowedKinds.map((k) => )} )} - {needsCreds && ( - <> - setUsername(e.target.value)} /> - setPassword(e.target.value)} /> - - )} - {needsCreds && ( - - SMS needs the provider gateway login — username and password are required so the balance can be pulled. - - )} + + Starts the module at "quoted" and opens its onboarding status line. +
) } @@ -355,22 +341,6 @@ export function ClientModuleStatusSelect(props: { ) } -export function RowDate(props: { - cm: ClientModule; field: 'installedOn' | 'completedOn' | 'trainedOn' - onPatched: () => void; onError: (msg: string) => void -}) { - return ( - { - patchClientModule(props.cm.id, { [props.field]: e.target.value !== '' ? e.target.value : null }) - .then(props.onPatched).catch((err: Error) => props.onError(err.message)) - }} - /> - ) -} - /** * Record a payment against the client — amounts entered in rupees, stored in * integer paise via fromRupees. Shared with DocumentView's "Record payment". diff --git a/apps/hq/scripts/apply-onboarding-pipeline.ts b/apps/hq/scripts/apply-onboarding-pipeline.ts new file mode 100644 index 0000000..fd49cc9 --- /dev/null +++ b/apps/hq/scripts/apply-onboarding-pipeline.ts @@ -0,0 +1,68 @@ +// One-off deploy step for the Client Detail redesign: overwrite the live onboarding-template +// settings with the composed quotation-led pipeline (shared front + per-module tail), then run +// the tick-preserving migration so existing SMS/RTGS/MobileApp projects adopt the new steps. +// Idempotent. Run from repo root: npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts +import { openDb } from '../src/db' +import { openPgDb } from '../src/db-pg' +import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' + +const FRONT = [ + { key: 'enquiry', label: 'Enquiry received' }, + { key: 'visit_meeting', label: 'Visit / meeting scheduled' }, + { key: 'quotation_sent', label: 'Quotation sent' }, + { key: 'quotation_approved', label: 'Quotation approved' }, +] +const TAILS: Record = { + SMS: [ + { key: 'document_collection', label: 'Document collection' }, + { key: 'dlt_registration', label: 'DLT registration' }, + { key: 'account_creation', label: 'Account creation & registration' }, + { key: 'installation', label: 'Installation' }, + { key: 'testing', label: 'Testing' }, + { key: 'training', label: 'Training' }, + { key: 'go_live', label: 'Go-live' }, + { key: 'payment_received', label: 'Payment received' }, + ], + RTGS: [ + { key: 'document_collection', label: 'Document collection' }, + { key: 'server_checkup', label: 'Server checkup' }, + { key: 'setup_configuration', label: 'Setup & configuration' }, + { key: 'installation', label: 'Installation' }, + { key: 'testing', label: 'Testing' }, + { key: 'training', label: 'Training' }, + { key: 'go_live', label: 'Go-live' }, + { key: 'payment_received', label: 'Payment received' }, + ], + MOBILEAPP: [ + { key: 'requirement_setup', label: 'Requirement & setup' }, + { key: 'configuration', label: 'Configuration' }, + { key: 'data_import', label: 'Data import' }, + { key: 'installation', label: 'Installation' }, + { key: 'testing', label: 'Testing' }, + { key: 'training', label: 'Training' }, + { key: 'go_live', label: 'Go-live' }, + { key: 'payment_received', label: 'Payment received' }, + ], +} + +async function main(): Promise { + const pgUrl = process.env['DATABASE_URL'] ?? '' + const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) + + const upsert = async (key: string, list: unknown): Promise => { + await db.run( + `INSERT INTO setting (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`, + key, JSON.stringify(list), + ) + } + await upsert('project.milestone_template.front', FRONT) + for (const [code, list] of Object.entries(TAILS)) await upsert(`project.milestone_template:${code}`, list) + // eslint-disable-next-line no-console + console.log('Template settings updated: front + SMS/RTGS/MOBILEAPP tails.') + + const res = await migrateOnboardingTemplates(db) + // eslint-disable-next-line no-console + console.log(`Onboarding migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ${res.dropped} dropped.`) +} + +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/apps/hq/scripts/migrate-onboarding-templates.ts b/apps/hq/scripts/migrate-onboarding-templates.ts new file mode 100644 index 0000000..c255d8a --- /dev/null +++ b/apps/hq/scripts/migrate-onboarding-templates.ts @@ -0,0 +1,19 @@ +// apps/hq/scripts/migrate-onboarding-templates.ts — Client Detail redesign task 5: one-time +// migration swapping existing SMS/RTGS/MobileApp projects off the old generic onboarding +// checklist onto their new per-module template, carrying mapped ticks. Idempotent — safe +// to re-run (a second run does nothing). +// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/migrate-onboarding-templates.ts +import { openDb } from '../src/db' +import { openPgDb } from '../src/db-pg' +import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' + +async function main() { + const pgUrl = process.env['DATABASE_URL'] ?? '' + const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) + + const res = await migrateOnboardingTemplates(db) + // eslint-disable-next-line no-console + console.log(`Onboarding template migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ${res.dropped} dropped.`) +} + +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 9c20d8e..283ff24 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -60,7 +60,7 @@ import { import { pullMonthlyCosts } from './aws-costs' import { clientProfitability, duesAging, gstSummary, moduleRevenue, type DateRange } from './repos-reports' import { listUsageRateCards, setUsageRateCard } from './repos-rate-card' -import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals' +import { renewalsDue, generateModuleRenewalQuote, generateBulkSmsPurchaseQuote } from './repos-renewals' import { misCockpit } from './repos-mis' import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms' import { globalSearch } from './repos-search' @@ -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) => { diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index cd0cef8..8ba8410 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -516,6 +516,11 @@ function migrate(db: SqliteRaw): void { if (!modCols2.some((c) => c.name === 'sort_order')) { db.exec(`ALTER TABLE module ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 100`) } + // Client Detail redesign task 4: link an advance/balance payment-step tick to the real payment. + const milestoneCols = db.prepare(`PRAGMA table_info(project_milestone)`).all() as { name: string }[] + if (!milestoneCols.some((c) => c.name === 'payment_id')) { + db.exec(`ALTER TABLE project_milestone ADD COLUMN payment_id TEXT`) + } rebuildStaffUserRoleCheck(db) rebuildReminderRuleKindCheck(db) } diff --git a/apps/hq/src/migrate-onboarding-templates.ts b/apps/hq/src/migrate-onboarding-templates.ts new file mode 100644 index 0000000..6c8ba27 --- /dev/null +++ b/apps/hq/src/migrate-onboarding-templates.ts @@ -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 = { + 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:` setting — task 2), rebuild its milestone rows + * from that template, carrying mapped ticks (done/done_on/payment_id) forward under their + * new key. Unmapped old ticks (e.g. `amc_start`, or `setup_done` for a module whose new + * template has no matching step) are dropped (and counted). Client_modules whose module + * has no per-module template are left completely untouched. + */ +export async function migrateOnboardingTemplates( + db: DB, +): Promise<{ projects: number; carried: number; dropped: number }> { + // Which module codes have a per-module template setting? + const codes = (await db.all<{ key: string }>( + `SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`)) + .map((r) => r.key.slice('project.milestone_template:'.length)) + let projects = 0, carried = 0, dropped = 0 + for (const code of codes) { + const template = await milestoneTemplate(db, code) + const templateKeys = new Set(template.map((t) => t.key)) + const cms = await db.all<{ id: string }>( + `SELECT cm.id FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE m.code=?`, code) + for (const { id: cmId } of cms) { + const oldRows = await db.all( + `SELECT key, done, done_on, payment_id FROM project_milestone WHERE client_module_id=?`, cmId) + // Build the carried tick-state keyed by NEW key. + const carriedState = new Map() + let alreadyNew = true + for (const r of oldRows) { + if (!templateKeys.has(r.key)) alreadyNew = false + const target = templateKeys.has(r.key) ? r.key : KEY_MAP[r.key] + if (target !== undefined && templateKeys.has(target)) { + if (r.done === 1) carriedState.set(target, r) + } else if (r.done === 1) { + dropped++ + } + } + // Idempotency: if the project's keys already ARE exactly the template, skip. + const oldKeySet = new Set(oldRows.map((r) => r.key)) + const identical = alreadyNew && oldKeySet.size === templateKeys.size + && [...templateKeys].every((k) => oldKeySet.has(k)) + if (identical) continue + projects++ + await db.transaction(async () => { + await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cmId) + let sort = 0 + for (const t of template) { + const c = carriedState.get(t.key) + await db.run( + `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + uuidv7(), cmId, t.key, t.label, + c !== undefined ? 1 : 0, c?.done_on ?? null, c?.payment_id ?? null, sort, + ) + if (c !== undefined) carried++ + sort++ + } + await writeAudit(db, 'system', 'migrate_onboarding', 'client_module', cmId, undefined, + { module: code, carried: [...carriedState.keys()] }) + }) + } + } + return { projects, carried, dropped } +} diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts index 36756e3..54abbd0 100644 --- a/apps/hq/src/migrations-pg.ts +++ b/apps/hq/src/migrations-pg.ts @@ -352,4 +352,9 @@ UPDATE client SET category = CASE END; `, }, + { + // Client Detail redesign task 4: link an advance/balance payment-step tick to the real payment. + id: '015-milestone-payment-id', + sql: `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS payment_id text;`, + }, ] diff --git a/apps/hq/src/repos-documents.ts b/apps/hq/src/repos-documents.ts index ba02a91..7245131 100644 --- a/apps/hq/src/repos-documents.ts +++ b/apps/hq/src/repos-documents.ts @@ -152,6 +152,8 @@ export interface DraftInput { terms?: string /** INVOICE only (D18): payment due date; when absent, issue stamps doc_date + terms. */ dueDate?: string + /** Provenance tag for the frontend's Quote/Renewal/Bulk-top-up label; default: schema default ('hq'). */ + source?: string } const todayIso = (): string => new Date().toISOString().slice(0, 10) @@ -240,18 +242,18 @@ async function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warn /** Insert a draft row + created event + audit. Callers wrap in a transaction. */ async function insertDocRow(db: DB, userId: string, a: { docType: DocType; clientId: string; refDocId: string | null; docDate: string - totals: BillTotals; payload: DocPayload; dueDate?: string | null + totals: BillTotals; payload: DocPayload; dueDate?: string | null; source?: string }): Promise { const id = uuidv7() const t = a.totals await db.run( `INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, due_date, status, ref_doc_id, taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise, - payload, created_by, created_at) - VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + payload, source, created_by, created_at) + VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, a.docType, fyOf(a.docDate), a.clientId, a.docDate, a.dueDate ?? null, a.refDocId, t.taxablePaise, t.cgstPaise, t.sgstPaise, t.igstPaise, t.roundOffPaise, t.payablePaise, - JSON.stringify(a.payload), userId, new Date().toISOString()) + JSON.stringify(a.payload), a.source ?? 'hq', userId, new Date().toISOString()) await addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {}) const doc = (await getDocument(db, id))! await writeAudit(db, userId, 'create', 'document', id, undefined, { @@ -334,7 +336,7 @@ export async function createDraft(db: DB, userId: string, input: DraftInput): Pr return db.transaction(() => insertDocRow(db, userId, { docType: prepared.docType, clientId: prepared.clientId, refDocId: null, docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload, - dueDate: prepared.dueDate ?? null, + dueDate: prepared.dueDate ?? null, source: input.source, })) } diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts index 5aba315..be5303d 100644 --- a/apps/hq/src/repos-milestones.ts +++ b/apps/hq/src/repos-milestones.ts @@ -1,5 +1,6 @@ import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' +import { setSetting } from './repos-reminders' import type { DB } from './db' /** @@ -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 = { + quoted: 0, ordered: 1, installing: 2, installed: 3, trained: 4, live: 5, expired: 6, cancelled: 7, +} +const STEP_STATUS: Record = { + 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 { + 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 { - 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 { + 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 { + 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 { + const front = await getFrontTemplate(db) + const tail = await getModuleTail(db, moduleCode ?? '') + return [...front, ...tail] +} + +/** Make a valid step key from a label: lowercase, non-alphanumeric -> `_`, collapse repeats, trim `_`. */ +function slugifyStepLabel(label: string): string { + return label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '') +} + +/** + * Validate + normalize a template-editor PUT body's `steps` into `TemplateEntry[]`: a + * non-empty array, every label non-blank after trim, every key unique. A missing/blank key + * is derived by slugifying its label (bumping a numeric suffix on collision). Throws with a + * clear message otherwise — the caller turns that into a 400. + */ +export function normalizeTemplateSteps(rawSteps: unknown): TemplateEntry[] { + if (!Array.isArray(rawSteps) || rawSteps.length === 0) throw new Error('Provide at least one step') + const used = new Set() + const out: TemplateEntry[] = [] + for (const raw of rawSteps) { + const r = (typeof raw === 'object' && raw !== null ? raw : {}) as { key?: unknown; label?: unknown } + const label = typeof r.label === 'string' ? r.label.trim() : '' + if (label === '') throw new Error('Every step needs a non-blank label') + let key = typeof r.key === 'string' ? r.key.trim() : '' + if (key === '') { + const base = slugifyStepLabel(label) || 'step' + key = base + let n = 2 + while (used.has(key)) key = `${base}_${n++}` + } + if (used.has(key)) throw new Error(`Duplicate step key: "${key}"`) + used.add(key) + out.push({ key, label }) + } + return out +} + +/** Owner editor: replace the shared front template. Config-over-code, audited via `setSetting`. */ +export async function setFrontTemplate(db: DB, userId: string, rawSteps: unknown): Promise { + const steps = normalizeTemplateSteps(rawSteps) + await setSetting(db, userId, 'project.milestone_template.front', JSON.stringify(steps)) + return steps +} + +/** Owner editor: replace one module's tail template. Config-over-code, audited via `setSetting`. */ +export async function setModuleTail(db: DB, userId: string, moduleCode: string, rawSteps: unknown): Promise { + const steps = normalizeTemplateSteps(rawSteps) + await setSetting(db, userId, `project.milestone_template:${moduleCode}`, JSON.stringify(steps)) + return steps } /** @@ -39,7 +151,10 @@ export async function milestoneTemplate(db: DB): Promise { * project's own custom milestones and any ticked state are never touched. */ export async function ensureProjectMilestones(db: DB, clientModuleId: string): Promise { - const template = await milestoneTemplate(db) + const cm = await db.get<{ module_code: string }>( + `SELECT m.code AS module_code FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE cm.id=?`, + clientModuleId) + const template = await milestoneTemplate(db, cm?.module_code) const existing = new Set( (await db.all<{ key: string }>(`SELECT key FROM project_milestone WHERE client_module_id=?`, clientModuleId)) .map((r) => r.key), @@ -61,32 +176,53 @@ export async function ensureProjectMilestones(db: DB, clientModuleId: string): P export async function listProjectMilestones(db: DB, clientModuleId: string): Promise { const rows = await db.all( - `SELECT key, label, done, done_on, sort FROM project_milestone + `SELECT key, label, done, done_on, sort, payment_id FROM project_milestone WHERE client_module_id=? ORDER BY sort, label`, clientModuleId, ) - return rows.map((r) => ({ key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort })) + return rows.map((r) => ({ + key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort, paymentId: r.payment_id, + })) } -/** Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears it. Audited. */ +/** + * Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears + * it (and any linked payment). A payment-step tick (advance_payment / balance_payment) can + * pass `paymentId` to link a real payment — it must belong to the same client as this + * project, and its `received_on` mirrors into `done_on` unless an explicit `doneOn` is + * also given. Audited. + */ export async function setMilestone( - db: DB, userId: string, clientModuleId: string, key: string, done: boolean, doneOn?: string, + db: DB, userId: string, clientModuleId: string, key: string, done: boolean, + doneOn?: string, paymentId?: string, ): Promise { return db.transaction(async () => { const before = await db.get( - `SELECT key, label, done, done_on, sort FROM project_milestone WHERE client_module_id=? AND key=?`, + `SELECT key, label, done, done_on, sort, payment_id FROM project_milestone WHERE client_module_id=? AND key=?`, clientModuleId, key, ) if (before === undefined) throw new Error('Milestone not found on this project') if (done && doneOn !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(doneOn)) { throw new Error('doneOn must be YYYY-MM-DD') } - const stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null + let stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null + let linkedPayment: string | null = null + if (done && paymentId !== undefined && paymentId !== '') { + const pay = await db.get<{ received_on: string }>( + `SELECT received_on FROM payment WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`, + paymentId, clientModuleId, + ) + if (pay === undefined) throw new Error('Payment not found for this client') + linkedPayment = paymentId + if (doneOn === undefined) stamp = pay.received_on // link date wins unless caller forced one + } await db.run( - `UPDATE project_milestone SET done=?, done_on=? WHERE client_module_id=? AND key=?`, - done ? 1 : 0, stamp, clientModuleId, key, + `UPDATE project_milestone SET done=?, done_on=?, payment_id=? WHERE client_module_id=? AND key=?`, + done ? 1 : 0, stamp, done ? linkedPayment : null, clientModuleId, key, ) - await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined, { key, done, doneOn: stamp }) + await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined, + { key, done, doneOn: stamp, paymentId: linkedPayment }) + if (done) await advanceStatusForStep(db, userId, clientModuleId, key) return listProjectMilestones(db, clientModuleId) }) } @@ -180,3 +316,30 @@ export async function projectsPendingMilestone(db: DB, key: string): Promise { + const rows = await db.all( + `SELECT pm.client_module_id AS "clientModuleId", MIN(cm.client_id) AS "clientId", + MIN(c.name) AS "clientName", MIN(m.code) AS "moduleCode", MIN(m.name) AS "moduleName", + MAX(pm.done_on) AS last_done + FROM project_milestone pm + JOIN client_module cm ON cm.id = pm.client_module_id + JOIN client c ON c.id = cm.client_id + JOIN module m ON m.id = cm.module_id + WHERE cm.active = 1 AND cm.status NOT IN ('live','expired','cancelled') + GROUP BY pm.client_module_id + HAVING SUM(CASE WHEN pm.done = 0 THEN 1 ELSE 0 END) > 0`, + ) + const cutoff = new Date(Date.parse(today) - olderThanDays * 86_400_000).toISOString().slice(0, 10) + return rows + .filter((r) => (r.last_done ?? '0000-00-00') < cutoff) + .map(({ last_done: _last_done, ...rest }) => rest) +} diff --git a/apps/hq/src/repos-payments.ts b/apps/hq/src/repos-payments.ts index 46a18b6..36455d8 100644 --- a/apps/hq/src/repos-payments.ts +++ b/apps/hq/src/repos-payments.ts @@ -231,7 +231,13 @@ export async function receiptForPayment(db: DB, userId: string, paymentId: strin // ---------- read views ---------- -export interface ClientLedger { documents: Doc[]; payments: Payment[]; advancePaise: number } +export interface LedgerOutstanding { + docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number +} +export interface LedgerPayment extends Payment { allocations: { docNo: string; amountPaise: number }[] } +export interface ClientLedger { + documents: Doc[]; payments: LedgerPayment[]; advancePaise: number; outstanding: LedgerOutstanding[] +} export async function clientLedger(db: DB, clientId: string): Promise { const payments = await listPayments(db, clientId) @@ -249,10 +255,40 @@ export async function clientLedger(db: DB, clientId: string): Promise= batch.total) break } + + // Per-payment allocations (docNo + amount) for the "Settled → document" column. + const allocRows = await db.all<{ payment_id: string; doc_no: string | null; amount_paise: number }>( + `SELECT a.payment_id, d.doc_no, a.amount_paise + FROM payment_allocation a JOIN document d ON d.id = a.document_id + JOIN payment p ON p.id = a.payment_id WHERE p.client_id=?`, clientId) + const allocByPayment = new Map() + for (const r of allocRows) { + const list = allocByPayment.get(r.payment_id) ?? [] + list.push({ docNo: r.doc_no ?? '—', amountPaise: r.amount_paise }) + allocByPayment.set(r.payment_id, list) + } + const ledgerPayments: LedgerPayment[] = payments.map((p) => ({ ...p, allocations: allocByPayment.get(p.id) ?? [] })) + + // Open documents with money still owed — issued invoices (outstanding > 0) + open proformas. + const moduleName = async (doc: Doc): Promise => + doc.payload.lines.map((l) => l.name).filter((n) => n !== '').join(', ') + const outstanding: LedgerOutstanding[] = [] + for (const d of documents) { + if (d.status === 'cancelled' || d.status === 'paid' || d.status === 'lost') continue + if (d.docType === 'INVOICE' && d.docNo !== null) { + const out = await outstandingPaise(db, d.id) + if (out > 0) outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: out }) + } else if (d.docType === 'PROFORMA' && (d.status === 'sent' || d.status === 'accepted')) { + outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: d.payablePaise }) + } + } + outstanding.sort((a, b) => (a.docNo ?? '').localeCompare(b.docNo ?? '')) + return { documents, - payments, + payments: ledgerPayments, advancePaise: settling - row.total, + outstanding, } } diff --git a/apps/hq/src/repos-renewals.ts b/apps/hq/src/repos-renewals.ts index a5edc42..e3e8180 100644 --- a/apps/hq/src/repos-renewals.ts +++ b/apps/hq/src/repos-renewals.ts @@ -72,7 +72,26 @@ export async function generateModuleRenewalQuote(db: DB, userId: string, clientM const cm = await getClientModule(db, clientModuleId) if (cm === null) throw new Error('Client module not found') return createDraft(db, userId, { - docType: 'PROFORMA', clientId: cm.clientId, + docType: 'PROFORMA', clientId: cm.clientId, source: 'module_renewal', lines: [{ moduleId: cm.moduleId, qty: 1, kind: cm.kind, edition: cm.edition }], }) } + +/** + * Ad-hoc bulk SMS credit purchase (top-up) — a PROFORMA priced through the same + * createDraft path as any other document, so the usage rate card (D23) resolves the + * per-SMS rate for `smsQty` the same way a subscription line would. Tagged + * `source: 'bulk_sms_topup'` so it reads distinctly from a subscription renewal + * (`module_renewal`) on the client-detail document list. + */ +export async function generateBulkSmsPurchaseQuote( + db: DB, userId: string, clientModuleId: string, smsQty: number, +): Promise { + if (!Number.isInteger(smsQty) || smsQty <= 0) throw new Error('SMS quantity must be a positive integer') + const cm = await getClientModule(db, clientModuleId) + if (cm === null) throw new Error('Client module not found') + return createDraft(db, userId, { + docType: 'PROFORMA', clientId: cm.clientId, source: 'bulk_sms_topup', + lines: [{ moduleId: cm.moduleId, qty: smsQty, kind: cm.kind, edition: cm.edition }], + }) +} diff --git a/apps/hq/src/seed.ts b/apps/hq/src/seed.ts index 8aac5b2..24f7072 100644 --- a/apps/hq/src/seed.ts +++ b/apps/hq/src/seed.ts @@ -86,19 +86,64 @@ export async function seedIfEmpty(db: DB): Promise { } if (seededBilling > 0) await writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS) - // Onboarding milestone template (D22): the standard checklist every new project - // (client_module) starts with. Config over code — owner-editable; a project can also add - // its own milestones. Order here is the display order. - const MILESTONE_TEMPLATE = JSON.stringify([ - { key: 'advance_paid', label: 'Advance payment received' }, - { key: 'setup_done', label: 'Setup / configuration done' }, - { key: 'installed', label: 'Installation done' }, - { key: 'go_live', label: 'Go-live' }, - { key: 'balance_paid', label: 'Balance payment received' }, - { key: 'amc_start', label: 'AMC / renewal start' }, + // Onboarding milestone template (D22), composed model: a shared "front" (same for every + // module — enquiry through quotation-approved) plus a per-module "tail". Config over code + // — owner-editable; a project can also add its own milestones. Order here is display order. + const MILESTONE_TEMPLATE_FRONT = JSON.stringify([ + { key: 'enquiry', label: 'Enquiry received' }, + { key: 'visit_meeting', label: 'Visit / meeting scheduled' }, + { key: 'quotation_sent', label: 'Quotation sent' }, + { key: 'quotation_approved', label: 'Quotation approved' }, ]) - const seededMilestone = (await db.run(INSERT_SETTING_SQL, 'project.milestone_template', MILESTONE_TEMPLATE)).changes - if (seededMilestone > 0) await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template', undefined, { set: true }) + const seededFront = (await db.run( + INSERT_SETTING_SQL, 'project.milestone_template.front', MILESTONE_TEMPLATE_FRONT)).changes + if (seededFront > 0) { + await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template.front', undefined, { set: true }) + } + + // Per-module onboarding tails (config over code). SMS/RTGS/Mobile App differ; each ends + // with go-live + payment received so a payment tick maps cleanly on migration. + const PER_MODULE_TEMPLATES: Record = { + SMS: [ + { key: 'document_collection', label: 'Document collection' }, + { 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. diff --git a/apps/hq/src/server.ts b/apps/hq/src/server.ts index 0ab3a2d..382c338 100644 --- a/apps/hq/src/server.ts +++ b/apps/hq/src/server.ts @@ -150,7 +150,7 @@ export async function startServer(port: number, opts: { scheduler?: boolean } = // and the test suite keep selecting SQLite (an unset/empty DATABASE_URL) and stay green. // ⚠️ Once the real password is filled in, this file holds a secret — do not push the // filled-in value to a repo others can read. - const HARDCODED_DATABASE_URL = 'postgres://postgres:CHANGE_ME_PASSWORD@host.docker.internal:5432/hq' + const HARDCODED_DATABASE_URL = 'postgres://postgres:inv123@host.docker.internal:5432/hq' // D19 engine selection: DATABASE_URL (or the hardcoded prod value) → Postgres; else SQLite. const pgUrl = process.env['DATABASE_URL'] || (process.env['NODE_ENV'] === 'production' ? HARDCODED_DATABASE_URL : '') const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) diff --git a/apps/hq/test/bulk-sms-purchase.test.ts b/apps/hq/test/bulk-sms-purchase.test.ts new file mode 100644 index 0000000..fdee684 --- /dev/null +++ b/apps/hq/test/bulk-sms-purchase.test.ts @@ -0,0 +1,74 @@ +// apps/hq/test/bulk-sms-purchase.test.ts — Task 7: ad-hoc bulk-SMS top-up documents, +// tagged distinctly from subscription renewals via document.source. +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, assignModule, setPrice, updateClientModule } from '../src/repos-modules' +import { setUsageRateCard } from '../src/repos-rate-card' +import { generateModuleRenewalQuote, generateBulkSmsPurchaseQuote } from '../src/repos-renewals' +import { createDraft, getDocument } from '../src/repos-documents' + +// The founder's real card: ₹0.40 / 0.37 / 0.34 / 0.30 per SMS = 40/37/34/30 paise. +const BANDS = [ + { minQty: 50_000, ratePaise: 40 }, + { minQty: 100_000, ratePaise: 37 }, + { minQty: 200_000, ratePaise: 34 }, + { minQty: 300_000, ratePaise: 30 }, +] + +async function world() { + const db = openDb(':memory:') + await seedIfEmpty(db) // company state 32, GST18 + const c = await createClient(db, 'u1', { name: 'Karapuzha SCB', stateCode: '32' }) + const sms = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS', allowedKinds: ['usage'] }) + await setUsageRateCard(db, 'u1', sms.id, '2026-04-01', BANDS) + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: sms.id, kind: 'usage' }) + return { db, c, sms, cm } +} + +describe('generateBulkSmsPurchaseQuote', () => { + it('raises a proforma tagged bulk_sms_topup for the given SMS quantity', async () => { + const { db, cm } = await world() + const doc = await generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 100_000) + expect(doc.docType).toBe('PROFORMA') + expect(doc.source).toBe('bulk_sms_topup') + expect(doc.payload.lines[0]!.qty).toBe(100_000) + expect(doc.payablePaise).toBeGreaterThan(0) + // Persisted correctly — a re-fetch from the DB carries the same tag. + const full = (await getDocument(db, doc.id))! + expect(full.source).toBe('bulk_sms_topup') + }) + + it('rejects a non-positive or fractional quantity', async () => { + const { db, cm } = await world() + await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 0)).rejects.toThrow() + await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, -5)).rejects.toThrow() + await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 1.5)).rejects.toThrow() + }) + + it('rejects an unknown client module', async () => { + const { db } = await world() + await expect(generateBulkSmsPurchaseQuote(db, 'u1', 'nope', 100_000)).rejects.toThrow(/not found/) + }) +}) + +describe('document.source tagging', () => { + it('tags a renewal proforma module_renewal, and a manual draft keeps the default', async () => { + const { db, c } = await world() + // A separate yearly-priced module for the renewal path (usage modules enforce a + // minimum-order qty that a qty:1 renewal line would fail). + const yearly = await createModule(db, 'u1', { code: 'CORE', name: 'Core App', allowedKinds: ['yearly'] }) + await setPrice(db, 'u1', { moduleId: yearly.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) + const cm2 = await assignModule(db, 'u1', { clientId: c.id, moduleId: yearly.id, kind: 'yearly' }) + await updateClientModule(db, 'u1', cm2.id, { nextRenewal: '2026-08-15' }) + const renewal = await generateModuleRenewalQuote(db, 'u1', cm2.id) + expect(renewal.source).toBe('module_renewal') + + const manual = await createDraft(db, 'u1', { + docType: 'PROFORMA', clientId: c.id, + lines: [{ moduleId: yearly.id, qty: 1, kind: 'yearly' }], + }) + expect(manual.source).toBe('hq') // default preserved when source is omitted + }) +}) diff --git a/apps/hq/test/ledger-outstanding.test.ts b/apps/hq/test/ledger-outstanding.test.ts new file mode 100644 index 0000000..045bc9f --- /dev/null +++ b/apps/hq/test/ledger-outstanding.test.ts @@ -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) { + 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([]) + }) +}) diff --git a/apps/hq/test/migrate-onboarding-templates.test.ts b/apps/hq/test/migrate-onboarding-templates.test.ts new file mode 100644 index 0000000..69500b0 --- /dev/null +++ b/apps/hq/test/migrate-onboarding-templates.test.ts @@ -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 { + 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 { + 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) + }) +}) diff --git a/apps/hq/test/milestone-payment.test.ts b/apps/hq/test/milestone-payment.test.ts new file mode 100644 index 0000000..9ef8510 --- /dev/null +++ b/apps/hq/test/milestone-payment.test.ts @@ -0,0 +1,66 @@ +// apps/hq/test/milestone-payment.test.ts — payment_id on milestone + link on payment-step tick. +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, assignModule } from '../src/repos-modules' +import { setMilestone, listProjectMilestones } from '../src/repos-milestones' +import { recordPayment } from '../src/repos-payments' + +async function world() { + const db = openDb(':memory:') + await seedIfEmpty(db) + const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' }) + const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' }) + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + return { db, clientId: c.id, cmId: cm.id } +} + +describe('milestone payment_id link (payment-step tick)', () => { + it('linking a payment to 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) + }) +}) diff --git a/apps/hq/test/milestone-status.test.ts b/apps/hq/test/milestone-status.test.ts new file mode 100644 index 0000000..abeaca7 --- /dev/null +++ b/apps/hq/test/milestone-status.test.ts @@ -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') + }) +}) diff --git a/apps/hq/test/milestones-templates.test.ts b/apps/hq/test/milestones-templates.test.ts new file mode 100644 index 0000000..ab84f19 --- /dev/null +++ b/apps/hq/test/milestones-templates.test.ts @@ -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]) + }) +}) diff --git a/apps/hq/test/milestones.test.ts b/apps/hq/test/milestones.test.ts index cf1a994..65c5d1b 100644 --- a/apps/hq/test/milestones.test.ts +++ b/apps/hq/test/milestones.test.ts @@ -21,10 +21,13 @@ async function world() { } describe('project milestones (D22)', () => { - it('seeds the standard checklist on assignment, in template order', async () => { + it('seeds the standard checklist on assignment, in template order (front + tail)', async () => { const { db, p1 } = await world() const ms = await listProjectMilestones(db, p1.id) - expect(ms.map((m) => m.key)).toEqual(['advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start']) + expect(ms.map((m) => m.key)).toEqual([ + 'enquiry', 'visit_meeting', 'quotation_sent', 'quotation_approved', + 'advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start', + ]) expect(ms.every((m) => !m.done && m.doneOn === null)).toBe(true) }) @@ -45,7 +48,7 @@ describe('project milestones (D22)', () => { it('adds a project-specific milestone after the standard set', async () => { const { db, p1 } = await world() const ms = await addProjectMilestone(db, 'u1', p1.id, 'AWS region confirmed') - expect(ms).toHaveLength(7) + expect(ms).toHaveLength(11) // 4 front + 6 tail + 1 custom expect(ms[ms.length - 1]!.label).toBe('AWS region confirmed') await expect(addProjectMilestone(db, 'u1', p1.id, ' ')).rejects.toThrow(/label/) }) @@ -59,9 +62,9 @@ describe('project milestones (D22)', () => { ) expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(0) await ensureProjectMilestones(db, 'imported-cm') - expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(6) + expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(10) // 4 front + 6 tail await ensureProjectMilestones(db, 'imported-cm') // second call adds nothing - expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(6) + expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(10) }) it('boards the whole book and drills into who is pending a milestone', async () => { diff --git a/apps/hq/test/seed-templates.test.ts b/apps/hq/test/seed-templates.test.ts new file mode 100644 index 0000000..a09fb93 --- /dev/null +++ b/apps/hq/test/seed-templates.test.ts @@ -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', + ]) + }) +}) diff --git a/apps/hq/test/stalled-onboarding.test.ts b/apps/hq/test/stalled-onboarding.test.ts new file mode 100644 index 0000000..ee8eb08 --- /dev/null +++ b/apps/hq/test/stalled-onboarding.test.ts @@ -0,0 +1,57 @@ +// apps/hq/test/stalled-onboarding.test.ts — Task 8: stalled-onboarding query (dashboard/MIS tile). +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, assignModule } from '../src/repos-modules' +import { setMilestone, stalledProjects } from '../src/repos-milestones' + +async function world() { + const db = openDb(':memory:') + await seedIfEmpty(db) + const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' }) + // A module code with no per-module milestone template seeded (D22 seed.ts only carries + // SMS/RTGS/MOBILEAPP overrides), so the global template applies: advance_paid, setup_done, + // installed, go_live, balance_paid, amc_start. + const m = await createModule(db, 'u1', { code: 'MISC', name: 'Misc Module' }) + return { db, c, m } +} + +describe('stalledProjects (D22 — dashboard/MIS tile)', () => { + it('lists a project parked past the threshold', async () => { + const { db, c, m } = await world() + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + // Early steps done on an old date; later steps (incl. go_live) stay pending — project is + // still active (status stays 'quoted'/'installed', never 'live'). + await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01') + await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01') + // last done_on = 2026-03-01, today = 2026-07-19 → ~140 days parked. + const stalled = await stalledProjects(db, 30, '2026-07-19') + expect(stalled.map((p) => p.clientModuleId)).toContain(cm.id) + const row = stalled.find((p) => p.clientModuleId === cm.id)! + expect(row.clientName).toBe('Kothavara SCB') + expect(row.moduleCode).toBe('MISC') + }) + + it('excludes a recently-progressed project', async () => { + const { db, c, m } = await world() + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01') + await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01') + const stalled = await stalledProjects(db, 365, '2026-07-19') + expect(stalled).toHaveLength(0) + }) + + it('excludes projects with no pending steps (fully ticked) and inactive/live projects', async () => { + const { db, c, m } = await world() + // Fully ticked project: nothing pending, so it should never appear regardless of age. + const done = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + for (const key of ['advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start']) { + await setMilestone(db, 'u1', done.id, key, true, '2026-01-01') + } + const stalled = await stalledProjects(db, 1, '2026-07-19') + expect(stalled.map((p) => p.clientModuleId)).not.toContain(done.id) + // 'live' status (from ticking go_live) also excludes it, which the loop above proves. + expect((await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, done.id))!.status).toBe('live') + }) +}) diff --git a/docs/06-DECISIONS.md b/docs/06-DECISIONS.md index 020dfc9..12135c1 100644 --- a/docs/06-DECISIONS.md +++ b/docs/06-DECISIONS.md @@ -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:` 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/`. diff --git a/docs/superpowers/plans/2026-07-19-client-detail-redesign-backend.md b/docs/superpowers/plans/2026-07-19-client-detail-redesign-backend.md new file mode 100644 index 0000000..d02ae34 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-client-detail-redesign-backend.md @@ -0,0 +1,941 @@ +# Client Detail Redesign — Backend & Data Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the data + repo layer the redesigned Client Detail page needs — per-module onboarding templates, a milestone→status auto-drive, milestone↔payment links, an extended ledger (outstanding + allocations), and a bulk-SMS-purchase document path — with a one-time migration that moves existing SMS/RTGS/Mobile-App projects onto their new step lists without losing ticks. + +**Architecture:** Everything is a plain exported function taking the `DB` handle first (portable-repo pattern). Onboarding step lists are DB `setting` rows resolved by module code (config over code). Schema changes are additive `ALTER TABLE` in `migrate()` (SQLite) mirrored in `migrations-pg.ts` (Postgres). No money math changes — the ledger extension only *reads* existing `payment_allocation` / `document` rows. Every mutation writes an audit row in the same transaction. + +**Tech Stack:** TypeScript (strict), `better-sqlite3` (dev) / Postgres (prod), Express, Vitest. + +## Global Constraints + +- Money is integer paise; never introduce floats. This plan touches **no** billing math (`computeBill`, allocation) — the ledger extension reads existing columns only. +- Config over code, dated: onboarding step lists are `setting` rows, never code constants (the code default is a fallback only). +- Append-only audit: every mutation calls `writeAudit(db, userId, action, entity, id, before, after)` in the same `db.transaction`. +- Portable SQL: no engine-specific SQL beyond the existing `ON CONFLICT … DO NOTHING` upsert idiom already used in `seed.ts`. Schema changes land in BOTH `apps/hq/src/db.ts` (`migrate()`, SQLite) and `apps/hq/src/migrations-pg.ts` (Postgres). +- Strict TS, green before landing: `npm run typecheck` and `npm test` clean before anything lands. +- Run all commands from the repo root `C:/SiMS/hq`. Tests: `npm test --workspace @sims/hq` (vitest). Typecheck: `npm run typecheck`. + +--- + +## File Structure + +- `apps/hq/src/repos-milestones.ts` — MODIFY: per-module template resolution; step→status auto-drive; `payment_id` on tick; stalled-projects query. +- `apps/hq/src/repos-modules.ts` — MODIFY: `ensureProjectMilestones` call site already lives here (assignment); no change unless noted. +- `apps/hq/src/seed.ts` — MODIFY: seed per-module template settings (SMS / RTGS / Mobile App). +- `apps/hq/src/db.ts` — MODIFY: `project_milestone.payment_id` column (SQLite `migrate()`); `client_module` module-code lookup helper if needed. +- `apps/hq/src/migrations-pg.ts` — MODIFY: mirror the `payment_id` column for Postgres. +- `apps/hq/src/migrate-onboarding-templates.ts` — CREATE: the one-time, idempotent milestone-swap migration function. +- `apps/hq/scripts/migrate-onboarding-templates.ts` — CREATE: thin CLI wrapper that opens the DB and runs the migration (mirrors `scripts/apply-name-fixes.ts`). +- `apps/hq/src/repos-payments.ts` — MODIFY: extend `clientLedger` with `outstanding` + per-payment `allocations`. +- `apps/hq/src/repos-documents.ts` — MODIFY: make `source` a settable draft field (default unchanged). +- `apps/hq/src/repos-renewals.ts` — MODIFY: tag the renewal proforma `source='module_renewal'`; ADD `generateBulkSmsPurchaseQuote`. +- `apps/hq/src/api.ts` — MODIFY: route for bulk-SMS-purchase; ledger route already returns `clientLedger` (shape widens automatically). +- Tests under `apps/hq/test/` — one file per task as noted. + +--- + +## Task 1: Per-module onboarding template resolution + +**Files:** +- Modify: `apps/hq/src/repos-milestones.ts` +- Test: `apps/hq/test/milestones-templates.test.ts` + +**Interfaces:** +- Produces: `milestoneTemplate(db: DB, moduleCode?: string): Promise` — resolves `project.milestone_template:` → `project.milestone_template` → `FALLBACK_TEMPLATE`. +- Produces: `ensureProjectMilestones(db: DB, clientModuleId: string): Promise` — now resolves the template by the client_module's module code. + +- [ ] **Step 1: Write the failing test** + +Create `apps/hq/test/milestones-templates.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest' +import { openDb, type DB } from '../src/db' +import { milestoneTemplate } from '../src/repos-milestones' + +describe('per-module milestone templates', () => { + let db: DB + beforeEach(async () => { db = await openDb(':memory:') }) + + it('falls back to the global template when no module-specific one exists', async () => { + await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`, + JSON.stringify([{ key: 'a', label: 'A' }])) + expect(await milestoneTemplate(db, 'SMS')).toEqual([{ key: 'a', label: 'A' }]) + }) + + it('prefers the module-specific template over the global one', async () => { + await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`, + JSON.stringify([{ key: 'a', label: 'A' }])) + await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template:SMS', ?)`, + JSON.stringify([{ key: 'doc', label: 'Document collection' }])) + expect(await milestoneTemplate(db, 'SMS')).toEqual([{ key: 'doc', label: 'Document collection' }]) + // A different module still gets the global one. + expect(await milestoneTemplate(db, 'RTGS')).toEqual([{ key: 'a', label: 'A' }]) + }) + + it('falls back to the code default when nothing is seeded', async () => { + const t = await milestoneTemplate(db, 'SMS') + expect(t.map((e) => e.key)).toContain('go_live') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- milestones-templates` +Expected: FAIL — `milestoneTemplate` does not accept a module code / does not read the `:CODE` key. + +- [ ] **Step 3: Implement the resolution** + +In `apps/hq/src/repos-milestones.ts`, replace `milestoneTemplate` and update `ensureProjectMilestones`: + +```ts +/** The checklist for a module (owner-editable per module via setting; global default; code fallback). */ +export async function milestoneTemplate(db: DB, moduleCode?: string): Promise { + const parse = (value: string): TemplateEntry[] | undefined => { + try { + const p = JSON.parse(value) as TemplateEntry[] + return Array.isArray(p) && p.length > 0 ? p : undefined + } catch { return undefined } + } + if (moduleCode !== undefined && moduleCode !== '') { + const perModule = await db.get<{ value: string }>( + `SELECT value FROM setting WHERE key=?`, `project.milestone_template:${moduleCode}`) + const parsed = perModule !== undefined ? parse(perModule.value) : undefined + if (parsed !== undefined) return parsed + } + const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='project.milestone_template'`) + const parsedGlobal = row !== undefined ? parse(row.value) : undefined + return parsedGlobal ?? FALLBACK_TEMPLATE +} +``` + +Then update `ensureProjectMilestones` to resolve by module code: + +```ts +export async function ensureProjectMilestones(db: DB, clientModuleId: string): Promise { + const cm = await db.get<{ module_code: string }>( + `SELECT m.code AS module_code FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE cm.id=?`, + clientModuleId) + const template = await milestoneTemplate(db, cm?.module_code) + const existing = new Set( + (await db.all<{ key: string }>(`SELECT key FROM project_milestone WHERE client_module_id=?`, clientModuleId)) + .map((r) => r.key), + ) + await db.transaction(async () => { + let sort = 0 + for (const t of template) { + if (!existing.has(t.key)) { + await db.run( + `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort) + VALUES (?, ?, ?, ?, 0, NULL, ?)`, + uuidv7(), clientModuleId, t.key, t.label, sort, + ) + } + sort++ + } + }) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test --workspace @sims/hq -- milestones-templates` +Expected: PASS (3 tests). + +- [ ] **Step 5: Typecheck + commit** + +```bash +npm run typecheck +git add apps/hq/src/repos-milestones.ts apps/hq/test/milestones-templates.test.ts +git commit -m "feat(onboarding): resolve milestone template per module (config over code)" +``` + +--- + +## Task 2: Seed per-module onboarding templates + +**Files:** +- Modify: `apps/hq/src/seed.ts` +- Test: `apps/hq/test/seed-templates.test.ts` + +**Interfaces:** +- Consumes: `milestoneTemplate` (Task 1). +- Produces: seeded settings `project.milestone_template:SMS|RTGS|MOBILEAPP`. (Confirm the Mobile App module code with `SELECT code FROM module` during Step 3; the mockup assumes `MOBILEAPP` — adjust the key to the real code.) + +- [ ] **Step 1: Write the failing test** + +Create `apps/hq/test/seed-templates.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest' +import { openDb, type DB } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { milestoneTemplate } from '../src/repos-milestones' + +describe('seed per-module onboarding templates', () => { + let db: DB + beforeEach(async () => { db = await openDb(':memory:'); await seedIfEmpty(db) }) + + it('seeds the SMS 9-step list', async () => { + const keys = (await milestoneTemplate(db, 'SMS')).map((e) => e.key) + expect(keys).toEqual([ + 'document_collection', 'dlt_registration', 'account_creation', 'installation', + 'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', + ]) + }) + + it('seeds the RTGS list ending in payment steps', async () => { + const keys = (await milestoneTemplate(db, 'RTGS')).map((e) => e.key) + expect(keys).toEqual([ + 'document_collection', 'server_checkup', 'setup_configuration', 'installation', + 'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', + ]) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- seed-templates` +Expected: FAIL — per-module templates not seeded (falls back to global keys). + +- [ ] **Step 3: Add the seed block** + +In `apps/hq/src/seed.ts`, after the existing `project.milestone_template` seed (line ~101), add: + +```ts + // Per-module onboarding templates (config over code). SMS/RTGS/Mobile App differ; each ends + // with advance + balance payment so a payment tick maps cleanly on migration. + const PER_MODULE_TEMPLATES: Record = { + SMS: [ + { key: 'document_collection', label: 'Document collection' }, + { key: 'dlt_registration', label: 'DLT registration' }, + { key: 'account_creation', label: 'Account creation & registration' }, + { key: 'installation', label: 'Installation' }, + { key: 'testing', label: 'Testing' }, + { key: 'training', label: 'Training' }, + { key: 'go_live', label: 'Go-live' }, + { key: 'advance_payment', label: 'Advance payment' }, + { key: 'balance_payment', label: 'Balance payment' }, + ], + RTGS: [ + { key: 'document_collection', label: 'Document collection' }, + { key: 'server_checkup', label: 'Server checkup' }, + { key: 'setup_configuration', label: 'Setup & configuration' }, + { key: 'installation', label: 'Installation' }, + { key: 'testing', label: 'Testing' }, + { key: 'training', label: 'Training' }, + { key: 'go_live', label: 'Go-live' }, + { key: 'advance_payment', label: 'Advance payment' }, + { key: 'balance_payment', label: 'Balance payment' }, + ], + MOBILEAPP: [ + { key: 'requirement_setup', label: 'Requirement & setup' }, + { key: 'configuration', label: 'Configuration' }, + { key: 'data_import', label: 'Data import' }, + { key: 'installation', label: 'Installation' }, + { key: 'testing', label: 'Testing' }, + { key: 'training', label: 'Training' }, + { key: 'go_live', label: 'Go-live' }, + { key: 'advance_payment', label: 'Advance payment' }, + { key: 'balance_payment', label: 'Balance payment' }, + ], + } + let seededPerModule = 0 + for (const [code, list] of Object.entries(PER_MODULE_TEMPLATES)) { + seededPerModule += (await db.run( + INSERT_SETTING_SQL, `project.milestone_template:${code}`, JSON.stringify(list))).changes + } + if (seededPerModule > 0) { + await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template:*', undefined, + { modules: Object.keys(PER_MODULE_TEMPLATES) }) + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test --workspace @sims/hq -- seed-templates` +Expected: PASS. Also run `npm test --workspace @sims/hq -- milestones-templates` — still green. + +- [ ] **Step 5: Commit** + +```bash +npm run typecheck +git add apps/hq/src/seed.ts apps/hq/test/seed-templates.test.ts +git commit -m "feat(onboarding): seed SMS/RTGS/MobileApp onboarding templates" +``` + +--- + +## Task 3: Milestone→status auto-drive + go-live + +**Files:** +- Modify: `apps/hq/src/repos-milestones.ts` (`setMilestone`) +- Test: `apps/hq/test/milestone-status.test.ts` + +**Interfaces:** +- Consumes: `setMilestone(db, userId, clientModuleId, key, done, doneOn?)` (existing signature unchanged). +- Produces: a module-status advance side-effect keyed by a `STEP_STATUS` map, written in the same transaction and audited. + +- [ ] **Step 1: Write the failing test** + +Create `apps/hq/test/milestone-status.test.ts`. Use the existing test helpers to create a client + module + client_module (copy the setup from `apps/hq/test/tickets.test.ts` which already builds a `client_module`). Assert: + +```ts +// after seeding milestones for an SMS client_module at status 'quoted': +it('ticking installation advances status to installed', async () => { + await setMilestone(db, 'u1', cmId, 'installation', true) + const cm = await getClientModule(db, cmId) + expect(cm!.status).toBe('installed') +}) + +it('ticking go_live advances status to live', async () => { + await setMilestone(db, 'u1', cmId, 'go_live', true) + expect((await getClientModule(db, cmId))!.status).toBe('live') +}) + +it('unticking never downgrades status', async () => { + await setMilestone(db, 'u1', cmId, 'go_live', true) + await setMilestone(db, 'u1', cmId, 'go_live', false) + expect((await getClientModule(db, cmId))!.status).toBe('live') +}) + +it('a lower step never downgrades a higher status', async () => { + await setMilestone(db, 'u1', cmId, 'go_live', true) // live + await setMilestone(db, 'u1', cmId, 'installation', true) // still live + expect((await getClientModule(db, cmId))!.status).toBe('live') +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- milestone-status` +Expected: FAIL — status stays `quoted`. + +- [ ] **Step 3: Implement the auto-drive** + +In `apps/hq/src/repos-milestones.ts`, add the map and fold the advance into `setMilestone`'s transaction (after the `UPDATE project_milestone` and its audit, before `return`): + +```ts +/** Ticking a step can advance the coarse client_module.status — never downgrade. Ranks match + * the status CHECK order: quoted = { + quoted: 0, ordered: 1, installing: 2, installed: 3, trained: 4, live: 5, expired: 6, cancelled: 7, +} +const STEP_STATUS: Record = { + installation: 'installed', training: 'trained', go_live: 'live', +} + +async function advanceStatusForStep(db: DB, userId: string, clientModuleId: string, key: string): Promise { + const target = STEP_STATUS[key] + if (target === undefined) return + const cm = await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, clientModuleId) + if (cm === undefined) return + if ((STATUS_RANK[cm.status] ?? 0) >= (STATUS_RANK[target] ?? 0)) return // never downgrade + await db.run(`UPDATE client_module SET status=? WHERE id=?`, target, clientModuleId) + await writeAudit(db, userId, 'update', 'client_module', clientModuleId, { status: cm.status }, { status: target }) +} +``` + +Inside `setMilestone`, after the milestone `writeAudit` and before `return listProjectMilestones(...)`, add: + +```ts + if (done) await advanceStatusForStep(db, userId, clientModuleId, key) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test --workspace @sims/hq -- milestone-status` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +npm run typecheck +git add apps/hq/src/repos-milestones.ts apps/hq/test/milestone-status.test.ts +git commit -m "feat(onboarding): status line auto-drives client_module.status (never downgrades)" +``` + +--- + +## Task 4: `payment_id` on milestone + link on payment-step tick + +**Files:** +- Modify: `apps/hq/src/db.ts` (`migrate()` SQLite) and `apps/hq/src/migrations-pg.ts` (Postgres) +- Modify: `apps/hq/src/repos-milestones.ts` (`Milestone` type, `listProjectMilestones`, `setMilestone` optional `paymentId`) +- Modify: `apps/hq/src/api.ts` (accept `paymentId` in the milestone POST body) +- Test: `apps/hq/test/milestone-payment.test.ts` + +**Interfaces:** +- Produces: `setMilestone(db, userId, clientModuleId, key, done, doneOn?, paymentId?)` — when a payment step is ticked with a `paymentId`, stores it and mirrors the payment's `received_on` into `done_on`. +- Produces: `Milestone` gains `paymentId: string | null`. + +- [ ] **Step 1: Add the column (schema, both engines)** + +In `apps/hq/src/db.ts` `migrate()`, alongside the other `PRAGMA table_info` blocks, add: + +```ts + const milestoneCols = db.prepare(`PRAGMA table_info(project_milestone)`).all() as { name: string }[] + if (!milestoneCols.some((c) => c.name === 'payment_id')) { + db.exec(`ALTER TABLE project_milestone ADD COLUMN payment_id TEXT`) + } +``` + +In `apps/hq/src/migrations-pg.ts`, mirror it (follow the file's existing additive-migration idiom — an `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS payment_id text`). + +- [ ] **Step 2: Write the failing test** + +Create `apps/hq/test/milestone-payment.test.ts` (reuse the client_module setup + record a payment via `recordPayment`): + +```ts +it('linking a payment to advance_payment stores payment_id and mirrors received_on', async () => { + const pay = await recordPayment(db, 'u1', { + clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00, + }) + await setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id) + const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'advance_payment')! + expect(ms.done).toBe(true) + expect(ms.doneOn).toBe('2026-05-01') + expect(ms.paymentId).toBe(pay.payment.id) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- milestone-payment` +Expected: FAIL — `setMilestone` has no `paymentId` param; `Milestone` has no `paymentId`. + +- [ ] **Step 4: Implement** + +In `repos-milestones.ts`: +- Add `paymentId: string | null` to `interface Milestone` and to `MilestoneRow` (`payment_id: string | null`). +- Include `payment_id` in the `SELECT` of `listProjectMilestones` and map it: `paymentId: r.payment_id`. +- Extend `setMilestone` signature with a trailing `paymentId?: string`. When `done` and `paymentId` is provided, resolve the payment's `received_on` and use it as the stamp, and set `payment_id`: + +```ts +export async function setMilestone( + db: DB, userId: string, clientModuleId: string, key: string, done: boolean, + doneOn?: string, paymentId?: string, +): Promise { + return db.transaction(async () => { + const before = await db.get( + `SELECT key, label, done, done_on, sort, payment_id FROM project_milestone WHERE client_module_id=? AND key=?`, + clientModuleId, key) + if (before === undefined) throw new Error('Milestone not found on this project') + if (done && doneOn !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(doneOn)) { + throw new Error('doneOn must be YYYY-MM-DD') + } + let stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null + let linkedPayment: string | null = null + if (done && paymentId !== undefined && paymentId !== '') { + const pay = await db.get<{ received_on: string }>( + `SELECT received_on FROM payment WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`, + paymentId, clientModuleId) + if (pay === undefined) throw new Error('Payment not found for this client') + linkedPayment = paymentId + if (doneOn === undefined) stamp = pay.received_on // link date wins unless caller forced one + } + await db.run( + `UPDATE project_milestone SET done=?, done_on=?, payment_id=? WHERE client_module_id=? AND key=?`, + done ? 1 : 0, stamp, done ? linkedPayment : null, clientModuleId, key) + await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined, + { key, done, doneOn: stamp, paymentId: linkedPayment }) + if (done) await advanceStatusForStep(db, userId, clientModuleId, key) + return listProjectMilestones(db, clientModuleId) + }) +} +``` + +In `api.ts` milestone POST route, read `paymentId` from the body and pass it through: + +```ts + const milestones = await setMilestone(db, staffId(res), id, body.key, body.done, + typeof body.doneOn === 'string' ? body.doneOn : undefined, + typeof (body as { paymentId?: unknown }).paymentId === 'string' ? (body as { paymentId?: string }).paymentId : undefined) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npm test --workspace @sims/hq -- milestone-payment milestone-status milestones-templates` +Expected: PASS (all). + +- [ ] **Step 6: Commit** + +```bash +npm run typecheck +git add apps/hq/src/db.ts apps/hq/src/migrations-pg.ts apps/hq/src/repos-milestones.ts apps/hq/src/api.ts apps/hq/test/milestone-payment.test.ts +git commit -m "feat(onboarding): link advance/balance payment steps to real payments" +``` + +--- + +## Task 5: One-time migration — swap seeded modules onto new templates, preserve ticks + +**Files:** +- Create: `apps/hq/src/migrate-onboarding-templates.ts` +- Create: `apps/hq/scripts/migrate-onboarding-templates.ts` +- Test: `apps/hq/test/migrate-onboarding-templates.test.ts` + +**Interfaces:** +- Produces: `migrateOnboardingTemplates(db: DB): Promise<{ projects: number; carried: number; dropped: number }>` — idempotent; for every client_module whose module has a per-module template setting, replaces the old rows with the module's template, carrying mapped ticks (`done` + `done_on` + `payment_id`). + +**Mapping (old generic key → new key):** `installed→installation`, `go_live→go_live`, `advance_paid→advance_payment`, `balance_paid→balance_payment`, `setup_done→setup_configuration` (only if the new template has that key). Old keys with no mapped target (e.g. `amc_start`, or `setup_done` for SMS) are dropped and counted. + +- [ ] **Step 1: Write the failing test** + +Create `apps/hq/test/migrate-onboarding-templates.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest' +import { openDb, type DB } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' +import { listProjectMilestones } from '../src/repos-milestones' +// + helpers to create an SMS client_module and seed the OLD generic milestones on it + +describe('onboarding template migration', () => { + let db: DB + beforeEach(async () => { db = await openDb(':memory:'); await seedIfEmpty(db) }) + + it('swaps an SMS project to the 9-step list carrying installed+go_live ticks', async () => { + const cmId = /* create SMS client_module */ '' + // seed OLD generic rows with two ticked: + for (const [i, r] of [ + ['advance_paid', 0, null], ['setup_done', 0, null], ['installed', 1, '2026-03-25'], + ['go_live', 1, '2026-04-02'], ['balance_paid', 0, null], ['amc_start', 0, null], + ].entries()) { + await db.run(`INSERT INTO project_milestone (id,client_module_id,key,label,done,done_on,sort) + VALUES (?,?,?,?,?,?,?)`, `m${i}`, cmId, r[0], String(r[0]), r[1], r[2], i) + } + const res = await migrateOnboardingTemplates(db) + expect(res.projects).toBe(1) + const ms = await listProjectMilestones(db, cmId) + expect(ms.map((m) => m.key)).toEqual([ + 'document_collection','dlt_registration','account_creation','installation', + 'testing','training','go_live','advance_payment','balance_payment']) + const installation = ms.find((m) => m.key === 'installation')! + expect(installation.done).toBe(true) + expect(installation.doneOn).toBe('2026-03-25') + expect(ms.find((m) => m.key === 'go_live')!.done).toBe(true) + // old setup_done / amc_start gone: + expect(ms.some((m) => m.key === 'setup_done' || m.key === 'amc_start')).toBe(false) + }) + + it('is idempotent (second run changes nothing new)', async () => { + /* run twice; assert the milestone set is identical after each */ + }) + + it('leaves a module without a per-module template untouched', async () => { + /* create a client_module for a module with NO :CODE setting; assert its rows unchanged */ + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- migrate-onboarding-templates` +Expected: FAIL — module `migrate-onboarding-templates` does not exist. + +- [ ] **Step 3: Implement the migration** + +Create `apps/hq/src/migrate-onboarding-templates.ts`: + +```ts +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import { milestoneTemplate } from './repos-milestones' +import type { DB } from './db' + +/** old generic key → new template key (only applied if the new template has that key). */ +const KEY_MAP: Record = { + installed: 'installation', go_live: 'go_live', + advance_paid: 'advance_payment', balance_paid: 'balance_payment', + setup_done: 'setup_configuration', +} + +interface OldRow { key: string; done: number; done_on: string | null; payment_id: string | null } + +/** + * Idempotent: for every client_module whose module has a per-module onboarding template, + * rebuild its milestone rows from that template, carrying mapped ticks (done/done_on/payment_id). + * Unmapped old ticks are dropped (counted). Modules with no per-module template are untouched. + */ +export async function migrateOnboardingTemplates(db: DB): Promise<{ projects: number; carried: number; dropped: number }> { + // Which module codes have a per-module template setting? + const codes = (await db.all<{ key: string }>( + `SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`)) + .map((r) => r.key.slice('project.milestone_template:'.length)) + let projects = 0, carried = 0, dropped = 0 + for (const code of codes) { + const template = await milestoneTemplate(db, code) + const templateKeys = new Set(template.map((t) => t.key)) + const cms = await db.all<{ id: string }>( + `SELECT cm.id FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE m.code=?`, code) + for (const { id: cmId } of cms) { + const oldRows = await db.all( + `SELECT key, done, done_on, payment_id FROM project_milestone WHERE client_module_id=?`, cmId) + // Build the carried tick-state keyed by NEW key. + const carriedState = new Map() + let alreadyNew = true + for (const r of oldRows) { + if (!templateKeys.has(r.key)) alreadyNew = false + const target = templateKeys.has(r.key) ? r.key : KEY_MAP[r.key] + if (target !== undefined && templateKeys.has(target)) { + if (r.done === 1) carriedState.set(target, r) + } else if (r.done === 1) { + dropped++ + } + } + // Idempotency: if the project's keys already ARE exactly the template, skip. + const oldKeySet = new Set(oldRows.map((r) => r.key)) + const identical = alreadyNew && oldKeySet.size === templateKeys.size + && [...templateKeys].every((k) => oldKeySet.has(k)) + if (identical) continue + projects++ + await db.transaction(async () => { + await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cmId) + let sort = 0 + for (const t of template) { + const c = carriedState.get(t.key) + await db.run( + `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + uuidv7(), cmId, t.key, t.label, + c !== undefined ? 1 : 0, c?.done_on ?? null, c?.payment_id ?? null, sort) + if (c !== undefined) carried++ + sort++ + } + await writeAudit(db, 'system', 'migrate_onboarding', 'client_module', cmId, undefined, + { module: code, carried: [...carriedState.keys()] }) + }) + } + } + return { projects, carried, dropped } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test --workspace @sims/hq -- migrate-onboarding-templates` +Expected: PASS (3 tests). + +- [ ] **Step 5: Create the CLI wrapper** + +Create `apps/hq/scripts/migrate-onboarding-templates.ts` (mirror `scripts/apply-name-fixes.ts` open-DB pattern): + +```ts +import { openDb } from '../src/db' +import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' + +async function main(): Promise { + const db = await openDb(process.env['HQ_DB_PATH'] ?? 'data/hq.db') + const res = await migrateOnboardingTemplates(db) + console.log(`onboarding migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ${res.dropped} dropped`) +} +main().catch((e) => { console.error(e); process.exit(1) }) +``` + +- [ ] **Step 6: Commit** + +```bash +npm run typecheck +git add apps/hq/src/migrate-onboarding-templates.ts apps/hq/scripts/migrate-onboarding-templates.ts apps/hq/test/migrate-onboarding-templates.test.ts +git commit -m "feat(onboarding): one-time migration swapping seeded modules to new templates (ticks preserved)" +``` + +--- + +## Task 6: Extend the ledger with outstanding + per-payment allocations + +**Files:** +- Modify: `apps/hq/src/repos-payments.ts` (`ClientLedger`, `clientLedger`) +- Modify: `apps/hq-web/src/api.ts` (`Ledger`, `Payment` types — web mirror) +- Test: `apps/hq/test/ledger-outstanding.test.ts` + +**Interfaces:** +- Produces: `ClientLedger` gains + `outstanding: { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number }[]` + and each `Payment` in the ledger gains `allocations: { docNo: string; amountPaise: number }[]`. +- Consumes: existing `outstandingPaise(db, docId)` (already exported in `repos-payments.ts`). + +- [ ] **Step 1: Write the failing test** + +Create `apps/hq/test/ledger-outstanding.test.ts`. Build a client with one issued INVOICE (partly paid) and one open PROFORMA; record a payment; then: + +```ts +it('reports outstanding open documents oldest-first with correct due amounts', async () => { + const led = await clientLedger(db, clientId) + expect(led.outstanding.map((o) => o.docNo)).toEqual(['INV-0001']) // proforma optional; see label rule + expect(led.outstanding[0]!.outstandingPaise).toBe(/* payable - allocated - credited */ 0) +}) + +it('attaches allocations to each payment', async () => { + const led = await clientLedger(db, clientId) + const p = led.payments[0]! + expect(p.allocations.reduce((s, a) => s + a.amountPaise, 0)).toBeLessThanOrEqual(p.amountPaise + p.tdsPaise) +}) +``` + +(Fill in exact expected paise from the fixture you build — pick round amounts so the assertions are concrete.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- ledger-outstanding` +Expected: FAIL — `outstanding` / `allocations` undefined. + +- [ ] **Step 3: Implement the extension** + +In `apps/hq/src/repos-payments.ts`, widen the interface and `clientLedger`: + +```ts +export interface LedgerOutstanding { + docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number +} +export interface LedgerPayment extends Payment { allocations: { docNo: string; amountPaise: number }[] } +export interface ClientLedger { + documents: Doc[]; payments: LedgerPayment[]; advancePaise: number; outstanding: LedgerOutstanding[] +} +``` + +In `clientLedger`, after building `documents`: + +```ts + // Per-payment allocations (docNo + amount) for the "Settled → document" column. + const allocRows = await db.all<{ payment_id: string; doc_no: string | null; amount_paise: number }>( + `SELECT a.payment_id, d.doc_no, a.amount_paise + FROM payment_allocation a JOIN document d ON d.id = a.document_id + JOIN payment p ON p.id = a.payment_id WHERE p.client_id=?`, clientId) + const allocByPayment = new Map() + for (const r of allocRows) { + const list = allocByPayment.get(r.payment_id) ?? [] + list.push({ docNo: r.doc_no ?? '—', amountPaise: r.amount_paise }) + allocByPayment.set(r.payment_id, list) + } + const ledgerPayments: LedgerPayment[] = payments.map((p) => ({ ...p, allocations: allocByPayment.get(p.id) ?? [] })) + + // Open documents with money still owed — issued invoices (outstanding > 0) + open proformas. + const moduleName = async (doc: Doc): Promise => + doc.payload.lines.map((l) => l.name).filter((n) => n !== '').join(', ') + const outstanding: LedgerOutstanding[] = [] + for (const d of documents) { + if (d.status === 'cancelled' || d.status === 'paid' || d.status === 'lost') continue + if (d.docType === 'INVOICE' && d.docNo !== null) { + const out = await outstandingPaise(db, d.id) + if (out > 0) outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: out }) + } else if (d.docType === 'PROFORMA' && (d.status === 'sent' || d.status === 'accepted' || d.status === 'draft')) { + outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: d.payablePaise }) + } + } + outstanding.sort((a, b) => (a.docNo ?? '').localeCompare(b.docNo ?? '')) +``` + +Return `{ documents, payments: ledgerPayments, advancePaise: settling - row.total, outstanding }`. + +- [ ] **Step 4: Mirror the web types** + +In `apps/hq-web/src/api.ts`, update `Payment` (add `allocations: { docNo: string; amountPaise: number }[]`) and `Ledger` (add `outstanding: { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number }[]`). + +- [ ] **Step 5: Run tests + typecheck** + +Run: `npm test --workspace @sims/hq -- ledger-outstanding` → PASS. +Run: `npm test --workspace @sims/hq` (full suite — confirm the 416 existing tests stay green; the ledger shape only widened). `npm run typecheck` clean. + +- [ ] **Step 6: Commit** + +```bash +git add apps/hq/src/repos-payments.ts apps/hq-web/src/api.ts apps/hq/test/ledger-outstanding.test.ts +git commit -m "feat(payments): ledger exposes outstanding docs + per-payment allocations" +``` + +--- + +## Task 7: Bulk-SMS-purchase document + source tagging + +**Files:** +- Modify: `apps/hq/src/repos-documents.ts` (`DraftInput.source?`, `insertDocRow` source, INSERT column) +- Modify: `apps/hq/src/repos-renewals.ts` (`source='module_renewal'`; ADD `generateBulkSmsPurchaseQuote`) +- Modify: `apps/hq/src/api.ts` (POST route for bulk SMS purchase) +- Test: `apps/hq/test/bulk-sms-purchase.test.ts` + +**Interfaces:** +- Produces: `generateBulkSmsPurchaseQuote(db, userId, clientModuleId, smsQty): Promise` — a PROFORMA priced via the usage rate card for `smsQty` SMS, `source='bulk_sms_topup'`. +- Produces: `createDraft` accepts optional `source` (default preserves current behaviour); documents carry it for the frontend's Quote/Renewal/Bulk-top-up label. + +- [ ] **Step 1: Write the failing test** + +Create `apps/hq/test/bulk-sms-purchase.test.ts`. Set up an SMS module with a usage rate card (copy the rate-card setup from an existing rate-card test), an SMS client_module, then: + +```ts +it('raises a proforma tagged bulk_sms_topup for the given SMS quantity', async () => { + const doc = await generateBulkSmsPurchaseQuote(db, 'u1', cmId, 100000) + expect(doc.docType).toBe('PROFORMA') + expect(doc.source).toBe('bulk_sms_topup') + expect(doc.payload.lines[0]!.qty).toBe(100000) + expect(doc.payablePaise).toBeGreaterThan(0) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- bulk-sms-purchase` +Expected: FAIL — `generateBulkSmsPurchaseQuote` not exported. + +- [ ] **Step 3: Make `source` settable on a draft** + +In `apps/hq/src/repos-documents.ts`: +- Add `source?: string` to `interface DraftInput`. +- Thread it into `insertDocRow`: add `source?: string` to its param object, add `source` to the INSERT column list + a `?` placeholder, and pass `a.source ?? 'manual'` (confirm the schema default; keep current behaviour when omitted). In `createDraft`, pass `source: input.source` into `insertDocRow`. + +```ts +// insertDocRow INSERT — add `source` column: +`INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, due_date, status, ref_doc_id, + taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise, + payload, source, created_by, created_at) + VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, +// ...bind a.source ?? 'manual' in the source position. +``` + +- [ ] **Step 4: Add the generator + tag the renewal** + +In `apps/hq/src/repos-renewals.ts`, tag the existing renewal proforma and add the bulk generator: + +```ts +export async function generateModuleRenewalQuote(db: DB, userId: string, clientModuleId: string): Promise { + const cm = await getClientModule(db, clientModuleId) + if (cm === null) throw new Error('Client module not found') + return createDraft(db, userId, { + docType: 'PROFORMA', clientId: cm.clientId, source: 'module_renewal', + lines: [{ moduleId: cm.moduleId, qty: 1, kind: cm.kind, edition: cm.edition }], + }) +} + +/** Ad-hoc bulk SMS credit purchase — a PROFORMA priced by the usage rate card for `smsQty` units. */ +export async function generateBulkSmsPurchaseQuote( + db: DB, userId: string, clientModuleId: string, smsQty: number, +): Promise { + if (!Number.isInteger(smsQty) || smsQty <= 0) throw new Error('SMS quantity must be a positive integer') + const cm = await getClientModule(db, clientModuleId) + if (cm === null) throw new Error('Client module not found') + return createDraft(db, userId, { + docType: 'PROFORMA', clientId: cm.clientId, source: 'bulk_sms_topup', + lines: [{ moduleId: cm.moduleId, qty: smsQty, kind: cm.kind, edition: cm.edition }], + }) +} +``` + +- [ ] **Step 5: Add the API route** + +In `apps/hq/src/api.ts`, near the other document routes, add: + +```ts + r.post('/client-modules/:id/bulk-sms-quote', requireAuth, async (req, res) => { + try { + const qty = Number((req.body as { smsQty?: unknown }).smsQty) + const doc = await generateBulkSmsPurchaseQuote(db, staffId(res), String(req.params['id'] ?? ''), qty) + res.json({ ok: true, document: doc }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) +``` + +Import `generateBulkSmsPurchaseQuote` alongside `generateModuleRenewalQuote`. + +- [ ] **Step 6: Run tests + full suite** + +Run: `npm test --workspace @sims/hq -- bulk-sms-purchase` → PASS. +Run: `npm test --workspace @sims/hq` → all green (source default preserved the existing document tests). `npm run typecheck` clean. + +- [ ] **Step 7: Commit** + +```bash +git add apps/hq/src/repos-documents.ts apps/hq/src/repos-renewals.ts apps/hq/src/api.ts apps/hq/test/bulk-sms-purchase.test.ts +git commit -m "feat(sms): bulk SMS purchase proforma + document source tagging (renewal vs top-up)" +``` + +--- + +## Task 8: Stalled-onboarding query + +**Files:** +- Modify: `apps/hq/src/repos-milestones.ts` +- Modify: `apps/hq/src/api.ts` (route) +- Test: `apps/hq/test/stalled-onboarding.test.ts` + +**Interfaces:** +- Produces: `stalledProjects(db: DB, olderThanDays: number, today: string): Promise` — active projects whose earliest pending step's most recent done_on (or module creation) is older than N days, i.e. parked. Reuses the `PendingProject` shape. + +- [ ] **Step 1: Write the failing test** + +Create `apps/hq/test/stalled-onboarding.test.ts`: build an active SMS project with early steps done on an old date and later steps pending; assert it appears for a small `olderThanDays` and not for a large one. + +```ts +it('lists a project parked past the threshold', async () => { + // last done_on = 2026-03-01, today = 2026-07-19 → ~140 days + const stalled = await stalledProjects(db, 30, '2026-07-19') + expect(stalled.map((p) => p.clientModuleId)).toContain(cmId) +}) +it('excludes a recently-progressed project', async () => { + const stalled = await stalledProjects(db, 365, '2026-07-19') + expect(stalled).toHaveLength(0) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace @sims/hq -- stalled-onboarding` +Expected: FAIL — `stalledProjects` not exported. + +- [ ] **Step 3: Implement** + +In `apps/hq/src/repos-milestones.ts`: + +```ts +/** Active projects with at least one pending step whose progress last moved > olderThanDays ago. */ +export async function stalledProjects(db: DB, olderThanDays: number, today: string): Promise { + const rows = await db.all( + `SELECT pm.client_module_id AS "clientModuleId", cm.client_id AS "clientId", + c.name AS "clientName", m.code AS "moduleCode", m.name AS "moduleName", + MAX(pm.done_on) AS last_done + FROM project_milestone pm + JOIN client_module cm ON cm.id = pm.client_module_id + JOIN client c ON c.id = cm.client_id + JOIN module m ON m.id = cm.module_id + WHERE cm.active = 1 AND cm.status NOT IN ('live','expired','cancelled') + GROUP BY pm.client_module_id + HAVING SUM(CASE WHEN pm.done = 0 THEN 1 ELSE 0 END) > 0`, + ) + const cutoff = new Date(Date.parse(today) - olderThanDays * 86400_000).toISOString().slice(0, 10) + return rows + .filter((r) => (r.last_done ?? '0000-00-00') < cutoff) + .map(({ last_done: _l, ...rest }) => rest) +} +``` + +In `api.ts` add a route: `r.get('/projects/stalled', requireAuth, …)` reading an `?days=` query (default from a `project.stall_days` setting, fallback 30) and `new Date().toISOString().slice(0,10)` for today. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test --workspace @sims/hq -- stalled-onboarding` → PASS. + +- [ ] **Step 5: Commit** + +```bash +npm run typecheck +git add apps/hq/src/repos-milestones.ts apps/hq/src/api.ts apps/hq/test/stalled-onboarding.test.ts +git commit -m "feat(onboarding): stalled-project query for the dashboard/MIS tile" +``` + +--- + +## Backend plan self-review + +- **Spec coverage:** §2 templates → T1/T2; §3 migration → T5; §7.1 status auto-drive → T3; §7.2 payment link → T4; §5 ledger → T6; §4a bulk SMS + §7.3 doc source → T7; §7.4 stalled → T8. Per-module Documents (§4) is pure frontend (client-side filter) — no backend task, handled in Plan 2. +- **Type consistency:** `milestoneTemplate(db, moduleCode?)`, `setMilestone(..., doneOn?, paymentId?)`, `ClientLedger.outstanding` / `LedgerPayment.allocations`, `generateBulkSmsPurchaseQuote(db, userId, cmId, smsQty)`, `stalledProjects(db, days, today)` — names used consistently across tasks and into Plan 2. +- **Placeholder scan:** test fixtures marked "fill in exact paise" (T6) and the client_module setup helpers (T3–T8) reference the existing `apps/hq/test/tickets.test.ts` setup — the implementer copies that concrete setup; not a code placeholder in shipped source. +- **Verify before done:** every task ends with `npm test` for its file plus `npm run typecheck`; T6 and T7 additionally run the full suite because they touch shared read/write paths. + +**Confirm before building:** the Mobile App module `code` (assumed `MOBILEAPP`) — check `SELECT code, name FROM module` and set the seed key + migration to the real code. diff --git a/docs/superpowers/plans/2026-07-19-client-detail-redesign-frontend.md b/docs/superpowers/plans/2026-07-19-client-detail-redesign-frontend.md new file mode 100644 index 0000000..f0858e8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-client-detail-redesign-frontend.md @@ -0,0 +1,572 @@ +# Client Detail Redesign — Frontend Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild the Modules and Payments tabs of Client Detail — module details as a 50/50 read-only report with a single Edit per group, onboarding as a horizontal per-module status line, per-module Documents (incl. a bulk-SMS-purchase action), and a Payments Outstanding panel — plus a per-module template editor and a stalled-onboarding tile, verified pixel-tight on mobile via a red-team alignment pass. + +**Architecture:** React + Vite; all data via the typed `api.ts` calls. Styling is token-based CSS in `apps/hq-web/src/app.css` (Warm/Slate/Graphite/Zinc neutrals × 7 accents × light/dark) — new surfaces use existing tokens only. This plan consumes the backend from `2026-07-19-client-detail-redesign-backend.md` (ledger `outstanding`/`allocations`, per-module templates, `setMilestone(paymentId)`, `generateBulkSmsPurchaseQuote`, `stalledProjects`). + +**Tech Stack:** React 18, TypeScript (strict), Vite, `@sims/ui`. + +## Global Constraints + +- **Prerequisite:** the backend plan is merged (its API shapes exist). If not, stop and do it first. +- No new colour constants — style through CSS custom properties (`--accent`, `--border`, `--ok/--warn/--err`, `--bg-raised`, `--text`, `--text-dim`, `--radius`, `--mono`). Must work in **light and dark** and all four neutrals. +- The page body never scrolls sideways; wide content scrolls inside its own `overflow-x:auto`. Numeric columns use `font-variant-numeric: tabular-nums`. +- No frontend unit-test harness exists (tests are backend vitest). Each task's verification is: `npm run typecheck` clean, `npm run build --workspace @sims/hq-web` clean, and a **manual check in the running app** (backend on :5182 serving the built UI, or Vite dev on :5183). The final task is the responsive + red-team gate. +- Run from repo root. Reference the approved mockup `scratchpad/client-detail-mockup.html` for exact layout/CSS. + +--- + +## File Structure + +- `apps/hq-web/src/app.css` — MODIFY: `.split`/`.kv`, `.track`/`.node`, `.out` outstanding-panel, doc-label pill classes. +- `apps/hq-web/src/pages/ClientDetail.tsx` — MODIFY: `ServiceDataCard` (50/50 + single edit), replace `MilestoneChecklist` with `StatusLine`, add `ModuleDocuments`, Payments tab (`OutstandingPanel` + Settled column), summary-table trim. +- `apps/hq-web/src/pages/client-forms.tsx` — MODIFY: `AssignModuleForm` copy → "Add a module". +- `apps/hq-web/src/api.ts` — MODIFY: add `generateBulkSmsPurchase`, `getStalledProjects`, `getModuleTemplate`/`setModuleTemplate`, and thread `paymentId` into `setMilestone`. +- `apps/hq-web/src/pages/Modules.tsx` — MODIFY: per-module onboarding template editor. +- `apps/hq-web/src/pages/Dashboard.tsx` — MODIFY: stalled-onboarding tile. + +Each new sub-component (`StatusLine`, `ModuleDocuments`, `OutstandingPanel`) is a local function component in `ClientDetail.tsx` next to the code it replaces (that file already houses `ServiceDataCard`, `MilestoneChecklist`, etc.). Keep them focused; if `ClientDetail.tsx` grows past readability, extract the module-block components into `pages/client-module-block.tsx` as a follow-up (note it, don't force it). + +--- + +## Task 1: CSS foundation (port the mockup styles) + +**Files:** +- Modify: `apps/hq-web/src/app.css` (append a new section) + +**Interfaces:** +- Produces: classes `.split`, `.kv .k/.val`, `.track`/`.node` (+ `.done/.now`), `.out` (+ `.oh/.orow/.ofoot`), `.doc-pill` used by later tasks. These MUST match the class names the JSX in Tasks 2–5 references. + +- [ ] **Step 1: Append the styles** + +Add to `apps/hq-web/src/app.css` (values ported from `scratchpad/client-detail-mockup.html`, swapping the mockup's literal colours for tokens): + +```css +/* ---- Client Detail redesign (D-CDR) ---- */ +/* 50/50 service sheet */ +.cdr-split { display: grid; grid-template-columns: 1fr 1fr; gap: 0 30px; } +.cdr-split > .col { padding: 13px 0; min-width: 0; } +.cdr-split > .col + .col { border-left: 1px solid var(--border); padding-left: 30px; } +.cdr-kv { display: flex; align-items: baseline; gap: 10px; padding: 6px 0; min-width: 0; } +.cdr-kv + .cdr-kv { border-top: 1px dashed var(--border); } +.cdr-kv > .k { flex: none; width: 120px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-dim); font-weight: 600; padding-top: 2px; } +.cdr-kv > .v { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } +.cdr-kv > .v .txt { overflow: hidden; text-overflow: ellipsis; } +@media (max-width: 720px) { + .cdr-split { grid-template-columns: 1fr; } + .cdr-split > .col + .col { border-left: none; padding-left: 0; border-top: 1px solid var(--border); } +} +/* horizontal onboarding status line */ +.cdr-track-wrap { overflow-x: auto; padding: 10px 2px 4px; } +.cdr-track { display: flex; min-width: min-content; } +.cdr-node { flex: 1 0 112px; display: flex; flex-direction: column; align-items: center; text-align: center; position: relative; padding: 0 4px; } +.cdr-node .bar { position: absolute; top: 11px; right: 50%; width: 100%; height: 2px; background: var(--border); } +.cdr-node.done .bar, .cdr-node.now .bar { background: var(--accent); } +.cdr-node:first-child .bar { display: none; } +.cdr-node .dot { position: relative; z-index: 1; width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--border-strong); background: var(--bg-raised); display: grid; place-items: center; font-size: 11px; color: transparent; cursor: pointer; } +.cdr-node.done .dot { background: var(--ok); border-color: var(--ok); color: #fff; } +.cdr-node.now .dot { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); } +.cdr-node .nm { font-size: 11px; margin-top: 7px; max-width: 104px; line-height: 1.35; } +.cdr-node.done .nm { color: var(--text-dim); } +.cdr-node.now .nm { font-weight: 600; } +.cdr-node .dt { font-size: 10px; color: var(--text-dim); font-family: var(--mono); margin-top: 3px; } +/* outstanding panel */ +.cdr-out { border: 1px solid color-mix(in srgb, var(--warn) 30%, var(--border)); border-radius: var(--radius); overflow: hidden; } +.cdr-out .oh { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); } +.cdr-out .orow { display: flex; align-items: center; gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 12.5px; } +.cdr-out .orow:last-child { border-bottom: none; } +.cdr-out .orow .due { margin-left: auto; font-family: var(--mono); font-weight: 600; } +.cdr-out .ofoot { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--bg-inset); } +/* document-type pill */ +.doc-pill { font-size: 10.5px; font-weight: 600; letter-spacing: .3px; border-radius: 999px; padding: 1px 8px; border: 1px solid var(--border); color: var(--text-dim); background: var(--bg-inset); } +``` + +- [ ] **Step 2: Verify build** + +Run: `npm run build --workspace @sims/hq-web` +Expected: build succeeds (CSS is valid, no selector errors). + +- [ ] **Step 3: Commit** + +```bash +git add apps/hq-web/src/app.css +git commit -m "style(client-detail): CSS for 50/50 sheet, status line, outstanding panel" +``` + +--- + +## Task 2: Module details → 50/50 report with single Edit per group + +**Files:** +- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` — `ServiceDataCard` (lines ~961–1103) and fold `ModuleFieldsForm` (lines ~1112–1209) presentation into it. + +**Interfaces:** +- Consumes: existing `patchClientModule`, `setModuleFields`, `revealModulePassword`, `SecretField`, `cm.details`, `cm.fieldValues`, `props.fieldSpec`. +- Produces: read-only 50/50 report; each group (`Access`, `Details`) has one **Edit** toggling that group's inputs; save = one audited call. + +- [ ] **Step 1: Replace the read view of `ServiceDataCard`** + +Rewrite the returned read view (the non-editing `return (...)` at ~1066) as the 50/50 report. Keep the existing edit-state machinery; change only what renders. The read view: + +```tsx +return ( +
+
+
+
+ Access + + +
+ + copy(cm.username ?? '', 'Username')} /> +
+ Password + + {revealed !== undefined + ? <>{revealed} + : cm.hasPassword + ? <>••••••••{managerial && } + : not set} + +
+ {cm.remark !== null && cm.remark !== '' && } +
+
+
+ Details + + +
+ {props.fieldSpec.filter((f) => f.type !== 'secret').map((f) => ( + + ))} + {cm.details.map((d, i) => )} + {props.fieldSpec.filter((f) => f.type === 'secret').map((f) => ( +
+ {f.label} + +
+ ))} +
+
+
+) +``` + +Add a small `Kv` helper in the same file: + +```tsx +function Kv(props: { label: string; value: string | undefined; mono?: boolean; copyable?: boolean; onCopy?: () => void }) { + const v = props.value + return ( +
+ {props.label} + + {v !== undefined && v !== '' ? {v} : } + {props.copyable === true && v !== undefined && v !== '' && } + +
+ ) +} +``` + +- [ ] **Step 2: Fold the fieldSpec edit into the Details Edit** + +In the editing branch of `ServiceDataCard` (~1028), extend the edit form to also render the non-secret `fieldSpec` inputs (move the control logic from `ModuleFieldsForm`), so one **Save** persists provider/username/password/remark + details + fieldValues. Remove the separate always-on `ModuleFieldsForm` render at line ~1095–1100 (secret fields stay as `SecretField` in the read view). Keep `saveEdit` calling `patchClientModule` for the D20 fields and `setModuleFields` for the fieldSpec values (two audited calls in sequence is acceptable; or combine if `patchClientModule` accepts field values — check its signature). + +- [ ] **Step 3: Verify** + +Run: `npm run typecheck && npm run build --workspace @sims/hq-web` +Then run the app and open a client with SMS + RTGS: confirm the 50/50 read view, one Edit per group, save round-trips, secret reveal still audited, `—` for empty fields, no `*` markers. + +- [ ] **Step 4: Commit** + +```bash +git add apps/hq-web/src/pages/ClientDetail.tsx +git commit -m "feat(client-detail): module details as 50/50 report with single edit per group" +``` + +--- + +## Task 3: Onboarding → horizontal status line (+ payment-step link) + +**Files:** +- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` — replace `MilestoneChecklist` (~1270–1357) with `StatusLine`. +- Modify: `apps/hq-web/src/api.ts` — thread `paymentId` into `setMilestone`. + +**Interfaces:** +- Consumes: `getMilestones`, `setMilestone(clientModuleId, key, done, doneOn?, paymentId?)`, `addMilestone`, `Milestone` (now with `paymentId`). +- Produces: `StatusLine` component rendering the horizontal `.cdr-track`. + +- [ ] **Step 1: Extend the web `setMilestone` call** + +In `apps/hq-web/src/api.ts`, update the milestone POST helper to accept an optional `paymentId` and include it in the body. Confirm the `Milestone` type includes `paymentId: string | null`. + +- [ ] **Step 2: Write `StatusLine`** + +Replace `MilestoneChecklist` with: + +```tsx +function StatusLine(props: { clientModuleId: string; name: string }) { + const toast = useToast() + const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId]) + const [busyKey, setBusyKey] = useState('') + const [adding, setAdding] = useState(false) + const [label, setLabel] = useState('') + + const toggle = (m: Milestone, done: boolean, doneOn?: string) => { + if (busyKey !== '') return + setBusyKey(m.key) + setMilestone(props.clientModuleId, m.key, done, doneOn) + .then(() => list.reload()).catch((e: Error) => toast.err(e.message)).finally(() => setBusyKey('')) + } + const addStep = () => { + if (label.trim() === '') return + addMilestone(props.clientModuleId, label.trim()) + .then(() => { setLabel(''); setAdding(false); list.reload() }).catch((e: Error) => toast.err(e.message)) + } + const ms = list.data + const done = ms?.filter((m) => m.done).length ?? 0 + // current = first not-done + const currentKey = ms?.find((m) => !m.done)?.key + + return ( +
+
+ {props.name} — status line + {ms !== undefined && ms.length > 0 && {done}/{ms.length}} + + {!adding ? : ( + <> + setLabel(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') addStep() }} /> + + + + )} +
+ {list.error !== undefined ? + : ms === undefined ? + : ms.length === 0 ? No steps yet. : ( +
+ {ms.map((m) => ( +
+ + + {m.label} + {m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'} +
+ ))} +
+ )} +
+ ) +} +``` + +Update the modules-tab render (line ~316) to use `` and rename the heading if present. + +- [ ] **Step 3: Payment-step linking (advance/balance)** + +When a node whose key is `advance_payment` or `balance_payment` is ticked, open a small dialog to pick an existing payment (from the client ledger) or record a new one, then call `setMilestone(cmId, key, true, undefined, paymentId)`. Minimum viable: a dialog listing `ledger.payments` (date + amount) to pick from, plus a "Record new payment" shortcut that switches to the Payments tab. Wire the dialog state in `ClientDetail` and pass an `onTickPayment(key)` callback into `StatusLine` for those two keys. (If a lighter first cut is preferred, tick normally with today's date and leave linking as a follow-up — but the spec calls for the link; implement the picker.) + +- [ ] **Step 4: Verify** + +Run: `npm run typecheck && npm run build --workspace @sims/hq-web`. In-app: tick along the SMS line → dots fill, current ring moves, ticking Installation flips the header status to `installed`, ticking Go-live → `live` (backend Task 3); line scrolls on a narrow window; ticking Advance payment prompts to link a payment. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/api.ts +git commit -m "feat(client-detail): onboarding as horizontal status line with payment-step linking" +``` + +--- + +## Task 4: Per-module Documents + Bulk SMS purchase + +**Files:** +- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` — add `ModuleDocuments`; render inside each module block after `StatusLine`. +- Modify: `apps/hq-web/src/api.ts` — add `generateBulkSmsPurchase(cmId, smsQty)`. + +**Interfaces:** +- Consumes: `ledger.data.documents` (each `Doc.payload.lines[].itemId === moduleId`), `doc.source` (`module_renewal`/`bulk_sms_topup`), `nav`. +- Produces: `ModuleDocuments` component; a Bulk SMS action for SMS-coded modules. + +- [ ] **Step 1: Add the web API call** + +In `apps/hq-web/src/api.ts`: + +```ts +export const generateBulkSmsPurchase = (clientModuleId: string, smsQty: number) => + post<{ document: Doc }>(`/client-modules/${clientModuleId}/bulk-sms-quote`, { smsQty }).then((r) => r.document) +``` + +(Match the existing `post`/typed-call idiom in the file.) + +- [ ] **Step 2: Write `ModuleDocuments`** + +```tsx +const DOC_SOURCE_LABEL: Record = { + module_renewal: 'Renewal', bulk_sms_topup: 'Bulk top-up', +} +function docLabel(d: Doc): string { + if (DOC_SOURCE_LABEL[d.source] !== undefined) return DOC_SOURCE_LABEL[d.source]! + return DOC_TYPE_LABEL[d.docType] ?? d.docType +} + +function ModuleDocuments(props: { cm: ClientModule; docs: Doc[]; moduleCode: string; onCreated: () => void }) { + const nav = useNavigate() + const toast = useToast() + const [busy, setBusy] = useState(false) + const mine = props.docs + .filter((d) => d.payload.lines.some((l) => l.itemId === props.cm.moduleId)) + .sort((a, b) => b.docDate.localeCompare(a.docDate)) + const isSms = props.moduleCode.toUpperCase() === 'SMS' + + const bulk = () => { + const qty = Number(window.prompt('Bulk SMS quantity to purchase?', '100000') ?? '') + if (!Number.isInteger(qty) || qty <= 0) return + setBusy(true) + generateBulkSmsPurchase(props.cm.id, qty) + .then((doc) => { toast.ok('Bulk SMS proforma created'); nav(`/documents/${doc.id}`) }) + .catch((e: Error) => { toast.err(e.message); setBusy(false) }) + } + + return ( +
+
+ Documents + {mine.length} + + {isSms && } +
+ {mine.length === 0 ?
No documents for this module yet.
+ : mine.map((d) => ( +
nav(`/documents/${d.id}`)} + style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '7px 0', borderTop: '1px solid var(--border)', cursor: 'pointer', fontSize: 12.5 }}> + {d.docNo ?? 'draft'} + {docLabel(d)} + {d.docDate} + {inr(d.payablePaise)} + {d.status} +
+ ))} +
+ ) +} +``` + +- [ ] **Step 3: Render it in each module block** + +In the modules-tab `.map` (~300–319), inside `.mod-section-body`, after `` add: + +```tsx + m.id === cm.moduleId)?.code ?? ''} onCreated={() => ledger.reload()} /> +``` + +- [ ] **Step 4: Verify** + +Run: `npm run typecheck && npm run build --workspace @sims/hq-web`. In-app: SMS block lists its quotes/proformas with correct pills (Renewal / Bulk top-up / Quote); "Bulk SMS purchase" creates a proforma and opens it; it then appears in the list and (Task 5) in Outstanding. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/api.ts +git commit -m "feat(client-detail): per-module documents list + bulk SMS purchase action" +``` + +--- + +## Task 5: Payments tab — Outstanding panel + Settled column + +**Files:** +- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` — Payments tab (~398–475). + +**Interfaces:** +- Consumes: `ledger.data.outstanding`, `ledger.data.payments[].allocations` (backend Task 6). + +- [ ] **Step 1: Add the Outstanding panel** + +At the top of the `tab === 'payments'` block, before ``, render: + +```tsx +{ledger.data !== undefined && ledger.data.outstanding.length > 0 && ( +
+

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

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