From 3f974e9d3d340dd41801ce1a4d7f8b0a1aaaef09 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 02:06:04 +0530 Subject: [PATCH] =?UTF-8?q?feat(d28):=20Module=20directory=20=E2=80=94=20e?= =?UTF-8?q?very=20client=20on=20a=20module=20x=20its=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/hq-web/src/api.ts | 13 +++ apps/hq-web/src/main.tsx | 2 + apps/hq-web/src/nav.ts | 48 ++++++++--- apps/hq-web/src/pages/ModuleDirectory.tsx | 98 +++++++++++++++++++++++ apps/hq/src/api.ts | 12 ++- apps/hq/src/repos-modules.ts | 47 +++++++++++ 6 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 apps/hq-web/src/pages/ModuleDirectory.tsx diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index c9d3725..d77f043 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -264,6 +264,19 @@ export const bulkUpdateClients = (ids: string[], patch: ClientWrite): Promise<{ method: 'POST', body: JSON.stringify({ ids, patch }), }).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 +} +export interface ModuleDirectory { + moduleId: string; code: string; name: string; fieldSpec: FieldDef[] + rows: ModuleDirectoryRow[]; total: number +} +export const getModuleDirectory = (moduleId: string): Promise => + apiFetch(`/modules/${moduleId}/directory`) + export const getModules = (): Promise => apiFetch<{ modules: Module[] }>('/modules').then((r) => r.modules) export const createModule = (body: Record): Promise => diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx index 15906b3..5c78baa 100644 --- a/apps/hq-web/src/main.tsx +++ b/apps/hq-web/src/main.tsx @@ -28,6 +28,7 @@ import { Renewals } from './pages/Renewals' import { Mis } from './pages/Mis' import { Audit } from './pages/Audit' import { SmsBalances } from './pages/SmsBalances' +import { ModuleDirectory } from './pages/ModuleDirectory' function App() { return ( @@ -43,6 +44,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/hq-web/src/nav.ts b/apps/hq-web/src/nav.ts index 42435e9..4a7b3f4 100644 --- a/apps/hq-web/src/nav.ts +++ b/apps/hq-web/src/nav.ts @@ -1,6 +1,6 @@ import { - BarChart3, BellRing, CalendarClock, Gauge, Boxes, FilePlus2, Files, Filter, LayoutDashboard, ListChecks, - MessageSquare, ScrollText, Settings as SettingsIcon, Upload, UserCog, Users, Wrench, + BarChart3, BellRing, Boxes, CalendarClock, Files, Filter, Gauge, LayoutDashboard, LayoutGrid, + ListChecks, MessageSquare, ScrollText, Settings as SettingsIcon, Table2, Upload, UserCog, Users, Wrench, type LucideIcon, } from 'lucide-react' @@ -11,30 +11,56 @@ export interface 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[] = [ { - label: 'Work', + label: 'Overview', items: [ + { to: '/home', label: 'Home', icon: LayoutGrid }, { to: '/', label: 'Dashboard', icon: LayoutDashboard }, + ], + }, + { + label: 'Work', + items: [ { to: '/pipeline', label: 'Pipeline', icon: Filter }, { to: '/reminders', label: 'Reminders', icon: BellRing }, - { to: '/projects', label: 'Onboarding', icon: ListChecks }, { to: '/tickets', label: 'Tickets', icon: Wrench }, - { to: '/renewals', label: 'Renewals', icon: CalendarClock }, - { to: '/sms-balances', label: 'SMS balances', icon: MessageSquare }, + { to: '/projects', label: 'Onboarding', icon: ListChecks }, + ], + }, + { + label: 'Clients & billing', + items: [ { to: '/clients', label: 'Clients', icon: Users }, { 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', items: [ { 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: '/settings', label: 'Settings', icon: SettingsIcon }, ], diff --git a/apps/hq-web/src/pages/ModuleDirectory.tsx b/apps/hq-web/src/pages/ModuleDirectory.tsx new file mode 100644 index 0000000..48b0cd3 --- /dev/null +++ b/apps/hq-web/src/pages/ModuleDirectory.tsx @@ -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 πŸ”’ + const v = (value ?? '').trim() + if (v === '') return β€” + if (/^https?:\/\//i.test(v)) { + return Open β†— + } + if (f.type === 'boolean') return {v === 'true' ? 'yes' : 'no'} + return {v} +} + +/** + * 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('') + 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( + () => (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 ( +
+ + + {modules.data === undefined ? : ( + ({ key: m.id, label: m.code }))} + active={current} + onChange={(k) => { setModuleId(k); setQ('') }} + /> + )} + + + setQ(e.target.value)} + /> + + {dir.data !== undefined && {shown.length} of {dir.data.total}} + + {dir.error !== undefined ? + : dir.data === undefined ? + : dir.data.rows.length === 0 ? No clients are on {dir.data.name} yet. + : shown.length === 0 ? No {dir.data.name} clients match β€œ{q}”. : ( +
+ ({ key: f.key, label: f.label })), + { key: 'login', label: 'Login' }, + ]} + rows={shown.map((r) => ({ + client: {r.clientCode} Β· {r.clientName}, + ...Object.fromEntries(cols.map((f) => [f.key, fieldCell(f, r.fieldValues[f.key])])), + login: r.username !== null && r.username !== '' + ? {r.username}{r.hasPassword ? ' πŸ”‘' : ''} + : β€”, + }))} + /> +
+ )} +
+ ) +} diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 480dfce..dbed003 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -13,7 +13,7 @@ import { } from './repos-employees' import { assignModule, createModule, getClientModule, getModule, listClientModules, - listClientsByModule, listModules, listPrices, revealModulePassword, revealModuleSecret, + listClientsByModule, moduleDirectory, listModules, listPrices, revealModulePassword, revealModuleSecret, setModuleFieldValues, setModulePassword, setModuleSecret, setPrice, updateClientModule, updateModule, type AssignModuleInput, type ClientModulePatch, type ModuleInput, type ModulePatch, @@ -456,6 +456,16 @@ export function apiRouter( }) // ---------- 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) => { try { const id = String(req.params['id'] ?? '') diff --git a/apps/hq/src/repos-modules.ts b/apps/hq/src/repos-modules.ts index a5cb163..3ea9b89 100644 --- a/apps/hq/src/repos-modules.ts +++ b/apps/hq/src/repos-modules.ts @@ -291,6 +291,53 @@ export async function listClientModules(db: DB, clientId: string): Promise +} +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 { + 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 = {} + try { fv = JSON.parse(r.field_values) as Record } 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) ---------- export interface ModuleClientRow {