feat(client-detail): module details as 50/50 report with single edit per group

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 days ago
parent d05c7ee31b
commit 9d3087c1fe

@ -1,4 +1,4 @@
import { Fragment, useState, type CSSProperties, type ReactNode } from 'react'
import { useState, type CSSProperties, type ReactNode } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import {
@ -959,23 +959,27 @@ function BranchList(props: { clientId: string; branches: Branch[]; onChanged: ()
}
/**
* 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.
* Per-service operational data (D20 APEX sms_clients_list parity) + D21 module-defined
* fields, rendered as a 50/50 report (Access | Details D-CDR): read-only key/value rows,
* the portal password as with managerial audited reveal (gated like the DB password),
* secret fieldSpec values as their own inline SecretField, and ONE Edit per column that
* swaps the whole card into a single form provider/username/remark/password + the
* freeform details + the non-secret fieldSpec values all save through one Save (two
* audited calls in sequence: patchClientModule for D20, setModuleFields for D21).
*/
function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: FieldDef[]; onChanged: () => void }) {
const toast = useToast()
const managerial = isManagerial()
const cm = props.cm
const sortedFieldSpec = [...props.fieldSpec].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
const nonSecretFields = sortedFieldSpec.filter((fld) => fld.type !== 'secret')
const secretFields = sortedFieldSpec.filter((fld) => fld.type === 'secret')
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: '', password: '' })
const [details, setDetails] = useState<ServiceDetail[]>([])
const [fieldDraft, setFieldDraft] = useState<Record<string, string>>({})
const copy = (text: string, what: string) => {
navigator.clipboard.writeText(text).then(() => toast.ok(`${what} copied`)).catch(() => toast.err('Copy failed'))
@ -988,42 +992,68 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
.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 ?? '', password: '' })
setDetails(cm.details.map((d) => ({ ...d })))
setFieldDraft(Object.fromEntries(nonSecretFields.map((fld) => [fld.key, cm.fieldValues[fld.key] ?? ''])))
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 }
const missingRequired = nonSecretFields.find((fld) => fld.required === true && (fieldDraft[fld.key] ?? '').trim() === '')
if (missingRequired !== undefined) { toast.err(`${missingRequired.label} is required`); return }
setBusy(true)
// Only the fieldSpec keys that actually changed — '' clears a value server-side.
const changedFields = Object.fromEntries(
nonSecretFields
.filter((fld) => (fieldDraft[fld.key] ?? '') !== (cm.fieldValues[fld.key] ?? ''))
.map((fld) => [fld.key, fieldDraft[fld.key] ?? '']),
)
patchClientModule(cm.id, {
provider: f.provider, username: f.username, remark: f.remark,
details: cleaned.map((d) => ({ label: d.label.trim(), value: d.value })),
// Password only when the managerial user typed one (blank = keep current).
...(managerial && f.password.trim() !== '' ? { password: f.password } : {}),
})
.then(() => { toast.ok('Service data saved'); setEditing(false); props.onChanged() })
.then(() => (Object.keys(changedFields).length > 0 ? setModuleFields(cm.id, changedFields) : undefined))
.then(() => { toast.ok('Service data saved'); setEditing(false); 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>
)
// D21 non-secret fieldSpec input — moved here from the old always-on ModuleFieldsForm so
// it saves alongside D20 through this one Edit/Save. Secret fields keep their own inline
// SecretField widget (independent Set/Update/Reveal) and stay out of this form.
const fieldControl = (fld: FieldDef) => {
const v = fieldDraft[fld.key] ?? ''
const set = (val: string) => setFieldDraft((p) => ({ ...p, [fld.key]: val }))
if (fld.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 (fld.type === 'select') {
return (
<select className="wf" style={{ width: 180 }} value={v} onChange={(e) => set(e.target.value)}>
<option value=""></option>
{(fld.options ?? []).map((o) => <option key={o} value={o}>{o}</option>)}
</select>
)
}
if (fld.type === 'number') {
return <input className="wf num" type="number" style={{ width: 140 }} value={v} onChange={(e) => set(e.target.value)} />
}
if (fld.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)} />
}
if (editing) {
return (
@ -1040,6 +1070,29 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
)}
<Field label="Remark"><input className="wf" style={{ width: 220 }} value={f.remark} onChange={(e) => setF((p) => ({ ...p, remark: e.target.value }))} /></Field>
</div>
{nonSecretFields.length > 0 && (
<>
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0 10px' }} />
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 10 }}>
{nonSecretFields.map((fld) => {
// A field holding an http(s) value gets a direct "Open ↗" link (portal/console shortcut).
const val = fieldDraft[fld.key] ?? ''
const isUrl = /^https?:\/\//i.test(val.trim())
return (
<Field key={fld.key} label={fld.required === true ? `${fld.label} *` : fld.label}>
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
{fieldControl(fld)}
{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>
</>
)}
{details.map((d, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<input
@ -1065,145 +1118,59 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
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 className="cdr-split">
<div className="col">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span className="lbl" style={{ fontWeight: 700 }}>Access</span>
<span style={{ flex: 1, height: 1, background: 'var(--border)' }} />
<Button onClick={startEdit}>Edit</Button>
</div>
<Kv label="Provider" value={cm.provider ?? undefined} />
<Kv label="Login" value={cm.username ?? undefined} mono copyable onCopy={() => copy(cm.username ?? '', 'Username')} />
<div className="cdr-kv">
<span className="k">Password</span>
<span className="v">
{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>}
</span>
</div>
{cm.remark !== null && cm.remark !== '' && <Kv label="Remark" value={cm.remark} />}
</div>
<div className="col">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span className="lbl" style={{ fontWeight: 700 }}>Details</span>
<span style={{ flex: 1, height: 1, background: 'var(--border)' }} />
<Button onClick={startEdit}>Edit</Button>
</div>
{nonSecretFields.map((fld) => (
<Kv key={fld.key} label={fld.label} value={cm.fieldValues[fld.key]} />
))}
{cm.details.map((d, i) => <Kv key={`d${i}`} label={d.label} value={d.value} />)}
{secretFields.map((fld) => (
<div className="cdr-kv" key={fld.key}>
<span className="k">{fld.label}</span>
<span className="v"><SecretField cm={cm} field={fld} managerial={managerial} onChanged={props.onChanged} /></span>
</div>
))}
</div>
</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)} />
}
/** One label/value row in the 50/50 service-data report; `—` for empty, optional mono + copy. */
function Kv(props: { label: string; value: string | undefined; mono?: boolean; copyable?: boolean; onCopy?: () => void }) {
const v = props.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 className="cdr-kv">
<span className="k">{props.label}</span>
<span className="v">
{v !== undefined && v !== '' ? <span className={`txt${props.mono === true ? ' mono' : ''}`}>{v}</span> : <span style={{ opacity: 0.5 }}></span>}
{props.copyable === true && v !== undefined && v !== '' && <Button onClick={props.onCopy}>Copy</Button>}
</span>
</div>
)
}

Loading…
Cancel
Save