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
parent
3f974e9d3d
commit
69c59815f0
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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…
Reference in New Issue