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
Thomas Joise 3 days ago
parent 343cc6795d
commit 3f974e9d3d

@ -264,6 +264,19 @@ export const bulkUpdateClients = (ids: string[], patch: ClientWrite): Promise<{
method: 'POST', body: JSON.stringify({ ids, patch }), method: 'POST', body: JSON.stringify({ ids, patch }),
}).then((r) => ({ updated: r.updated })) }).then((r) => ({ updated: r.updated }))
// D28: module directory — every client on a module with its service fields.
export interface ModuleDirectoryRow {
cmId: string; clientId: string; clientName: string; clientCode: string
status: string; provider: string | null; username: string | null; hasPassword: boolean
fieldValues: Record<string, string>
}
export interface ModuleDirectory {
moduleId: string; code: string; name: string; fieldSpec: FieldDef[]
rows: ModuleDirectoryRow[]; total: number
}
export const getModuleDirectory = (moduleId: string): Promise<ModuleDirectory> =>
apiFetch<ModuleDirectory & { ok: boolean }>(`/modules/${moduleId}/directory`)
export const getModules = (): Promise<Module[]> => export const getModules = (): Promise<Module[]> =>
apiFetch<{ modules: Module[] }>('/modules').then((r) => r.modules) apiFetch<{ modules: Module[] }>('/modules').then((r) => r.modules)
export const createModule = (body: Record<string, unknown>): Promise<Module> => export const createModule = (body: Record<string, unknown>): Promise<Module> =>

