feat(hq-web): Client 360 — record header, KPI row, tabbed sections, forms extracted

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 9aa376fa2b
commit c5a524bab5

@ -1,31 +1,39 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR, fromRupees } from '@sims/domain' import { formatINR } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui' import {
Badge, Button, DataTable, EmptyState, ErrorState, RecordHeader, Skeleton,
StatCard, Stats, Tabs, Toolbar,
} from '@sims/ui'
import { import {
assignClientModule, getClient, getClientModules, getLedger, getModules, assignClientModule, getClient, getClientModules, getLedger, getModules,
patchClient, patchClientModule, recordPayment, patchClient, role, isManagerial, getEmployees, setClientOwner,
role, isManagerial, getEmployees, setClientOwner, getRecurringPlans, deactivateRecurringPlan,
getRecurringPlans, createRecurringPlan, deactivateRecurringPlan, getAmc, deactivateAmc, generateAmcRenewalInvoice,
getAmc, createAmc, deactivateAmc, generateAmcRenewalInvoice, getInteractions, getInteractionTypes, updateInteraction,
getInteractions, getInteractionTypes, createInteraction, updateInteraction,
getClientAwsUsage, getClientAwsUsage,
CLIENT_MODULE_STATUSES, CLIENT_STATUSES, KIND_LABEL, PAYMENT_MODES, CLIENT_STATUSES, KIND_LABEL,
type AmcPaidStatus, type ClientModule, type ClientModuleStatus, type ClientStatus, type AmcPaidStatus, type ClientStatus, type Outcome,
type Employee, type InteractionType, type Kind, type Module, type Outcome, type PaymentMode,
} from '../api' } from '../api'
import { CLIENT_TONE, DOC_TONE, useData } from './Clients' 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 inr = (p: number) => formatINR(p, { symbol: false })
const today = () => new Date().toISOString().slice(0, 10)
const AMC_TONE: Record<AmcPaidStatus, 'ok' | 'warn' | 'err'> = { paid: 'ok', unpaid: 'err', unbilled: 'warn' } 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 OUTCOME_TONE: Record<Outcome, 'ok' | 'warn' | 'err'> = { 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() { export function ClientDetail() {
const id = useParams()['id'] ?? '' const id = useParams()['id'] ?? ''
const nav = useNavigate() const nav = useNavigate()
const [sp, setSp] = useSearchParams()
const client = useData(() => getClient(id), [id]) const client = useData(() => getClient(id), [id])
const modules = useData(getModules, []) const modules = useData(getModules, [])
const cms = useData(() => getClientModules(id), [id]) const cms = useData(() => getClientModules(id), [id])
@ -37,40 +45,59 @@ export function ClientDetail() {
const awsUsage = useData(() => getClientAwsUsage(id), [id]) const awsUsage = useData(() => getClientAwsUsage(id), [id])
const employees = useData(() => getEmployees(), []) const employees = useData(() => getEmployees(), [])
const isOwner = role() === 'owner' const isOwner = role() === 'owner'
const canRoute = isManagerial() // account-owner routing is owner/manager only (server-enforced too) const canRoute = isManagerial()
const [actionErr, setActionErr] = useState<string | undefined>() 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 const c = client.data
if (client.error !== undefined) return <Notice tone="err">{client.error}</Notice> if (client.error !== undefined) return <ErrorState message={client.error} onRetry={client.reload} />
if (c === undefined) return <EmptyState>Loading</EmptyState> if (c === undefined) return <div className="wf-page"><Skeleton rows={6} /></div>
const moduleName = (moduleId: string) => const moduleName = (moduleId: string) =>
modules.data?.find((m) => m.id === moduleId)?.name ?? moduleId 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 ( return (
<div className="wf-page"> <div className="wf-page">
<PageHeader <div className="wf-crumbs"><Link to="/clients">Clients</Link><span>/</span><span>{c.code}</span></div>
title={c.name} badge={c.code} <RecordHeader
desc={`State ${c.stateCode}${c.gstin !== undefined && c.gstin !== '' ? ` · GSTIN ${c.gstin}` : ''}${c.address !== '' ? ` · ${c.address}` : ''}`} 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={ actions={
<select <>
className="wf" value={c.status} <select
onChange={(e) => { className="wf" style={{ width: 'auto' }} value={c.status}
setActionErr(undefined) onChange={(e) => {
patchClient(id, { status: e.target.value as ClientStatus }) setActionErr(undefined)
.then(client.reload).catch((err: Error) => setActionErr(err.message)) 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> {CLIENT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<Button tone="primary" onClick={() => nav('/documents/new')}>New document</Button>
</>
} }
/> />
<Toolbar> {canRoute && employees.data !== undefined && (
<Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge> <Toolbar>
{c.contacts.map((ct, i) => (
<Badge key={i}>{[ct.name, ct.email, ct.phone].filter((x) => x !== undefined && x !== '').join(' · ')}</Badge>
))}
{canRoute && employees.data !== undefined && (
<AccountOwnerSelect <AccountOwnerSelect
ownerId={c.ownerId} ownerId={c.ownerId}
employees={employees.data.employees} employees={employees.data.employees}
@ -80,517 +107,282 @@ export function ClientDetail() {
.then(client.reload).catch((err: Error) => setActionErr(err.message)) .then(client.reload).catch((err: Error) => setActionErr(err.message))
}} }}
/> />
)} </Toolbar>
</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>} {actionErr !== undefined && <ErrorState message={actionErr} />}
{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> <Tabs
{ledger.data !== undefined && ( active={tab}
<Stats> onChange={setTab}
<StatCard label="Advance on account" value={formatINR(ledger.data.advancePaise)} hint="unallocated payments" /> tabs={[
</Stats> { key: 'overview', label: 'Overview' },
)} { key: 'modules', label: 'Modules', count: (cms.data ?? []).length },
<PaymentForm { key: 'documents', label: 'Documents', count: docs.length },
clientId={id} { key: 'payments', label: 'Payments & plans', count: (ledger.data?.payments ?? []).length },
// amc.reload too: AMC paid state derives from settlement, so a payment { key: 'amc', label: 'AMC', count: (amc.data ?? []).length },
// must flip the Paid badge live (Task 12 walk §3.4). { key: 'interactions', label: 'Interactions', count: (interactions.data ?? []).length },
onDone={() => { ledger.reload(); cms.reload(); amc.reload() }} { key: 'aws', label: 'AWS', count: (awsUsage.data ?? []).length },
]}
/> />
{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) : '—',
}))}
/>
)}
<h3 style={{ marginTop: 20 }}>Recurring plans</h3> {tab === 'overview' && (
{isOwner && cms.data !== undefined && ( <>
<NewRecurringForm <Stats>
clientId={id} <StatCard label="Active modules" value={String(activeModules.length)} />
options={cms.data.filter((cm) => cm.active).map((cm) => ({ <StatCard label="Advance on account" value={ledger.data !== undefined ? formatINR(ledger.data.advancePaise) : '…'} hint="unallocated payments" />
id: cm.id, label: `${moduleName(cm.moduleId)} · ${KIND_LABEL[cm.kind]}`, <StatCard label="Open documents" value={String(openDocs.length)} hint={`${docs.length} total`} />
}))} <StatCard label="Last interaction" value={lastInteraction ?? '—'} />
onDone={() => plans.reload()} </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>
)}
</>
)} )}
{plans.error !== undefined && <Notice tone="err">{plans.error}</Notice>}
{plans.data === undefined || 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>
) : '—',
}
})}
/>
)}
<h3 style={{ marginTop: 20 }}>AMC contracts</h3> {tab === 'modules' && (
{isOwner && <NewAmcForm clientId={id} onDone={() => amc.reload()} />} <>
{amc.error !== undefined && <Notice tone="err">{amc.error}</Notice>} {modules.data !== undefined && (
{amc.data === undefined || amc.data.length === 0 <AssignModuleForm
? <EmptyState>No AMC contracts.</EmptyState> modules={modules.data}
: ( onAssign={(moduleId, kind) => {
<DataTable setActionErr(undefined)
columns={[ assignClientModule(id, { moduleId, kind })
{ key: 'coverage', label: 'Coverage' }, { key: 'period', label: 'Period' }, .then(() => { cms.reload(); ledger.reload() })
{ key: 'amount', label: 'Amount', numeric: true }, .catch((err: Error) => setActionErr(err.message))
{ key: 'days', label: 'Reminder days', numeric: true }, }}
{ key: 'paid', label: 'Paid' }, { key: 'act', label: '' }, />
]} )}
rows={amc.data.map((a) => ({ {cms.error !== undefined && <ErrorState message={cms.error} onRetry={cms.reload} />}
coverage: a.coverage !== '' ? a.coverage : '—', {cms.data === undefined && cms.error === undefined ? <Skeleton />
period: `${a.periodFrom}${a.periodTo}`, : (cms.data ?? []).length === 0 ? <EmptyState>No modules assigned yet.</EmptyState> : (
amount: inr(a.amountPaise), <DataTable
days: a.renewalReminderDays, columns={[
paid: <Badge tone={AMC_TONE[a.paidStatus]}>{a.paidStatus}</Badge>, { key: 'module', label: 'Module' }, { key: 'kind', label: 'Kind' },
act: ( { key: 'status', label: 'Status' },
<div style={{ display: 'flex', gap: 6 }}> { key: 'installed', label: 'Installed' }, { key: 'completed', label: 'Completed' },
{a.paidStatus !== 'unpaid' && ( { key: 'trained', label: 'Trained' },
<Button tone="primary" onClick={() => { { key: 'billed', label: 'Billed', numeric: true },
setActionErr(undefined) { key: 'settled', label: 'Settled', numeric: true },
generateAmcRenewalInvoice(a.id) ]}
.then((doc) => nav(`/documents/${doc.id}`)).catch((err: Error) => setActionErr(err.message)) rows={(cms.data ?? []).map((cm) => {
}}>Generate renewal invoice</Button> const paid = ledger.data?.modulePaid.find((mp) => mp.moduleId === cm.moduleId)
)} return {
{a.active && ( module: moduleName(cm.moduleId),
<Button onClick={() => { kind: KIND_LABEL[cm.kind],
setActionErr(undefined) status: <ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={setActionErr} />,
deactivateAmc(a.id) installed: <RowDate cm={cm} field="installedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
.then(() => amc.reload()).catch((err: Error) => setActionErr(err.message)) completed: <RowDate cm={cm} field="completedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
}}>Deactivate</Button> trained: <RowDate cm={cm} field="trainedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
)} billed: paid !== undefined ? inr(paid.billedPaise) : '—',
</div> settled: paid !== undefined ? inr(paid.settledPaise) : '—',
), }
}))} })}
/> />
)} )}
</>
<h3 style={{ marginTop: 20 }}>Interactions</h3>
{types.data !== undefined && (
<LogInteractionForm clientId={id} types={types.data} onDone={() => interactions.reload()} />
)} )}
{interactions.error !== undefined && <Notice tone="err">{interactions.error}</Notice>}
{interactions.data === undefined || 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))
}}
/>
),
}))}
/>
)}
<h3 style={{ marginTop: 20 }}>AWS usage</h3>
{awsUsage.error !== undefined && <Notice tone="err">{awsUsage.error}</Notice>}
{awsUsage.data === undefined || awsUsage.data.length === 0
? <EmptyState>No AWS usage recorded for this client.</EmptyState>
: (
<DataTable
columns={[
{ key: 'month', label: 'Month' }, { 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>
)
}
/**
* 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 (
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: 'var(--muted, #6b7280)', fontSize: 13 }}>Account owner</span>
<select
className="wf" value={props.ownerId ?? ''}
onChange={(e) => props.onChange(e.target.value !== '' ? e.target.value : null)}
>
<option value=""> unassigned </option>
{props.employees
.filter((emp) => emp.active || emp.id === props.ownerId)
.map((emp) => <option key={emp.id} value={emp.id}>{emp.displayName}</option>)}
</select>
</label>
)
}
/** 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<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 = () => {
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 (
<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="Module">
<select className="wf" value={f.clientModuleId} onChange={set('clientModuleId')}>
<option value="">Module</option>
{props.options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</Field>
<Field label="Cadence">
<select className="wf" value={f.cadence} onChange={set('cadence')}>
<option value="monthly">monthly</option>
<option value="yearly">yearly</option>
</select>
</Field>
<Field label="Amount (₹, optional)"><input className="wf num" style={{ width: 120 }} value={f.amountRs} onChange={set('amountRs')} /></Field>
<Field label="Next run"><input type="date" className="wf" value={f.nextRun} onChange={set('nextRun')} /></Field>
<Field label="Policy">
<select className="wf" value={f.policy} onChange={set('policy')}>
<option value="manual">manual</option>
<option value="auto">auto</option>
</select>
</Field>
<Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'New recurring plan'}</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}
/** 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<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 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 (
<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="Coverage"><input className="wf" value={f.coverage} onChange={set('coverage')} /></Field>
<Field label="Period from"><input type="date" className="wf" value={f.periodFrom} onChange={set('periodFrom')} /></Field>
<Field label="Period to"><input type="date" className="wf" value={f.periodTo} onChange={set('periodTo')} /></Field>
<Field label="Amount (₹)"><input className="wf num" style={{ width: 120 }} value={f.amountRs} onChange={set('amountRs')} /></Field>
<Field label="Reminder days"><input className="wf num" style={{ width: 80 }} value={f.reminderDays} onChange={set('reminderDays')} /></Field>
<Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'New AMC'}</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}
/** 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<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 = () => {
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 (
<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="Type">
<select className="wf" value={f.typeCode} onChange={set('typeCode')}>
<option value="">Type</option>
{props.types.map((t) => <option key={t.code} value={t.code}>{t.label}</option>)}
</select>
</Field>
<Field label="On"><input type="date" className="wf" value={f.onDate} onChange={set('onDate')} /></Field>
<Field label="Outcome">
<select className="wf" value={f.outcome} onChange={set('outcome')}>
<option value=""></option>
<option value="positive">positive</option>
<option value="neutral">neutral</option>
<option value="negative">negative</option>
</select>
</Field>
<Field label="Follow-up on"><input type="date" className="wf" value={f.followUpOn} onChange={set('followUpOn')} /></Field>
<Button tone="primary" onClick={save}>{saving ? 'Logging…' : 'Log interaction'}</Button>
</div>
<div style={{ marginTop: 8 }}>
<Field label="Notes"><textarea className="wf" rows={2} style={{ width: '100%' }} value={f.notes} onChange={set('notes')} /></Field>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}
function AssignModuleForm(props: { modules: Module[]; onAssign: (moduleId: string, kind: Kind) => void }) { {tab === 'documents' && (
const [moduleId, setModuleId] = useState('') <>
const mod = props.modules.find((m) => m.id === moduleId) {ledger.error !== undefined && <ErrorState message={ledger.error} onRetry={ledger.reload} />}
const [kind, setKind] = useState<Kind | ''>('') {ledger.data === undefined && ledger.error === undefined ? <Skeleton />
return ( : docs.length === 0 ? <EmptyState>No documents yet compose one from New Document.</EmptyState> : (
<Toolbar> <DataTable
<select columns={[
className="wf" value={moduleId} { key: 'no', label: 'No', mono: true }, { key: 'type', label: 'Type' },
onChange={(e) => { setModuleId(e.target.value); setKind('') }} { key: 'date', label: 'Date' }, { key: 'payable', label: 'Payable', numeric: true },
> { key: 'status', label: 'Status' },
<option value="">Assign module</option> ]}
{props.modules.filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)} onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
</select> rows={docs.map((d) => ({
{mod !== undefined && ( no: d.docNo ?? <Badge tone="warn">draft</Badge>,
<select className="wf" value={kind} onChange={(e) => setKind(e.target.value as Kind)}> type: d.docType, date: d.docDate,
<option value="">Kind</option> payable: inr(d.payablePaise),
{mod.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)} status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
</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: { {tab === 'payments' && (
cm: ClientModule; field: 'installedOn' | 'completedOn' | 'trainedOn' <>
onPatched: () => void; onError: (msg: string) => void {ledger.data !== undefined && (
}) { <Stats>
return ( <StatCard label="Advance on account" value={formatINR(ledger.data.advancePaise)} hint="unallocated payments" />
<input </Stats>
type="date" className="wf" style={{ width: 140 }} )}
value={props.cm[props.field] ?? ''} <PaymentForm clientId={id} onDone={() => { ledger.reload(); cms.reload(); amc.reload() }} />
onChange={(e) => { {ledger.data !== undefined && ledger.data.payments.length > 0 && (
patchClientModule(props.cm.id, { [props.field]: e.target.value !== '' ? e.target.value : null }) <DataTable
.then(props.onPatched).catch((err: Error) => props.onError(err.message)) 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' && (
* Record a payment against the client amounts entered in rupees, stored in <>
* integer paise via fromRupees. Shared with DocumentView's "Record payment". {isOwner && <NewAmcForm clientId={id} onDone={() => amc.reload()} />}
*/ {amc.error !== undefined && <ErrorState message={amc.error} onRetry={amc.reload} />}
export function PaymentForm(props: { clientId: string; onDone: () => void }) { {amc.data === undefined && amc.error === undefined ? <Skeleton />
const [f, setF] = useState({ amountRs: '', tdsRs: '', mode: 'bank' as PaymentMode, reference: '', receivedOn: today() }) : (amc.data ?? []).length === 0 ? <EmptyState>No AMC contracts.</EmptyState> : (
const [error, setError] = useState<string | undefined>() <DataTable
const [saving, setSaving] = useState(false) columns={[
const set = (k: keyof typeof f) => (e: { target: { value: string } }) => { key: 'coverage', label: 'Coverage' }, { key: 'period', label: 'Period' },
setF((p) => ({ ...p, [k]: e.target.value })) { 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>
),
}))}
/>
)}
</>
)}
const save = () => { {tab === 'interactions' && (
const amount = Number(f.amountRs) <>
if (!Number.isFinite(amount) || amount <= 0) { setError('Enter a payment amount in rupees'); return } {types.data !== undefined && (
const tds = f.tdsRs === '' ? 0 : Number(f.tdsRs) <LogInteractionForm clientId={id} types={types.data} onDone={() => interactions.reload()} />
if (!Number.isFinite(tds) || tds < 0) { setError('TDS must be a non-negative rupee amount'); return } )}
setError(undefined) {interactions.error !== undefined && <ErrorState message={interactions.error} onRetry={interactions.reload} />}
setSaving(true) {interactions.data === undefined && interactions.error === undefined ? <Skeleton />
recordPayment({ : (interactions.data ?? []).length === 0 ? <EmptyState>No interactions logged yet.</EmptyState> : (
clientId: props.clientId, receivedOn: f.receivedOn, mode: f.mode, reference: f.reference, <DataTable
amountPaise: fromRupees(amount), tdsPaise: fromRupees(tds), columns={[
}) { key: 'on', label: 'Date' }, { key: 'type', label: 'Type' },
.then(() => { setF({ amountRs: '', tdsRs: '', mode: 'bank', reference: '', receivedOn: today() }); props.onDone() }) { key: 'notes', label: 'Notes' }, { key: 'outcome', label: 'Outcome' },
.catch((e: Error) => setError(e.message)) { key: 'follow', label: 'Follow-up' },
.finally(() => setSaving(false)) ]}
} 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))
}}
/>
),
}))}
/>
)}
</>
)}
return ( {tab === 'aws' && (
<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' }}> {awsUsage.error !== undefined && <ErrorState message={awsUsage.error} onRetry={awsUsage.reload} />}
<Field label="Amount (₹)"><input className="wf num" style={{ width: 120 }} value={f.amountRs} onChange={set('amountRs')} /></Field> {awsUsage.data === undefined && awsUsage.error === undefined ? <Skeleton />
<Field label="TDS (₹)"><input className="wf num" style={{ width: 100 }} value={f.tdsRs} onChange={set('tdsRs')} /></Field> : (awsUsage.data ?? []).length === 0 ? <EmptyState>No AWS usage recorded for this client.</EmptyState> : (
<Field label="Mode"> <DataTable
<select className="wf" value={f.mode} onChange={set('mode')}> columns={[
{PAYMENT_MODES.map((m) => <option key={m} value={m}>{m}</option>)} { key: 'month', label: 'Month', mono: true }, { key: 'storage', label: 'Storage GB', numeric: true },
</select> { key: 'transfer', label: 'Transfer GB', numeric: true }, { key: 'cost', label: 'Cost', numeric: true },
</Field> { key: 'source', label: 'Source' },
<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> rows={(awsUsage.data ?? []).map((u) => ({
<Button tone="primary" onClick={save}>{saving ? 'Recording…' : 'Record payment'}</Button> month: u.month, storage: u.storageGb, transfer: u.transferGb, cost: inr(u.costPaise), source: u.source,
</div> }))}
{error !== undefined && <Notice tone="err">{error}</Notice>} />
)}
</>
)}
</div> </div>
) )
} }

