From c5a524bab582cdd75abe6c5ad7f327ad6d895105 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 02:38:38 +0530 Subject: [PATCH] =?UTF-8?q?feat(hq-web):=20Client=20360=20=E2=80=94=20reco?= =?UTF-8?q?rd=20header,=20KPI=20row,=20tabbed=20sections,=20forms=20extrac?= =?UTF-8?q?ted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq-web/src/pages/ClientDetail.tsx | 858 ++++++++++--------------- apps/hq-web/src/pages/DocumentView.tsx | 2 +- apps/hq-web/src/pages/client-forms.tsx | 291 +++++++++ 3 files changed, 617 insertions(+), 534 deletions(-) create mode 100644 apps/hq-web/src/pages/client-forms.tsx diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index c40b5b2..212c21c 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -1,31 +1,39 @@ 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 { 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, patchClientModule, recordPayment, - role, isManagerial, getEmployees, setClientOwner, - getRecurringPlans, createRecurringPlan, deactivateRecurringPlan, - getAmc, createAmc, deactivateAmc, generateAmcRenewalInvoice, - getInteractions, getInteractionTypes, createInteraction, updateInteraction, + patchClient, role, isManagerial, getEmployees, setClientOwner, + getRecurringPlans, deactivateRecurringPlan, + getAmc, deactivateAmc, generateAmcRenewalInvoice, + getInteractions, getInteractionTypes, updateInteraction, getClientAwsUsage, - CLIENT_MODULE_STATUSES, CLIENT_STATUSES, KIND_LABEL, PAYMENT_MODES, - type AmcPaidStatus, type ClientModule, type ClientModuleStatus, type ClientStatus, - type Employee, type InteractionType, type Kind, type Module, type Outcome, type PaymentMode, + 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 today = () => new Date().toISOString().slice(0, 10) const AMC_TONE: Record = { paid: 'ok', unpaid: 'err', unbilled: 'warn' } const OUTCOME_TONE: Record = { positive: 'ok', neutral: 'warn', negative: 'err' } -/** Client 360° — header, modules, documents, payments & dues (14-SPEC §Client registry). */ +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]) @@ -37,40 +45,59 @@ export function ClientDetail() { const awsUsage = useData(() => getClientAwsUsage(id), [id]) const employees = useData(() => getEmployees(), []) const isOwner = role() === 'owner' - const canRoute = isManagerial() // account-owner routing is owner/manager only (server-enforced too) + const canRoute = isManagerial() const [actionErr, setActionErr] = 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 {client.error} - if (c === undefined) return Loading… + 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 + 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={ - + <> + + + } /> - - {c.status} - {c.contacts.map((ct, i) => ( - {[ct.name, ct.email, ct.phone].filter((x) => x !== undefined && x !== '').join(' · ')} - ))} - {canRoute && employees.data !== undefined && ( + {canRoute && employees.data !== undefined && ( + setActionErr(err.message)) }} /> - )} - - {actionErr !== undefined && {actionErr}} - -

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.error}} - {cms.data === 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) : '—', - } - })} - /> - )} - -

Documents

- {ledger.error !== undefined && {ledger.error}} - {ledger.data === undefined || ledger.data.documents.length === 0 - ? No documents yet — compose one from New Document. - : ( - nav(`/documents/${ledger.data!.documents[i]!.id}`)} - rows={ledger.data.documents.map((d) => ({ - no: d.docNo ?? draft, - type: d.docType, date: d.docDate, - payable: inr(d.payablePaise), - status: {d.status}, - }))} - /> - )} + {actionErr !== undefined && } -

Payments & dues

- {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 && ( - cm.active).map((cm) => ({ - id: cm.id, label: `${moduleName(cm.moduleId)} · ${KIND_LABEL[cm.kind]}`, - }))} - onDone={() => plans.reload()} - /> + {tab === 'overview' && ( + <> + + + + + + + {/* Pulse ribbon lands here in the next task. */} +

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 && ( + + )} + )} - {plans.error !== undefined && {plans.error}} - {plans.data === 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: plan.active ? ( - - ) : '—', - } - })} - /> - )} -

AMC contracts

- {isOwner && amc.reload()} />} - {amc.error !== undefined && {amc.error}} - {amc.data === 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: ( -
- {a.paidStatus !== 'unpaid' && ( - - )} - {a.active && ( - - )} -
- ), - }))} - /> - )} - -

Interactions

- {types.data !== undefined && ( - interactions.reload()} /> + {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) : '—', + } + })} + /> + )} + )} - {interactions.error !== undefined && {interactions.error}} - {interactions.data === 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)) - }} - /> - ), - }))} - /> - )} - -

