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 { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain' import { formatINR } from '@sims/domain'
import { import {
@ -959,23 +959,27 @@ function BranchList(props: { clientId: string; branches: Branch[]; onChanged: ()
} }
/** /**
* Per-service operational data (D20 APEX sms_clients_list parity), following * Per-service operational data (D20 APEX sms_clients_list parity) + D21 module-defined
* SupportCard's exact visual pattern: label/value cells, the portal password as * fields, rendered as a 50/50 report (Access | Details D-CDR): read-only key/value rows,
* with managerial audited reveal (gated like the DB password), and Edit * the portal password as with managerial audited reveal (gated like the DB password),
* switching provider/username/remark + the labeled details into inputs one * secret fieldSpec values as their own inline SecretField, and ONE Edit per column that
* audited PATCH saves the lot. * 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 }) { function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: FieldDef[]; onChanged: () => void }) {
const toast = useToast() const toast = useToast()
const managerial = isManagerial() const managerial = isManagerial()
const cm = props.cm 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 [revealed, setRevealed] = useState<string | undefined>()
const [settingPw, setSettingPw] = useState(false)
const [pw, setPw] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [f, setF] = useState({ provider: '', username: '', remark: '', password: '' }) const [f, setF] = useState({ provider: '', username: '', remark: '', password: '' })
const [details, setDetails] = useState<ServiceDetail[]>([]) const [details, setDetails] = useState<ServiceDetail[]>([])
const [fieldDraft, setFieldDraft] = useState<Record<string, string>>({})
const copy = (text: string, what: string) => { const copy = (text: string, what: string) => {
navigator.clipboard.writeText(text).then(() => toast.ok(`${what} copied`)).catch(() => toast.err('Copy failed')) 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)) .catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false)) .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 = () => { const startEdit = () => {
setF({ provider: cm.provider ?? '', username: cm.username ?? '', remark: cm.remark ?? '', password: '' }) setF({ provider: cm.provider ?? '', username: cm.username ?? '', remark: cm.remark ?? '', password: '' })
setDetails(cm.details.map((d) => ({ ...d }))) setDetails(cm.details.map((d) => ({ ...d })))
setFieldDraft(Object.fromEntries(nonSecretFields.map((fld) => [fld.key, cm.fieldValues[fld.key] ?? ''])))
setEditing(true) setEditing(true)
} }
const saveEdit = () => { const saveEdit = () => {
if (busy) return if (busy) return
const cleaned = details.filter((d) => d.label.trim() !== '' || d.value.trim() !== '') 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 } 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) 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, { patchClientModule(cm.id, {
provider: f.provider, username: f.username, remark: f.remark, provider: f.provider, username: f.username, remark: f.remark,
details: cleaned.map((d) => ({ label: d.label.trim(), value: d.value })), details: cleaned.map((d) => ({ label: d.label.trim(), value: d.value })),
// Password only when the managerial user typed one (blank = keep current). // Password only when the managerial user typed one (blank = keep current).
...(managerial && f.password.trim() !== '' ? { password: f.password } : {}), ...(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)) .catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false)) .finally(() => setBusy(false))
} }
const cell = (label: string, value: string | undefined, mono = false, copyable = false) => ( // D21 non-secret fieldSpec input — moved here from the old always-on ModuleFieldsForm so
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6, marginRight: 18 }}> // it saves alongside D20 through this one Edit/Save. Secret fields keep their own inline
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>{label}</span> // SecretField widget (independent Set/Update/Reveal) and stay out of this form.
<span className={mono ? 'mono' : undefined}>{value ?? '—'}</span> const fieldControl = (fld: FieldDef) => {
{copyable && value !== undefined && <Button onClick={() => copy(value, label)}>Copy</Button>} const v = fieldDraft[fld.key] ?? ''
</span> 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) { if (editing) {
return ( 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> <Field label="Remark"><input className="wf" style={{ width: 220 }} value={f.remark} onChange={(e) => setF((p) => ({ ...p, remark: e.target.value }))} /></Field>
</div> </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) => ( {details.map((d, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}> <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<input <input
@ -1065,145 +1118,59 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
return ( return (
<div className="wf-card" style={{ padding: '10px 14px' }}> <div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}> <div className="cdr-split">
<span style={{ fontWeight: 600, marginRight: 10 }}>{props.name}</span> <div className="col">
{cell('Provider', cm.provider ?? undefined)} <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
{cell('Username', cm.username ?? undefined, true, true)} <span className="lbl" style={{ fontWeight: 700 }}>Access</span>
{cm.details.map((d, i) => <Fragment key={i}>{cell(d.label, d.value)}</Fragment>)} <span style={{ flex: 1, height: 1, background: 'var(--border)' }} />
{cell('Remark', cm.remark ?? undefined)} <Button onClick={startEdit}>Edit</Button>
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6 }}> </div>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>Portal password</span> <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 {revealed !== undefined
? <><span className="mono">{revealed}</span><Button onClick={() => copy(revealed, 'Portal password')}>Copy</Button><Button onClick={() => setRevealed(undefined)}>Hide</Button></> ? <><span className="mono">{revealed}</span><Button onClick={() => copy(revealed, 'Portal password')}>Copy</Button><Button onClick={() => setRevealed(undefined)}>Hide</Button></>
: cm.hasPassword : cm.hasPassword
? <><span className="mono"></span>{managerial && <Button onClick={reveal}>{busy ? '…' : 'Reveal'}</Button>}</> ? <><span className="mono"></span>{managerial && <Button onClick={reveal}>{busy ? '…' : 'Reveal'}</Button>}</>
: <span style={{ opacity: 0.6 }}>not set</span>} : <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>
<span style={{ flex: 1 }} /> </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> <Button onClick={startEdit}>Edit</Button>
</div> </div>
{/* D21: dynamic form from the owning module's fieldSpec, below the D20 block. */} {nonSecretFields.map((fld) => (
{props.fieldSpec.length > 0 && ( <Kv key={fld.key} label={fld.label} value={cm.fieldValues[fld.key]} />
<> ))}
<div style={{ borderTop: '1px solid var(--border)', margin: '12px 0 10px' }} /> {cm.details.map((d, i) => <Kv key={`d${i}`} label={d.label} value={d.value} />)}
<ModuleFieldsForm cm={cm} fieldSpec={props.fieldSpec} onChanged={props.onChanged} /> {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>
</div> </div>
) )
} }
/** /** One label/value row in the 50/50 service-data report; `—` for empty, optional mono + copy. */
* D21 module-defined fields (config over code): renders one input per field the owning function Kv(props: { label: string; value: string | undefined; mono?: boolean; copyable?: boolean; onCopy?: () => void }) {
* module DECLARES in its fieldSpec. Non-secret edits collect into a single audited PUT const v = props.value
* (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 ( return (
<select className="wf" style={{ width: 180 }} value={v} onChange={(e) => set(e.target.value)}> <div className="cdr-kv">
<option value=""></option> <span className="k">{props.label}</span>
{(f.options ?? []).map((o) => <option key={o} value={o}>{o}</option>)} <span className="v">
</select> {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>}
}
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> </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> </div>
) )
} }

Loading…
Cancel
Save