feat(d28): Portals quick-list + global search (clients/docs/tickets)

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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 3 days ago
parent 3f974e9d3d
commit 69c59815f0

@ -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<string, string> = { 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() {
</button>
<button type="button" className="hq-search" onClick={() => setPaletteOpen(true)}>
<Search size={14} aria-hidden />
<span>Search clients, pages</span>
<span>Search clients, documents, tickets</span>
<kbd>Ctrl K</kbd>
</button>
<span style={{ flex: 1 }} />
@ -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…"
/>
</div>
)

@ -277,6 +277,22 @@ export interface ModuleDirectory {
export const getModuleDirectory = (moduleId: string): Promise<ModuleDirectory> =>
apiFetch<ModuleDirectory & { ok: boolean }>(`/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<PortalRow[]> =>
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<SearchResult> =>
apiFetch<SearchResult & { ok: boolean }>(`/search?q=${encodeURIComponent(q)}`)
export const getModules = (): Promise<Module[]> =>
apiFetch<{ modules: Module[] }>('/modules').then((r) => r.modules)
export const createModule = (body: Record<string, unknown>): Promise<Module> =>

@ -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 {

@ -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() {
<Route path="/login" element={<Login />} />
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/home" element={<Home />} />
<Route path="/pipeline" element={<Pipeline />} />
<Route path="/reminders" element={<Reminders />} />
<Route path="/projects" element={<Projects />} />
@ -45,6 +48,7 @@ function App() {
<Route path="/audit" element={<Audit />} />
<Route path="/sms-balances" element={<SmsBalances />} />
<Route path="/module-data" element={<ModuleDirectory />} />
<Route path="/portals" element={<Portals />} />
<Route path="/tickets" element={<Tickets />} />
<Route path="/clients" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} />

@ -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 },
],
},
{

@ -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: 'Todays 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 (
<div className="wf-page">
<div className="hub-head">
<h1>Welcome back{name !== '' ? `, ${name}` : ''}</h1>
<p>Jump straight to any part of the console.</p>
</div>
{groups.map((g) => {
const cards = g.cards.filter((c) => c.ownerOnly !== true || isOwner)
if (cards.length === 0) return null
return (
<section className="hub-section" key={g.label}>
<div className="hub-eyebrow">{g.label}</div>
<div className="hub-cards">
{cards.map((c) => (
<Link key={c.to} to={c.to} className="hub-card">
<span className="hub-ic"><c.icon size={18} strokeWidth={1.9} aria-hidden /></span>
<span className="hub-body">
<span className="hub-t">{c.label}</span>
<span className="hub-d">{c.desc}</span>
{c.stat !== undefined && (
<span className={`hub-stat${c.stat.tone !== undefined ? ` ${c.stat.tone}` : ''}`}>{c.stat.text}</span>
)}
</span>
<span className="hub-arrow" aria-hidden></span>
</Link>
))}
</div>
</section>
)
})}
</div>
)
}

@ -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 (
<div className="wf-page">
<PageHeader
title="Portals"
desc="Every stored login across all modules — jump straight into any client's provider panel or admin console."
/>
<Toolbar>
<input
className="wf" style={{ maxWidth: 300 }} placeholder="Search client / module / username / URL…"
aria-label="Search portals"
value={q} onChange={(e) => setQ(e.target.value)}
/>
<span style={{ flex: 1 }} />
{list.data !== undefined && <Badge>{shown.length} of {list.data.length}</Badge>}
</Toolbar>
{list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
: list.data === undefined ? <Skeleton rows={8} />
: list.data.length === 0 ? <EmptyState>No stored logins yet.</EmptyState>
: shown.length === 0 ? <EmptyState>No portals match {q}.</EmptyState> : (
<DataTable
columns={[
{ key: 'client', label: 'Client' },
{ key: 'module', label: 'Module', mono: true },
{ key: 'provider', label: 'Provider' },
{ key: 'login', label: 'Username' },
{ key: 'links', label: 'Open' },
]}
rows={shown.map((r) => ({
client: <Link to={`/clients/${r.clientId}?tab=modules`}>{r.clientCode} · {r.clientName}</Link>,
module: r.moduleCode,
provider: r.provider ?? '—',
login: r.username !== null && r.username !== ''
? <span className="mono" style={{ fontSize: 12 }}>{r.username}{r.hasPassword ? ' 🔑' : ''}</span>
: r.hasPassword ? <span style={{ opacity: 0.7, fontSize: 12 }}>🔑 password only</span> : <span style={{ opacity: 0.4 }}></span>,
links: r.links.length === 0 ? <span style={{ opacity: 0.4 }}></span> : (
<span style={{ display: 'inline-flex', gap: 10, flexWrap: 'wrap' }}>
{r.links.map((l, i) => (
<a key={i} href={l.url} target="_blank" rel="noopener noreferrer" title={l.url} style={{ fontSize: 12.5 }}>
{l.label}
</a>
))}
</span>
),
}))}
/>
)}
</div>
)
}

@ -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 {

@ -338,6 +338,58 @@ export async function moduleDirectory(db: DB, moduleId: string): Promise<ModuleD
return { moduleId, code: mod.code, name: mod.name, fieldSpec: mod.fieldSpec, rows: out, total: out.length }
}
// ---------- portals quick-list (D28): 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
/** URL-valued fields of this subscription (portal/console shortcuts). */
links: { label: string; url: string }[]
}
/**
* Every subscription that has a way in a stored username, a portal password, or a
* URL-valued field across ALL modules, so support can reach any client's panel from
* one place. Passwords never leave the repo (hasPassword only); reveal stays on Client
* 360. Read-only, no audit. No silent cap (bounded by active subscriptions).
*/
export async function listPortals(db: DB): Promise<PortalRow[]> {
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<string, string> = {}
let spec: FieldDef[] = []
try { fv = JSON.parse(r.field_values) as Record<string, string> } 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 {

@ -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<SearchResult> {
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 }
}
Loading…
Cancel
Save