@ -7,7 +7,7 @@ import {
revokeShare, type Client, type Doc, type Share, revokeShare, type Client, type Doc, type Share,
} from '../api' } from '../api'
import { DOC_TONE, useData } from './Clients' import { DOC_TONE, useData } from './Clients'
import { PaymentForm } from './ClientDetail' import { PaymentForm } from './client-forms'
const TITLE: Record<Doc['docType'], string> = { const TITLE: Record<Doc['docType'], string> = {
QUOTATION: 'Quotation', PROFORMA: 'Proforma Invoice', INVOICE: 'Tax Invoice', QUOTATION: 'Quotation', PROFORMA: 'Proforma Invoice', INVOICE: 'Tax Invoice',

@ -0,0 +1,291 @@
import { useState } from 'react'
import { fromRupees } from '@sims/domain'
import { Button, Field, Notice, Toolbar } from '@sims/ui'
import {
createAmc, createInteraction, createRecurringPlan, patchClientModule, recordPayment,
CLIENT_MODULE_STATUSES, KIND_LABEL, PAYMENT_MODES,
type ClientModule, type ClientModuleStatus, type Employee, type InteractionType,
type Kind, type Module, type PaymentMode,
} from '../api'
const today = () => new Date().toISOString().slice(0, 10)
/**
* 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.
*/
export function AccountOwnerSelect(props: {
ownerId?: string; employees: Employee[]; onChange: (ownerId: string | null) => void
}) {
return (
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: 'var(--muted, #6b7280)', fontSize: 13 }}>Account owner</span>
<select
className="wf" value={props.ownerId ?? ''}
onChange={(e) => props.onChange(e.target.value !== '' ? e.target.value : null)}
>
<option value=""> unassigned </option>
{props.employees
.filter((emp) => emp.active || emp.id === props.ownerId)
.map((emp) => <option key={emp.id} value={emp.id}>{emp.displayName}</option>)}
</select>
</label>
)
}
/** Owner-only: create a recurring plan on one of the client's modules. */
export 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<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 = () => {
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 (
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Module">
<select className="wf" value={f.clientModuleId} onChange={set('clientModuleId')}>
<option value="">Module</option>
{props.options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</Field>
<Field label="Cadence">
<select className="wf" value={f.cadence} onChange={set('cadence')}>
<option value="monthly">monthly</option>
<option value="yearly">yearly</option>
</select>
</Field>
<Field label="Amount (₹, optional)"><input className="wf num" style={{ width: 120 }} value={f.amountRs} onChange={set('amountRs')} /></Field>
<Field label="Next run"><input type="date" className="wf" value={f.nextRun} onChange={set('nextRun')} /></Field>
<Field label="Policy">
<select className="wf" value={f.policy} onChange={set('policy')}>
<option value="manual">manual</option>
<option value="auto">auto</option>
</select>
</Field>
<Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'New recurring plan'}</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}
/** Owner-only: create an AMC contract — amount entered in rupees, stored in paise. */
export function NewAmcForm(props: { clientId: string; onDone: () => void }) {
const [f, setF] = useState({ coverage: '', periodFrom: today(), periodTo: '', amountRs: '', reminderDays: '30' })
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 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 (
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Coverage"><input className="wf" value={f.coverage} onChange={set('coverage')} /></Field>
<Field label="Period from"><input type="date" className="wf" value={f.periodFrom} onChange={set('periodFrom')} /></Field>
<Field label="Period to"><input type="date" className="wf" value={f.periodTo} onChange={set('periodTo')} /></Field>
<Field label="Amount (₹)"><input className="wf num" style={{ width: 120 }} value={f.amountRs} onChange={set('amountRs')} /></Field>
<Field label="Reminder days"><input className="wf num" style={{ width: 80 }} value={f.reminderDays} onChange={set('reminderDays')} /></Field>
<Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'New AMC'}</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}
/** All roles: log a call / visit / training …; a follow-up date feeds the daily scan. */
export function LogInteractionForm(props: { clientId: string; types: InteractionType[]; onDone: () => void }) {
const [f, setF] = useState({ typeCode: '', onDate: today(), notes: '', outcome: '', followUpOn: '' })
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 = () => {
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 (
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Type">
<select className="wf" value={f.typeCode} onChange={set('typeCode')}>
<option value="">Type</option>
{props.types.map((t) => <option key={t.code} value={t.code}>{t.label}</option>)}
</select>
</Field>
<Field label="On"><input type="date" className="wf" value={f.onDate} onChange={set('onDate')} /></Field>
<Field label="Outcome">
<select className="wf" value={f.outcome} onChange={set('outcome')}>
<option value=""></option>
<option value="positive">positive</option>
<option value="neutral">neutral</option>
<option value="negative">negative</option>
</select>
</Field>
<Field label="Follow-up on"><input type="date" className="wf" value={f.followUpOn} onChange={set('followUpOn')} /></Field>
<Button tone="primary" onClick={save}>{saving ? 'Logging…' : 'Log interaction'}</Button>
</div>
<div style={{ marginTop: 8 }}>
<Field label="Notes"><textarea className="wf" rows={2} style={{ width: '100%' }} value={f.notes} onChange={set('notes')} /></Field>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}
export 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>
)
}
export 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>
)
}
export 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 className="wf-card">
<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>
)
}
Loading…
Cancel
Save