diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 8a311be..d4fd4db 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -362,18 +362,26 @@ export const revealModuleSecret = (id: string, key: string): Promise => // ---------- onboarding milestones (D22) ---------- -/** One checklist step on a project (client_module). Ticking stamps `doneOn`. */ -export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number } +/** One checklist step on a project (client_module). Ticking stamps `doneOn`; the + * advance/balance payment steps additionally carry `paymentId`, linking a real + * payment from the client's ledger. */ +export interface Milestone { + key: string; label: string; done: boolean; doneOn: string | null; sort: number; paymentId: string | null +} /** The project's checklist; the server auto-seeds the standard template on first read. */ export const getMilestones = (clientModuleId: string): Promise => apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`).then((r) => r.milestones) -/** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it. */ +/** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it. + * `paymentId` links a real payment to a step (advance_payment / balance_payment). */ export const setMilestone = ( - clientModuleId: string, key: string, done: boolean, doneOn?: string, + clientModuleId: string, key: string, done: boolean, doneOn?: string, paymentId?: string, ): Promise => apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`, { - method: 'POST', body: JSON.stringify({ key, done, ...(doneOn !== undefined ? { doneOn } : {}) }), + method: 'POST', + body: JSON.stringify({ + key, done, ...(doneOn !== undefined ? { doneOn } : {}), ...(paymentId !== undefined ? { paymentId } : {}), + }), }).then((r) => r.milestones) /** Add a project-specific step beyond the standard template. */ export const addMilestone = (clientModuleId: string, label: string): Promise => diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index eca7ded..e0aa996 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -2,7 +2,7 @@ import { useState, type CSSProperties, type ReactNode } from 'react' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { formatINR } from '@sims/domain' import { - Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, Field, RecordHeader, Skeleton, + Badge, Button, ConfirmDialog, DataTable, Dialog, EmptyState, ErrorState, Field, Notice, RecordHeader, Skeleton, StatCard, Stats, Tabs, Toolbar, useToast, } from '@sims/ui' import { @@ -18,7 +18,8 @@ import { getMilestones, setMilestone, addMilestone, CLIENT_STATUSES, KIND_LABEL, type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule, - type ClientStatus, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome, type RecurringPlan, type ServiceDetail, + type ClientStatus, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome, + type Payment, type RecurringPlan, type ServiceDetail, } from '../api' import { buildStatement } from '../statement' import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients' @@ -73,6 +74,13 @@ export function ClientDetail() { const [interactionDialog, setInteractionDialog] = useState<{ initial?: Interaction } | undefined>() const [confirm, setConfirm] = useState() const [statementOpen, setStatementOpen] = useState(false) + // Onboarding status line (D38): ticking the advance/balance payment step opens this + // picker instead of stamping a bare date. `milestonesVersion` bumps to force every + // StatusLine's milestone list to refetch after a pick lands (see `refreshToken` below). + const [paymentPicker, setPaymentPicker] = useState<{ clientModuleId: string; key: string; label: string } | undefined>() + const [linkingPayment, setLinkingPayment] = useState(false) + const [linkError, setLinkError] = useState() + const [milestonesVersion, setMilestonesVersion] = useState(0) const rawTab = sp.get('tab') ?? 'overview' const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview' @@ -313,7 +321,12 @@ export function ClientDetail() { fieldSpec={modules.data?.find((m) => m.id === cm.moduleId)?.fieldSpec ?? []} onChanged={() => cms.reload()} /> - + { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }} + /> ))} @@ -607,6 +620,30 @@ export function ClientDetail() { {statementOpen && ( setStatementOpen(false)} /> )} + {paymentPicker !== undefined && ( + b.receivedOn.localeCompare(a.receivedOn))} + saving={linkingPayment} + error={linkError} + onClose={() => { if (!linkingPayment) setPaymentPicker(undefined) }} + onPick={(paymentId) => { + if (linkingPayment) return + setLinkingPayment(true) + setLinkError(undefined) + setMilestone(paymentPicker.clientModuleId, paymentPicker.key, true, undefined, paymentId) + .then(() => { + toast.ok('Payment linked') + setMilestonesVersion((v) => v + 1) + setPaymentPicker(undefined) + }) + .catch((e: Error) => setLinkError(e.message)) + .finally(() => setLinkingPayment(false)) + }} + onRecordNew={() => { setPaymentPicker(undefined); setTab('payments') }} + /> + )} ) } @@ -1229,14 +1266,23 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo } /** - * Onboarding checklist (D22 — per-project detail): the project's milestones as a row - * of toggles. Ticking one stamps the date (a compact date input adjusts it; toggling - * on with no date uses today); "+ Add step" appends a project-specific milestone. Every - * change is one audited POST that returns the fresh list, so the card re-renders in place. + * Onboarding as a horizontal status line (D38 redesign — per-project detail): the + * project's milestones as a point-by-point track (`.cdr-track`) — a connector line + * joins ordered dots, the current (first not-done) step is ringed ("now"), done steps + * show a check and their date. Ticking a plain step stamps today's date and drives the + * module status (installed/live) exactly as before; ticking the advance/balance payment + * steps instead calls `onTickPayment` so the parent can open a picker that links a real + * ledger payment (`ClientDetail`'s `PaymentPickerDialog`) rather than a bare date. + * "+ Add step" appends a project-specific milestone. Every change is one audited POST + * that returns the fresh list; `refreshToken` lets the parent force a refetch after a + * payment link lands from outside this component. */ -function MilestoneChecklist(props: { clientModuleId: string; name: string }) { +function StatusLine(props: { + clientModuleId: string; name: string; refreshToken: number + onTickPayment: (key: string, label: string) => void +}) { const toast = useToast() - const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId]) + const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId, props.refreshToken]) const [busyKey, setBusyKey] = useState('') const [adding, setAdding] = useState(false) const [label, setLabel] = useState('') @@ -1261,64 +1307,106 @@ function MilestoneChecklist(props: { clientModuleId: string; name: string }) { const ms = list.data const done = ms?.filter((m) => m.done).length ?? 0 + // current = first not-done step + const currentKey = ms?.find((m) => !m.done)?.key + const isPaymentStep = (key: string) => key === 'advance_payment' || key === 'balance_payment' + + // Ticking ON a payment step opens the picker (parent owns that dialog — it needs the + // client's ledger + the Payments tab); un-ticking and every other step toggle directly. + const onDotClick = (m: Milestone) => { + if (busyKey !== '') return + if (!m.done && isPaymentStep(m.key)) { props.onTickPayment(m.key, m.label); return } + toggle(m, !m.done) + } return (
-
- {props.name} — onboarding +
+ {props.name} — status line {ms !== undefined && ms.length > 0 && ( {done}/{ms.length} )} + + {!adding ? : ( + <> + setLabel(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') addStep() }} + /> + + + + )}
{list.error !== undefined ? : ms === undefined ? : ms.length === 0 ? No steps yet. : ( -
+
{ms.map((m) => (
- - {m.done && ( - <> - on - toggle(m, true, e.target.value !== '' ? e.target.value : undefined)} - /> - - )} + + + {m.label} + {m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'}
))} -
+
+ )} +
+ ) +} + +/** + * Payment-step picker (D38): ticking the advance/balance payment status step opens this + * instead of stamping a bare date — it links a real payment row so the milestone and the + * ledger agree (`setMilestone(..., paymentId)`; the server mirrors that payment's + * `receivedOn` into `doneOn`). Lists the client's payments, newest first; "Record new + * payment" defers to the Payments tab rather than guessing an amount here. + */ +function PaymentPickerDialog(props: { + open: boolean; onClose: () => void; stepLabel: string + payments: Payment[]; saving: boolean; error?: string + onPick: (paymentId: string) => void; onRecordNew: () => void +}) { + return ( + { if (!props.saving) props.onClose() }} + title={`Link a payment — ${props.stepLabel}`} + size="sm" + footer={} + > + {props.payments.length === 0 + ? No payments recorded for this client yet. + : ( + ({ + on: p.receivedOn, + amount: inr(p.amountPaise), + act: , + }))} + /> )} - {!adding - ? - : ( - <> - setLabel(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') addStep() }} - /> - - - - )} + + -
+ {props.error !== undefined && {props.error}} + ) }