feat(d28): Module directory — every client on a module x its fields
New 'Module data' screen (Catalog): pick a module, see all active clients on it with that module's own field-spec values as columns — the whole book at once (all RTGS IPs, all CloudBackup buckets, all RecoverX portals), searchable, portal URLs clickable, each row links to the client. moduleDirectory(db,moduleId) returns fieldSpec + rows; secret VALUES never leave the server (shown as a lock), reveal stays on Client 360. Reuses the field-specs so a new module field appears here with zero code change. typecheck green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
343cc6795d
commit
3f974e9d3d
@ -0,0 +1,98 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Badge, DataTable, EmptyState, ErrorState, FilterChips, PageHeader, Skeleton, Toolbar,
|
||||
} from '@sims/ui'
|
||||
import { getModules, getModuleDirectory, type FieldDef, type ModuleDirectory as Dir } from '../api'
|
||||
import { useData } from './Clients'
|
||||
|
||||
/** Render one field value: portal URLs become a direct link; blanks are muted. */
|
||||
function fieldCell(f: FieldDef, value: string | undefined) {
|
||||
if (f.type === 'secret') return <span style={{ opacity: 0.5 }}>🔒</span>
|
||||
const v = (value ?? '').trim()
|
||||
if (v === '') return <span style={{ opacity: 0.4 }}>—</span>
|
||||
if (/^https?:\/\//i.test(v)) {
|
||||
return <a href={v} target="_blank" rel="noopener noreferrer" title={v}>Open ↗</a>
|
||||
}
|
||||
if (f.type === 'boolean') return <Badge tone={v === 'true' ? 'green' : undefined}>{v === 'true' ? 'yes' : 'no'}</Badge>
|
||||
return <span style={{ fontSize: 13 }}>{v}</span>
|
||||
}
|
||||
|
||||
/**
|
||||
* Module directory (D28): pick a module, see every client on it with that module's own
|
||||
* fields as columns — the whole book at once (all RTGS IPs, all CloudBackup buckets, …),
|
||||
* searchable, each row linking to the client. Reuses the module field-specs, so a field
|
||||
* added to a module shows up here with no code change. Secret values stay hidden (🔒);
|
||||
* reveal happens on Client 360.
|
||||
*/
|
||||
export function ModuleDirectory() {
|
||||
const modules = useData(getModules, [])
|
||||
const [moduleId, setModuleId] = useState<string>('')
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
const active = (modules.data ?? []).filter((m) => m.active)
|
||||
// Default to the first module once the catalog loads.
|
||||
const current = moduleId !== '' ? moduleId : active[0]?.id ?? ''
|
||||
const dir = useData<Dir | undefined>(
|
||||
() => (current === '' ? Promise.resolve(undefined) : getModuleDirectory(current)),
|
||||
[current],
|
||||
)
|
||||
|
||||
const cols = useMemo(() => (dir.data?.fieldSpec ?? []), [dir.data])
|
||||
const shown = useMemo(() => {
|
||||
const rows = dir.data?.rows ?? []
|
||||
const needle = q.trim().toLowerCase()
|
||||
if (needle === '') return rows
|
||||
return rows.filter((r) =>
|
||||
`${r.clientCode} ${r.clientName} ${r.provider ?? ''} ${r.username ?? ''} ${Object.values(r.fieldValues).join(' ')}`
|
||||
.toLowerCase().includes(needle))
|
||||
}, [dir.data, q])
|
||||
|
||||
return (
|
||||
<div className="wf-page">
|
||||
<PageHeader
|
||||
title="Module data"
|
||||
desc="Every client on a module with its service details side by side — pick a module, scan the whole book, jump to any client."
|
||||
/>
|
||||
<Toolbar>
|
||||
{modules.data === undefined ? <Skeleton rows={1} /> : (
|
||||
<FilterChips
|
||||
chips={active.map((m) => ({ key: m.id, label: m.code }))}
|
||||
active={current}
|
||||
onChange={(k) => { setModuleId(k); setQ('') }}
|
||||
/>
|
||||
)}
|
||||
</Toolbar>
|
||||
<Toolbar>
|
||||
<input
|
||||
className="wf" style={{ maxWidth: 280 }} placeholder="Search client / field value…"
|
||||
aria-label="Search module data"
|
||||
value={q} onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<span style={{ flex: 1 }} />
|
||||
{dir.data !== undefined && <Badge>{shown.length} of {dir.data.total}</Badge>}
|
||||
</Toolbar>
|
||||
{dir.error !== undefined ? <ErrorState message={dir.error} onRetry={dir.reload} />
|
||||
: dir.data === undefined ? <Skeleton rows={6} />
|
||||
: dir.data.rows.length === 0 ? <EmptyState>No clients are on {dir.data.name} yet.</EmptyState>
|
||||
: shown.length === 0 ? <EmptyState>No {dir.data.name} clients match “{q}”.</EmptyState> : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'client', label: 'Client' },
|
||||
...cols.map((f) => ({ key: f.key, label: f.label })),
|
||||
{ key: 'login', label: 'Login' },
|
||||
]}
|
||||
rows={shown.map((r) => ({
|
||||
client: <Link to={`/clients/${r.clientId}?tab=modules`}>{r.clientCode} · {r.clientName}</Link>,
|
||||
...Object.fromEntries(cols.map((f) => [f.key, fieldCell(f, r.fieldValues[f.key])])),
|
||||
login: r.username !== null && r.username !== ''
|
||||
? <span className="mono" style={{ fontSize: 12 }}>{r.username}{r.hasPassword ? ' 🔑' : ''}</span>
|
||||
: <span style={{ opacity: 0.4 }}>—</span>,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue