From a9f78c4e3cdb63f4d301a920d7a5c316f463aeea Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 15:59:06 +0530 Subject: [PATCH] feat(hq-web): client 360 recurring, amc and interaction sections Co-Authored-By: Claude Fable 5 --- apps/hq-web/src/pages/ClientDetail.tsx | 279 ++++++++++++++++++++++++- 1 file changed, 277 insertions(+), 2 deletions(-) diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index b1daacf..bcf47a3 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -5,15 +5,21 @@ import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, StatCa import { assignClientModule, getClient, getClientModules, getLedger, getModules, patchClient, patchClientModule, recordPayment, + role, getRecurringPlans, createRecurringPlan, deactivateRecurringPlan, + getAmc, createAmc, deactivateAmc, generateAmcRenewalInvoice, + getInteractions, getInteractionTypes, createInteraction, updateInteraction, CLIENT_MODULE_STATUSES, CLIENT_STATUSES, KIND_LABEL, PAYMENT_MODES, - type ClientModule, type ClientModuleStatus, type ClientStatus, type Kind, - type Module, type PaymentMode, + type AmcPaidStatus, type ClientModule, type ClientModuleStatus, type ClientStatus, + type InteractionType, type Kind, type Module, type Outcome, 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) +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). */ export function ClientDetail() { const id = useParams()['id'] ?? '' @@ -22,6 +28,11 @@ export function ClientDetail() { 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 isOwner = role() === 'owner' const [actionErr, setActionErr] = useState() const c = client.data @@ -149,6 +160,270 @@ export function ClientDetail() { }))} /> )} + +

Recurring plans

+ {isOwner && cms.data !== undefined && ( + cm.active).map((cm) => ({ + id: cm.id, label: `${moduleName(cm.moduleId)} · ${KIND_LABEL[cm.kind]}`, + }))} + onDone={() => plans.reload()} + /> + )} + {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()} /> + )} + {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)) + }} + /> + ), + }))} + /> + )} + + ) +} + +/** 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 ( +
+
+ + + + + + + + + +
+
+