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

389 lines
18 KiB
TypeScript

import { useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import {
Badge, Button, DataTable, EmptyState, ErrorState, RecordHeader, Skeleton,
StatCard, Stats, Tabs, Toolbar,
} from '@sims/ui'
import {
assignClientModule, getClient, getClientModules, getLedger, getModules,
patchClient, role, isManagerial, getEmployees, setClientOwner,
getRecurringPlans, deactivateRecurringPlan,
getAmc, deactivateAmc, generateAmcRenewalInvoice,
getInteractions, getInteractionTypes, updateInteraction,
getClientAwsUsage,
CLIENT_STATUSES, KIND_LABEL,
type AmcPaidStatus, type ClientStatus, type Outcome,
} from '../api'
import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
import {
AccountOwnerSelect, AssignModuleForm, ClientModuleStatusSelect,
LogInteractionForm, NewAmcForm, NewRecurringForm, PaymentForm, RowDate,
} from './client-forms'
const inr = (p: number) => formatINR(p, { symbol: false })
const AMC_TONE: Record<AmcPaidStatus, 'ok' | 'warn' | 'err'> = { paid: 'ok', unpaid: 'err', unbilled: 'warn' }
const OUTCOME_TONE: Record<Outcome, 'ok' | 'warn' | 'err'> = { positive: 'ok', neutral: 'warn', negative: 'err' }
const TAB_KEYS = ['overview', 'modules', 'documents', 'payments', 'amc', 'interactions', 'aws'] as const
type TabKey = (typeof TAB_KEYS)[number]
/** Client 360° — record header, KPIs, pulse ribbon (Task 10), tabbed sections. */
export function ClientDetail() {
const id = useParams()['id'] ?? ''
const nav = useNavigate()
const [sp, setSp] = useSearchParams()
const client = useData(() => getClient(id), [id])
const modules = useData(getModules, [])
const cms = useData(() => getClientModules(id), [id])
const ledger = useData(() => getLedger(id), [id])
const plans = useData(() => getRecurringPlans(id), [id])
const amc = useData(() => getAmc(id), [id])
const interactions = useData(() => getInteractions(id), [id])
const types = useData(getInteractionTypes, [])
const awsUsage = useData(() => getClientAwsUsage(id), [id])
const employees = useData(() => getEmployees(), [])
const isOwner = role() === 'owner'
const canRoute = isManagerial()
const [actionErr, setActionErr] = useState<string | undefined>()
const rawTab = sp.get('tab') ?? 'overview'
const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview'
const setTab = (k: string) => setSp(k === 'overview' ? {} : { tab: k }, { replace: true })
const c = client.data
if (client.error !== undefined) return <ErrorState message={client.error} onRetry={client.reload} />
if (c === undefined) return <div className="wf-page"><Skeleton rows={6} /></div>
const moduleName = (moduleId: string) =>
modules.data?.find((m) => m.id === moduleId)?.name ?? moduleId
const docs = ledger.data?.documents ?? []
const openDocs = docs.filter((d) => d.status === 'draft' || d.status === 'sent' || d.status === 'part_paid')
const activeModules = (cms.data ?? []).filter((m) => m.active)
const lastInteraction = interactions.data?.[0]?.onDate
return (
<div className="wf-page">
<div className="wf-crumbs"><Link to="/clients">Clients</Link><span>/</span><span>{c.code}</span></div>
<RecordHeader
name={c.name}
status={<Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>}
meta={
<>
<span className="mono">{c.code}</span>
<span>· State {c.stateCode}</span>
{c.gstin !== undefined && c.gstin !== '' && <span className="mono">· {c.gstin}</span>}
{c.contacts[0] !== undefined && (
<span>· {[c.contacts[0].name, c.contacts[0].email, c.contacts[0].phone].filter((x) => x !== undefined && x !== '').join(' · ')}</span>
)}
</>
}
actions={
<>
<select
className="wf" style={{ width: 'auto' }} 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>
<Button tone="primary" onClick={() => nav('/documents/new')}>New document</Button>
</>
}
/>
{canRoute && employees.data !== undefined && (
<Toolbar>
<AccountOwnerSelect
ownerId={c.ownerId}
employees={employees.data.employees}
onChange={(ownerId) => {
setActionErr(undefined)
setClientOwner(id, ownerId)
.then(client.reload).catch((err: Error) => setActionErr(err.message))
}}
/>
</Toolbar>
)}
{actionErr !== undefined && <ErrorState message={actionErr} />}
<Tabs
active={tab}
onChange={setTab}
tabs={[
{ key: 'overview', label: 'Overview' },
{ key: 'modules', label: 'Modules', count: (cms.data ?? []).length },
{ key: 'documents', label: 'Documents', count: docs.length },
{ key: 'payments', label: 'Payments & plans', count: (ledger.data?.payments ?? []).length },
{ key: 'amc', label: 'AMC', count: (amc.data ?? []).length },
{ key: 'interactions', label: 'Interactions', count: (interactions.data ?? []).length },
{ key: 'aws', label: 'AWS', count: (awsUsage.data ?? []).length },
]}
/>
{tab === 'overview' && (
<>
<Stats>
<StatCard label="Active modules" value={String(activeModules.length)} />
<StatCard label="Advance on account" value={ledger.data !== undefined ? formatINR(ledger.data.advancePaise) : '…'} hint="unallocated payments" />
<StatCard label="Open documents" value={String(openDocs.length)} hint={`${docs.length} total`} />
<StatCard label="Last interaction" value={lastInteraction ?? '—'} />
</Stats>
{/* Pulse ribbon lands here in the next task. */}
<h3 style={{ marginTop: 18 }}>Recent documents</h3>
{docs.length === 0 ? <EmptyState>No documents yet.</EmptyState> : (
<DataTable
columns={[
{ key: 'no', label: 'No', mono: true }, { key: 'type', label: 'Type' },
{ key: 'date', label: 'Date' }, { key: 'payable', label: 'Payable', numeric: true },
{ key: 'status', label: 'Status' },
]}
onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
rows={docs.slice(0, 5).map((d) => ({
no: d.docNo ?? 'draft', type: d.docType, date: d.docDate,
payable: inr(d.payablePaise),
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
}))}
/>
)}
{docs.length > 5 && (
<Toolbar><Button onClick={() => setTab('documents')}>All {docs.length} documents </Button></Toolbar>
)}
</>
)}
{tab === 'modules' && (
<>
{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 && <ErrorState message={cms.error} onRetry={cms.reload} />}
{cms.data === undefined && cms.error === undefined ? <Skeleton />
: (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) : '—',
}
})}
/>
)}
</>
)}
{tab === 'documents' && (
<>
{ledger.error !== undefined && <ErrorState message={ledger.error} onRetry={ledger.reload} />}
{ledger.data === undefined && ledger.error === undefined ? <Skeleton />
: docs.length === 0 ? <EmptyState>No documents yet compose one from New Document.</EmptyState> : (
<DataTable
columns={[
{ key: 'no', label: 'No', mono: true }, { key: 'type', label: 'Type' },
{ key: 'date', label: 'Date' }, { key: 'payable', label: 'Payable', numeric: true },
{ key: 'status', label: 'Status' },
]}
onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
rows={docs.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>,
}))}
/>
)}
</>
)}
{tab === 'payments' && (
<>
{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(); amc.reload() }} />
{ledger.data !== undefined && ledger.data.payments.length > 0 && (
<DataTable
columns={[
{ key: 'on', label: 'Received' }, { key: 'mode', label: 'Mode' },
{ key: 'ref', label: 'Reference', mono: true },
{ 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) : '—',
}))}
/>
)}
<h3 style={{ marginTop: 20 }}>Recurring plans</h3>
{isOwner && cms.data !== undefined && (
<NewRecurringForm
clientId={id}
options={(cms.data ?? []).filter((cm) => cm.active).map((cm) => ({
id: cm.id, label: `${moduleName(cm.moduleId)} · ${KIND_LABEL[cm.kind]}`,
}))}
onDone={() => plans.reload()}
/>
)}
{plans.error !== undefined && <ErrorState message={plans.error} onRetry={plans.reload} />}
{plans.data === undefined && plans.error === undefined ? <Skeleton />
: (plans.data ?? []).length === 0 ? <EmptyState>No recurring plans.</EmptyState> : (
<DataTable
columns={[
{ key: 'module', label: 'Module' }, { key: 'cadence', label: 'Cadence' },
{ key: 'amount', label: 'Amount', numeric: true }, { key: 'next', label: 'Next run' },
{ key: 'policy', label: 'Policy' }, { key: 'active', label: 'Active' }, { key: 'act', label: '' },
]}
rows={(plans.data ?? []).map((plan) => {
const cm = cms.data?.find((x) => x.id === plan.clientModuleId)
return {
module: cm !== undefined ? moduleName(cm.moduleId) : '—',
cadence: plan.cadence,
amount: plan.amountPaise !== null ? inr(plan.amountPaise) : 'price book',
next: plan.nextRun,
policy: <Badge tone={plan.policy === 'auto' ? 'accent' : 'warn'}>{plan.policy}</Badge>,
active: plan.active ? 'yes' : 'no',
act: plan.active ? (
<Button onClick={() => {
setActionErr(undefined)
deactivateRecurringPlan(plan.id)
.then(() => plans.reload()).catch((err: Error) => setActionErr(err.message))
}}>Deactivate</Button>
) : '—',
}
})}
/>
)}
</>
)}
{tab === 'amc' && (
<>
{isOwner && <NewAmcForm clientId={id} onDone={() => amc.reload()} />}
{amc.error !== undefined && <ErrorState message={amc.error} onRetry={amc.reload} />}
{amc.data === undefined && amc.error === undefined ? <Skeleton />
: (amc.data ?? []).length === 0 ? <EmptyState>No AMC contracts.</EmptyState> : (
<DataTable
columns={[
{ key: 'coverage', label: 'Coverage' }, { key: 'period', label: 'Period' },
{ key: 'amount', label: 'Amount', numeric: true },
{ key: 'days', label: 'Reminder days', numeric: true },
{ key: 'paid', label: 'Paid' }, { key: 'act', label: '' },
]}
rows={(amc.data ?? []).map((a) => ({
coverage: a.coverage !== '' ? a.coverage : '—',
period: `${a.periodFrom}${a.periodTo}`,
amount: inr(a.amountPaise),
days: a.renewalReminderDays,
paid: <Badge tone={AMC_TONE[a.paidStatus]}>{a.paidStatus}</Badge>,
act: (
<div style={{ display: 'flex', gap: 6 }}>
{a.paidStatus !== 'unpaid' && (
<Button tone="primary" onClick={() => {
setActionErr(undefined)
generateAmcRenewalInvoice(a.id)
.then((doc) => nav(`/documents/${doc.id}`)).catch((err: Error) => setActionErr(err.message))
}}>Generate renewal invoice</Button>
)}
{a.active && (
<Button onClick={() => {
setActionErr(undefined)
deactivateAmc(a.id)
.then(() => amc.reload()).catch((err: Error) => setActionErr(err.message))
}}>Deactivate</Button>
)}
</div>
),
}))}
/>
)}
</>
)}
{tab === 'interactions' && (
<>
{types.data !== undefined && (
<LogInteractionForm clientId={id} types={types.data} onDone={() => interactions.reload()} />
)}
{interactions.error !== undefined && <ErrorState message={interactions.error} onRetry={interactions.reload} />}
{interactions.data === undefined && interactions.error === undefined ? <Skeleton />
: (interactions.data ?? []).length === 0 ? <EmptyState>No interactions logged yet.</EmptyState> : (
<DataTable
columns={[
{ key: 'on', label: 'Date' }, { key: 'type', label: 'Type' },
{ key: 'notes', label: 'Notes' }, { key: 'outcome', label: 'Outcome' },
{ key: 'follow', label: 'Follow-up' },
]}
rows={(interactions.data ?? []).map((i) => ({
on: i.onDate,
type: types.data?.find((t) => t.code === i.typeCode)?.label ?? i.typeCode,
notes: i.notes !== '' ? i.notes : '—',
outcome: i.outcome !== null ? <Badge tone={OUTCOME_TONE[i.outcome]}>{i.outcome}</Badge> : '—',
follow: (
<input
type="date" className="wf" style={{ width: 140 }}
value={i.followUpOn ?? ''}
onChange={(e) => {
setActionErr(undefined)
updateInteraction(i.id, { followUpOn: e.target.value !== '' ? e.target.value : null })
.then(() => interactions.reload()).catch((err: Error) => setActionErr(err.message))
}}
/>
),
}))}
/>
)}
</>
)}
{tab === 'aws' && (
<>
{awsUsage.error !== undefined && <ErrorState message={awsUsage.error} onRetry={awsUsage.reload} />}
{awsUsage.data === undefined && awsUsage.error === undefined ? <Skeleton />
: (awsUsage.data ?? []).length === 0 ? <EmptyState>No AWS usage recorded for this client.</EmptyState> : (
<DataTable
columns={[
{ key: 'month', label: 'Month', mono: true }, { key: 'storage', label: 'Storage GB', numeric: true },
{ key: 'transfer', label: 'Transfer GB', numeric: true }, { key: 'cost', label: 'Cost', numeric: true },
{ key: 'source', label: 'Source' },
]}
rows={(awsUsage.data ?? []).map((u) => ({
month: u.month, storage: u.storageGb, transfer: u.transferGb, cost: inr(u.costPaise), source: u.source,
}))}
/>
)}
</>
)}
</div>
)
}