You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq-web/src/pages/ClientDetail.tsx

262 lines
11 KiB
TypeScript

import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui'
import {
assignClientModule, getClient, getClientModules, getLedger, getModules,
patchClient, patchClientModule, recordPayment,
CLIENT_MODULE_STATUSES, CLIENT_STATUSES, KIND_LABEL, PAYMENT_MODES,
type ClientModule, type ClientModuleStatus, type ClientStatus, type Kind,
type Module, type PaymentMode,
} from '../api'
import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
const inr = (p: number) => formatINR(p, { symbol: false })
const today = () => new Date().toISOString().slice(0, 10)
/** Client 360° — header, modules, documents, payments & dues (14-SPEC §Client registry). */
export function ClientDetail() {
const id = useParams()['id'] ?? ''
const nav = useNavigate()
const client = useData(() => getClient(id), [id])
const modules = useData(getModules, [])
const cms = useData(() => getClientModules(id), [id])
const ledger = useData(() => getLedger(id), [id])
const [actionErr, setActionErr] = useState<string | undefined>()
const c = client.data
if (client.error !== undefined) return <Notice tone="err">{client.error}</Notice>
if (c === undefined) return <EmptyState>Loading</EmptyState>
const moduleName = (moduleId: string) =>
modules.data?.find((m) => m.id === moduleId)?.name ?? moduleId
return (
<div className="wf-page">
<PageHeader
title={c.name} badge={c.code}
desc={`State ${c.stateCode}${c.gstin !== undefined && c.gstin !== '' ? ` · GSTIN ${c.gstin}` : ''}${c.address !== '' ? ` · ${c.address}` : ''}`}
actions={
<select
className="wf" value={c.status}
onChange={(e) => {
setActionErr(undefined)
patchClient(id, { status: e.target.value as ClientStatus })
.then(client.reload).catch((err: Error) => setActionErr(err.message))
}}
>
{CLIENT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
}
/>
<Toolbar>
<Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>
{c.contacts.map((ct, i) => (
<Badge key={i}>{[ct.name, ct.email, ct.phone].filter((x) => x !== undefined && x !== '').join(' · ')}</Badge>
))}
</Toolbar>
{actionErr !== undefined && <Notice tone="err">{actionErr}</Notice>}
<h3>Modules</h3>
{modules.data !== undefined && (
<AssignModuleForm
modules={modules.data}
onAssign={(moduleId, kind) => {
setActionErr(undefined)
assignClientModule(id, { moduleId, kind })
.then(() => { cms.reload(); ledger.reload() })
.catch((err: Error) => setActionErr(err.message))
}}
/>
)}
{cms.error !== undefined && <Notice tone="err">{cms.error}</Notice>}
{cms.data === undefined || cms.data.length === 0
? <EmptyState>No modules assigned yet.</EmptyState>
: (
<DataTable
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 },
]}
rows={cms.data.map((cm) => {
const paid = ledger.data?.modulePaid.find((mp) => mp.moduleId === cm.moduleId)
return {
module: moduleName(cm.moduleId),
kind: KIND_LABEL[cm.kind],
status: (
<ClientModuleStatusSelect
cm={cm}
onPatched={() => cms.reload()}
onError={setActionErr}
/>
),
installed: <RowDate cm={cm} field="installedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
completed: <RowDate cm={cm} field="completedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
trained: <RowDate cm={cm} field="trainedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
billed: paid !== undefined ? inr(paid.billedPaise) : '—',
settled: paid !== undefined ? inr(paid.settledPaise) : '—',
}
})}
/>
)}
<h3 style={{ marginTop: 20 }}>Documents</h3>
{ledger.error !== undefined && <Notice tone="err">{ledger.error}</Notice>}
{ledger.data === undefined || ledger.data.documents.length === 0
? <EmptyState>No documents yet compose one from New Document.</EmptyState>
: (
<DataTable
columns={[
{ key: 'no', label: 'No' }, { key: 'type', label: 'Type' },
{ key: 'date', label: 'Date' }, { key: 'payable', label: 'Payable', numeric: true },
{ key: 'status', label: 'Status' },
]}
onRowClick={(_row, i) => nav(`/documents/${ledger.data!.documents[i]!.id}`)}
rows={ledger.data.documents.map((d) => ({
no: d.docNo ?? <Badge tone="warn">draft</Badge>,
type: d.docType, date: d.docDate,
payable: inr(d.payablePaise),
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
}))}
/>
)}
<h3 style={{ marginTop: 20 }}>Payments &amp; dues</h3>
{ledger.data !== undefined && (
<Stats>
<StatCard label="Advance on account" value={formatINR(ledger.data.advancePaise)} hint="unallocated payments" />
</Stats>
)}
<PaymentForm
clientId={id}
onDone={() => { ledger.reload(); cms.reload() }}
/>
{ledger.data !== undefined && ledger.data.payments.length > 0 && (
<DataTable
columns={[
{ key: 'on', label: 'Received' }, { key: 'mode', label: 'Mode' },
{ key: 'ref', label: 'Reference' },
{ key: 'amount', label: 'Amount', numeric: true },
{ key: 'tds', label: 'TDS', numeric: true },
]}
rows={ledger.data.payments.map((p) => ({
on: p.receivedOn, mode: p.mode, ref: p.reference !== '' ? p.reference : '—',
amount: inr(p.amountPaise), tds: p.tdsPaise > 0 ? inr(p.tdsPaise) : '—',
}))}
/>
)}
</div>
)
}
function AssignModuleForm(props: { modules: Module[]; onAssign: (moduleId: string, kind: Kind) => void }) {
const [moduleId, setModuleId] = useState('')
const mod = props.modules.find((m) => m.id === moduleId)
const [kind, setKind] = useState<Kind | ''>('')
return (
<Toolbar>
<select
className="wf" value={moduleId}
onChange={(e) => { setModuleId(e.target.value); setKind('') }}
>
<option value="">Assign module</option>
{props.modules.filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
{mod !== undefined && (
<select className="wf" value={kind} onChange={(e) => setKind(e.target.value as Kind)}>
<option value="">Kind</option>
{mod.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select>
)}
<Button
tone="primary"
onClick={() => { if (moduleId !== '' && kind !== '') { props.onAssign(moduleId, kind); setModuleId(''); setKind('') } }}
>
Assign
</Button>
</Toolbar>
)
}
function ClientModuleStatusSelect(props: {
cm: ClientModule; onPatched: () => void; onError: (msg: string) => void
}) {
return (
<select
className="wf" value={props.cm.status}
onChange={(e) => {
patchClientModule(props.cm.id, { status: e.target.value as ClientModuleStatus })
.then(props.onPatched).catch((err: Error) => props.onError(err.message))
}}
>
{CLIENT_MODULE_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
)
}
function RowDate(props: {
cm: ClientModule; field: 'installedOn' | 'completedOn' | 'trainedOn'
onPatched: () => void; onError: (msg: string) => void
}) {
return (
<input
type="date" className="wf" style={{ width: 140 }}
value={props.cm[props.field] ?? ''}
onChange={(e) => {
patchClientModule(props.cm.id, { [props.field]: e.target.value !== '' ? e.target.value : null })
.then(props.onPatched).catch((err: Error) => props.onError(err.message))
}}
/>
)
}
/**
* Record a payment against the client — amounts entered in rupees, stored in
* integer paise via fromRupees. Shared with DocumentView's "Record payment".
*/
export function PaymentForm(props: { clientId: string; onDone: () => void }) {
const [f, setF] = useState({ amountRs: '', tdsRs: '', mode: 'bank' as PaymentMode, reference: '', receivedOn: today() })
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
setF((p) => ({ ...p, [k]: e.target.value }))
const save = () => {
const amount = Number(f.amountRs)
if (!Number.isFinite(amount) || amount <= 0) { setError('Enter a payment amount in rupees'); return }
const tds = f.tdsRs === '' ? 0 : Number(f.tdsRs)
if (!Number.isFinite(tds) || tds < 0) { setError('TDS must be a non-negative rupee amount'); return }
setError(undefined)
setSaving(true)
recordPayment({
clientId: props.clientId, receivedOn: f.receivedOn, mode: f.mode, reference: f.reference,
amountPaise: fromRupees(amount), tdsPaise: fromRupees(tds),
})
.then(() => { setF({ amountRs: '', tdsRs: '', mode: 'bank', reference: '', receivedOn: today() }); props.onDone() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
}
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, margin: '10px 0' }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Amount (₹)"><input className="wf num" style={{ width: 120 }} value={f.amountRs} onChange={set('amountRs')} /></Field>
<Field label="TDS (₹)"><input className="wf num" style={{ width: 100 }} value={f.tdsRs} onChange={set('tdsRs')} /></Field>
<Field label="Mode">
<select className="wf" value={f.mode} onChange={set('mode')}>
{PAYMENT_MODES.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
</Field>
<Field label="Reference"><input className="wf" value={f.reference} onChange={set('reference')} /></Field>
<Field label="Received on"><input type="date" className="wf" value={f.receivedOn} onChange={set('receivedOn')} /></Field>
<Button tone="primary" onClick={save}>{saving ? 'Recording…' : 'Record payment'}</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}