|
|
|
|
@ -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<AmcPaidStatus, 'ok' | 'warn' | 'err'> = { paid: 'ok', unpaid: 'err', unbilled: 'warn' }
|
|
|
|
|
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). */
|
|
|
|
|
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<string | undefined>()
|
|
|
|
|
|
|
|
|
|
const c = client.data
|
|
|
|
|
@ -149,6 +160,270 @@ export function ClientDetail() {
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<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 && <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>
|
|
|
|
|
{isOwner && <NewAmcForm clientId={id} onDone={() => amc.reload()} />}
|
|
|
|
|
{amc.error !== undefined && <Notice tone="err">{amc.error}</Notice>}
|
|
|
|
|
{amc.data === undefined || 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>
|
|
|
|
|
),
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<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))
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|