feat(client-detail): onboarding as horizontal status line with payment-step linking

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
Thomas Joise 2 days ago
parent 343fa94efd
commit e5ed3517dc

@ -362,18 +362,26 @@ export const revealModuleSecret = (id: string, key: string): Promise<string> =>
// ---------- onboarding milestones (D22) ---------- // ---------- onboarding milestones (D22) ----------
/** One checklist step on a project (client_module). Ticking stamps `doneOn`. */ /** One checklist step on a project (client_module). Ticking stamps `doneOn`; the
export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number } * advance/balance payment steps additionally carry `paymentId`, linking a real
* payment from the client's ledger. */
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. */ /** The project's checklist; the server auto-seeds the standard template on first read. */
export const getMilestones = (clientModuleId: string): Promise<Milestone[]> => export const getMilestones = (clientModuleId: string): Promise<Milestone[]> =>
apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`).then((r) => r.milestones) apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`).then((r) => r.milestones)
/** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it. */ /** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it.
* `paymentId` links a real payment to a step (advance_payment / balance_payment). */
export const setMilestone = ( export const setMilestone = (
clientModuleId: string, key: string, done: boolean, doneOn?: string, clientModuleId: string, key: string, done: boolean, doneOn?: string, paymentId?: string,
): Promise<Milestone[]> => ): Promise<Milestone[]> =>
apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`, { apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`, {
method: 'POST', body: JSON.stringify({ key, done, ...(doneOn !== undefined ? { doneOn } : {}) }), method: 'POST',
body: JSON.stringify({
key, done, ...(doneOn !== undefined ? { doneOn } : {}), ...(paymentId !== undefined ? { paymentId } : {}),
}),
}).then((r) => r.milestones) }).then((r) => r.milestones)
/** Add a project-specific step beyond the standard template. */ /** Add a project-specific step beyond the standard template. */
export const addMilestone = (clientModuleId: string, label: string): Promise<Milestone[]> => export const addMilestone = (clientModuleId: string, label: string): Promise<Milestone[]> =>

