|
|
|
|
@ -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<Confirm | undefined>()
|
|
|
|
|
const [statementOpen, setStatementOpen] = useState(false)
|
|
|
|
|
// Onboarding status line (D38): ticking the advance/balance payment step opens this
|
|
|
|
|
// picker instead of stamping a bare date. `milestonesVersion` bumps to force every
|
|
|
|
|
// StatusLine's milestone list to refetch after a pick lands (see `refreshToken` below).
|
|
|
|
|
const [paymentPicker, setPaymentPicker] = useState<{ clientModuleId: string; key: string; label: string } | undefined>()
|
|
|
|
|
const [linkingPayment, setLinkingPayment] = useState(false)
|
|
|
|
|
const [linkError, setLinkError] = useState<string | undefined>()
|
|
|
|
|
const [milestonesVersion, setMilestonesVersion] = useState(0)
|
|
|
|
|
|
|
|
|
|
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()}
|
|
|
|
|
/>
|
|
|
|
|
<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>
|
|
|
|
|
</details>
|
|
|
|
|
))}
|
|
|
|
|
@ -607,6 +620,30 @@ export function ClientDetail() {
|
|
|
|
|
{statementOpen && (
|
|
|
|
|
<ClientStatement client={c} ledger={ledger.data} onClose={() => setStatementOpen(false)} />
|
|
|
|
|
)}
|
|
|
|
|
{paymentPicker !== undefined && (
|
|
|
|
|
<PaymentPickerDialog
|
|
|
|
|
open
|
|
|
|
|
stepLabel={paymentPicker.label}
|
|
|
|
|
payments={[...(ledger.data?.payments ?? [])].sort((a, b) => b.receivedOn.localeCompare(a.receivedOn))}
|
|
|
|
|
saving={linkingPayment}
|
|
|
|
|
error={linkError}
|
|
|
|
|
onClose={() => { if (!linkingPayment) setPaymentPicker(undefined) }}
|
|
|
|
|
onPick={(paymentId) => {
|
|
|
|
|
if (linkingPayment) return
|
|
|
|
|
setLinkingPayment(true)
|
|
|
|
|
setLinkError(undefined)
|
|
|
|
|
setMilestone(paymentPicker.clientModuleId, paymentPicker.key, true, undefined, paymentId)
|
|
|
|
|
.then(() => {
|
|
|
|
|
toast.ok('Payment linked')
|
|
|
|
|
setMilestonesVersion((v) => v + 1)
|
|
|
|
|
setPaymentPicker(undefined)
|
|
|
|
|
})
|
|
|
|
|
.catch((e: Error) => setLinkError(e.message))
|
|
|
|
|
.finally(() => setLinkingPayment(false))
|
|
|
|
|
}}
|
|
|
|
|
onRecordNew={() => { setPaymentPicker(undefined); setTab('payments') }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</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
|
|
|
|
|
* 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 (
|
|
|
|
|
<div className="wf-card" style={{ padding: '10px 14px' }}>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
|
|
|
<span style={{ fontWeight: 600 }}>{props.name} — onboarding</span>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
|
|
|
|
<span style={{ fontWeight: 600 }}>{props.name} — status line</span>
|
|
|
|
|
{ms !== undefined && ms.length > 0 && (
|
|
|
|
|
<Badge tone={done === ms.length ? 'ok' : undefined}>{done}/{ms.length}</Badge>
|
|
|
|
|
)}
|
|
|
|
|
<span style={{ flex: 1 }} />
|
|
|
|
|
{!adding ? <Button onClick={() => setAdding(true)}>+ Add step</Button> : (
|
|
|
|
|
<>
|
|
|
|
|
<input
|
|
|
|
|
className="wf" style={{ maxWidth: 200 }} placeholder="Step label" aria-label="New step label"
|
|
|
|
|
value={label} onChange={(e) => setLabel(e.target.value)}
|
|
|
|
|
onKeyDown={(e) => { if (e.key === 'Enter') addStep() }}
|
|
|
|
|
/>
|
|
|
|
|
<Button tone="primary" disabled={addBusy || label.trim() === ''} onClick={addStep}>{addBusy ? 'Adding…' : 'Add'}</Button>
|
|
|
|
|
<Button onClick={() => { setAdding(false); setLabel('') }}>Cancel</Button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
|
|
|
|
|
: ms === undefined ? <Skeleton rows={2} />
|
|
|
|
|
: ms.length === 0 ? <EmptyState>No steps yet.</EmptyState> : (
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
|
|
|
|
<div className="cdr-track-wrap"><div className="cdr-track">
|
|
|
|
|
{ms.map((m) => (
|
|
|
|
|
<div
|
|
|
|
|
key={m.key}
|
|
|
|
|
style={{
|
|
|
|
|
display: 'inline-flex', alignItems: 'center', gap: 8,
|
|
|
|
|
border: '1px solid var(--border)', borderRadius: 6, padding: '4px 10px',
|
|
|
|
|
opacity: busyKey === m.key ? 0.6 : 1,
|
|
|
|
|
}}
|
|
|
|
|
className={`cdr-node ${m.done ? 'done' : m.key === currentKey ? 'now' : ''}`}
|
|
|
|
|
style={{ opacity: busyKey === m.key ? 0.5 : 1 }}
|
|
|
|
|
>
|
|
|
|
|
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox" checked={m.done} disabled={busyKey === m.key}
|
|
|
|
|
onChange={(e) => toggle(m, e.target.checked)}
|
|
|
|
|
/>
|
|
|
|
|
<span style={m.done ? { textDecoration: 'line-through', opacity: 0.7 } : undefined}>{m.label}</span>
|
|
|
|
|
</label>
|
|
|
|
|
{m.done && (
|
|
|
|
|
<>
|
|
|
|
|
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>on</span>
|
|
|
|
|
<input
|
|
|
|
|
type="date" className="wf" style={{ width: 140 }} value={m.doneOn ?? ''}
|
|
|
|
|
disabled={busyKey === m.key}
|
|
|
|
|
onChange={(e) => toggle(m, true, e.target.value !== '' ? e.target.value : undefined)}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<span className="bar" />
|
|
|
|
|
<button
|
|
|
|
|
className="dot" disabled={busyKey === m.key}
|
|
|
|
|
title={m.done ? 'Click to un-tick' : isPaymentStep(m.key) ? 'Click to link a payment' : 'Click to mark done (today)'}
|
|
|
|
|
onClick={() => onDotClick(m)}
|
|
|
|
|
>
|
|
|
|
|
{m.done ? '✓' : m.key === currentKey ? '●' : ''}
|
|
|
|
|
</button>
|
|
|
|
|
<span className="nm">{m.label}</span>
|
|
|
|
|
<span className="dt">{m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div></div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Payment-step picker (D38): ticking the advance/balance payment status step opens this
|
|
|
|
|
* instead of stamping a bare date — it links a real payment row so the milestone and the
|
|
|
|
|
* ledger agree (`setMilestone(..., paymentId)`; the server mirrors that payment's
|
|
|
|
|
* `receivedOn` into `doneOn`). Lists the client's payments, newest first; "Record new
|
|
|
|
|
* payment" defers to the Payments tab rather than guessing an amount here.
|
|
|
|
|
*/
|
|
|
|
|
function PaymentPickerDialog(props: {
|
|
|
|
|
open: boolean; onClose: () => void; stepLabel: string
|
|
|
|
|
payments: Payment[]; saving: boolean; error?: string
|
|
|
|
|
onPick: (paymentId: string) => void; onRecordNew: () => void
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<Dialog
|
|
|
|
|
open={props.open}
|
|
|
|
|
onClose={() => { if (!props.saving) props.onClose() }}
|
|
|
|
|
title={`Link a payment — ${props.stepLabel}`}
|
|
|
|
|
size="sm"
|
|
|
|
|
footer={<Button onClick={props.onClose} disabled={props.saving}>Cancel</Button>}
|
|
|
|
|
>
|
|
|
|
|
{props.payments.length === 0
|
|
|
|
|
? <EmptyState>No payments recorded for this client yet.</EmptyState>
|
|
|
|
|
: (
|
|
|
|
|
<DataTable
|
|
|
|
|
columns={[
|
|
|
|
|
{ key: 'on', label: 'Received' }, { key: 'amount', label: 'Amount', numeric: true },
|
|
|
|
|
{ key: 'act', label: '' },
|
|
|
|
|
]}
|
|
|
|
|
rows={props.payments.map((p) => ({
|
|
|
|
|
on: p.receivedOn,
|
|
|
|
|
amount: inr(p.amountPaise),
|
|
|
|
|
act: <Button tone="primary" disabled={props.saving} onClick={() => props.onPick(p.id)}>Use</Button>,
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<Toolbar>
|
|
|
|
|
{!adding
|
|
|
|
|
? <Button onClick={() => setAdding(true)}>+ Add step</Button>
|
|
|
|
|
: (
|
|
|
|
|
<>
|
|
|
|
|
<input
|
|
|
|
|
className="wf" style={{ maxWidth: 240 }} placeholder="Step label" aria-label="New step label"
|
|
|
|
|
value={label} onChange={(e) => setLabel(e.target.value)}
|
|
|
|
|
onKeyDown={(e) => { if (e.key === 'Enter') addStep() }}
|
|
|
|
|
/>
|
|
|
|
|
<Button tone="primary" disabled={addBusy || label.trim() === ''} onClick={addStep}>{addBusy ? 'Adding…' : 'Add'}</Button>
|
|
|
|
|
<Button onClick={() => { setAdding(false); setLabel('') }}>Cancel</Button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<span style={{ flex: 1 }} />
|
|
|
|
|
<Button disabled={props.saving} onClick={props.onRecordNew}>Record new payment…</Button>
|
|
|
|
|
</Toolbar>
|
|
|
|
|
</div>
|
|
|
|
|
{props.error !== undefined && <Notice tone="err">{props.error}</Notice>}
|
|
|
|
|
</Dialog>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|