diff --git a/src/app/(app)/search/page.test.tsx b/src/app/(app)/search/page.test.tsx index ceb1541..c5e1e2d 100644 --- a/src/app/(app)/search/page.test.tsx +++ b/src/app/(app)/search/page.test.tsx @@ -63,7 +63,19 @@ const RESULT = { { type: "order", label: "Orders", - hits: [{ id: "o1", title: "ORD-1", href: "/orders/o1", matchedOn: "supplier" }], + hits: [ + { + id: "o1", + title: "ORD-1", + href: "/orders/o1", + matchedOn: "supplier", + preview: [ + { label: "Order", value: "ORD-1" }, + { label: "Customer", value: "Telegenix Trading 168 CC" }, + { label: "Status", value: "po_generated" }, + ], + }, + ], total: 1, truncated: false, tookMs: 5, @@ -226,7 +238,7 @@ describe("UniversalSearchPage — the never-hide-a-cap invariant", () => { }); }); -describe("UniversalSearchPage — results open in a new tab", () => { +describe("UniversalSearchPage — quick overview dialog", () => { beforeEach(() => { fetchMock.mockReset(); vi.useFakeTimers({ shouldAdvanceTime: true }); @@ -235,6 +247,7 @@ describe("UniversalSearchPage — results open in a new tab", () => { afterEach(() => { cleanup(); vi.useRealTimers(); + vi.restoreAllMocks(); }); async function type(value: string) { @@ -243,38 +256,69 @@ describe("UniversalSearchPage — results open in a new tab", () => { await vi.advanceTimersByTimeAsync(400); } - it("uses a real anchor with target=_blank, not a client-side route change", async () => { - // next/link unmounts this page on navigation, which destroys the - // query and the result set. A plain anchor in a new tab leaves the - // search tab mounted, so clicking away and back returns to exactly - // where you were — the behaviour the operator asked for. + it("clicking a row opens the overview instead of navigating", async () => { + // The point of the preview: stay IN the result list. A client-side + // route change would unmount this page and destroy the query and + // the result set. fetchMock.mockResolvedValue(jsonResponse(RESULT)); render(); await type("truco"); - const link = await screen.findByText("ORD-1"); - const anchor = link.closest("a"); + // Scope to the list row: once the dialog is open "ORD-1" also + // appears as the dialog title, so an unscoped query is ambiguous. + const matches = await screen.findAllByText("ORD-1"); + const row = matches.find( + (el) => el.tagName === "SPAN" && el.className.includes("font-medium"), + ); + expect(row).toBeDefined(); + fireEvent.click(row as HTMLElement); - expect(anchor).not.toBeNull(); - expect(anchor).toHaveAttribute("href", "/orders/o1"); - expect(anchor).toHaveAttribute("target", "_blank"); - // Required: without noopener the opened page can reach back through - // window.opener. - expect(anchor).toHaveAttribute("rel", "noopener noreferrer"); + // Preview rows render... + await waitFor(() => + expect(screen.getByText("Telegenix Trading 168 CC")).toBeInTheDocument(), + ); + expect(screen.getByText("po_generated")).toBeInTheDocument(); + // ...and the row is a button, so there is no href to navigate. + expect((row as HTMLElement).closest("a")).toBeNull(); }); - it("keeps the query in the input after a result is opened", async () => { - // The whole point of the new tab: returning to this tab must not - // have reset the search. + it("keeps the full page one click away from the dialog", async () => { + // A preview must never become the ONLY way to reach a record. fetchMock.mockResolvedValue(jsonResponse(RESULT)); render(); await type("truco"); + fireEvent.click(await screen.findByText("ORD-1")); - await screen.findByText("ORD-1"); + const openFull = await screen.findByText("Open full page"); + const link = openFull.closest("a"); + expect(link).not.toBeNull(); + expect(link).toHaveAttribute("href", "/orders/o1"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("dismissing the dialog returns to the unchanged search results", async () => { + fetchMock.mockResolvedValue(jsonResponse(RESULT)); + + render(); + await type("truco"); + fireEvent.click(await screen.findByText("ORD-1")); + await screen.findByText("Telegenix Trading 168 CC"); + + // Close via Escape — the standard dismiss affordance. + fireEvent.keyDown(document.activeElement ?? document.body, { + key: "Escape", + }); + + await waitFor(() => + expect(screen.queryByText("Telegenix Trading 168 CC")).not.toBeInTheDocument(), + ); + // Query and results still exactly where we left them. expect( (screen.getByPlaceholderText(/Order no, quote no/i) as HTMLInputElement).value, ).toBe("truco"); + expect(screen.getAllByText("ORD-1").length).toBeGreaterThan(0); }); -}); \ No newline at end of file +}); diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx index 9755c23..238383f 100644 --- a/src/app/(app)/search/page.tsx +++ b/src/app/(app)/search/page.tsx @@ -1,9 +1,16 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Search as SearchIcon, X, Loader2, AlertTriangle, ArrowUpRight } from "lucide-react"; +import { Search as SearchIcon, X, Loader2, AlertTriangle, ArrowUpRight, ExternalLink } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import getPocketBase from "@/lib/pocketbase"; /** @@ -25,17 +32,23 @@ import getPocketBase from "@/lib/pocketbase"; * different statements. */ +type PreviewRow = { label: string; value: string }; + +type SearchHit = { + id: string; + title: string; + subtitle?: string; + meta?: string; + href: string; + matchedOn?: string; + /** Key/value rows the service fills from data it already has. */ + preview?: PreviewRow[]; +}; + type Group = { type: string; label: string; - hits: { - id: string; - title: string; - subtitle?: string; - meta?: string; - href: string; - matchedOn?: string; - }[]; + hits: SearchHit[]; total: number; truncated: boolean; tookMs: number; @@ -83,6 +96,17 @@ export default function UniversalSearchPage() { const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); const [failed, setFailed] = useState(null); + /** + * The hit whose quick overview is open, or null. + * + * Clicking a row opens this instead of navigating. The search state + * is never unmounted, so dismissing returns the operator to exactly + * the result list they left. The full page stays one click away via + * the dialog's "Open full page" link — a preview should never be the + * only way to reach a record. + */ + const [previewHit, setPreviewHit] = useState(null); + const [previewGroupLabel, setPreviewGroupLabel] = useState(""); // Guards against a slow response for an abandoned query landing // after a newer one has already rendered. @@ -282,30 +306,30 @@ export default function UniversalSearchPage() { {group.hits.map((hit) => (
  • {/* - Deliberately a plain , not - next/link. + Click opens the quick-overview dialog, NOT the + detail page. - next/link does a CLIENT-SIDE route change, which - unmounts this page — so the query, the expanded - result set and the scroll position are gone when - you come back, and "going back" means a router - back that re-runs the search anyway. + The page is never unmounted either way, so the + query, the full result set and the scroll position + survive both paths. The dialog keeps you IN the + result list — which is what you want when + comparing several hits — while the previous + new-tab behaviour left you in a different tab + entirely. - A real anchor with target="_blank" opens the record - in a new tab and leaves this tab mounted and - untouched. Clicking away and back returns to - exactly where you were, results and all. - - rel="noopener noreferrer" is required: without it - the opened page gets a handle on this window via - window.opener. + A plain
  • ))} @@ -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,