|
|
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>
|
|
|
)
|
|
|
}
|