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 = { paid: 'ok', unpaid: 'err', unbilled: 'warn' } const OUTCOME_TONE: Record = { 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 } /** 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() 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 if (c === undefined) return
const moduleName = (moduleId: string) => modules.data?.find((m) => m.id === moduleId)?.name ?? moduleId const docs = ledger.data?.documents ?? [] const openDocs = docs.filter((d) => d.status === 'draft' || d.status === 'sent' || d.status === 'part_paid') const activeModules = (cms.data ?? []).filter((m) => m.active) const lastInteraction = interactions.data?.[0]?.onDate const todayIso = new Date().toISOString().slice(0, 10) const DOC_PULSE_TONE: Record = { 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 (
Clients/{c.code}
{c.status}} meta={ <> {c.code} · State {c.stateCode} {c.gstin !== undefined && c.gstin !== '' && · {c.gstin}} {c.contacts[0] !== undefined && ( · {[c.contacts[0].name, c.contacts[0].email, c.contacts[0].phone].filter((x) => x !== undefined && x !== '').join(' · ')} )} } actions={ <> } /> {canRoute && employees.data !== undefined && ( { setClientOwner(id, ownerId) .then(() => { toast.ok('Owner updated'); client.reload() }) .catch((err: Error) => toast.err(err.message)) }} /> )} client.reload()} /> setEditing(false)} initial={c} onSaved={() => client.reload()} /> {tab === 'overview' && ( <>

Recent documents

{docs.length === 0 ? No documents yet. : ( nav(`/documents/${docs[i]!.id}`)} rows={docs.slice(0, 5).map((d) => ({ no: d.docNo ?? 'draft', type: {DOC_TYPE_LABEL[d.docType]}, date: d.docDate, payable: inr(d.payablePaise), status: {d.status}, }))} /> )} {docs.length > 5 && ( )}

Branches

{branches.error !== undefined && } {branches.data === undefined && branches.error === undefined ? : branches.data !== undefined && ( )} )} {tab === 'modules' && ( <> {modules.data !== undefined && ( { assignClientModule(id, { moduleId, kind }) .then(() => { cms.reload(); ledger.reload() }) .catch((err: Error) => toast.err(err.message)) }} /> )} {cms.error !== undefined && } {cms.data === undefined && cms.error === undefined ? : (cms.data ?? []).length === 0 ? No modules assigned yet. : ( { const paid = ledger.data?.modulePaid.find((mp) => mp.moduleId === cm.moduleId) return { module: moduleName(cm.moduleId), kind: KIND_LABEL[cm.kind], status: cms.reload()} onError={toast.err} />, installed: cms.reload()} onError={toast.err} />, completed: cms.reload()} onError={toast.err} />, trained: cms.reload()} onError={toast.err} />, billed: paid !== undefined ? inr(paid.billedPaise) : '—', settled: paid !== undefined ? inr(paid.settledPaise) : '—', } })} /> )} {(cms.data ?? []).length > 0 &&

Service data

} {(cms.data ?? []).map((cm) => ( m.id === cm.moduleId)?.fieldSpec ?? []} onChanged={() => cms.reload()} /> ))} )} {tab === 'tickets' && ( <> {tickets.data !== undefined && {tickets.data.total} total} {tickets.error !== undefined && } {tickets.data === undefined && tickets.error === undefined ? : tickets.data !== undefined && tickets.data.tickets.length === 0 ? No tickets for this client. : tickets.data !== undefined && ( <> ({ opened: t.openedOn, branch: t.branchName ?? '—', module: t.moduleCode ?? '—', kind: t.kind !== '' ? t.kind : '—', desc: , assigned: t.assignedName ?? '—', status: {TICKET_STATUS_LABEL[t.status]}, act: , }))} /> {Math.ceil(tickets.data.total / tickets.data.pageSize) > 1 && (
{tickets.data.total} ticket(s) Page {tickets.data.page}/{Math.ceil(tickets.data.total / tickets.data.pageSize)}
)} )} setTicketDialog(false)} onCreated={() => tickets.reload()} kinds={tickets.data?.kinds ?? []} initialClient={{ id: c.id, code: c.code, name: c.name }} /> )} {tab === 'documents' && ( <> {ledger.error !== undefined && } {ledger.data === undefined && ledger.error === undefined ? : docs.length === 0 ? No documents yet — compose one from New Document. : ( nav(`/documents/${docs[i]!.id}`)} rows={docs.map((d) => ({ no: d.docNo ?? draft, type: {DOC_TYPE_LABEL[d.docType]}, date: d.docDate, payable: inr(d.payablePaise), status: {d.status}, }))} /> )} )} {tab === 'payments' && ( <> {ledger.data !== undefined && ( )} { ledger.reload(); cms.reload(); amc.reload() }} /> {ledger.data !== undefined && ledger.data.payments.length > 0 && ( ({ on: p.receivedOn, mode: p.mode, ref: p.reference !== '' ? p.reference : '—', amount: inr(p.amountPaise), tds: p.tdsPaise > 0 ? inr(p.tdsPaise) : '—', }))} /> )}

Recurring plans

{isOwner && cms.data !== undefined && ( )} {plans.error !== undefined && } {plans.data === undefined && plans.error === undefined ? : (plans.data ?? []).length === 0 ? No recurring plans. : ( { const cm = cms.data?.find((x) => x.id === plan.clientModuleId) return { module: cm !== undefined ? moduleName(cm.moduleId) : '—', cadence: plan.cadence, amount: plan.amountPaise !== null ? inr(plan.amountPaise) : 'price book', next: plan.nextRun, policy: {plan.policy}, active: plan.active ? 'yes' : 'no', act: (
{isOwner && } {plan.active && ( )}
), } })} /> )} 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 && ( )} {amc.error !== undefined && } {amc.data === undefined && amc.error === undefined ? : (amc.data ?? []).length === 0 ? No AMC contracts. : ( ({ coverage: a.coverage !== '' ? a.coverage : '—', period: `${a.periodFrom} → ${a.periodTo}`, amount: inr(a.amountPaise), days: a.renewalReminderDays, paid: {a.paidStatus}, act: (
{isOwner && } {a.paidStatus !== 'unpaid' && ( )} {a.active && ( )}
), }))} /> )} setAmcDialog(undefined)} clientId={id} initial={amcDialog?.initial} onDone={() => amc.reload()} /> )} {tab === 'interactions' && ( <> {interactions.error !== undefined && } {interactions.data === undefined && interactions.error === undefined ? : (interactions.data ?? []).length === 0 ? No interactions logged yet. : ( ({ on: i.onDate, type: types.data?.find((t) => t.code === i.typeCode)?.label ?? i.typeCode, notes: i.notes !== '' ? i.notes : '—', outcome: i.outcome !== null ? {i.outcome} : '—', follow: ( { updateInteraction(i.id, { followUpOn: e.target.value !== '' ? e.target.value : null }) .then(() => interactions.reload()).catch((err: Error) => toast.err(err.message)) }} /> ), act: , }))} /> )} setInteractionDialog(undefined)} clientId={id} types={types.data ?? []} initial={interactionDialog?.initial} onDone={() => interactions.reload()} /> )} {tab === 'aws' && ( <> {awsUsage.error !== undefined && } {awsUsage.data === undefined && awsUsage.error === undefined ? : (awsUsage.data ?? []).length === 0 ? No AWS usage recorded for this client. : ( ({ month: u.month, storage: u.storageGb, transfer: u.transferGb, cost: inr(u.costPaise), source: u.source, }))} /> )} )} {confirm !== undefined && ( setConfirm(undefined)} onConfirm={confirm.run} title={confirm.title} body={confirm.body} confirmLabel={confirm.label} {...(confirm.tone !== undefined ? { tone: confirm.tone } : {})} /> )} {statementOpen && ( setStatementOpen(false)} /> )}
) } /** * 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 (
{coName}
{coAddr !== '' && (
{coAddr}
)} {coGstin !== '' && (
GSTIN {coGstin}
)}
Statement of Account
As on {printedOn}
Client {props.client.name}
Code {props.client.code}
{props.client.gstin !== undefined && props.client.gstin !== '' && (
GSTIN {props.client.gstin}
)}
{statement === undefined ? : statement.rows.length === 0 ? No invoices, credit notes or payments on record. : ( {statement.rows.map((r, i) => ( ))}
DateParticularsReference DebitCreditBalance
{r.date} {r.particulars} {r.ref !== '' ? r.ref : '—'} {r.debitPaise > 0 ? formatINR(r.debitPaise) : ''} {r.creditPaise > 0 ? formatINR(r.creditPaise) : ''} {balanceLabel(r.balancePaise)}
Totals {formatINR(statement.totalDebitPaise)} {formatINR(statement.totalCreditPaise)} {balanceLabel(statement.closingPaise)}
)} {statement !== undefined && (
Closing balance {balanceLabel(statement.closingPaise)}
{led !== undefined && (
Advance on account {formatINR(led.advancePaise)}
)}
)}
Dr = amount owed to us · Cr = client in credit. Reflects issued invoices, credit notes and recorded payments. This is a computer-generated statement.
) } /** 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) => (
{label} {value}
) return (

Details

{field('Name', c.name)} {field('Code', c.code, true)} {c.gstin !== undefined && c.gstin !== '' && field('GSTIN', c.gstin, true)} {field('Status', {c.status})} {field('State', c.stateCode)}
{c.address !== '' && (
Address
{c.address}
)} {c.contacts.length > 0 && ( <>
Contacts
{c.contacts.map((ct, i) => (
{contactRoleLabel(ct.role)} {ct.name !== undefined && ct.name !== '' && {ct.name}} {ct.email !== undefined && ct.email !== '' && {ct.email}} {ct.phone !== undefined && ct.phone !== '' && {ct.phone}} {(ct.name === undefined || ct.name === '') && (ct.email === undefined || ct.email === '') && (ct.phone === undefined || ct.phone === '') && }
))}
)}
) } /** * 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() 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) => ( {label} {value ?? '—'} {copyable && value !== undefined && } ) return (
{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 ( {ct.role === 'whatsapp' ? 'WhatsApp' : 'Secretary'} {[ct.name, num].filter((x) => x !== undefined && x !== '').join(' · ')} {num !== undefined && num !== '' && ( )} ) })} DB password {revealed !== undefined ? <>{revealed} : c.hasDbPassword ? <>••••••••{managerial && } : not set} {managerial && !settingPw && } {managerial && settingPw && ( <> setPw(e.target.value)} /> )}
) } /** * 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 ( <> setName(e.target.value)} /> setCode(e.target.value)} /> {props.branches.length === 0 ? No branches — head office only. : ( { const editing = edit !== undefined && edit.id === b.id return { name: editing ? setEdit((p) => p && ({ ...p, name: e.target.value }))} /> : b.name, code: editing ? setEdit((p) => p && ({ ...p, code: e.target.value }))} /> : (b.code ?? '—'), active: b.active ? active : inactive, act: editing ? ( ) : ( {b.active ? : } ), } })} /> )} ) } /** * 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() 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([]) 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) => ( {label} {value ?? '—'} {copyable && value !== undefined && } ) if (editing) { return (
{props.name} — service data
setF((p) => ({ ...p, provider: e.target.value }))} /> setF((p) => ({ ...p, username: e.target.value }))} /> setF((p) => ({ ...p, remark: e.target.value }))} />
{details.map((d, i) => (
setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} /> setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)))} />
))}
) } return (
{props.name} {cell('Provider', cm.provider ?? undefined)} {cell('Username', cm.username ?? undefined, true, true)} {cm.details.map((d, i) => {cell(d.label, d.value)})} {cell('Remark', cm.remark ?? undefined)} Portal password {revealed !== undefined ? <>{revealed} : cm.hasPassword ? <>••••••••{managerial && } : not set} {managerial && !settingPw && } {managerial && settingPw && ( <> setPw(e.target.value)} /> )}
{/* D21: dynamic form from the owning module's fieldSpec, below the D20 block. */} {props.fieldSpec.length > 0 && ( <>
)}
) } /** * 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 => Object.fromEntries(nonSecret.map((f) => [f.key, cm.fieldValues[f.key] ?? ''])) const [draft, setDraft] = useState>(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 ( ) } if (f.type === 'select') { return ( ) } if (f.type === 'number') { return set(e.target.value)} /> } if (f.type === 'date') { return set(e.target.value)} /> } return set(e.target.value)} /> } return (
{nonSecret.length > 0 && ( <>
{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 ( {control(f)} {isUrl && ( Open ↗ )} ) })}
)} {secrets.map((f) => ( ))}
) } /** 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() 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 (
{field.required === true ? `${field.label} *` : field.label} {revealed !== undefined ? <>{revealed} : has ? <>••••••••{managerial && } : not set} {managerial && !setting && } {managerial && setting && ( <> setVal(e.target.value)} /> )}
) } /** * 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 (
{props.name} — onboarding {ms !== undefined && ms.length > 0 && ( {done}/{ms.length} )}
{list.error !== undefined ? : ms === undefined ? : ms.length === 0 ? No steps yet. : (
{ms.map((m) => (
{m.done && ( <> on toggle(m, true, e.target.value !== '' ? e.target.value : undefined)} /> )}
))}
)} {!adding ? : ( <> setLabel(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') addStep() }} /> )}
) }