You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq-web/src/pages/ClientDetail.tsx

1332 lines
64 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { Fragment, useState, type CSSProperties, type ReactNode } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import {
Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, Field, RecordHeader, Skeleton,
StatCard, Stats, Tabs, Toolbar, useToast,
} from '@sims/ui'
import {
assignClientModule, getClient, getClientModules, getLedger, getModules,
patchClient, patchClientModule, revealDbPassword, revealModulePassword,
setModuleFields, setModuleSecret, revealModuleSecret,
role, isManagerial, getEmployees, setClientOwner,
getRecurringPlans, deactivateRecurringPlan,
getAmc, deactivateAmc, generateAmcRenewalInvoice,
getInteractions, getInteractionTypes, updateInteraction,
getClientAwsUsage, getCompanyProfile,
getBranches, createBranch, patchBranch, getTickets,
getMilestones, setMilestone, addMilestone,
CLIENT_STATUSES, KIND_LABEL,
type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule,
type ClientStatus, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome, type RecurringPlan, type ServiceDetail,
} from '../api'
import { buildStatement } from '../statement'
import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients'
import {
NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE,
} from './Tickets'
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<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 TAB_KEYS = ['overview', 'modules', 'tickets', '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<void> }
/** 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 branches = useData(() => getBranches(id), [id])
const [ticketPage, setTicketPage] = useState(1)
const tickets = useData(() => getTickets({ clientId: id, page: ticketPage, pageSize: 50 }), [id, ticketPage])
const [ticketDialog, setTicketDialog] = useState(false)
const isOwner = role() === 'owner'
const canRoute = isManagerial()
const toast = useToast()
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<Confirm | undefined>()
const [statementOpen, setStatementOpen] = useState(false)
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 <ErrorState message={client.error} onRetry={client.reload} />
if (c === undefined) return <div className="wf-page"><Skeleton rows={6} /></div>
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<string, PulseEvent['tone']> = {
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 (
<div className="wf-page">
<div className="wf-crumbs"><Link to="/clients">Clients</Link><span>/</span><span>{c.code}</span></div>
<RecordHeader
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={
<>
<Button onClick={() => setEditing(true)}>Edit</Button>
<select
className="wf" style={{ width: 'auto' }} value={c.status}
onChange={(e) => {
const next = e.target.value as ClientStatus
const apply = () => patchClient(id, { status: next })
.then(() => { toast.ok('Status updated'); client.reload() })
.catch((err: Error) => toast.err(err.message))
// Destructive transition — confirm before dropping a client to lost.
if (next === 'lost' && c.status !== 'lost') {
setConfirm({
title: 'Mark client lost',
body: `Mark ${c.name} as lost? They drop out of the active book — you can change the status back later.`,
label: 'Mark lost',
tone: 'danger',
run: apply,
})
} else {
void apply()
}
}}
>
{CLIENT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<Button onClick={() => setTab('interactions')}>Log call</Button>
<Button onClick={() => setTab('payments')}>Record payment</Button>
<Button onClick={() => setStatementOpen(true)}>Statement</Button>
<Button tone="primary" onClick={() => nav('/documents/new')}>New document</Button>
</>
}
/>
{canRoute && employees.data !== undefined && (
<Toolbar>
<AccountOwnerSelect
ownerId={c.ownerId}
employees={employees.data.employees}
onChange={(ownerId) => {
setClientOwner(id, ownerId)
.then(() => { toast.ok('Owner updated'); client.reload() })
.catch((err: Error) => toast.err(err.message))
}}
/>
</Toolbar>
)}
<SupportCard client={c} onChanged={() => client.reload()} />
<ClientFormDialog
open={editing}
onClose={() => setEditing(false)}
initial={c}
onSaved={() => client.reload()}
/>
<Tabs
active={tab}
onChange={setTab}
tabs={[
{ key: 'overview', label: 'Overview' },
{ key: 'modules', label: 'Modules', count: (cms.data ?? []).length },
{ key: 'tickets', label: 'Tickets', count: tickets.data?.total ?? 0 },
{ key: 'documents', label: 'Documents', count: docs.length },
{ key: 'payments', label: 'Payments & plans', count: (ledger.data?.payments ?? []).length },
{ key: 'amc', label: 'AMC', count: (amc.data ?? []).length },
{ key: 'interactions', label: 'Interactions', count: (interactions.data ?? []).length },
{ key: 'aws', label: 'AWS', count: (awsUsage.data ?? []).length },
]}
/>
{tab === 'overview' && (
<>
<Stats>
<StatCard label="Active modules" value={String(activeModules.length)} />
<StatCard label="Advance on account" value={ledger.data !== undefined ? formatINR(ledger.data.advancePaise) : '…'} hint="unallocated payments" />
<StatCard label="Open documents" value={String(openDocs.length)} hint={`${docs.length} total`} />
<StatCard label="Last interaction" value={lastInteraction ?? '—'} />
</Stats>
<ClientDetailsCard client={c} />
<PulseRibbon events={pulseEvents} todayIso={todayIso} onOpen={openPulse} />
<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: <Badge tone={DOC_TYPE_TONE[d.docType]}>{DOC_TYPE_LABEL[d.docType]}</Badge>,
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>
)}
<h3 style={{ marginTop: 18 }}>Branches</h3>
{branches.error !== undefined && <ErrorState message={branches.error} onRetry={branches.reload} />}
{branches.data === undefined && branches.error === undefined ? <Skeleton />
: branches.data !== undefined && (
<BranchList clientId={id} branches={branches.data} onChanged={branches.reload} />
)}
</>
)}
{tab === 'modules' && (
<>
{modules.data !== undefined && (
<AssignModuleForm
modules={modules.data}
onAssign={(moduleId, kind) => {
assignClientModule(id, { moduleId, kind })
.then(() => { cms.reload(); ledger.reload() })
.catch((err: Error) => toast.err(err.message))
}}
/>
)}
{cms.error !== undefined && <ErrorState message={cms.error} onRetry={cms.reload} />}
{cms.data === undefined && cms.error === undefined ? <Skeleton />
: (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={toast.err} />,
installed: <RowDate cm={cm} field="installedOn" onPatched={() => cms.reload()} onError={toast.err} />,
completed: <RowDate cm={cm} field="completedOn" onPatched={() => cms.reload()} onError={toast.err} />,
trained: <RowDate cm={cm} field="trainedOn" onPatched={() => cms.reload()} onError={toast.err} />,
billed: paid !== undefined ? inr(paid.billedPaise) : '—',
settled: paid !== undefined ? inr(paid.settledPaise) : '—',
}
})}
/>
)}
{(cms.data ?? []).length > 0 && <h3 style={{ marginTop: 20 }}>Service data</h3>}
{(cms.data ?? []).map((cm) => (
<Fragment key={cm.id}>
<ServiceDataCard
cm={cm}
name={moduleName(cm.moduleId)}
fieldSpec={modules.data?.find((m) => m.id === cm.moduleId)?.fieldSpec ?? []}
onChanged={() => cms.reload()}
/>
<MilestoneChecklist clientModuleId={cm.id} name={moduleName(cm.moduleId)} />
</Fragment>
))}
</>
)}
{tab === 'tickets' && (
<>
<Toolbar>
<Button tone="primary" onClick={() => setTicketDialog(true)}>New ticket</Button>
<span style={{ flex: 1 }} />
{tickets.data !== undefined && <Badge>{tickets.data.total} total</Badge>}
</Toolbar>
{tickets.error !== undefined && <ErrorState message={tickets.error} onRetry={tickets.reload} />}
{tickets.data === undefined && tickets.error === undefined ? <Skeleton />
: tickets.data !== undefined && tickets.data.tickets.length === 0
? <EmptyState>No tickets for this client.</EmptyState>
: tickets.data !== undefined && (
<>
<DataTable
columns={[
{ key: 'opened', label: 'Opened' }, { key: 'branch', label: 'Branch' },
{ key: 'module', label: 'Module', mono: true }, { key: 'kind', label: 'Kind' },
{ key: 'desc', label: 'Description' }, { key: 'assigned', label: 'Assigned' },
{ key: 'status', label: 'Status' }, { key: 'act', label: '' },
]}
rows={tickets.data.tickets.map((t) => ({
opened: t.openedOn,
branch: t.branchName ?? '—',
module: t.moduleCode ?? '—',
kind: t.kind !== '' ? t.kind : '—',
desc: <TicketDescription text={t.description} />,
assigned: t.assignedName ?? '—',
status: <Badge tone={TICKET_TONE[t.status]}>{TICKET_STATUS_LABEL[t.status]}</Badge>,
act: <TicketActions t={t} onDone={tickets.reload} onError={toast.err} />,
}))}
/>
{Math.ceil(tickets.data.total / tickets.data.pageSize) > 1 && (
<div className="wf-pager">
<span>{tickets.data.total} ticket(s)</span>
<Button onClick={() => { if (ticketPage > 1) setTicketPage((p) => p - 1) }}> Prev</Button>
<span>Page {tickets.data.page}/{Math.ceil(tickets.data.total / tickets.data.pageSize)}</span>
<Button onClick={() => { if (ticketPage < Math.ceil(tickets.data!.total / tickets.data!.pageSize)) setTicketPage((p) => p + 1) }}>Next </Button>
</div>
)}
</>
)}
<NewTicketDialog
open={ticketDialog}
onClose={() => setTicketDialog(false)}
onCreated={() => tickets.reload()}
kinds={tickets.data?.kinds ?? []}
initialClient={{ id: c.id, code: c.code, name: c.name }}
/>
</>
)}
{tab === 'documents' && (
<>
{ledger.error !== undefined && <ErrorState message={ledger.error} onRetry={ledger.reload} />}
{ledger.data === undefined && ledger.error === undefined ? <Skeleton />
: docs.length === 0 ? <EmptyState>No documents yet compose one from New Document.</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.map((d) => ({
no: d.docNo ?? <Badge tone="warn">draft</Badge>,
type: <Badge tone={DOC_TYPE_TONE[d.docType]}>{DOC_TYPE_LABEL[d.docType]}</Badge>,
date: d.docDate,
payable: inr(d.payablePaise),
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
}))}
/>
)}
</>
)}
{tab === 'payments' && (
<>
{ledger.data !== undefined && (
<Stats>
<StatCard label="Advance on account" value={formatINR(ledger.data.advancePaise)} hint="unallocated payments" />
</Stats>
)}
<PaymentForm clientId={id} onDone={() => { ledger.reload(); cms.reload(); amc.reload() }} />
{ledger.data !== undefined && ledger.data.payments.length > 0 && (
<DataTable
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 && (
<Toolbar>
<Button tone="primary" onClick={() => setPlanDialog({})}>New recurring plan</Button>
</Toolbar>
)}
{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: (
<div style={{ display: 'flex', gap: 6 }}>
{isOwner && <Button onClick={() => setPlanDialog({ initial: plan })}>Edit</Button>}
{plan.active && (
<Button tone="danger" onClick={() => setConfirm({
title: 'Deactivate recurring plan',
body: `Deactivate the ${plan.cadence} recurring plan${cm !== undefined ? ` for ${moduleName(cm.moduleId)}` : ''}?`,
label: 'Deactivate',
tone: 'danger',
run: () => deactivateRecurringPlan(plan.id).then(() => { toast.ok('Plan deactivated'); plans.reload() }),
})}>Deactivate</Button>
)}
</div>
),
}
})}
/>
)}
<PlanDialog
open={planDialog !== undefined}
onClose={() => 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 && (
<Toolbar>
<Button tone="primary" onClick={() => setAmcDialog({})}>New AMC</Button>
</Toolbar>
)}
{amc.error !== undefined && <ErrorState message={amc.error} onRetry={amc.reload} />}
{amc.data === undefined && amc.error === undefined ? <Skeleton />
: (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 }}>
{isOwner && <Button onClick={() => setAmcDialog({ initial: a })}>Edit</Button>}
{a.paidStatus !== 'unpaid' && (
<Button tone="primary" onClick={() => {
generateAmcRenewalInvoice(a.id)
.then((doc) => nav(`/documents/${doc.id}`)).catch((err: Error) => toast.err(err.message))
}}>Generate renewal invoice</Button>
)}
{a.active && (
<Button tone="danger" onClick={() => setConfirm({
title: 'Deactivate AMC contract',
body: `Deactivate the AMC contract${a.coverage !== '' ? ` "${a.coverage}"` : ''} (${a.periodFrom}${a.periodTo})? This does not affect existing invoices.`,
label: 'Deactivate',
tone: 'danger',
run: () => deactivateAmc(a.id).then(() => { toast.ok('AMC deactivated'); amc.reload() }),
})}>Deactivate</Button>
)}
</div>
),
}))}
/>
)}
<AmcDialog
open={amcDialog !== undefined}
onClose={() => setAmcDialog(undefined)}
clientId={id}
initial={amcDialog?.initial}
onDone={() => amc.reload()}
/>
</>
)}
{tab === 'interactions' && (
<>
<Toolbar>
<Button tone="primary" onClick={() => setInteractionDialog({})}>Log interaction</Button>
</Toolbar>
{interactions.error !== undefined && <ErrorState message={interactions.error} onRetry={interactions.reload} />}
{interactions.data === undefined && interactions.error === undefined ? <Skeleton />
: (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' }, { key: 'act', label: '' },
]}
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) => {
updateInteraction(i.id, { followUpOn: e.target.value !== '' ? e.target.value : null })
.then(() => interactions.reload()).catch((err: Error) => toast.err(err.message))
}}
/>
),
act: <Button onClick={() => setInteractionDialog({ initial: i })}>Edit</Button>,
}))}
/>
)}
<InteractionDialog
open={interactionDialog !== undefined}
onClose={() => setInteractionDialog(undefined)}
clientId={id}
types={types.data ?? []}
initial={interactionDialog?.initial}
onDone={() => interactions.reload()}
/>
</>
)}
{tab === 'aws' && (
<>
{awsUsage.error !== undefined && <ErrorState message={awsUsage.error} onRetry={awsUsage.reload} />}
{awsUsage.data === undefined && awsUsage.error === undefined ? <Skeleton />
: (awsUsage.data ?? []).length === 0 ? <EmptyState>No AWS usage recorded for this client.</EmptyState> : (
<DataTable
columns={[
{ key: 'month', label: 'Month', mono: true }, { 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,
}))}
/>
)}
</>
)}
{confirm !== undefined && (
<ConfirmDialog
open
onClose={() => setConfirm(undefined)}
onConfirm={confirm.run}
title={confirm.title}
body={confirm.body}
confirmLabel={confirm.label}
{...(confirm.tone !== undefined ? { tone: confirm.tone } : {})}
/>
)}
{statementOpen && (
<ClientStatement client={c} ledger={ledger.data} onClose={() => setStatementOpen(false)} />
)}
</div>
)
}
/**
* Printable account statement (D26). A full-screen overlay that reads like paper on
* screen in either theme; a `@media print` block (app.css) isolates `.statement-print`
* so only the statement — not the app chrome — hits the page. The ledger of invoices,
* credit notes and payments is ordered by date with a running balance; the closing
* balance and the unallocated advance both surface. Company header from the profile.
*/
function ClientStatement(props: { client: Client; ledger?: Ledger; onClose: () => void }) {
const company = useData(getCompanyProfile, [])
const co = company.data
const coName = co?.['company.name'] ?? 'SiMS HQ'
const coAddr = co?.['company.address'] ?? ''
const coGstin = co?.['company.gstin'] ?? ''
const led = props.ledger
const statement = led !== undefined ? buildStatement(led.documents, led.payments) : undefined
const printedOn = new Date().toISOString().slice(0, 10)
const balanceLabel = (paise: number): string =>
paise > 0 ? `${formatINR(paise)} Dr` : paise < 0 ? `${formatINR(-paise)} Cr` : formatINR(0)
return (
<div className="statement-overlay" role="dialog" aria-modal="true" aria-label="Client statement">
<div className="statement-toolbar no-print">
<Button onClick={props.onClose}>Close</Button>
<Button tone="primary" onClick={() => window.print()}>Print</Button>
</div>
<div className="statement-print">
<div className="statement-head">
<div>
<div className="statement-co">{coName}</div>
{coAddr !== '' && (
<div className="statement-co-meta" style={{ whiteSpace: 'pre-wrap' }}>{coAddr}</div>
)}
{coGstin !== '' && (
<div className="statement-co-meta">GSTIN {coGstin}</div>
)}
</div>
<div style={{ textAlign: 'right' }}>
<div className="statement-title">Statement of Account</div>
<div className="statement-co-meta">As on {printedOn}</div>
</div>
</div>
<div className="statement-client">
<div><span className="statement-lbl">Client</span> <strong>{props.client.name}</strong></div>
<div><span className="statement-lbl">Code</span> <span className="mono">{props.client.code}</span></div>
{props.client.gstin !== undefined && props.client.gstin !== '' && (
<div><span className="statement-lbl">GSTIN</span> <span className="mono">{props.client.gstin}</span></div>
)}
</div>
{statement === undefined ? <Skeleton rows={5} />
: statement.rows.length === 0 ? <EmptyState>No invoices, credit notes or payments on record.</EmptyState>
: (
<table className="statement-table">
<thead>
<tr>
<th>Date</th><th>Particulars</th><th>Reference</th>
<th className="num">Debit</th><th className="num">Credit</th><th className="num">Balance</th>
</tr>
</thead>
<tbody>
{statement.rows.map((r, i) => (
<tr key={i}>
<td className="mono">{r.date}</td>
<td>{r.particulars}</td>
<td className="mono">{r.ref !== '' ? r.ref : '—'}</td>
<td className="num">{r.debitPaise > 0 ? formatINR(r.debitPaise) : ''}</td>
<td className="num">{r.creditPaise > 0 ? formatINR(r.creditPaise) : ''}</td>
<td className="num">{balanceLabel(r.balancePaise)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={3}><strong>Totals</strong></td>
<td className="num"><strong>{formatINR(statement.totalDebitPaise)}</strong></td>
<td className="num"><strong>{formatINR(statement.totalCreditPaise)}</strong></td>
<td className="num"><strong>{balanceLabel(statement.closingPaise)}</strong></td>
</tr>
</tfoot>
</table>
)}
{statement !== undefined && (
<div className="statement-summary">
<div>
<span className="statement-lbl">Closing balance</span>
<strong>{balanceLabel(statement.closingPaise)}</strong>
</div>
{led !== undefined && (
<div>
<span className="statement-lbl">Advance on account</span>
<strong>{formatINR(led.advancePaise)}</strong>
</div>
)}
</div>
)}
<div className="statement-note">
Dr = amount owed to us · Cr = client in credit. Reflects issued invoices, credit
notes and recorded payments. This is a computer-generated statement.
</div>
</div>
</div>
)
}
/** Contact-role → display label (spec §5 typed contacts). Office is the default for
* a blank/'office'/'primary' role; a custom role shows verbatim. */
function contactRoleLabel(role: string | undefined): string {
if (role === 'secretary') return 'Secretary'
if (role === 'whatsapp') return 'WhatsApp'
if (role === undefined || role === '' || role === 'office' || role === 'primary') return 'Office'
return role
}
/**
* Client core-details card (Overview tab): identity, full address and a sectioned,
* role-labelled contact list — each with mailto:/tel: links — so the whole record
* reads at a glance instead of only contacts[0] inline in the header. Reuses the
* wf-card surface and the file's micro-label styling; no dedicated CSS.
*/
function ClientDetailsCard(props: { client: Client }) {
const c = props.client
const micro: CSSProperties = { fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }
const field = (label: string, value: ReactNode, mono = false) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, minWidth: 130 }}>
<span style={micro}>{label}</span>
<span className={mono ? 'mono' : undefined}>{value}</span>
</div>
)
return (
<div className="wf-card">
<h3 style={{ margin: '0 0 12px' }}>Details</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '14px 28px' }}>
{field('Name', c.name)}
{field('Code', c.code, true)}
{c.gstin !== undefined && c.gstin !== '' && field('GSTIN', c.gstin, true)}
{field('Status', <Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>)}
{field('State', c.stateCode)}
</div>
{c.address !== '' && (
<div style={{ marginTop: 14 }}>
<span style={micro}>Address</span>
<div style={{ marginTop: 3, whiteSpace: 'pre-wrap' }}>{c.address}</div>
</div>
)}
{c.contacts.length > 0 && (
<>
<div style={{ borderTop: '1px solid var(--border)', margin: '14px 0 12px' }} />
<span style={micro}>Contacts</span>
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
{c.contacts.map((ct, i) => (
<div key={i} style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: '4px 14px' }}>
<span style={{ ...micro, minWidth: 84 }}>{contactRoleLabel(ct.role)}</span>
{ct.name !== undefined && ct.name !== '' && <span style={{ fontWeight: 500 }}>{ct.name}</span>}
{ct.email !== undefined && ct.email !== '' && <a href={`mailto:${ct.email}`}>{ct.email}</a>}
{ct.phone !== undefined && ct.phone !== '' && <a href={`tel:${ct.phone}`} className="mono">{ct.phone}</a>}
{(ct.name === undefined || ct.name === '') && (ct.email === undefined || ct.email === '')
&& (ct.phone === undefined || ct.phone === '') && <span style={{ opacity: 0.6 }}></span>}
</div>
))}
</div>
</>
)}
</div>
)
}
/**
* 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<string | undefined>()
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) => (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6, marginRight: 18 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>{label}</span>
<span className={mono ? 'mono' : undefined}>{value ?? '—'}</span>
{copyable && value !== undefined && <Button onClick={() => copy(value, label)}>Copy</Button>}
</span>
)
return (
<div className="wf-card" style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8, padding: '10px 14px' }}>
{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 (
<span key={i} style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6, marginRight: 18 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>
{ct.role === 'whatsapp' ? 'WhatsApp' : 'Secretary'}
</span>
<span>{[ct.name, num].filter((x) => x !== undefined && x !== '').join(' · ')}</span>
{num !== undefined && num !== '' && (
<Button onClick={() => copy(num, ct.role === 'whatsapp' ? 'WhatsApp number' : 'Secretary contact')}>Copy</Button>
)}
</span>
)
})}
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>DB password</span>
{revealed !== undefined
? <><span className="mono">{revealed}</span><Button onClick={() => copy(revealed, 'DB password')}>Copy</Button><Button onClick={() => setRevealed(undefined)}>Hide</Button></>
: c.hasDbPassword
? <><span className="mono"></span>{managerial && <Button onClick={reveal}>{busy ? '…' : 'Reveal'}</Button>}</>
: <span style={{ opacity: 0.6 }}>not set</span>}
{managerial && !settingPw && <Button onClick={() => setSettingPw(true)}>{c.hasDbPassword ? 'Update' : 'Set…'}</Button>}
{managerial && settingPw && (
<>
<input className="wf mono" type="password" style={{ width: 160 }} placeholder="DB password" aria-label="DB password"
value={pw} onChange={(e) => setPw(e.target.value)} />
<Button tone="primary" onClick={savePw}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => { setSettingPw(false); setPw('') }}>Cancel</Button>
</>
)}
</span>
</div>
)
}
/**
* Branch book (D20 — APEX ho_branch_list parity): list + add + inline rename /
* deactivate. Branches are never deleted (tickets reference them) — deactivating
* hides them from pickers while history keeps its name.
*/
function BranchList(props: { clientId: string; branches: Branch[]; onChanged: () => void }) {
const toast = useToast()
const [name, setName] = useState('')
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [edit, setEdit] = useState<{ id: string; name: string; code: string } | undefined>()
const add = () => {
if (busy || name.trim() === '') return
setBusy(true)
createBranch(props.clientId, { name, ...(code.trim() !== '' ? { code } : {}) })
.then(() => { toast.ok('Branch added'); setName(''); setCode(''); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const saveEdit = () => {
if (edit === undefined || busy) return
setBusy(true)
patchBranch(edit.id, { name: edit.name, code: edit.code })
.then(() => { toast.ok('Branch saved'); setEdit(undefined); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const setActive = (id: string, active: boolean) => {
if (busy) return
setBusy(true)
patchBranch(id, { active })
.then(() => { toast.ok(active ? 'Branch reactivated' : 'Branch deactivated'); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
return (
<>
<Toolbar>
<input
className="wf" style={{ maxWidth: 220 }} placeholder="Branch name" aria-label="Branch name"
value={name} onChange={(e) => setName(e.target.value)}
/>
<input
className="wf" style={{ maxWidth: 120 }} placeholder="Code (optional)" aria-label="Branch code"
value={code} onChange={(e) => setCode(e.target.value)}
/>
<Button tone="primary" disabled={busy || name.trim() === ''} onClick={add}>Add branch</Button>
</Toolbar>
{props.branches.length === 0 ? <EmptyState>No branches head office only.</EmptyState> : (
<DataTable
columns={[
{ key: 'name', label: 'Name' }, { key: 'code', label: 'Code', mono: true },
{ key: 'active', label: 'Active' }, { key: 'act', label: '' },
]}
rows={props.branches.map((b) => {
const editing = edit !== undefined && edit.id === b.id
return {
name: editing
? <input className="wf" aria-label="Name" value={edit.name} onChange={(e) => setEdit((p) => p && ({ ...p, name: e.target.value }))} />
: b.name,
code: editing
? <input className="wf" aria-label="Code" style={{ width: 100 }} value={edit.code} onChange={(e) => setEdit((p) => p && ({ ...p, code: e.target.value }))} />
: (b.code ?? '—'),
active: b.active ? <Badge tone="ok">active</Badge> : <Badge>inactive</Badge>,
act: editing
? (
<span style={{ display: 'flex', gap: 6 }}>
<Button tone="primary" disabled={busy} onClick={saveEdit}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => setEdit(undefined)}>Cancel</Button>
</span>
)
: (
<span style={{ display: 'flex', gap: 6 }}>
<Button onClick={() => setEdit({ id: b.id, name: b.name, code: b.code ?? '' })}>Rename</Button>
{b.active
? <Button tone="danger" disabled={busy} onClick={() => setActive(b.id, false)}>Deactivate</Button>
: <Button disabled={busy} onClick={() => setActive(b.id, true)}>Reactivate</Button>}
</span>
),
}
})}
/>
)}
</>
)
}
/**
* Per-service operational data (D20 — APEX sms_clients_list parity), following
* SupportCard's exact visual pattern: label/value cells, the portal password as
* •••• with managerial audited reveal (gated like the DB password), and Edit
* switching provider/username/remark + the labeled details into inputs — one
* audited PATCH saves the lot.
*/
function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: FieldDef[]; onChanged: () => void }) {
const toast = useToast()
const managerial = isManagerial()
const cm = props.cm
const [revealed, setRevealed] = useState<string | undefined>()
const [settingPw, setSettingPw] = useState(false)
const [pw, setPw] = useState('')
const [busy, setBusy] = useState(false)
const [editing, setEditing] = useState(false)
const [f, setF] = useState({ provider: '', username: '', remark: '' })
const [details, setDetails] = useState<ServiceDetail[]>([])
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)
revealModulePassword(cm.id)
.then((p) => setRevealed(p))
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const savePw = () => {
if (busy || pw === '') return
setBusy(true)
patchClientModule(cm.id, { password: pw })
.then(() => { toast.ok('Portal password stored (encrypted)'); setSettingPw(false); setPw(''); setRevealed(undefined); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const startEdit = () => {
setF({ provider: cm.provider ?? '', username: cm.username ?? '', remark: cm.remark ?? '' })
setDetails(cm.details.map((d) => ({ ...d })))
setEditing(true)
}
const saveEdit = () => {
if (busy) return
const cleaned = details.filter((d) => d.label.trim() !== '' || d.value.trim() !== '')
if (cleaned.some((d) => d.label.trim() === '')) { toast.err('Every detail needs a label'); return }
setBusy(true)
patchClientModule(cm.id, {
provider: f.provider, username: f.username, remark: f.remark,
details: cleaned.map((d) => ({ label: d.label.trim(), value: d.value })),
})
.then(() => { toast.ok('Service data saved'); setEditing(false); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const cell = (label: string, value: string | undefined, mono = false, copyable = false) => (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6, marginRight: 18 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>{label}</span>
<span className={mono ? 'mono' : undefined}>{value ?? '—'}</span>
{copyable && value !== undefined && <Button onClick={() => copy(value, label)}>Copy</Button>}
</span>
)
if (editing) {
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{props.name} service data</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
<Field label="Provider"><input className="wf" style={{ width: 140 }} value={f.provider} onChange={(e) => setF((p) => ({ ...p, provider: e.target.value }))} /></Field>
<Field label="Username"><input className="wf mono" style={{ width: 160 }} value={f.username} onChange={(e) => setF((p) => ({ ...p, username: e.target.value }))} /></Field>
<Field label="Remark"><input className="wf" style={{ width: 220 }} value={f.remark} onChange={(e) => setF((p) => ({ ...p, remark: e.target.value }))} /></Field>
</div>
{details.map((d, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<input
className="wf" placeholder="Label" aria-label="Detail label" style={{ maxWidth: 180 }} value={d.label}
onChange={(e) => setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))}
/>
<input
className="wf" placeholder="Value" aria-label="Detail value" value={d.value}
onChange={(e) => setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)))}
/>
<Button aria-label="Remove detail" onClick={() => setDetails((prev) => prev.filter((_x, j) => j !== i))}></Button>
</div>
))}
<div style={{ display: 'flex', gap: 6 }}>
<Button onClick={() => setDetails((prev) => [...prev, { label: '', value: '' }])}>+ Add detail</Button>
<span style={{ flex: 1 }} />
<Button tone="primary" disabled={busy} onClick={saveEdit}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => setEditing(false)}>Cancel</Button>
</div>
</div>
)
}
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<span style={{ fontWeight: 600, marginRight: 10 }}>{props.name}</span>
{cell('Provider', cm.provider ?? undefined)}
{cell('Username', cm.username ?? undefined, true, true)}
{cm.details.map((d, i) => <Fragment key={i}>{cell(d.label, d.value)}</Fragment>)}
{cell('Remark', cm.remark ?? undefined)}
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>Portal password</span>
{revealed !== undefined
? <><span className="mono">{revealed}</span><Button onClick={() => copy(revealed, 'Portal password')}>Copy</Button><Button onClick={() => setRevealed(undefined)}>Hide</Button></>
: cm.hasPassword
? <><span className="mono"></span>{managerial && <Button onClick={reveal}>{busy ? '…' : 'Reveal'}</Button>}</>
: <span style={{ opacity: 0.6 }}>not set</span>}
{managerial && !settingPw && <Button onClick={() => setSettingPw(true)}>{cm.hasPassword ? 'Update' : 'Set…'}</Button>}
{managerial && settingPw && (
<>
<input className="wf mono" type="password" style={{ width: 160 }} placeholder="Portal password" aria-label="Portal password"
value={pw} onChange={(e) => setPw(e.target.value)} />
<Button tone="primary" onClick={savePw}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => { setSettingPw(false); setPw('') }}>Cancel</Button>
</>
)}
</span>
<span style={{ flex: 1 }} />
<Button onClick={startEdit}>Edit</Button>
</div>
{/* D21: dynamic form from the owning module's fieldSpec, below the D20 block. */}
{props.fieldSpec.length > 0 && (
<>
<div style={{ borderTop: '1px solid var(--border)', margin: '12px 0 10px' }} />
<ModuleFieldsForm cm={cm} fieldSpec={props.fieldSpec} onChanged={props.onChanged} />
</>
)}
</div>
)
}
/**
* D21 module-defined fields (config over code): renders one input per field the owning
* module DECLARES in its fieldSpec. Non-secret edits collect into a single audited PUT
* (setModuleFields); secret fields never leave the server unrevealed — •••• with a
* managerial audited Reveal + Set/Update (setModuleSecret), exactly like the portal
* password. Values come from cm.fieldValues; secret set-state from cm.secretKeys.
*/
function ModuleFieldsForm(props: { cm: ClientModule; fieldSpec: FieldDef[]; onChanged: () => void }) {
const toast = useToast()
const managerial = isManagerial()
const cm = props.cm
const sorted = [...props.fieldSpec].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
const nonSecret = sorted.filter((f) => f.type !== 'secret')
const secrets = sorted.filter((f) => f.type === 'secret')
const seed = (): Record<string, string> =>
Object.fromEntries(nonSecret.map((f) => [f.key, cm.fieldValues[f.key] ?? '']))
const [draft, setDraft] = useState<Record<string, string>>(seed)
const [busy, setBusy] = useState(false)
const dirty = nonSecret.some((f) => (draft[f.key] ?? '') !== (cm.fieldValues[f.key] ?? ''))
const missingRequired = nonSecret.find((f) => f.required === true && (draft[f.key] ?? '').trim() === '')
const save = () => {
if (busy) return
if (missingRequired !== undefined) { toast.err(`${missingRequired.label} is required`); return }
setBusy(true)
// Send only the changed keys; '' clears server-side.
const values = Object.fromEntries(
nonSecret
.filter((f) => (draft[f.key] ?? '') !== (cm.fieldValues[f.key] ?? ''))
.map((f) => [f.key, draft[f.key] ?? '']),
)
setModuleFields(cm.id, values)
.then((updated) => {
toast.ok('Fields saved')
setDraft(Object.fromEntries(nonSecret.map((f) => [f.key, updated.fieldValues[f.key] ?? ''])))
props.onChanged()
})
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const control = (f: FieldDef) => {
const v = draft[f.key] ?? ''
const set = (val: string) => setDraft((p) => ({ ...p, [f.key]: val }))
if (f.type === 'boolean') {
return (
<select className="wf" style={{ width: 120 }} value={v} onChange={(e) => set(e.target.value)}>
<option value=""></option>
<option value="true">true</option>
<option value="false">false</option>
</select>
)
}
if (f.type === 'select') {
return (
<select className="wf" style={{ width: 180 }} value={v} onChange={(e) => set(e.target.value)}>
<option value=""></option>
{(f.options ?? []).map((o) => <option key={o} value={o}>{o}</option>)}
</select>
)
}
if (f.type === 'number') {
return <input className="wf num" type="number" style={{ width: 140 }} value={v} onChange={(e) => set(e.target.value)} />
}
if (f.type === 'date') {
return <input className="wf" type="date" style={{ width: 160 }} value={v} onChange={(e) => set(e.target.value)} />
}
return <input className="wf" style={{ width: 200 }} value={v} onChange={(e) => set(e.target.value)} />
}
return (
<div>
{nonSecret.length > 0 && (
<>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'flex-end' }}>
{nonSecret.map((f) => {
// A field holding an http(s) value gets a direct "Open ↗" link (portal/console shortcut).
const val = draft[f.key] ?? ''
const isUrl = /^https?:\/\//i.test(val.trim())
return (
<Field key={f.key} label={f.required === true ? `${f.label} *` : f.label}>
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
{control(f)}
{isUrl && (
<a href={val.trim()} target="_blank" rel="noopener noreferrer"
title="Open in a new tab" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>Open </a>
)}
</span>
</Field>
)
})}
</div>
<Toolbar>
<Button tone="primary" disabled={busy || !dirty} onClick={save}>{busy ? 'Saving…' : 'Save fields'}</Button>
</Toolbar>
</>
)}
{secrets.map((f) => (
<SecretField key={f.key} cm={cm} field={f} managerial={managerial} onChanged={props.onChanged} />
))}
</div>
)
}
/** One `secret` module field: •••• with managerial audited Reveal/Copy/Hide + Set/Update. */
function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boolean; onChanged: () => void }) {
const toast = useToast()
const { cm, field, managerial } = props
const has = cm.secretKeys.includes(field.key)
const [revealed, setRevealed] = useState<string | undefined>()
const [setting, setSetting] = useState(false)
const [val, setVal] = useState('')
const [busy, setBusy] = useState(false)
const copy = (text: string) => {
navigator.clipboard.writeText(text).then(() => toast.ok(`${field.label} copied`)).catch(() => toast.err('Copy failed'))
}
const reveal = () => {
if (busy) return
setBusy(true)
revealModuleSecret(cm.id, field.key)
.then((v) => setRevealed(v))
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const save = () => {
if (busy) return
setBusy(true)
setModuleSecret(cm.id, field.key, val)
.then(() => { toast.ok(`${field.label} stored (encrypted)`); setSetting(false); setVal(''); setRevealed(undefined); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
return (
<div style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6, marginTop: 8, marginRight: 18 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>
{field.required === true ? `${field.label} *` : field.label}
</span>
{revealed !== undefined
? <><span className="mono">{revealed}</span><Button onClick={() => copy(revealed)}>Copy</Button><Button onClick={() => setRevealed(undefined)}>Hide</Button></>
: has
? <><span className="mono"></span>{managerial && <Button onClick={reveal}>{busy ? '…' : 'Reveal'}</Button>}</>
: <span style={{ opacity: 0.6 }}>not set</span>}
{managerial && !setting && <Button onClick={() => setSetting(true)}>{has ? 'Update' : 'Set…'}</Button>}
{managerial && setting && (
<>
<input className="wf mono" type="password" style={{ width: 160 }} placeholder={field.label} aria-label={field.label}
value={val} onChange={(e) => setVal(e.target.value)} />
<Button tone="primary" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => { setSetting(false); setVal('') }}>Cancel</Button>
</>
)}
</div>
)
}
/**
* Onboarding checklist (D22 — per-project detail): the project's milestones as a row
* of toggles. Ticking one stamps the date (a compact date input adjusts it; toggling
* on with no date uses today); "+ Add step" appends a project-specific milestone. Every
* change is one audited POST that returns the fresh list, so the card re-renders in place.
*/
function MilestoneChecklist(props: { clientModuleId: string; name: string }) {
const toast = useToast()
const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId])
const [busyKey, setBusyKey] = useState('')
const [adding, setAdding] = useState(false)
const [label, setLabel] = useState('')
const [addBusy, setAddBusy] = useState(false)
const toggle = (m: Milestone, done: boolean, doneOn?: string) => {
if (busyKey !== '') return
setBusyKey(m.key)
setMilestone(props.clientModuleId, m.key, done, doneOn)
.then(() => list.reload())
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusyKey(''))
}
const addStep = () => {
if (addBusy || label.trim() === '') return
setAddBusy(true)
addMilestone(props.clientModuleId, label.trim())
.then(() => { toast.ok('Step added'); setLabel(''); setAdding(false); list.reload() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setAddBusy(false))
}
const ms = list.data
const done = ms?.filter((m) => m.done).length ?? 0
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{ fontWeight: 600 }}>{props.name} onboarding</span>
{ms !== undefined && ms.length > 0 && (
<Badge tone={done === ms.length ? 'ok' : undefined}>{done}/{ms.length}</Badge>
)}
</div>
{list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
: ms === undefined ? <Skeleton rows={2} />
: ms.length === 0 ? <EmptyState>No steps yet.</EmptyState> : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{ms.map((m) => (
<div
key={m.key}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
border: '1px solid var(--border)', borderRadius: 6, padding: '4px 10px',
opacity: busyKey === m.key ? 0.6 : 1,
}}
>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="checkbox" checked={m.done} disabled={busyKey === m.key}
onChange={(e) => toggle(m, e.target.checked)}
/>
<span style={m.done ? { textDecoration: 'line-through', opacity: 0.7 } : undefined}>{m.label}</span>
</label>
{m.done && (
<>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>on</span>
<input
type="date" className="wf" style={{ width: 140 }} value={m.doneOn ?? ''}
disabled={busyKey === m.key}
onChange={(e) => toggle(m, true, e.target.value !== '' ? e.target.value : undefined)}
/>
</>
)}
</div>
))}
</div>
)}
<Toolbar>
{!adding
? <Button onClick={() => setAdding(true)}>+ Add step</Button>
: (
<>
<input
className="wf" style={{ maxWidth: 240 }} placeholder="Step label" aria-label="New step label"
value={label} onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addStep() }}
/>
<Button tone="primary" disabled={addBusy || label.trim() === ''} onClick={addStep}>{addBusy ? 'Adding…' : 'Add'}</Button>
<Button onClick={() => { setAdding(false); setLabel('') }}>Cancel</Button>
</>
)}
</Toolbar>
</div>
)
}