diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index 97b42cc..eca7ded 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -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() - 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([]) + const [fieldDraft, setFieldDraft] = useState>({}) 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) => ( - - {label} - {value ?? '—'} - {copyable && value !== undefined && } - - ) + // 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 ( + + ) + } + if (fld.type === 'select') { + return ( + + ) + } + if (fld.type === 'number') { + return set(e.target.value)} /> + } + if (fld.type === 'date') { + return set(e.target.value)} /> + } + return set(e.target.value)} /> + } if (editing) { return ( @@ -1040,6 +1070,29 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie )} setF((p) => ({ ...p, remark: e.target.value }))} /> + {nonSecretFields.length > 0 && ( + <> +
+
+ {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 ( + + + {fieldControl(fld)} + {isUrl && ( + Open ↗ + )} + + + ) + })} +
+ + )} {details.map((d, i) => (
-
- {props.name} - {cell('Provider', cm.provider ?? undefined)} - {cell('Username', cm.username ?? undefined, true, true)} - {cm.details.map((d, i) => {cell(d.label, d.value)})} - {cell('Remark', cm.remark ?? undefined)} - - Portal password - {revealed !== undefined - ? <>{revealed} - : cm.hasPassword - ? <>••••••••{managerial && } - : not set} - {managerial && !settingPw && } - {managerial && settingPw && ( - <> - setPw(e.target.value)} /> - - - - )} - - - +
+
+
+ Access + + +
+ + copy(cm.username ?? '', 'Username')} /> +
+ Password + + {revealed !== undefined + ? <>{revealed} + : cm.hasPassword + ? <>••••••••{managerial && } + : not set} + +
+ {cm.remark !== null && cm.remark !== '' && } +
+
+
+ Details + + +
+ {nonSecretFields.map((fld) => ( + + ))} + {cm.details.map((d, i) => )} + {secretFields.map((fld) => ( +
+ {fld.label} + +
+ ))} +
- {/* D21: dynamic form from the owning module's fieldSpec, below the D20 block. */} - {props.fieldSpec.length > 0 && ( - <> -
- - - )}
) } -/** - * 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 => - Object.fromEntries(nonSecret.map((f) => [f.key, cm.fieldValues[f.key] ?? ''])) - const [draft, setDraft] = useState>(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 ( - - ) - } - if (f.type === 'select') { - return ( - - ) - } - if (f.type === 'number') { - return set(e.target.value)} /> - } - if (f.type === 'date') { - return set(e.target.value)} /> - } - return 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 ( -
- {nonSecret.length > 0 && ( - <> -
- {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 ( - - - {control(f)} - {isUrl && ( - Open ↗ - )} - - - ) - })} -
- - - - - )} - {secrets.map((f) => ( - - ))} +
+ {props.label} + + {v !== undefined && v !== '' ? {v} : } + {props.copyable === true && v !== undefined && v !== '' && } +
) }