diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index e26de09..7aff614 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -98,10 +98,21 @@ export const KIND_LABEL: Record = { 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 + /** 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 => 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): Promise => + 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 => + 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 => + 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' diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index 3ef711e..8567f45 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -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 &&

Service data

} {(cms.data ?? []).map((cm) => ( - cms.reload()} /> + 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 ( -
- {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)} /> - - - - )} +
+
+ {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)} /> + + + + )} + + + +
+ {/* 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)} /> + } + + return ( +
+ {nonSecret.length > 0 && ( + <> +
+ {nonSecret.map((f) => ( + + {control(f)} + + ))} +
+ + + + + )} + {secrets.map((f) => ( + + ))} +
+ ) +} + +/** 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() + 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 ( +
+ + {field.required === true ? `${field.label} *` : field.label} - - + {revealed !== undefined + ? <>{revealed} + : has + ? <>••••••••{managerial && } + : not set} + {managerial && !setting && } + {managerial && setting && ( + <> + setVal(e.target.value)} /> + + + + )}
) } diff --git a/apps/hq-web/src/pages/Modules.tsx b/apps/hq-web/src/pages/Modules.tsx index e0c5696..7f8be10 100644 --- a/apps/hq-web/src/pages/Modules.tsx +++ b/apps/hq-web/src/pages/Modules.tsx @@ -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() { <> modules.reload()} /> + modules.reload()} /> )} @@ -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(() => props.module.fieldSpec.map(toDraft)) + const [error, setError] = useState() + const [saving, setSaving] = useState(false) + + const setRow = (i: number, patch: Partial) => + 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 ( + <> +

Fields — {props.module.name}

+ {!props.isOwner ? ( + props.module.fieldSpec.length === 0 ? No fields defined. : ( + ({ + label: f.label, k: f.key, type: f.type, + req: f.required === true ? required : '—', + options: f.type === 'select' ? (f.options ?? []).join(', ') : '—', + help: f.help !== undefined && f.help !== '' ? f.help : '—', + }))} + /> + ) + ) : ( +
+

+ The operational fields every client of this module fills in — a new field shows up on + every service card immediately, no code change. +

+ {rows.length === 0 ? No fields yet — add the first below. : rows.map((r, i) => ( +
+ + + + + setRow(i, { label: e.target.value })} + /> + setRow(i, { key: e.target.value })} + /> + + {r.type === 'select' && ( + setRow(i, { options: e.target.value })} + /> + )} + setRow(i, { help: e.target.value })} + /> + + +
+ ))} + + + + + + {error !== undefined && {error}} +
+ )} + + ) +} + function NewModuleDialog(props: { open: boolean; onClose: () => void; onCreated: () => void }) { const toast = useToast() const [f, setF] = useState({ code: '', name: '', sac: '998313' })