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

585 lines
28 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 { useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import {
Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, RecordHeader, Skeleton,
StatCard, Stats, Tabs, Toolbar, useToast,
} from '@sims/ui'
import {
assignClientModule, getClient, getClientModules, getLedger, getModules,
patchClient, revealDbPassword, role, isManagerial, getEmployees, setClientOwner,
getRecurringPlans, deactivateRecurringPlan,
getAmc, deactivateAmc, generateAmcRenewalInvoice,
getInteractions, getInteractionTypes, updateInteraction,
getClientAwsUsage,
CLIENT_STATUSES, KIND_LABEL,
type AmcContract, type AmcPaidStatus, type Client, type ClientStatus, type Interaction,
type Outcome, type RecurringPlan,
} from '../api'
import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
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', '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 isOwner = role() === 'owner'
const canRoute = isManagerial()
const toast = useToast()
const [actionErr, setActionErr] = useState<string | undefined>()
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 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) => {
setActionErr(undefined)
patchClient(id, { status: e.target.value as ClientStatus })
.then(() => { toast.ok('Status updated'); client.reload() })
.catch((err: Error) => setActionErr(err.message))
}}
>
{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 tone="primary" onClick={() => nav('/documents/new')}>New document</Button>
</>
}
/>
{canRoute && employees.data !== undefined && (
<Toolbar>
<AccountOwnerSelect
ownerId={c.ownerId}
employees={employees.data.employees}
onChange={(ownerId) => {
setActionErr(undefined)
setClientOwner(id, ownerId)
.then(() => { toast.ok('Owner updated'); client.reload() })
.catch((err: Error) => setActionErr(err.message))
}}
/>
</Toolbar>
)}
{actionErr !== undefined && <ErrorState message={actionErr} />}
<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: '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>
<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: d.docType, date: d.docDate,
payable: inr(d.payablePaise),
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
}))}
/>
)}
{docs.length > 5 && (
<Toolbar><Button onClick={() => setTab('documents')}>All {docs.length} documents </Button></Toolbar>
)}
</>
)}
{tab === 'modules' && (
<>
{modules.data !== undefined && (
<AssignModuleForm
modules={modules.data}
onAssign={(moduleId, kind) => {
setActionErr(undefined)
assignClientModule(id, { moduleId, kind })
.then(() => { cms.reload(); ledger.reload() })
.catch((err: Error) => setActionErr(err.message))
}}
/>
)}
{cms.error !== undefined && <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={setActionErr} />,
installed: <RowDate cm={cm} field="installedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
completed: <RowDate cm={cm} field="completedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
trained: <RowDate cm={cm} field="trainedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
billed: paid !== undefined ? inr(paid.billedPaise) : '—',
settled: paid !== undefined ? inr(paid.settledPaise) : '—',
}
})}
/>
)}
</>
)}
{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: d.docType, 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={() => {
setActionErr(undefined)
generateAmcRenewalInvoice(a.id)
.then((doc) => nav(`/documents/${doc.id}`)).catch((err: Error) => setActionErr(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) => {
setActionErr(undefined)
updateInteraction(i.id, { followUpOn: e.target.value !== '' ? e.target.value : null })
.then(() => interactions.reload()).catch((err: Error) => setActionErr(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 } : {})}
/>
)}
</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"
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>
)
}