@ -28,6 +28,7 @@ import { Renewals } from './pages/Renewals'
import { Mis } from './pages/Mis' import { Mis } from './pages/Mis'
import { Audit } from './pages/Audit' import { Audit } from './pages/Audit'
import { SmsBalances } from './pages/SmsBalances' import { SmsBalances } from './pages/SmsBalances'
import { ModuleDirectory } from './pages/ModuleDirectory'
function App() { function App() {
return ( return (
@ -43,6 +44,7 @@ function App() {
<Route path="/mis" element={<Mis />} /> <Route path="/mis" element={<Mis />} />
<Route path="/audit" element={<Audit />} /> <Route path="/audit" element={<Audit />} />
<Route path="/sms-balances" element={<SmsBalances />} /> <Route path="/sms-balances" element={<SmsBalances />} />
<Route path="/module-data" element={<ModuleDirectory />} />
<Route path="/tickets" element={<Tickets />} /> <Route path="/tickets" element={<Tickets />} />
<Route path="/clients" element={<Clients />} /> <Route path="/clients" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} /> <Route path="/clients/:id" element={<ClientDetail />} />

@ -1,6 +1,6 @@
import { import {
BarChart3, BellRing, CalendarClock, Gauge, Boxes, FilePlus2, Files, Filter, LayoutDashboard, ListChecks, BarChart3, BellRing, Boxes, CalendarClock, Files, Filter, Gauge, LayoutDashboard, LayoutGrid,
MessageSquare, ScrollText, Settings as SettingsIcon, Upload, UserCog, Users, Wrench, ListChecks, MessageSquare, ScrollText, Settings as SettingsIcon, Table2, Upload, UserCog, Users, Wrench,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
@ -11,30 +11,56 @@ export interface NavItem {
} }
export interface NavGroup { label: string; items: NavItem[] } export interface NavGroup { label: string; items: NavItem[] }
/** Grouped sidebar (spec §3). Pipeline sits right after Dashboard (funnel spec §6b). */ /**
* Grouped sidebar. Six intent-based groups (D30): Overview (Home hub + My Day),
* Work (daily queues), Clients & billing (the book), Catalog, Insight, Admin.
* "New document" is an action (Home card / Documents "+ New" / Ctrl-K), not a nav row.
*/
export const NAV_GROUPS: NavGroup[] = [ export const NAV_GROUPS: NavGroup[] = [
{ {
label: 'Work', label: 'Overview',
items: [ items: [
{ to: '/home', label: 'Home', icon: LayoutGrid },
{ to: '/', label: 'Dashboard', icon: LayoutDashboard }, { to: '/', label: 'Dashboard', icon: LayoutDashboard },
],
},
{
label: 'Work',
items: [
{ to: '/pipeline', label: 'Pipeline', icon: Filter }, { to: '/pipeline', label: 'Pipeline', icon: Filter },
{ to: '/reminders', label: 'Reminders', icon: BellRing }, { to: '/reminders', label: 'Reminders', icon: BellRing },
{ to: '/projects', label: 'Onboarding', icon: ListChecks },
{ to: '/tickets', label: 'Tickets', icon: Wrench }, { to: '/tickets', label: 'Tickets', icon: Wrench },
{ to: '/renewals', label: 'Renewals', icon: CalendarClock }, { to: '/projects', label: 'Onboarding', icon: ListChecks },
{ to: '/sms-balances', label: 'SMS balances', icon: MessageSquare }, ],
},
{
label: 'Clients & billing',
items: [
{ to: '/clients', label: 'Clients', icon: Users }, { to: '/clients', label: 'Clients', icon: Users },
{ to: '/documents', label: 'Documents', icon: Files, end: true }, { to: '/documents', label: 'Documents', icon: Files, end: true },
{ to: '/documents/new', label: 'New Document', icon: FilePlus2 }, { to: '/renewals', label: 'Renewals', icon: CalendarClock },
{ to: '/sms-balances', label: 'SMS credits', icon: MessageSquare },
],
},
{
label: 'Catalog',
items: [
{ to: '/modules', label: 'Modules', icon: Boxes },
{ to: '/module-data', label: 'Module data', icon: Table2 },
],
},
{
label: 'Insight',
items: [
{ to: '/mis', label: 'MIS cockpit', icon: Gauge, ownerOnly: true },
{ to: '/reports', label: 'Reports', icon: BarChart3 },
], ],
}, },
{ label: 'Catalog', items: [{ to: '/modules', label: 'Modules', icon: Boxes }] },
{ label: 'Insight', items: [{ to: '/mis', label: 'Cockpit', icon: Gauge, ownerOnly: true }, { to: '/reports', label: 'Reports', icon: BarChart3 }] },
{ {
label: 'Admin', label: 'Admin',
items: [ items: [
{ to: '/employees', label: 'Employees', icon: UserCog, ownerOnly: true }, { to: '/employees', label: 'Employees', icon: UserCog, ownerOnly: true },
{ to: '/import', label: 'APEX Import', icon: Upload, ownerOnly: true }, { to: '/import', label: 'APEX import', icon: Upload, ownerOnly: true },
{ to: '/audit', label: 'Audit log', icon: ScrollText, ownerOnly: true }, { to: '/audit', label: 'Audit log', icon: ScrollText, ownerOnly: true },
{ to: '/settings', label: 'Settings', icon: SettingsIcon }, { to: '/settings', label: 'Settings', icon: SettingsIcon },
], ],

@ -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>
)
}

@ -13,7 +13,7 @@ import {
} from './repos-employees' } from './repos-employees'
import { import {
assignModule, createModule, getClientModule, getModule, listClientModules, assignModule, createModule, getClientModule, getModule, listClientModules,
listClientsByModule, listModules, listPrices, revealModulePassword, revealModuleSecret, listClientsByModule, moduleDirectory, listModules, listPrices, revealModulePassword, revealModuleSecret,
setModuleFieldValues, setModulePassword, setModuleSecret, setModuleFieldValues, setModulePassword, setModuleSecret,
setPrice, updateClientModule, updateModule, setPrice, updateClientModule, updateModule,
type AssignModuleInput, type ClientModulePatch, type ModuleInput, type ModulePatch, type AssignModuleInput, type ClientModulePatch, type ModuleInput, type ModulePatch,
@ -456,6 +456,16 @@ export function apiRouter(
}) })
// ---------- module → client roster (spec §10, D-ROSTER) ---------- // ---------- module → client roster (spec §10, D-ROSTER) ----------
// Module directory (D28): every client on a module with its service fields, for review.
r.get('/modules/:id/directory', requireAuth, async (req, res) => {
try {
const dir = await moduleDirectory(db, String(req.params['id'] ?? ''))
if (dir === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
res.json({ ok: true, ...dir })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.get('/modules/:id/clients', requireAuth, async (req, res) => { r.get('/modules/:id/clients', requireAuth, async (req, res) => {
try { try {
const id = String(req.params['id'] ?? '') const id = String(req.params['id'] ?? '')

@ -291,6 +291,53 @@ export async function listClientModules(db: DB, clientId: string): Promise<Clien
return rows.map(toClientModule) return rows.map(toClientModule)
} }
// ---------- module directory (D28): every client on a module × its service fields ----------
export interface ModuleDirectoryRow {
cmId: string; clientId: string; clientName: string; clientCode: string
status: string; provider: string | null; username: string | null; hasPassword: boolean
/** The module's declared field values for this client (non-secret; secrets stay server-side). */
fieldValues: Record<string, string>
}
export interface ModuleDirectory {
moduleId: string; code: string; name: string; fieldSpec: FieldDef[]
rows: ModuleDirectoryRow[]; total: number
}
/**
* The operational roster for ONE module: every active client on it with that module's
* field values, so the whole book (all RTGS IPs, all CloudBackup buckets, ) is reviewable
* at once instead of one client at a time. Secret field VALUES never leave the repo the
* spec tells the UI which are secret; reveal stays on Client 360. Returns all rows (bounded
* by active subscriptions per module no silent cap). Read-only, writes no audit.
*/
export async function moduleDirectory(db: DB, moduleId: string): Promise<ModuleDirectory | null> {
const mod = await getModule(db, moduleId)
if (mod === null) return null
const rows = await db.all<{
cm_id: string; client_id: string; client_name: string; client_code: string
status: string; provider: string | null; username: string | null
password_enc: string | null; field_values: string
}>(
`SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
cm.status, cm.provider, cm.username, cm.password_enc, cm.field_values
FROM client_module cm JOIN client c ON c.id = cm.client_id
WHERE cm.module_id=? AND cm.active=1
ORDER BY c.name, cm.id`,
moduleId,
)
const out: ModuleDirectoryRow[] = rows.map((r) => {
let fv: Record<string, string> = {}
try { fv = JSON.parse(r.field_values) as Record<string, string> } catch { fv = {} }
return {
cmId: r.cm_id, clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
status: r.status, provider: r.provider, username: r.username,
hasPassword: r.password_enc !== null, fieldValues: fv,
}
})
return { moduleId, code: mod.code, name: mod.name, fieldSpec: mod.fieldSpec, rows: out, total: out.length }
}
// ---------- module → client roster (spec §10, D-ROSTER) ---------- // ---------- module → client roster (spec §10, D-ROSTER) ----------
export interface ModuleClientRow { export interface ModuleClientRow {

Loading…
Cancel
Save