@ -2,7 +2,7 @@ import { useState, type CSSProperties, type ReactNode } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain' import { formatINR } from '@sims/domain'
import { 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, StatCard, Stats, Tabs, Toolbar, useToast,
} from '@sims/ui' } from '@sims/ui'
import { import {
@ -18,7 +18,8 @@ import {
getMilestones, setMilestone, addMilestone, getMilestones, setMilestone, addMilestone,
CLIENT_STATUSES, KIND_LABEL, CLIENT_STATUSES, KIND_LABEL,
type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule, 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' } from '../api'
import { buildStatement } from '../statement' import { buildStatement } from '../statement'
import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients' 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 [interactionDialog, setInteractionDialog] = useState<{ initial?: Interaction } | undefined>()
const [confirm, setConfirm] = useState<Confirm | undefined>() const [confirm, setConfirm] = useState<Confirm | undefined>()
const [statementOpen, setStatementOpen] = useState(false) const [statementOpen, setStatementOpen] = useState(false)
// Onboarding status line (D38): ticking the advance/balance payment step opens this
// picker instead of stamping a bare date. `milestonesVersion` bumps to force every
// StatusLine's milestone list to refetch after a pick lands (see `refreshToken` below).
const [paymentPicker, setPaymentPicker] = useState<{ clientModuleId: string; key: string; label: string } | undefined>()
const [linkingPayment, setLinkingPayment] = useState(false)
const [linkError, setLinkError] = useState<string | undefined>()
const [milestonesVersion, setMilestonesVersion] = useState(0)
const rawTab = sp.get('tab') ?? 'overview' const rawTab = sp.get('tab') ?? 'overview'
const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : '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 ?? []} fieldSpec={modules.data?.find((m) => m.id === cm.moduleId)?.fieldSpec ?? []}
onChanged={() => cms.reload()} onChanged={() => cms.reload()}
/> />
<MilestoneChecklist clientModuleId={cm.id} name={moduleName(cm.moduleId)} /> <StatusLine
clientModuleId={cm.id}
name={moduleName(cm.moduleId)}
refreshToken={milestonesVersion}
onTickPayment={(key, label) => { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }}
/>
</div> </div>
</details> </details>
))} ))}
@ -607,6 +620,30 @@ export function ClientDetail() {
{statementOpen && ( {statementOpen && (
<ClientStatement client={c} ledger={ledger.data} onClose={() => setStatementOpen(false)} /> <ClientStatement client={c} ledger={ledger.data} onClose={() => setStatementOpen(false)} />
)} )}
{paymentPicker !== undefined && (
<PaymentPickerDialog
open
stepLabel={paymentPicker.label}
payments={[...(ledger.data?.payments ?? [])].sort((a, b) => b.receivedOn.localeCompare(a.receivedOn))}
saving={linkingPayment}
error={linkError}
onClose={() => { if (!linkingPayment) setPaymentPicker(undefined) }}
onPick={(paymentId) => {
if (linkingPayment) return
setLinkingPayment(true)
setLinkError(undefined)
setMilestone(paymentPicker.clientModuleId, paymentPicker.key, true, undefined, paymentId)
.then(() => {
toast.ok('Payment linked')
setMilestonesVersion((v) => v + 1)
setPaymentPicker(undefined)
})
.catch((e: Error) => setLinkError(e.message))
.finally(() => setLinkingPayment(false))
}}
onRecordNew={() => { setPaymentPicker(undefined); setTab('payments') }}
/>
)}
</div> </div>
) )
} }
@ -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 * Onboarding as a horizontal status line (D38 redesign per-project detail): the
* of toggles. Ticking one stamps the date (a compact date input adjusts it; toggling * project's milestones as a point-by-point track (`.cdr-track`) a connector line
* on with no date uses today); "+ Add step" appends a project-specific milestone. Every * joins ordered dots, the current (first not-done) step is ringed ("now"), done steps
* change is one audited POST that returns the fresh list, so the card re-renders in place. * 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 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 [busyKey, setBusyKey] = useState('')
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [label, setLabel] = useState('') const [label, setLabel] = useState('')
@ -1261,64 +1307,106 @@ function MilestoneChecklist(props: { clientModuleId: string; name: string }) {
const ms = list.data const ms = list.data
const done = ms?.filter((m) => m.done).length ?? 0 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 ( return (
<div className="wf-card" style={{ padding: '10px 14px' }}> <div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontWeight: 600 }}>{props.name} onboarding</span> <span style={{ fontWeight: 600 }}>{props.name} status line</span>
{ms !== undefined && ms.length > 0 && ( {ms !== undefined && ms.length > 0 && (
<Badge tone={done === ms.length ? 'ok' : undefined}>{done}/{ms.length}</Badge> <Badge tone={done === ms.length ? 'ok' : undefined}>{done}/{ms.length}</Badge>
)} )}
<span style={{ flex: 1 }} />
{!adding ? <Button onClick={() => setAdding(true)}>+ Add step</Button> : (
<>
<input
className="wf" style={{ maxWidth: 200 }} placeholder="Step label" aria-label="New step label"
value={label} onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addStep() }}
/>
<Button tone="primary" disabled={addBusy || label.trim() === ''} onClick={addStep}>{addBusy ? 'Adding…' : 'Add'}</Button>
<Button onClick={() => { setAdding(false); setLabel('') }}>Cancel</Button>
</>
)}
</div> </div>
{list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} /> {list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
: ms === undefined ? <Skeleton rows={2} /> : ms === undefined ? <Skeleton rows={2} />
: ms.length === 0 ? <EmptyState>No steps yet.</EmptyState> : ( : ms.length === 0 ? <EmptyState>No steps yet.</EmptyState> : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}> <div className="cdr-track-wrap"><div className="cdr-track">
{ms.map((m) => ( {ms.map((m) => (
<div <div
key={m.key} key={m.key}
style={{ className={`cdr-node ${m.done ? 'done' : m.key === currentKey ? 'now' : ''}`}
display: 'inline-flex', alignItems: 'center', gap: 8, style={{ opacity: busyKey === m.key ? 0.5 : 1 }}
border: '1px solid var(--border)', borderRadius: 6, padding: '4px 10px',
opacity: busyKey === m.key ? 0.6 : 1,
}}
> >
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}> <span className="bar" />
<input <button
type="checkbox" checked={m.done} disabled={busyKey === m.key} className="dot" disabled={busyKey === m.key}
onChange={(e) => toggle(m, e.target.checked)} title={m.done ? 'Click to un-tick' : isPaymentStep(m.key) ? 'Click to link a payment' : 'Click to mark done (today)'}
/> onClick={() => onDotClick(m)}
<span style={m.done ? { textDecoration: 'line-through', opacity: 0.7 } : undefined}>{m.label}</span> >
</label> {m.done ? '✓' : m.key === currentKey ? '●' : ''}
{m.done && ( </button>
<> <span className="nm">{m.label}</span>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>on</span> <span className="dt">{m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'}</span>
<input
type="date" className="wf" style={{ width: 140 }} value={m.doneOn ?? ''}
disabled={busyKey === m.key}
onChange={(e) => toggle(m, true, e.target.value !== '' ? e.target.value : undefined)}
/>
</>
)}
</div> </div>
))} ))}
</div> </div></div>
)} )}
<Toolbar> </div>
{!adding )
? <Button onClick={() => setAdding(true)}>+ Add step</Button> }
/**
* Payment-step picker (D38): ticking the advance/balance payment status step opens this
* instead of stamping a bare date it links a real payment row so the milestone and the
* ledger agree (`setMilestone(..., paymentId)`; the server mirrors that payment's
* `receivedOn` into `doneOn`). Lists the client's payments, newest first; "Record new
* payment" defers to the Payments tab rather than guessing an amount here.
*/
function PaymentPickerDialog(props: {
open: boolean; onClose: () => void; stepLabel: string
payments: Payment[]; saving: boolean; error?: string
onPick: (paymentId: string) => void; onRecordNew: () => void
}) {
return (
<Dialog
open={props.open}
onClose={() => { if (!props.saving) props.onClose() }}
title={`Link a payment — ${props.stepLabel}`}
size="sm"
footer={<Button onClick={props.onClose} disabled={props.saving}>Cancel</Button>}
>
{props.payments.length === 0
? <EmptyState>No payments recorded for this client yet.</EmptyState>
: ( : (
<> <DataTable
<input columns={[
className="wf" style={{ maxWidth: 240 }} placeholder="Step label" aria-label="New step label" { key: 'on', label: 'Received' }, { key: 'amount', label: 'Amount', numeric: true },
value={label} onChange={(e) => setLabel(e.target.value)} { key: 'act', label: '' },
onKeyDown={(e) => { if (e.key === 'Enter') addStep() }} ]}
rows={props.payments.map((p) => ({
on: p.receivedOn,
amount: inr(p.amountPaise),
act: <Button tone="primary" disabled={props.saving} onClick={() => props.onPick(p.id)}>Use</Button>,
}))}
/> />
<Button tone="primary" disabled={addBusy || label.trim() === ''} onClick={addStep}>{addBusy ? 'Adding…' : 'Add'}</Button>
<Button onClick={() => { setAdding(false); setLabel('') }}>Cancel</Button>
</>
)} )}
<Toolbar>
<span style={{ flex: 1 }} />
<Button disabled={props.saving} onClick={props.onRecordNew}>Record new payment</Button>
</Toolbar> </Toolbar>
</div> {props.error !== undefined && <Notice tone="err">{props.error}</Notice>}
</Dialog>
) )
} }

Loading…
Cancel
Save