feat(d21): P3 — module Fields editor + self-rendering service form

Modules page: owner FieldSpecEditor where a module defines its own fields
(label, snake_case key, type, select options, required, reorder) → saved via
patchModule({ fieldSpec }); staff read-only. Client 360 service card: below
the D20 login block, a dynamic form built from the owning module fieldSpec —
one typed input per field (text/number/date/boolean/select), required honored,
non-secret edits collected into one PUT; secret fields as ••••/Reveal/Copy +
Set/Update mirroring the portal-password pattern (managerial, audited).
Empty fieldSpec renders nothing extra. Additive — D20 UI intact. typecheck +
web build clean, web tests 4/4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 427ac31c57
commit 892ae9e96e

@ -98,10 +98,21 @@ export const KIND_LABEL: Record<Kind, string> = {
one_time: 'One-time', monthly: 'Monthly', yearly: 'Yearly', usage: 'Usage',
}
/** D21: a module-declared operational field. `type` decides the service-card input;
* `secret` values are encrypted at rest and never returned in the module list. */
export type FieldType = 'text' | 'secret' | 'date' | 'number' | 'select' | 'boolean'
export interface FieldDef {
key: string; label: string; type: FieldType
options?: string[] // for type 'select'
required?: boolean; help?: string; sort?: number
}
export interface Module {
id: string; code: string; name: string; sac: string
allowedKinds: Kind[]; multiSubscription: boolean; active: boolean
quoteContent: string[]
/** D21: the operational fields this module declares — drives the service-card form. */
fieldSpec: FieldDef[]
}
export interface ModulePrice {
@ -128,6 +139,10 @@ export interface ClientModule {
provider: string | null; username: string | null
details: ServiceDetail[]; remark: string | null
hasPassword: boolean
/** D21: non-secret values for the owning module's fieldSpec, keyed by field key. */
fieldValues: Record<string, string>
/** D21: which secret fields have a value stored — the value itself is reveal-only. */
secretKeys: string[]
}
export interface ClientModulePatch {
@ -243,6 +258,25 @@ export const revealModulePassword = (id: string): Promise<string> =>
apiFetch<{ password: string }>(`/client-modules/${id}/reveal-password`, { method: 'POST', body: '{}' })
.then((r) => r.password)
// ---------- D21: module-defined field values + secrets ----------
/** Write the non-secret module-field values in one shot (any signed-in user);
* server validates keys/types against the owning module's fieldSpec, '' clears a value. */
export const setModuleFields = (id: string, values: Record<string, string>): Promise<ClientModule> =>
apiFetch<{ clientModule: ClientModule }>(`/client-modules/${id}/fields`, {
method: 'PUT', body: JSON.stringify({ values }),
}).then((r) => r.clientModule)
/** Set/clear ONE secret field — owner/manager (server 403s otherwise); '' clears. */
export const setModuleSecret = (id: string, key: string, value: string): Promise<ClientModule> =>
apiFetch<{ clientModule: ClientModule }>(`/client-modules/${id}/secrets`, {
method: 'POST', body: JSON.stringify({ key, value }),
}).then((r) => r.clientModule)
/** Decrypt-and-return one secret field value — owner/manager; every call is audited. */
export const revealModuleSecret = (id: string, key: string): Promise<string> =>
apiFetch<{ value: string }>(`/client-modules/${id}/reveal-secret`, {
method: 'POST', body: JSON.stringify({ key }),
}).then((r) => r.value)
// ---------- ticket desk + client branches (D20 APEX parity) ----------
export type TicketStatus = 'open' | 'in_progress' | 'waiting' | 'closed' | 'dropped'

@ -8,6 +8,7 @@ import {
import {
assignClientModule, getClient, getClientModules, getLedger, getModules,
patchClient, patchClientModule, revealDbPassword, revealModulePassword,
setModuleFields, setModuleSecret, revealModuleSecret,
role, isManagerial, getEmployees, setClientOwner,
getRecurringPlans, deactivateRecurringPlan,
getAmc, deactivateAmc, generateAmcRenewalInvoice,
@ -16,7 +17,7 @@ import {
getBranches, createBranch, patchBranch, getTickets,
CLIENT_STATUSES, KIND_LABEL,
type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule,
type ClientStatus, type Interaction, type Outcome, type RecurringPlan, type ServiceDetail,
type ClientStatus, type FieldDef, type Interaction, type Outcome, type RecurringPlan, type ServiceDetail,
} from '../api'
import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
import {
@ -275,7 +276,13 @@ export function ClientDetail() {
)}
{(cms.data ?? []).length > 0 && <h3 style={{ marginTop: 20 }}>Service data</h3>}
{(cms.data ?? []).map((cm) => (
<ServiceDataCard key={cm.id} cm={cm} name={moduleName(cm.moduleId)} onChanged={() => cms.reload()} />
<ServiceDataCard
key={cm.id}
cm={cm}
name={moduleName(cm.moduleId)}
fieldSpec={modules.data?.find((m) => m.id === cm.moduleId)?.fieldSpec ?? []}
onChanged={() => cms.reload()}
/>
))}
</>
)}
@ -750,7 +757,7 @@ function BranchList(props: { clientId: string; branches: Branch[]; onChanged: ()
* switching provider/username/remark + the labeled details into inputs one
* audited PATCH saves the lot.
*/
function ServiceDataCard(props: { cm: ClientModule; name: string; onChanged: () => void }) {
function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: FieldDef[]; onChanged: () => void }) {
const toast = useToast()
const managerial = isManagerial()
const cm = props.cm
@ -841,31 +848,188 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; onChanged: ()
}
return (
<div className="wf-card" style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8, padding: '10px 14px' }}>
<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"
value={pw} onChange={(e) => setPw(e.target.value)} />
<Button tone="primary" onClick={savePw}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => { setSettingPw(false); setPw('') }}>Cancel</Button>
</>
)}
<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"
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) => (
<Field key={f.key} label={f.required === true ? `${f.label} *` : f.label}>
{control(f)}
</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>
<span style={{ flex: 1 }} />
<Button onClick={startEdit}>Edit</Button>
{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}
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>
)
}