AWS usage

- {awsUsage.error !== undefined && {awsUsage.error}} - {awsUsage.data === 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, - }))} - /> - )} - - ) -} - -/** - * Owner/manager only (server re-checks): pick the employee who owns this account, - * so a lead with no quote is still routable to someone's queue. Writes client.owner_id - * via the audited PATCH /clients/:id/owner. Inactive employees are hidden from the - * picker, but a current owner who was since deactivated still renders. - */ -function AccountOwnerSelect(props: { - ownerId?: string; employees: Employee[]; onChange: (ownerId: string | null) => void -}) { - return ( - - ) -} - -/** Owner-only: create a recurring plan on one of the client's modules. */ -function NewRecurringForm(props: { - clientId: string; options: { id: string; label: string }[]; onDone: () => void -}) { - const [f, setF] = useState({ clientModuleId: '', cadence: 'monthly', amountRs: '', nextRun: today(), policy: 'manual' }) - const [error, setError] = useState() - const [saving, setSaving] = useState(false) - const set = (k: keyof typeof f) => (e: { target: { value: string } }) => - setF((p) => ({ ...p, [k]: e.target.value })) - - const save = () => { - if (f.clientModuleId === '') { setError('Pick a subscribed module'); return } - const amount = f.amountRs === '' ? undefined : Number(f.amountRs) - if (amount !== undefined && (!Number.isFinite(amount) || amount <= 0)) { - setError('Amount must be a positive rupee value (or blank for the price book)'); return - } - setError(undefined) - setSaving(true) - createRecurringPlan(props.clientId, { - clientModuleId: f.clientModuleId, cadence: f.cadence, nextRun: f.nextRun, policy: f.policy, - ...(amount !== undefined ? { amountPaise: fromRupees(amount) } : {}), - }) - .then(() => { setF({ clientModuleId: '', cadence: 'monthly', amountRs: '', nextRun: today(), policy: 'manual' }); props.onDone() }) - .catch((e: Error) => setError(e.message)) - .finally(() => setSaving(false)) - } - - return ( -
-
- - - - - - - - - - - - -
- {error !== undefined && {error}} -
- ) -} - -/** Owner-only: create an AMC contract — amount entered in rupees, stored in paise. */ -function NewAmcForm(props: { clientId: string; onDone: () => void }) { - const [f, setF] = useState({ coverage: '', periodFrom: today(), periodTo: '', amountRs: '', reminderDays: '30' }) - const [error, setError] = useState() - 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 the contract amount in rupees'); return } - if (f.periodTo === '') { setError('Pick the period end date'); return } - const days = Number(f.reminderDays) - if (!Number.isInteger(days) || days < 0) { setError('Reminder days must be a non-negative integer'); return } - setError(undefined) - setSaving(true) - createAmc(props.clientId, { - coverage: f.coverage, periodFrom: f.periodFrom, periodTo: f.periodTo, - amountPaise: fromRupees(amount), renewalReminderDays: days, - }) - .then(() => { setF({ coverage: '', periodFrom: today(), periodTo: '', amountRs: '', reminderDays: '30' }); props.onDone() }) - .catch((e: Error) => setError(e.message)) - .finally(() => setSaving(false)) - } - - return ( -
-
- - - - - - -
- {error !== undefined && {error}} -
- ) -} - -/** All roles: log a call / visit / training …; a follow-up date feeds the daily scan. */ -function LogInteractionForm(props: { clientId: string; types: InteractionType[]; onDone: () => void }) { - const [f, setF] = useState({ typeCode: '', onDate: today(), notes: '', outcome: '', followUpOn: '' }) - const [error, setError] = useState() - const [saving, setSaving] = useState(false) - const set = (k: keyof typeof f) => (e: { target: { value: string } }) => - setF((p) => ({ ...p, [k]: e.target.value })) - - const save = () => { - if (f.typeCode === '') { setError('Pick an interaction type'); return } - setError(undefined) - setSaving(true) - createInteraction(props.clientId, { - typeCode: f.typeCode, onDate: f.onDate, notes: f.notes, - ...(f.outcome !== '' ? { outcome: f.outcome } : {}), - ...(f.followUpOn !== '' ? { followUpOn: f.followUpOn } : {}), - }) - .then(() => { setF({ typeCode: '', onDate: today(), notes: '', outcome: '', followUpOn: '' }); props.onDone() }) - .catch((e: Error) => setError(e.message)) - .finally(() => setSaving(false)) - } - - return ( -
-
- - - - - - - - - -
-
-