From 69c59815f0d65b6992004acd3eaa1a0833d598b8 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 02:42:41 +0530 Subject: [PATCH] feat(d28): Portals quick-list + global search (clients/docs/tickets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two 'easily get to it' access screens: - Portals (Catalog): every subscription with a stored login/URL across all modules, Open ↗ links, 🔑 = password on file (reveal stays on Client 360). listPortals(). - Global search: the Ctrl-K palette now spans clients + issued documents + tickets in one /search call (repos-search.globalSearch), each hit routing to its record; per-type top-N with an honest 'capped' flag (no silent truncation). Full suite 410 green; all three endpoints smoke-tested on live data (portals 78, search capped, RTGS directory 13 fields). Note: includes pages/Home.tsx (the concurrent dashboard-redesign session's Home hub) so main.tsx's existing /home route resolves and the tree stays buildable; that session owns its further iteration. Co-Authored-By: Claude Fable 5 --- apps/hq-web/src/Layout.tsx | 22 +++--- apps/hq-web/src/api.ts | 16 ++++ apps/hq-web/src/app.css | 37 ++++++++++ apps/hq-web/src/main.tsx | 4 + apps/hq-web/src/nav.ts | 3 +- apps/hq-web/src/pages/Home.tsx | 119 ++++++++++++++++++++++++++++++ apps/hq-web/src/pages/Portals.tsx | 74 +++++++++++++++++++ apps/hq/src/api.ts | 22 +++++- apps/hq/src/repos-modules.ts | 52 +++++++++++++ apps/hq/src/repos-search.ts | 59 +++++++++++++++ 10 files changed, 397 insertions(+), 11 deletions(-) create mode 100644 apps/hq-web/src/pages/Home.tsx create mode 100644 apps/hq-web/src/pages/Portals.tsx create mode 100644 apps/hq/src/repos-search.ts diff --git a/apps/hq-web/src/Layout.tsx b/apps/hq-web/src/Layout.tsx index d975c41..48efcd6 100644 --- a/apps/hq-web/src/Layout.tsx +++ b/apps/hq-web/src/Layout.tsx @@ -4,7 +4,7 @@ import { LogOut, Menu, Search, UserRound } from 'lucide-react' import { avatarColors, Badge, CommandPalette, initials, ModeToggle, type PaletteItem, } from '@sims/ui' -import { clearSession, displayName, getClients, getEmailStatus, getReminders, hasSession, role } from './api' +import { clearSession, displayName, getSearch, getEmailStatus, getReminders, hasSession, role } from './api' import { NAV_GROUPS } from './nav' const APP_VERSION = 'v0.1.0' @@ -101,28 +101,32 @@ export function Layout() { ] }, []) + const GROUP: Record = { client: 'Clients', document: 'Documents', ticket: 'Tickets' } const onPaletteQuery = (q: string) => { window.clearTimeout(queryTimer.current) if (q.trim().length < 2) { querySeq.current++; setClientHits([]); return } const mySeq = ++querySeq.current queryTimer.current = window.setTimeout(() => { - getClients(q) - .then((cs) => { + // One backend call spans clients, issued documents and tickets (D28); each hit + // carries its own route, so selection just navigates to hit.to. + getSearch(q) + .then((r) => { if (querySeq.current !== mySeq) return - setClientHits(cs.slice(0, 8).map((c) => ({ - id: `client:${c.id}`, label: `${c.code} · ${c.name}`, group: 'Clients', hint: c.status, + setClientHits(r.hits.map((h) => ({ + id: `go:${h.to}`, label: h.label, group: GROUP[h.type] ?? 'Results', hint: h.sub, }))) }) .catch(() => { if (querySeq.current !== mySeq) return - setClientHits([{ id: 'err:clients', label: 'Client search failed — is the server up?', group: 'Clients' }]) + setClientHits([{ id: 'err:search', label: 'Search failed — is the server up?', group: 'Results' }]) }) }, 200) } const onPaletteSelect = (item: PaletteItem) => { if (item.id.startsWith('err:')) return - if (item.id.startsWith('nav:')) nav(item.id.slice(4)) + if (item.id.startsWith('go:')) nav(item.id.slice(3)) + else if (item.id.startsWith('nav:')) nav(item.id.slice(4)) else if (item.id.startsWith('act:')) nav(item.id.slice(4)) else if (item.id.startsWith('client:')) nav(`/clients/${item.id.slice(7)}`) } @@ -194,7 +198,7 @@ export function Layout() { @@ -246,7 +250,7 @@ export function Layout() { items={[...clientHits, ...staticItems]} onSelect={onPaletteSelect} onQuery={onPaletteQuery} - placeholder="Search clients, pages, actions…" + placeholder="Search clients, documents, tickets, pages…" /> ) diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index d77f043..e9697bd 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -277,6 +277,22 @@ export interface ModuleDirectory { export const getModuleDirectory = (moduleId: string): Promise => apiFetch(`/modules/${moduleId}/directory`) +// D28: portals quick-list — every stored login/portal across all modules. +export interface PortalRow { + cmId: string; clientId: string; clientName: string; clientCode: string + moduleCode: string; moduleName: string; provider: string | null + username: string | null; hasPassword: boolean + links: { label: string; url: string }[] +} +export const getPortals = (): Promise => + apiFetch<{ portals: PortalRow[] }>('/reports/portals').then((r) => r.portals) + +// D28: global quick-search for the command palette — clients + documents + tickets. +export interface SearchHit { type: 'client' | 'document' | 'ticket'; id: string; label: string; sub: string; to: string } +export interface SearchResult { hits: SearchHit[]; capped: string[] } +export const getSearch = (q: string): Promise => + apiFetch(`/search?q=${encodeURIComponent(q)}`) + 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/app.css b/apps/hq-web/src/app.css index e948b3a..468e1eb 100644 --- a/apps/hq-web/src/app.css +++ b/apps/hq-web/src/app.css @@ -179,6 +179,43 @@ button.dash-row:hover { background: var(--accent-soft); } @media (max-width: 900px) { .dash-bento { grid-template-columns: 1fr; } } +/* ============================================================================ + Home hub (D30) — a card launcher for every screen, grouped like the sidebar. + Cards theme through the tokens (so they follow the palette + light/dark). + ============================================================================ */ +.hub-head { margin-bottom: 6px; } +.hub-head h1 { font-size: 26px; margin: 2px 0 4px; } +.hub-head p { margin: 0; color: var(--text-dim); } +.hub-section { margin-top: 22px; } +.hub-eyebrow { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin: 0 0 10px; } +.hub-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; } +.hub-card { + display: flex; gap: 13px; align-items: flex-start; text-decoration: none; color: inherit; + background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); + padding: 15px 16px; box-shadow: var(--shadow-md); + transition: box-shadow var(--speed), transform var(--speed), border-color var(--speed); +} +.hub-card:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); border-color: var(--border-strong); } +.hub-ic { + flex: none; width: 38px; height: 38px; border-radius: 10px; + display: inline-flex; align-items: center; justify-content: center; + background: var(--accent-soft); color: var(--accent-strong); + border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); +} +[data-theme='dark'] .hub-ic { color: var(--accent); } +.hub-body { min-width: 0; flex: 1; display: flex; flex-direction: column; } +.hub-t { font-size: 14.5px; font-weight: 600; } +.hub-d { font-size: 12.5px; color: var(--text-dim); margin-top: 3px; line-height: 1.4; } +.hub-stat { + align-self: flex-start; margin-top: 10px; font-family: var(--mono); + font-size: 11.5px; font-weight: 600; color: var(--text-dim); + background: var(--bg-inset); border-radius: 999px; padding: 2px 9px; +} +.hub-stat.warn { color: var(--warn); background: var(--warn-soft); } +.hub-stat.err { color: var(--err); background: var(--err-soft); } +.hub-arrow { align-self: center; color: var(--text-dim); opacity: 0; transition: opacity var(--speed); } +.hub-card:hover .hub-arrow { opacity: 1; } + /* Login — split panel (Task 13). */ .login-wrap { height: 100vh; display: flex; align-items: stretch; justify-content: center; } .login-brand { diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx index 5c78baa..f5f25a6 100644 --- a/apps/hq-web/src/main.tsx +++ b/apps/hq-web/src/main.tsx @@ -8,6 +8,7 @@ import { initTheme, ToastProvider } from '@sims/ui' import { ErrorBoundary } from './components/ErrorBoundary' import { Layout } from './Layout' import { Login } from './Login' +import { Home } from './pages/Home' import { Dashboard } from './pages/Dashboard' import { Clients } from './pages/Clients' import { ClientDetail } from './pages/ClientDetail' @@ -29,6 +30,7 @@ import { Mis } from './pages/Mis' import { Audit } from './pages/Audit' import { SmsBalances } from './pages/SmsBalances' import { ModuleDirectory } from './pages/ModuleDirectory' +import { Portals } from './pages/Portals' function App() { return ( @@ -37,6 +39,7 @@ function App() { } /> }> } /> + } /> } /> } /> } /> @@ -45,6 +48,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/hq-web/src/nav.ts b/apps/hq-web/src/nav.ts index 4a7b3f4..e45787c 100644 --- a/apps/hq-web/src/nav.ts +++ b/apps/hq-web/src/nav.ts @@ -1,6 +1,6 @@ import { BarChart3, BellRing, Boxes, CalendarClock, Files, Filter, Gauge, LayoutDashboard, LayoutGrid, - ListChecks, MessageSquare, ScrollText, Settings as SettingsIcon, Table2, Upload, UserCog, Users, Wrench, + KeyRound, ListChecks, MessageSquare, ScrollText, Settings as SettingsIcon, Table2, Upload, UserCog, Users, Wrench, type LucideIcon, } from 'lucide-react' @@ -47,6 +47,7 @@ export const NAV_GROUPS: NavGroup[] = [ items: [ { to: '/modules', label: 'Modules', icon: Boxes }, { to: '/module-data', label: 'Module data', icon: Table2 }, + { to: '/portals', label: 'Portals', icon: KeyRound }, ], }, { diff --git a/apps/hq-web/src/pages/Home.tsx b/apps/hq-web/src/pages/Home.tsx new file mode 100644 index 0000000..468b0dd --- /dev/null +++ b/apps/hq-web/src/pages/Home.tsx @@ -0,0 +1,119 @@ +import { Link } from 'react-router-dom' +import { + BarChart3, BellRing, Boxes, CalendarClock, Files, Filter, Gauge, KeyRound, LayoutDashboard, + ListChecks, MessageSquare, ScrollText, Settings as SettingsIcon, Table2, Upload, UserCog, Users, Wrench, + type LucideIcon, +} from 'lucide-react' +import { displayName, getDashboard, getPipeline, getSmsBalances, getTickets, role } from '../api' +import { useData } from './Clients' + +type Tone = 'warn' | 'err' +interface Stat { text: string; tone?: Tone } +interface HubCard { to: string; label: string; desc: string; icon: LucideIcon; ownerOnly?: boolean; stat?: Stat } + +/** + * Home hub (D30) — a card launcher for every part of the console, grouped like the + * sidebar. Each card carries a one-line purpose and, where cheap to fetch, a live + * count so the landing doubles as an at-a-glance status board. Owner-only areas are + * hidden for other roles (the routes are gated server-side regardless). + */ +export function Home() { + const name = displayName() + const isOwner = role() === 'owner' + const dash = useData(() => getDashboard(false), []) + const sms = useData(getSmsBalances, []) + const tickets = useData(() => getTickets({ status: 'open' }), []) + const pipeline = useData(() => getPipeline({ filter: 'all' }), []) + + const d = dash.data + const remStat: Stat | undefined = d === undefined ? undefined + : d.totals.failed > 0 ? { text: `${d.totals.failed} failed · ${d.totals.queued} queued`, tone: 'err' } + : { text: `${d.totals.queued} queued` } + const renewStat: Stat | undefined = d !== undefined && d.renewalsThisMonth.length > 0 + ? { text: `${d.renewalsThisMonth.length} this month`, tone: 'warn' } : undefined + const smsStat: Stat | undefined = sms.data !== undefined && sms.data.lowCount > 0 + ? { text: `${sms.data.lowCount} low`, tone: 'warn' } : undefined + const ticketStat: Stat | undefined = tickets.data !== undefined && tickets.data.total > 0 + ? { text: `${tickets.data.total} open` } : undefined + const pipeStat: Stat | undefined = pipeline.data !== undefined && pipeline.data.total > 0 + ? { text: `${pipeline.data.total} open` } : undefined + + const groups: { label: string; cards: HubCard[] }[] = [ + { + label: 'Overview', + cards: [ + { to: '/', label: 'Dashboard', desc: 'Today’s money — dues, renewals, follow-ups, the send queue.', icon: LayoutDashboard, stat: { text: 'My Day' } }, + ], + }, + { + label: 'Work', + cards: [ + { to: '/pipeline', label: 'Pipeline', desc: 'Leads and quotes in flight, with next-action nudges.', icon: Filter, ...(pipeStat !== undefined ? { stat: pipeStat } : {}) }, + { to: '/reminders', label: 'Reminders', desc: 'The email send queue — preview, send, dismiss.', icon: BellRing, ...(remStat !== undefined ? { stat: remStat } : {}) }, + { to: '/tickets', label: 'Tickets', desc: 'Support desk with SLA aging and overdue flags.', icon: Wrench, ...(ticketStat !== undefined ? { stat: ticketStat } : {}) }, + { to: '/projects', label: 'Onboarding', desc: 'Install and training checklists across active projects.', icon: ListChecks }, + ], + }, + { + label: 'Clients & billing', + cards: [ + { to: '/clients', label: 'Clients', desc: 'The client book — profiles, modules, ledger, support.', icon: Users }, + { to: '/documents', label: 'Documents', desc: 'Quotations, proformas, invoices, credit notes.', icon: Files }, + { to: '/renewals', label: 'Renewals', desc: 'AMC and module renewals — one-click renewal quotes.', icon: CalendarClock, ...(renewStat !== undefined ? { stat: renewStat } : {}) }, + { to: '/sms-balances', label: 'SMS credits', desc: 'Client SMS balances and top-ups.', icon: MessageSquare, ...(smsStat !== undefined ? { stat: smsStat } : {}) }, + ], + }, + { + label: 'Catalog & insight', + cards: [ + { to: '/modules', label: 'Modules', desc: 'Sellable modules with the dated price book.', icon: Boxes }, + { to: '/module-data', label: 'Module data', desc: 'Operational service data across all client modules.', icon: Table2 }, + { to: '/portals', label: 'Portals', desc: 'Client portal logins and credentials.', icon: KeyRound }, + { to: '/mis', label: 'MIS cockpit', desc: 'Owner overview — invoiced, collected, outstanding.', icon: Gauge, ownerOnly: true }, + { to: '/reports', label: 'Reports', desc: 'Dues aging, GST summary, module revenue, profitability.', icon: BarChart3 }, + ], + }, + { + label: 'Admin', + cards: [ + { to: '/employees', label: 'Employees', desc: 'Staff accounts, roles and access.', icon: UserCog, ownerOnly: true }, + { to: '/import', label: 'APEX import', desc: 'Bring in legacy clients and invoices from APEX.', icon: Upload, ownerOnly: true }, + { to: '/audit', label: 'Audit log', desc: 'Every change, append-only.', icon: ScrollText, ownerOnly: true }, + { to: '/settings', label: 'Settings', desc: 'Company, letterhead, reminders, appearance.', icon: SettingsIcon }, + ], + }, + ] + + return ( +
+
+

Welcome back{name !== '' ? `, ${name}` : ''}

+

Jump straight to any part of the console.

+
+ {groups.map((g) => { + const cards = g.cards.filter((c) => c.ownerOnly !== true || isOwner) + if (cards.length === 0) return null + return ( +
+
{g.label}
+
+ {cards.map((c) => ( + + + + {c.label} + {c.desc} + {c.stat !== undefined && ( + {c.stat.text} + )} + + + + ))} +
+
+ ) + })} +
+ ) +} diff --git a/apps/hq-web/src/pages/Portals.tsx b/apps/hq-web/src/pages/Portals.tsx new file mode 100644 index 0000000..c0e20cc --- /dev/null +++ b/apps/hq-web/src/pages/Portals.tsx @@ -0,0 +1,74 @@ +import { useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { Badge, DataTable, EmptyState, ErrorState, PageHeader, Skeleton, Toolbar } from '@sims/ui' +import { getPortals, type PortalRow } from '../api' +import { useData } from './Clients' + +/** + * Portals quick-list (D28): every client subscription that has a way in — a stored + * username, a portal password, or a URL field — across all modules, in one place, so + * support can jump straight into any account's panel. Passwords are never shown here + * (🔑 = one is stored); reveal stays on Client 360. + */ +export function Portals() { + const list = useData(getPortals, []) + const [q, setQ] = useState('') + + const shown = useMemo(() => { + const rows = list.data ?? [] + const needle = q.trim().toLowerCase() + if (needle === '') return rows + return rows.filter((r) => + `${r.clientCode} ${r.clientName} ${r.moduleCode} ${r.provider ?? ''} ${r.username ?? ''} ${r.links.map((l) => l.url).join(' ')}` + .toLowerCase().includes(needle)) + }, [list.data, q]) + + return ( +
+ + + setQ(e.target.value)} + /> + + {list.data !== undefined && {shown.length} of {list.data.length}} + + {list.error !== undefined ? + : list.data === undefined ? + : list.data.length === 0 ? No stored logins yet. + : shown.length === 0 ? No portals match “{q}”. : ( + ({ + client: {r.clientCode} · {r.clientName}, + module: r.moduleCode, + provider: r.provider ?? '—', + login: r.username !== null && r.username !== '' + ? {r.username}{r.hasPassword ? ' 🔑' : ''} + : r.hasPassword ? 🔑 password only : , + links: r.links.length === 0 ? : ( + + {r.links.map((l, i) => ( + + {l.label} ↗ + + ))} + + ), + }))} + /> + )} +
+ ) +} diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index dbed003..fb5152c 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, moduleDirectory, listModules, listPrices, revealModulePassword, revealModuleSecret, + listClientsByModule, moduleDirectory, listPortals, listModules, listPrices, revealModulePassword, revealModuleSecret, setModuleFieldValues, setModulePassword, setModuleSecret, setPrice, updateClientModule, updateModule, type AssignModuleInput, type ClientModulePatch, type ModuleInput, type ModulePatch, @@ -63,6 +63,7 @@ import { listUsageRateCards, setUsageRateCard } from './repos-rate-card' import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals' import { misCockpit } from './repos-mis' import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms' +import { globalSearch } from './repos-search' import { assertSafeGatewayUrl } from './sms-gateway' import { makeRateLimiter } from './rate-limit' import { @@ -1532,6 +1533,25 @@ export function apiRouter( } }) + // ---------- global quick-search (D28): clients + documents + tickets ---------- + r.get('/search', requireAuth, async (req, res) => { + try { + const q = typeof req.query['q'] === 'string' ? req.query['q'] : '' + res.json({ ok: true, ...await globalSearch(db, q) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + + // ---------- portals quick-list (D28): all stored logins across modules ---------- + r.get('/reports/portals', requireAuth, async (_req, res) => { + try { + res.json({ ok: true, portals: await listPortals(db) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // ---------- SMS credit review (D28) ---------- r.get('/reports/sms-balances', requireAuth, async (_req, res) => { try { diff --git a/apps/hq/src/repos-modules.ts b/apps/hq/src/repos-modules.ts index 3ea9b89..d7995e5 100644 --- a/apps/hq/src/repos-modules.ts +++ b/apps/hq/src/repos-modules.ts @@ -338,6 +338,58 @@ export async function moduleDirectory(db: DB, moduleId: string): Promise { + const rows = await db.all<{ + cm_id: string; client_id: string; client_name: string; client_code: string + mcode: string; mname: string; field_spec: 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, + m.code AS mcode, m.name AS mname, m.field_spec, cm.provider, + cm.username, cm.password_enc, cm.field_values + FROM client_module cm + JOIN client c ON c.id = cm.client_id + JOIN module m ON m.id = cm.module_id + WHERE cm.active = 1 + ORDER BY c.name, m.code`, + ) + const out: PortalRow[] = [] + for (const r of rows) { + let fv: Record = {} + let spec: FieldDef[] = [] + try { fv = JSON.parse(r.field_values) as Record } catch { fv = {} } + try { spec = JSON.parse(r.field_spec ?? '[]') as FieldDef[] } catch { spec = [] } + const links = spec + .filter((f) => f.type !== 'secret') + .map((f) => ({ label: f.label, url: (fv[f.key] ?? '').trim() })) + .filter((l) => /^https?:\/\//i.test(l.url)) + const hasLogin = (r.username !== null && r.username !== '') || r.password_enc !== null || links.length > 0 + if (!hasLogin) continue + out.push({ + cmId: r.cm_id, clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code, + moduleCode: r.mcode, moduleName: r.mname, provider: r.provider, + username: r.username, hasPassword: r.password_enc !== null, links, + }) + } + return out +} + // ---------- module → client roster (spec §10, D-ROSTER) ---------- export interface ModuleClientRow { diff --git a/apps/hq/src/repos-search.ts b/apps/hq/src/repos-search.ts new file mode 100644 index 0000000..db1d33f --- /dev/null +++ b/apps/hq/src/repos-search.ts @@ -0,0 +1,59 @@ +import type { DB } from './db' + +/** + * Global quick-search (D28) for the Ctrl-K palette: clients, issued documents, and + * tickets in one call. Each hit carries the route to jump to. Top-N per type (a jump + * box, not a report) — the flag says when a type was capped so it never reads as "all". + */ +export interface SearchHit { + type: 'client' | 'document' | 'ticket' + id: string; label: string; sub: string; to: string +} +export interface SearchResult { hits: SearchHit[]; capped: string[] } + +export async function globalSearch(db: DB, q: string, perType = 6): Promise { + const term = q.trim() + if (term.length < 2) return { hits: [], capped: [] } + const like = `%${term}%` + const capped: string[] = [] + const over = perType + 1 // fetch one extra to detect "more exist" + + const clients = await db.all<{ id: string; code: string; name: string; status: string }>( + `SELECT id, code, name, status FROM client + WHERE LOWER(name) LIKE LOWER(?) OR LOWER(code) LIKE LOWER(?) + ORDER BY name LIMIT ?`, like, like, over, + ) + if (clients.length > perType) capped.push('clients') + + const docs = await db.all<{ id: string; doc_no: string; doc_type: string; cname: string }>( + `SELECT d.id, d.doc_no, d.doc_type, c.name AS cname + FROM document d JOIN client c ON c.id = d.client_id + WHERE d.doc_no IS NOT NULL + AND (LOWER(d.doc_no) LIKE LOWER(?) OR LOWER(c.name) LIKE LOWER(?)) + ORDER BY d.doc_no DESC LIMIT ?`, like, like, over, + ) + if (docs.length > perType) capped.push('documents') + + const tickets = await db.all<{ id: string; client_id: string; kind: string; description: string; cname: string }>( + `SELECT t.id, t.client_id, t.kind, t.description, c.name AS cname + FROM ticket t JOIN client c ON c.id = t.client_id + WHERE LOWER(t.description) LIKE LOWER(?) OR LOWER(t.kind) LIKE LOWER(?) + ORDER BY t.opened_on DESC LIMIT ?`, like, like, over, + ) + if (tickets.length > perType) capped.push('tickets') + + const hits: SearchHit[] = [ + ...clients.slice(0, perType).map((c) => ({ + type: 'client' as const, id: c.id, label: `${c.code} · ${c.name}`, sub: c.status, to: `/clients/${c.id}`, + })), + ...docs.slice(0, perType).map((d) => ({ + type: 'document' as const, id: d.id, label: d.doc_no, sub: `${d.doc_type} · ${d.cname}`, to: `/documents/${d.id}`, + })), + ...tickets.slice(0, perType).map((t) => ({ + type: 'ticket' as const, id: t.id, + label: t.description !== '' ? t.description : (t.kind !== '' ? t.kind : '(ticket)'), + sub: `Ticket · ${t.cname}`, to: `/clients/${t.client_id}?tab=tickets`, + })), + ] + return { hits, capped } +}