import { useState } from 'react' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { formatINR } from '@sims/domain' import { Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, RecordHeader, Skeleton, StatCard, Stats, Tabs, Toolbar, useToast, } from '@sims/ui' import { assignClientModule, getClient, getClientModules, getLedger, getModules, patchClient, revealDbPassword, role, isManagerial, getEmployees, setClientOwner, getRecurringPlans, deactivateRecurringPlan, getAmc, deactivateAmc, generateAmcRenewalInvoice, getInteractions, getInteractionTypes, updateInteraction, getClientAwsUsage, CLIENT_STATUSES, KIND_LABEL, type AmcContract, type AmcPaidStatus, type Client, type ClientStatus, type Interaction, type Outcome, type RecurringPlan, } from '../api' import { CLIENT_TONE, DOC_TONE, useData } from './Clients' import { AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect, InteractionDialog, PaymentForm, PlanDialog, RowDate, } from './client-forms' import { ClientFormDialog } from '../components/ClientFormDialog' import { PulseRibbon } from '../components/PulseRibbon' import { type PulseEvent } from '../pulse' const inr = (p: number) => formatINR(p, { symbol: false }) const AMC_TONE: Record = { paid: 'ok', unpaid: 'err', unbilled: 'warn' } const OUTCOME_TONE: Record = { positive: 'ok', neutral: 'warn', negative: 'err' } const TAB_KEYS = ['overview', 'modules', 'documents', 'payments', 'amc', 'interactions', 'aws'] as const type TabKey = (typeof TAB_KEYS)[number] /** One pending confirmation — replaces the browser confirm() popup with the styled dialog * (same pattern as DocumentView Task 5). */ interface Confirm { title: string; body: string; label: string; tone?: 'danger'; run: () => void | Promise } /** 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 toast = useToast() const [actionErr, setActionErr] = useState() const [editing, setEditing] = useState(false) const [amcDialog, setAmcDialog] = useState<{ initial?: AmcContract } | undefined>() const [planDialog, setPlanDialog] = useState<{ initial?: RecurringPlan } | undefined>() const [interactionDialog, setInteractionDialog] = useState<{ initial?: Interaction } | undefined>() const [confirm, setConfirm] = useState() 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 if (c === undefined) return
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 const todayIso = new Date().toISOString().slice(0, 10) const DOC_PULSE_TONE: Record = { paid: 'ok', part_paid: 'warn', lost: 'err', cancelled: 'err', } const pulseEvents: PulseEvent[] = [ ...docs.map((d): PulseEvent => ({ id: `doc:${d.id}`, lane: 0, date: d.docDate, label: `${d.docType} ${d.docNo ?? '(draft)'} — ${formatINR(d.payablePaise)} · ${d.status}`, tone: DOC_PULSE_TONE[d.status] ?? 'accent', })), ...(ledger.data?.payments ?? []).map((p): PulseEvent => ({ id: `pay:${p.id}`, lane: 1, date: p.receivedOn, label: `Payment ${formatINR(p.amountPaise)} · ${p.mode}`, tone: 'ok', })), ...(interactions.data ?? []).map((i): PulseEvent => ({ id: `int:${i.id}`, lane: 2, date: i.onDate, label: `${i.outcome !== null ? i.outcome + ' · ' : ''}${types.data?.find((t) => t.code === i.typeCode)?.label ?? i.typeCode}${i.notes !== '' ? ` — ${i.notes.slice(0, 60)}` : ''}`, tone: i.outcome === 'negative' ? 'err' : i.outcome === 'positive' ? 'ok' : 'warn', })), ...(amc.data ?? []).flatMap((a): PulseEvent[] => [ { id: `amc-from:${a.id}`, lane: 3, date: a.periodFrom, label: `AMC starts — ${a.coverage !== '' ? a.coverage : 'contract'}`, tone: 'accent' }, { id: `amc-to:${a.id}`, lane: 3, date: a.periodTo, label: `AMC renewal — ${a.paidStatus}`, tone: a.paidStatus === 'unpaid' ? 'err' : 'warn' }, ]), ...activeModules.filter((cm) => cm.nextRenewal !== null).map((cm): PulseEvent => ({ id: `ren:${cm.id}`, lane: 3, date: cm.nextRenewal!, label: `${moduleName(cm.moduleId)} renews`, tone: 'warn', })), ] const openPulse = (ev: PulseEvent) => { if (ev.id.startsWith('doc:')) nav(`/documents/${ev.id.slice(4)}`) else if (ev.id.startsWith('pay:')) setTab('payments') else if (ev.id.startsWith('int:')) setTab('interactions') else setTab('amc') } return (
Clients/{c.code}
{c.status}} meta={ <> {c.code} · State {c.stateCode} {c.gstin !== undefined && c.gstin !== '' && · {c.gstin}} {c.contacts[0] !== undefined && ( · {[c.contacts[0].name, c.contacts[0].email, c.contacts[0].phone].filter((x) => x !== undefined && x !== '').join(' · ')} )} } actions={ <> } /> {canRoute && employees.data !== undefined && ( { setActionErr(undefined) setClientOwner(id, ownerId) .then(() => { toast.ok('Owner updated'); client.reload() }) .catch((err: Error) => setActionErr(err.message)) }} /> )} {actionErr !== undefined && } client.reload()} /> setEditing(false)} initial={c} onSaved={() => client.reload()} /> {tab === 'overview' && ( <>

Recent documents

{docs.length === 0 ? No documents yet. : ( 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: {d.status}, }))} /> )} {docs.length > 5 && ( )} )} {tab === 'modules' && ( <> {modules.data !== undefined && ( { setActionErr(undefined) assignClientModule(id, { moduleId, kind }) .then(() => { cms.reload(); ledger.reload() }) .catch((err: Error) => setActionErr(err.message)) }} /> )} {cms.error !== undefined && } {cms.data === undefined && cms.error === undefined ? : (cms.data ?? []).length === 0 ? No modules assigned yet. : ( { const paid = ledger.data?.modulePaid.find((mp) => mp.moduleId === cm.moduleId) return { module: moduleName(cm.moduleId), kind: KIND_LABEL[cm.kind], status: cms.reload()} onError={setActionErr} />, installed: cms.reload()} onError={setActionErr} />, completed: cms.reload()} onError={setActionErr} />, trained: cms.reload()} onError={setActionErr} />, billed: paid !== undefined ? inr(paid.billedPaise) : '—', settled: paid !== undefined ? inr(paid.settledPaise) : '—', } })} /> )} )} {tab === 'documents' && ( <> {ledger.error !== undefined && } {ledger.data === undefined && ledger.error === undefined ? : docs.length === 0 ? No documents yet — compose one from New Document. : ( nav(`/documents/${docs[i]!.id}`)} rows={docs.map((d) => ({ no: d.docNo ?? draft, type: d.docType, date: d.docDate, payable: inr(d.payablePaise), status: {d.status}, }))} /> )} )} {tab === 'payments' && ( <> {ledger.data !== undefined && ( )} { ledger.reload(); cms.reload(); amc.reload() }} /> {ledger.data !== undefined && ledger.data.payments.length > 0 && ( ({ on: p.receivedOn, mode: p.mode, ref: p.reference !== '' ? p.reference : '—', amount: inr(p.amountPaise), tds: p.tdsPaise > 0 ? inr(p.tdsPaise) : '—', }))} /> )}

Recurring plans

{isOwner && cms.data !== undefined && ( )} {plans.error !== undefined && } {plans.data === undefined && plans.error === undefined ? : (plans.data ?? []).length === 0 ? No recurring plans. : ( { 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: {plan.policy}, active: plan.active ? 'yes' : 'no', act: (
{isOwner && } {plan.active && ( )}
), } })} /> )} setPlanDialog(undefined)} clientId={id} options={(cms.data ?? []).filter((cm) => cm.active).map((cm) => ({ id: cm.id, label: `${moduleName(cm.moduleId)} · ${KIND_LABEL[cm.kind]}`, }))} initial={planDialog?.initial} onDone={() => plans.reload()} /> )} {tab === 'amc' && ( <> {isOwner && ( )} {amc.error !== undefined && } {amc.data === undefined && amc.error === undefined ? : (amc.data ?? []).length === 0 ? No AMC contracts. : ( ({ coverage: a.coverage !== '' ? a.coverage : '—', period: `${a.periodFrom} → ${a.periodTo}`, amount: inr(a.amountPaise), days: a.renewalReminderDays, paid: {a.paidStatus}, act: (
{isOwner && } {a.paidStatus !== 'unpaid' && ( )} {a.active && ( )}
), }))} /> )} setAmcDialog(undefined)} clientId={id} initial={amcDialog?.initial} onDone={() => amc.reload()} /> )} {tab === 'interactions' && ( <> {interactions.error !== undefined && } {interactions.data === undefined && interactions.error === undefined ? : (interactions.data ?? []).length === 0 ? No interactions logged yet. : ( ({ on: i.onDate, type: types.data?.find((t) => t.code === i.typeCode)?.label ?? i.typeCode, notes: i.notes !== '' ? i.notes : '—', outcome: i.outcome !== null ? {i.outcome} : '—', follow: ( { setActionErr(undefined) updateInteraction(i.id, { followUpOn: e.target.value !== '' ? e.target.value : null }) .then(() => interactions.reload()).catch((err: Error) => setActionErr(err.message)) }} /> ), act: , }))} /> )} setInteractionDialog(undefined)} clientId={id} types={types.data ?? []} initial={interactionDialog?.initial} onDone={() => interactions.reload()} /> )} {tab === 'aws' && ( <> {awsUsage.error !== undefined && } {awsUsage.data === undefined && awsUsage.error === undefined ? : (awsUsage.data ?? []).length === 0 ? No AWS usage recorded for this client. : ( ({ month: u.month, storage: u.storageGb, transfer: u.transferGb, cost: inr(u.costPaise), source: u.source, }))} /> )} )} {confirm !== undefined && ( setConfirm(undefined)} onConfirm={confirm.run} title={confirm.title} body={confirm.body} confirmLabel={confirm.label} {...(confirm.tone !== undefined ? { tone: confirm.tone } : {})} /> )}
) } /** * Support-access card (D18 WS-F): the fields the old APEX book carried on every * client row — AnyDesk id (44× referenced there), OS, district/sector — plus the * encrypted DB password: shown as ••••, revealed only by owner/manager, and every * reveal is audited server-side. Setting/updating it rides PATCH (managerial). */ function SupportCard(props: { client: Client; onChanged: () => void }) { const toast = useToast() const managerial = isManagerial() const c = props.client const [revealed, setRevealed] = useState() const [settingPw, setSettingPw] = useState(false) const [pw, setPw] = useState('') const [busy, setBusy] = useState(false) const copy = (text: string, what: string) => { navigator.clipboard.writeText(text).then(() => toast.ok(`${what} copied`)).catch(() => toast.err('Copy failed')) } const reveal = () => { if (busy) return setBusy(true) revealDbPassword(c.id) .then((p) => setRevealed(p)) .catch((e: Error) => toast.err(e.message)) .finally(() => setBusy(false)) } const savePw = () => { if (busy || pw === '') return setBusy(true) patchClient(c.id, { dbPassword: pw }) .then(() => { toast.ok('DB password stored (encrypted)'); setSettingPw(false); setPw(''); setRevealed(undefined); props.onChanged() }) .catch((e: Error) => toast.err(e.message)) .finally(() => setBusy(false)) } const cell = (label: string, value: string | undefined, mono = false, copyable = false) => ( {label} {value ?? '—'} {copyable && value !== undefined && } ) return (
{cell('AnyDesk', c.anydesk, true, true)} {cell('OS', c.os)} {cell('District', c.district)} {cell('Sector', c.sector)} {/* Typed contact rows (spec §5): whatsapp/secretary contacts surface here by label. */} {c.contacts .filter((ct) => ct.role === 'whatsapp' || ct.role === 'secretary') .map((ct, i) => { const num = ct.phone ?? ct.email return ( {ct.role === 'whatsapp' ? 'WhatsApp' : 'Secretary'} {[ct.name, num].filter((x) => x !== undefined && x !== '').join(' · ')} {num !== undefined && num !== '' && ( )} ) })} DB password {revealed !== undefined ? <>{revealed} : c.hasDbPassword ? <>••••••••{managerial && } : not set} {managerial && !settingPw && } {managerial && settingPw && ( <> setPw(e.target.value)} /> )}
) }