@ -9,12 +9,13 @@ import {
getModuleClients, getModules, getPrices, notifyModuleClients, patchClientModule, patchModule,
previewSample, role,
CLIENT_MODULE_STATUSES, KIND_LABEL,
type Client, type ClientModuleStatus, type Kind, type Module,
type Client, type ClientModuleStatus, type FieldDef, type FieldType, type Kind, type Module,
} from '../api'
import { LivePreview } from '../components/LivePreview'
import { useData } from './Clients'
const ALL_KINDS: Kind[] = ['one_time', 'monthly', 'yearly', 'usage']
const FIELD_TYPES: FieldType[] = ['text', 'secret', 'date', 'number', 'select', 'boolean']
const today = () => new Date().toISOString().slice(0, 10)
/** Textarea ↔ string[]: one "what's included" bullet per row, blanks dropped. */
@ -58,6 +59,7 @@ export function Modules() {
<>
<ModuleClients key={`mc-${selected.id}`} module={selected} />
<QuoteContent key={selected.id} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} />
<FieldSpecEditor key={`fs-${selected.id}`} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} />
<PriceBook module={selected} isOwner={isOwner} />
</>
)}
@ -329,6 +331,131 @@ function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () =>
)
}
/**
* D21 "Fields" editor where a module DEFINES ITSELF (config over code): the list of
* operational fields (key/label/type/options/required/help) that every client of this
* module then fills in on its service card. Owner edits; staff read a compact list.
* A blank options list on a `select` and a bad key are rejected server-side (validateFieldSpec).
*/
interface FieldDraft { key: string; label: string; type: FieldType; options: string; required: boolean; help: string }
const toDraft = (f: FieldDef): FieldDraft => ({
key: f.key, label: f.label, type: f.type,
options: (f.options ?? []).join(', '), required: f.required === true, help: f.help ?? '',
})
function FieldSpecEditor(props: { module: Module; isOwner: boolean; onSaved: () => void }) {
const toast = useToast()
const [rows, setRows] = useState<FieldDraft[]>(() => props.module.fieldSpec.map(toDraft))
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
const setRow = (i: number, patch: Partial<FieldDraft>) =>
setRows((prev) => prev.map((r, j) => (j === i ? { ...r, ...patch } : r)))
const move = (i: number, dir: -1 | 1) =>
setRows((prev) => {
const j = i + dir
if (j < 0 || j >= prev.length) return prev
const next = [...prev]
;[next[i], next[j]] = [next[j]!, next[i]!]
return next
})
const save = () => {
if (saving) return
const cleaned = rows.filter((r) => r.key.trim() !== '' || r.label.trim() !== '')
// sort follows on-screen order so the service card renders top-to-bottom as arranged.
const spec = cleaned.map((r, i): FieldDef => ({
key: r.key.trim(), label: r.label.trim(), type: r.type, sort: i,
...(r.type === 'select'
? { options: r.options.split(',').map((o) => o.trim()).filter((o) => o !== '') }
: {}),
...(r.required ? { required: true } : {}),
...(r.help.trim() !== '' ? { help: r.help.trim() } : {}),
}))
setError(undefined)
setSaving(true)
patchModule(props.module.id, { fieldSpec: spec })
.then(() => { toast.ok('Fields saved'); setRows(spec.map(toDraft)); props.onSaved() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
}
return (
<>
<h3 style={{ marginTop: 20 }}>Fields {props.module.name}</h3>
{!props.isOwner ? (
props.module.fieldSpec.length === 0 ? <EmptyState>No fields defined.</EmptyState> : (
<DataTable
columns={[
{ key: 'label', label: 'Label' }, { key: 'k', label: 'Key', mono: true },
{ key: 'type', label: 'Type' }, { key: 'req', label: 'Required' },
{ key: 'options', label: 'Options' }, { key: 'help', label: 'Help' },
]}
rows={props.module.fieldSpec.map((f) => ({
label: f.label, k: f.key, type: f.type,
req: f.required === true ? <Badge tone="accent">required</Badge> : '—',
options: f.type === 'select' ? (f.options ?? []).join(', ') : '—',
help: f.help !== undefined && f.help !== '' ? f.help : '—',
}))}
/>
)
) : (
<div className="wf-card">
<p style={{ margin: '0 0 10px', fontSize: 12, color: 'var(--text-dim)' }}>
The operational fields every client of this module fills in a new field shows up on
every service card immediately, no code change.
</p>
{rows.length === 0 ? <EmptyState>No fields yet add the first below.</EmptyState> : rows.map((r, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 8 }}>
<span style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button disabled={i === 0} onClick={() => move(i, -1)}></Button>
<Button disabled={i === rows.length - 1} onClick={() => move(i, 1)}></Button>
</span>
<input
className="wf" placeholder="Label" style={{ width: 160 }} aria-label="Field label"
value={r.label} onChange={(e) => setRow(i, { label: e.target.value })}
/>
<input
className="wf mono" placeholder="key" style={{ width: 130 }} aria-label="Field key"
value={r.key} onChange={(e) => setRow(i, { key: e.target.value })}
/>
<select
className="wf" style={{ width: 110 }} aria-label="Field type"
value={r.type} onChange={(e) => setRow(i, { type: e.target.value as FieldType })}
>
{FIELD_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
{r.type === 'select' && (
<input
className="wf" placeholder="options, comma-separated" style={{ width: 200 }} aria-label="Select options"
value={r.options} onChange={(e) => setRow(i, { options: e.target.value })}
/>
)}
<input
className="wf" placeholder="Help (optional)" style={{ width: 180 }} aria-label="Field help"
value={r.help} onChange={(e) => setRow(i, { help: e.target.value })}
/>
<label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}>
<input type="checkbox" checked={r.required} onChange={(e) => setRow(i, { required: e.target.checked })} />
required
</label>
<Button tone="danger" onClick={() => setRows((prev) => prev.filter((_x, j) => j !== i))}></Button>
</div>
))}
<Toolbar>
<Button onClick={() => setRows((prev) => [...prev, { key: '', label: '', type: 'text', options: '', required: false, help: '' }])}>
+ Add field
</Button>
<span style={{ flex: 1 }} />
<Button tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : 'Save fields'}</Button>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)}
</>
)
}
function NewModuleDialog(props: { open: boolean; onClose: () => void; onCreated: () => void }) {
const toast = useToast()
const [f, setF] = useState({ code: '', name: '', sac: '998313' })

Loading…
Cancel
Save