fix(ui): module page as master→detail screen (clear which module you're in)

Selecting a module used to dump a stack of panels below the list with no indication of
which module — confusing (founder feedback on 'Fields — Mobile App'). Now clicking a
module opens its OWN screen: a breadcrumb + big header naming the module (name · code ·
SAC · kinds), a Back button, then clearly-labelled sections — Details (editable),
Clients on this module, Fields (plain-language: 'the details you record for each client
on this module'), Quote content, Price book. The list hides while you're in a module.
Row action is now 'Open →'. typecheck + build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 days ago
parent 56686dd4d6
commit 8485e69ff0

@ -26,9 +26,10 @@ export function Modules() {
const isOwner = role() === 'owner'
const modules = useData(getModules, [])
const [creating, setCreating] = useState(false)
const [selected, setSelected] = useState<Module | undefined>()
const [editingId, setEditingId] = useState<string | undefined>() // inline "Edit module" form
const editingModule = modules.data?.find((m) => m.id === editingId)
// Master → detail: selecting a module opens its own screen (derive from fresh data so the
// header + fields reflect edits immediately after a reload).
const [selectedId, setSelectedId] = useState<string | undefined>()
const selected = modules.data?.find((m) => m.id === selectedId)
// How many clients sit on each module — one lightweight roster count per module,
// best-effort so a slow/failed one never blocks the table (cell shows '…' until then).
const [clientCounts, setClientCounts] = useState<Record<string, number>>({})
@ -44,11 +45,34 @@ export function Modules() {
return () => { live = false }
}, [modules.data])
// --- Detail screen: one module at a time, clearly headed, with a Back button. ---
if (selected !== undefined) {
return (
<div className="wf-page">
<div className="wf-crumbs">
<button type="button" className="wf" style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 0 }}
onClick={() => setSelectedId(undefined)}> All modules</button>
<span>/</span><span className="mono">{selected.code}</span>
</div>
<PageHeader
title={`${selected.name} · ${selected.code}`}
desc={`SAC ${selected.sac} · ${selected.allowedKinds.map((k) => KIND_LABEL[k]).join(' · ')} · ${selected.active ? 'active' : 'inactive'}`}
actions={<Button onClick={() => setSelectedId(undefined)}> Back to all modules</Button>}
/>
<ModuleDetails key={`md-${selected.id}`} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} />
<ModuleClients key={`mc-${selected.id}`} module={selected} />
<FieldSpecEditor key={`fs-${selected.id}`} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} />
<QuoteContent key={selected.id} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} />
<PriceBook module={selected} isOwner={isOwner} />
</div>
)
}
return (
<div className="wf-page">
<PageHeader
title="Modules"
desc="What we sell — each with a dated price book. Use Edit to change a module's details; click a row to see its clients, fields, quote content and prices."
desc="What we sell — each with a dated price book. Click a module to open it: its clients, install details, fields, quote content and prices."
actions={isOwner
? <Button tone="primary" onClick={() => setCreating(true)}>New module</Button>
: <Badge>read-only (staff)</Badge>}
@ -64,31 +88,17 @@ export function Modules() {
{ key: 'multi', label: 'Multi-sub' }, { key: 'active', label: 'Active' },
{ key: 'act', label: '' },
]}
onRowClick={(_row, i) => setSelected(modules.data![i])}
onRowClick={(_row, i) => setSelectedId(modules.data![i]!.id)}
rows={modules.data.map((m) => ({
code: m.code, name: m.name, sac: m.sac,
clients: clientCounts[m.id] !== undefined ? clientCounts[m.id] : '…',
kinds: m.allowedKinds.map((k) => KIND_LABEL[k]).join(' · '),
multi: m.multiSubscription ? <Badge tone="accent">yes</Badge> : '—',
active: m.active ? <Badge tone="ok">active</Badge> : <Badge>inactive</Badge>,
act: isOwner
? <span onClick={(e) => e.stopPropagation()}><Button onClick={() => setEditingId(m.id)}>Edit</Button></span>
: null,
act: <span onClick={(e) => e.stopPropagation()}><Button onClick={() => setSelectedId(m.id)}>Open </Button></span>,
}))}
/>
)}
{editingModule !== undefined && (
<ModuleDetails key={`md-${editingModule.id}`} module={editingModule} isOwner={isOwner}
onSaved={() => modules.reload()} onClose={() => setEditingId(undefined)} />
)}
{selected !== undefined && (
<>
<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} />
</>
)}
{isOwner && (
<NewModuleDialog
open={creating}
@ -328,7 +338,7 @@ function ModuleClients(props: { module: Module }) {
/** Edit a module's core details (owner only): name, SAC, billing kinds, multi-sub, active.
* Code is the identity and is not editable. Field-spec / quote-content / prices have their
* own panels below. */
function ModuleDetails(props: { module: Module; isOwner: boolean; onSaved: () => void; onClose: () => void }) {
function ModuleDetails(props: { module: Module; isOwner: boolean; onSaved: () => void; onClose?: () => void }) {
const toast = useToast()
const m = props.module
const [name, setName] = useState(m.name)
@ -348,7 +358,7 @@ function ModuleDetails(props: { module: Module; isOwner: boolean; onSaved: () =>
if (kinds.length === 0) { setError('Pick at least one billing kind'); return }
setError(undefined); setBusy(true)
patchModule(m.id, { name: name.trim(), sac: sac.trim(), allowedKinds: kinds, multiSubscription: multi, active })
.then(() => { toast.ok('Module saved'); props.onSaved(); props.onClose() })
.then(() => { toast.ok('Module saved'); props.onSaved() })
.catch((e: Error) => setError(e.message))
.finally(() => setBusy(false))
}
@ -356,7 +366,7 @@ function ModuleDetails(props: { module: Module; isOwner: boolean; onSaved: () =>
if (!props.isOwner) return null
return (
<>
<h3 style={{ marginTop: 20 }}>Edit module <span className="mono">{m.code}</span></h3>
<h3 style={{ marginTop: 20 }}>Details</h3>
<div className="wf-card" style={{ padding: 14 }}>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Name"><input className="wf" style={{ width: 260 }} value={name} onChange={(e) => setName(e.target.value)} /></Field>
@ -378,7 +388,7 @@ function ModuleDetails(props: { module: Module; isOwner: boolean; onSaved: () =>
</div>
<Toolbar>
<Button tone="primary" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save module'}</Button>
<Button onClick={props.onClose}>Cancel</Button>
{props.onClose !== undefined && <Button onClick={props.onClose}>Cancel</Button>}
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
@ -402,7 +412,7 @@ function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () =>
return (
<>
<h3 style={{ marginTop: 20 }}>Quote content {props.module.name}</h3>
<h3 style={{ marginTop: 20 }}>Quote content <span style={{ fontWeight: 400, fontSize: 13, opacity: 0.65 }}> the whats included lines printed on quotes</span></h3>
{props.isOwner ? (
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ flex: '0 0 560px', minWidth: 0 }}>
@ -483,7 +493,7 @@ function FieldSpecEditor(props: { module: Module; isOwner: boolean; onSaved: ()
return (
<>
<h3 style={{ marginTop: 20 }}>Fields {props.module.name}</h3>
<h3 style={{ marginTop: 20 }}>Fields <span style={{ fontWeight: 400, fontSize: 13, opacity: 0.65 }}> the details you record for each client on this module</span></h3>
{!props.isOwner ? (
props.module.fieldSpec.length === 0 ? <EmptyState>No fields defined.</EmptyState> : (
<DataTable

Loading…
Cancel
Save