From 2736ebd7dc7ca308e14d424d6df25e5fa38bddac Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 14 Oct 2026 13:17:08 +0200 Subject: [PATCH] feat(search): quick-overview dialog per search hit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a result now opens a summary dialog instead of navigating away, for all eight result types. Deliberately NOT an iframe of the detail page and NOT eight bespoke preview components. Both were on the table: - Iframe: cheap, but `src/app/(app)/layout.tsx:37` renders the for every page in the group, so the modal would show a sidebar inside a sidebar. - Per-type components: 8 fetch + 8 field maps, drifting out of date as the pages they mirror change. Instead the SERVICE returns a flat `preview: [{label, value}]` list on each hit, built from data it already holds in the snapshot and reference caches. One generic renderer then serves every type, opening a preview costs no extra round-trip, and a ninth source needs no new UI. Verified live against PB — e.g. searching "pro-tech" now returns, per hit: order number, customer, company, rep, status mix, item count, created date, suppliers, first line descriptions. Two gaps found while verifying the payload rather than assuming it was populated: - `item_status` was not in the item fetch, so the order's Status row was silently absent — the single most useful field in the preview. Added. - `customers.company` is a relation id; the preview would have printed a raw 15-char PB id. Now resolved to the company name. Rows became ))} @@ -336,6 +360,72 @@ export default function UniversalSearchPage() { ))} + + {/* + Quick overview. + + One generic renderer for all eight result types: the service + returns a flat label/value list per hit, so a new source gets a + preview without any new UI here. + + `key={previewHit.id}` is deliberate — it remounts the content + when the hit changes, so switching results re-runs the + initialisers against the new record instead of syncing props + into existing state. + */} + { + if (!open) setPreviewHit(null); + }} + > + + {previewHit && ( + <> + + + {previewHit.title} + {previewHit.matchedOn && ( + + {previewHit.matchedOn} + + )} + + {previewGroupLabel && ( + {previewGroupLabel} + )} + + + {previewHit.preview && previewHit.preview.length > 0 ? ( +
+ {previewHit.preview.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ ) : ( +

+ No details available for this result. +

+ )} + + {/* The escape hatch: the preview must never be the only + way to reach a record. */} + + + Open full page + + + )} +
+
); } \ No newline at end of file diff --git a/src/lib/server/universal-search-service.ts b/src/lib/server/universal-search-service.ts index 67a37d5..9854cb4 100644 --- a/src/lib/server/universal-search-service.ts +++ b/src/lib/server/universal-search-service.ts @@ -93,6 +93,15 @@ const NON_TERMINAL_STATUSES: string[] = Array.from( ), ); +/** + * Field lists for the bounded reference collections. Named so the + * search scoring and the quick-overview dialog pull from the SAME cached + * read — two different field strings would be two cache keys and two PB + * round-trips for the same data. + */ +const CUSTOMER_FIELDS = "id,customer,email,number,company"; +const COMPANY_FIELDS = "id,company,address,vendor,company_email,company_tel"; + /* ============================================================================ * Types * ========================================================================== */ @@ -117,6 +126,42 @@ export interface UniversalHit { href: string; /** Which surface matched, e.g. "SKU" — answers "why is this here?". */ matchedOn?: string; + /** + * Short label/value rows shown in the quick-overview dialog. + * + * Deliberately a GENERIC key/value list rather than a typed payload + * per entity: one renderer then serves all eight result types, so + * adding a ninth source needs no new UI. The per-type shaping lives + * here in the service, which already has the data in hand from the + * snapshot/reference caches — so opening a preview costs no extra + * round-trip. + * + * Rows are ordered most-identifying-first. + */ + preview?: PreviewRow[]; +} + +export interface PreviewRow { + label: string; + value: string; +} + +/** Drops rows whose value is empty so previews don't render blank lines. */ +function previewRows(rows: Array<[string, unknown]>): PreviewRow[] { + return rows + .map(([label, value]) => ({ + label, + value: value === null || value === undefined ? "" : String(value).trim(), + })) + .filter((row) => row.value.length > 0); +} + +/** Truncates long free text for a compact dialog cell. */ +function clip(value: unknown, max = 160): string { + const s = value === null || value === undefined ? "" : String(value).trim(); + if (!s) return ""; + const oneLine = s.replace(/\s+/g, " "); + return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine; } export type SourceStatus = "ok" | "timeout" | "error"; @@ -248,6 +293,8 @@ interface OrderSnapshot { orders: Record[]; itemsByOrder: Map[]>; supplierNames: Map; + /** customer/company id → display name, for the quick-overview dialog. */ + partyNames: Map; expiresAt: number; } @@ -376,7 +423,10 @@ async function fetchItemsByOrderIds( .map((id) => `order = "${escapeForPbFilter(id)}"`) .join(" || "), fields: - "id,order,description,po_description,sku,notes,selling_price,cost_price,supplier", + // `item_status` is load-bearing: the quick-overview dialog + // summarises an order's status mix, and omitting it produced + // a preview with no Status row at all. + "id,order,item_status,description,po_description,sku,notes,selling_price,cost_price,supplier", batch: 1000, }), ), @@ -454,6 +504,24 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise(); + for (const item of orderItems) { + const s = String(item.item_status ?? ""); + if (s) statusCounts.set(s, (statusCounts.get(s) ?? 0) + 1); + } + const statusSummary = [...statusCounts.entries()] + .map(([s, n]) => (n > 1 ? `${s} (${n})` : s)) + .join(", "); + const supplierNamesForOrder = Array.from( + new Set( + orderItems + .map((i) => snapshot.supplierNames.get(String(i.supplier ?? ""))) + .filter((n): n is string => Boolean(n)), + ), + ).slice(0, 4); + scored.push({ score, hit: { @@ -463,6 +531,31 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise clip(i.description, 48)) + .filter(Boolean) + .join(" · "), + ], + ["Notes", clip(order.notes, 140)], + ]), }, }); } @@ -493,6 +586,7 @@ async function getOrderSnapshot(pb: AdminPocketBase): Promise { orders: [], itemsByOrder: new Map(), supplierNames: new Map(), + partyNames: new Map(), expiresAt: Date.now() + ORDER_SNAPSHOT_TTL_MS, }; orderSnapshotValue = empty; @@ -504,11 +598,23 @@ async function getOrderSnapshot(pb: AdminPocketBase): Promise { pb, "orders", orderIds, - "id,order_number,description,notes,contact_name,rfq_number,rep,customer,company", + "id,order_number,description,notes,contact_name,rfq_number,rep,customer,company,created", ), fetchItemsByOrderIds(pb, orderIds), ]); + // Resolve customer/company ids to names for the preview dialog. + // Both collections are already in the reference cache (the universal + // page loads them for their own search sources), so this is a map + // lookup rather than another PB round-trip. + const [customerRows, companyRows] = await Promise.all([ + loadReferenceRows>(pb, "customers", CUSTOMER_FIELDS), + loadReferenceRows>(pb, "companies", COMPANY_FIELDS), + ]); + const partyNames = new Map(); + for (const row of customerRows) partyNames.set(`c:${row.id}`, String(row.customer ?? "")); + for (const row of companyRows) partyNames.set(`k:${row.id}`, String(row.company ?? "")); + // Resolve supplier names ONCE for the whole item set, so "truco" // matches the orders that bought from Truco rather than the rows // carrying Truco's PB record id. @@ -530,6 +636,7 @@ async function getOrderSnapshot(pb: AdminPocketBase): Promise { orders, itemsByOrder, supplierNames, + partyNames, expiresAt: Date.now() + ORDER_SNAPSHOT_TTL_MS, }; orderSnapshotValue = snapshot; @@ -568,15 +675,39 @@ async function searchQuoteSource(q: string): Promise { const result = await searchQuotes({ query: q, perPage: HITS_PER_GROUP }); const hits: UniversalHit[] = result.hits.slice(0, HITS_PER_GROUP).map((h) => { const quote = h.quote as unknown as Record; - const expandedCustomer = quote?.expand as - | { customer?: { customer?: string } } + const expanded = quote?.expand as + | { + customer?: Record; + company?: Record; + } | undefined; return { id: String(quote?.id ?? ""), title: String(quote?.quote_number ?? quote?.id ?? ""), - subtitle: expandedCustomer?.customer?.customer, + subtitle: expanded?.customer?.customer + ? String(expanded.customer.customer) + : undefined, href: `/quotes/${quote?.id ?? ""}`, matchedOn: "quote", + preview: previewRows([ + ["Quote", quote?.quote_number], + ["Customer", expanded?.customer?.customer], + ["Company", expanded?.company?.company], + ["Status", quote?.status], + ["Subject", clip(quote?.email_subject, 90)], + ["RFQ", quote?.rfq_number], + ["Created", quote?.created ? String(quote.created).slice(0, 10) : ""], + [ + "Lines", + (h.items ?? []) + .slice(0, 3) + .map((item) => + clip((item as unknown as Record).description, 48), + ) + .filter(Boolean) + .join(" · "), + ], + ]), }; }); const total = result.totalCandidates ?? hits.length; @@ -761,6 +892,21 @@ async function searchStockPurchases( meta: `${poLines.length} line${poLines.length === 1 ? "" : "s"}`, href: `/stock-orders/${id}`, matchedOn, + preview: previewRows([ + ["Stock PO", header.purchase_number], + ["Supplier", supplierName ? supplierNames.get(String(header.supplier)) : ""], + ["Status", header.status], + ["Lines", poLines.length], + ["Value", header.ordered_value ? `R ${header.ordered_value}` : ""], + [ + "Items", + poLines + .slice(0, 3) + .map((l) => clip(l.description, 48)) + .filter(Boolean) + .join(" · "), + ], + ]), }, }); } @@ -831,12 +977,12 @@ export async function warmUniversalSearchCaches(): Promise { await Promise.all([ getOrderSnapshot(pb), loadReferenceRows(pb, "suppliers", "id,suppliername,email,suppliernumber,supply_tags"), - loadReferenceRows(pb, "companies", "id,company,address,vendor"), - loadReferenceRows(pb, "customers", "id,customer,email,number"), + loadReferenceRows(pb, "companies", COMPANY_FIELDS), + loadReferenceRows(pb, "customers", CUSTOMER_FIELDS), - loadReferenceRows(pb, "stock_items", "id,name,sku,description,unit"), - loadReferenceRows(pb, "purchase_orders", "id,po_number,status,order"), - loadReferenceRows(pb, "stock_purchases", "id,purchase_number,status,supplier"), + loadReferenceRows(pb, "stock_items", "id,name,sku,description,unit,current_qty,cost_price,selling_price"), + loadReferenceRows(pb, "purchase_orders", "id,po_number,status,total_amount,order,supplier"), + loadReferenceRows(pb, "stock_purchases", "id,purchase_number,status,supplier,ordered_value"), loadReferenceRows( pb, "stock_purchase_items", @@ -877,6 +1023,19 @@ export async function universalSearch( const pb = await getAdminPocketBase(); pb.autoCancellation(false); + // company id → name, for the Customers preview. `customers.company` is + // a relation id, and printing a raw 15-char PB id in a summary dialog + // is worse than printing nothing. Already cached (the Companies + // source loads the same rows under the same field string). + const companyRows = await loadReferenceRows>( + pb, + "companies", + COMPANY_FIELDS, + ); + const companyNameById = new Map( + companyRows.map((row) => [String(row.id), String(row.company ?? "")]), + ); + const groups = await Promise.all([ runSource("order", "Orders", () => searchOrders(pb, query)), runSource("quote", "Quotes", () => searchQuoteSource(query)), @@ -892,7 +1051,7 @@ export async function universalSearch( // detail page in this app — POs are reviewed from inside the // order — so the parent order is the hit's destination, with // the PDF as the fallback for an orphaned PO. - fields: "id,po_number,status,order", + fields: "id,po_number,status,total_amount,order,supplier", score: (r, q) => bestScore( [ @@ -906,11 +1065,20 @@ export async function universalSearch( return { id: String(r.id), title: String(r.po_number ?? r.id), - subtitle: parentOrder ? `PO · ${r.status ?? ""}` : `PO · ${r.status ?? ""}`, + subtitle: `PO · ${r.status ?? ""}`, href: parentOrder ? `/orders/${parentOrder}` : `/api/v1/purchase-orders/${r.id}/pdf?download=1`, matchedOn: "PO number", + preview: previewRows([ + ["PO", r.po_number], + ["Status", r.status], + [ + "Value", + r.total_amount ? `R ${r.total_amount}` : "", + ], + ["Created", r.created ? String(r.created).slice(0, 10) : ""], + ]), }; }, }, @@ -946,6 +1114,13 @@ export async function universalSearch( subtitle: r.email ? String(r.email) : undefined, href: `/suppliers/${r.id}`, matchedOn: "supplier", + preview: previewRows([ + ["Supplier", r.suppliername], + ["Email", r.email], + ["Phone", r.suppliernumber], + ["Address", clip(r.supplieraddress, 90)], + ["Tags", r.supply_tags], + ]), }), }, ql, @@ -959,7 +1134,7 @@ export async function universalSearch( type: "company", label: "Companies", collection: "companies", - fields: "id,company,address,vendor", + fields: COMPANY_FIELDS, score: (r, q) => bestScore( [ @@ -975,6 +1150,13 @@ export async function universalSearch( subtitle: r.address ? String(r.address) : undefined, href: `/companies/${r.id}`, matchedOn: "company", + preview: previewRows([ + ["Company", r.company], + ["Address", clip(r.address, 90)], + ["Email", r.company_email], + ["Phone", r.company_tel], + ["Vendor", r.vendor], + ]), }), }, ql, @@ -988,7 +1170,7 @@ export async function universalSearch( type: "customer", label: "Customers", collection: "customers", - fields: "id,customer,email,number", + fields: CUSTOMER_FIELDS, score: (r, q) => bestScore( [ @@ -1004,6 +1186,12 @@ export async function universalSearch( subtitle: r.email ? String(r.email) : undefined, href: `/customers/${r.id}`, matchedOn: "customer", + preview: previewRows([ + ["Customer", r.customer], + ["Email", r.email], + ["Phone", r.number], + ["Company", companyNameById.get(String(r.company ?? ""))], + ]), }), }, ql, @@ -1029,7 +1217,7 @@ export async function universalSearch( type: "stock_item", label: "Stock Items", collection: "stock_items", - fields: "id,name,sku,description,unit", + fields: "id,name,sku,description,unit,current_qty,cost_price,selling_price", score: (r, q) => bestScore( [ @@ -1045,6 +1233,15 @@ export async function universalSearch( subtitle: r.sku ? String(r.sku) : undefined, href: `/stock/${r.id}`, matchedOn: r.sku ? "SKU" : "item", + preview: previewRows([ + ["Item", r.name], + ["SKU", r.sku], + ["Description", clip(r.description, 110)], + ["Unit", r.unit], + ["On hand", r.current_qty], + ["Cost", r.cost_price ? `R ${r.cost_price}` : ""], + ["Selling", r.selling_price ? `R ${r.selling_price}` : ""], + ]), }), }, ql,