diff --git a/src/app/(app)/search/page.test.tsx b/src/app/(app)/search/page.test.tsx index 627a688..ceb1541 100644 --- a/src/app/(app)/search/page.test.tsx +++ b/src/app/(app)/search/page.test.tsx @@ -138,6 +138,12 @@ describe("UniversalSearchPage — the never-hide-a-cap invariant", () => { afterEach(() => { cleanup(); vi.useRealTimers(); + // The session-expiry test swaps the pocketbase module's default + // export for a token-less one. Without restoring it that stub + // leaks into every later test, which then render no results at all + // and fail for a reason that has nothing to do with what they + // assert. + vi.restoreAllMocks(); }); async function type(value: string) { @@ -218,4 +224,57 @@ describe("UniversalSearchPage — the never-hide-a-cap invariant", () => { expect(screen.getByText(/session has expired/i)).toBeInTheDocument(), ); }); +}); + +describe("UniversalSearchPage — results open in a new tab", () => { + beforeEach(() => { + fetchMock.mockReset(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + async function type(value: string) { + const input = screen.getByPlaceholderText(/Order no, quote no/i); + fireEvent.change(input, { target: { value } }); + 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. + fetchMock.mockResolvedValue(jsonResponse(RESULT)); + + render(); + await type("truco"); + + const link = await screen.findByText("ORD-1"); + const anchor = link.closest("a"); + + 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"); + }); + + 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. + fetchMock.mockResolvedValue(jsonResponse(RESULT)); + + render(); + await type("truco"); + + await screen.findByText("ORD-1"); + expect( + (screen.getByPlaceholderText(/Order no, quote no/i) as HTMLInputElement).value, + ).toBe("truco"); + }); }); \ No newline at end of file diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx index 84fecbf..9755c23 100644 --- a/src/app/(app)/search/page.tsx +++ b/src/app/(app)/search/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import Link from "next/link"; import { Search as SearchIcon, X, Loader2, AlertTriangle, ArrowUpRight } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -282,8 +281,30 @@ export default function UniversalSearchPage() { diff --git a/src/lib/server/universal-search-service.ts b/src/lib/server/universal-search-service.ts index c1907f2..6f4e8a1 100644 --- a/src/lib/server/universal-search-service.ts +++ b/src/lib/server/universal-search-service.ts @@ -605,6 +605,135 @@ async function searchReferenceCollection>( }; } +/** + * Stock purchase orders. + * + * Matching the header number alone made these effectively unfindable by + * anything a person actually remembers: live probe showed "saw chain", + * "protech" and product descriptions returning ZERO Stock Orders hits + * while only the literal `PO26101404STK` matched. An operator hunting a + * stock order thinks in product or supplier, not in our internal + * sequence number, so the source now matches: + * - purchase_number / status (header) + * - supplier name (resolved from `supplier` on the header) + * - every line's description / sku / supplier_sku / product_code + * + * The whole collection is small (165 headers, a few hundred lines), so + * this loads it into the shared reference cache and matches in memory + * rather than doing the chunked id dance the orders source needs. + */ +async function searchStockPurchases( + pb: AdminPocketBase, + ql: string, +): Promise { + const headers = await loadReferenceRows>( + pb, + "stock_purchases", + "id,purchase_number,status,supplier", + ); + if (headers.length === 0) { + return { type: "stock_purchase", label: "Stock Orders", hits: [], total: 0, truncated: false }; + } + + const lines = await loadReferenceRows>( + pb, + "stock_purchase_items", + "id,purchase,description,sku,supplier_sku,product_code", + ); + const supplierIds = Array.from( + new Set(headers.map((h) => String(h.supplier ?? "")).filter(Boolean)), + ); + const supplierNames = await fetchSupplierNameMap(pb, supplierIds); + + const linesByPurchase = new Map[]>(); + for (const line of lines) { + const key = String(line.purchase ?? ""); + if (!key) continue; + const bucket = linesByPurchase.get(key); + if (bucket) bucket.push(line); + else linesByPurchase.set(key, [line]); + } + + const scored: { hit: UniversalHit; score: number }[] = []; + for (const header of headers) { + const id = String(header.id); + const poLines = linesByPurchase.get(id) ?? []; + const supplierName = supplierNames.get(String(header.supplier ?? "")); + + let score = bestScore( + [ + [String(header.purchase_number ?? ""), "id"], + [supplierName, "name"], + [String(header.status ?? ""), "body"], + ], + ql, + ); + // Label the surface that ACTUALLY matched, not just "a header + // matched" — a hit tagged "PO number" when the query was a supplier + // name tells the operator the tag is noise and stops them trusting + // any of them. + let matchedOn: string | undefined; + if (score > 0) { + matchedOn = scoreField(String(header.purchase_number ?? ""), ql, "id") > 0 + ? "PO number" + : scoreField(supplierName, ql, "name") > 0 + ? "supplier" + : "status"; + } + + if (score === 0) { + for (const line of poLines) { + const s = bestScore( + [ + [String(line.sku ?? ""), "id"], + [String(line.supplier_sku ?? ""), "id"], + [String(line.description ?? ""), "name"], + ], + ql, + ); + if (s > 0) { + score = s; + matchedOn = + String(line.sku ?? "").toLowerCase().includes(ql) || + String(line.supplier_sku ?? "").toLowerCase().includes(ql) + ? "SKU" + : "item"; + break; + } + } + } + + if (score === 0) continue; + + scored.push({ + score, + hit: { + id, + title: String(header.purchase_number ?? id), + subtitle: [ + `Stock PO · ${header.status ?? ""}`, + supplierName ? String(supplierNames.get(String(header.supplier))) : "", + poLines[0] ? String(poLines[0].description ?? "") : "", + ] + .filter(Boolean) + .join(" · "), + meta: `${poLines.length} line${poLines.length === 1 ? "" : "s"}`, + href: `/stock-orders/${id}`, + matchedOn, + }, + }); + } + + scored.sort((a, b) => b.score - a.score); + return { + type: "stock_purchase", + label: "Stock Orders", + hits: scored.slice(0, HITS_PER_GROUP).map((s) => s.hit), + total: scored.length, + truncated: scored.length > HITS_PER_GROUP, + }; +} + /* ============================================================================ * Orchestrator * ========================================================================== */ @@ -665,8 +794,13 @@ export async function warmUniversalSearchCaches(): Promise { loadReferenceRows(pb, "customers", "id,customer,email,number"), loadReferenceRows(pb, "stock_items", "id,name,sku,description,unit"), - loadReferenceRows(pb, "purchase_orders", "id,po_number,status"), - loadReferenceRows(pb, "stock_purchases", "id,purchase_number,status"), + loadReferenceRows(pb, "purchase_orders", "id,po_number,status,order"), + loadReferenceRows(pb, "stock_purchases", "id,purchase_number,status,supplier"), + loadReferenceRows( + pb, + "stock_purchase_items", + "id,purchase,description,sku,supplier_sku,product_code", + ), ]); } catch { // Best-effort. The first real search will simply rebuild. @@ -744,31 +878,7 @@ export async function universalSearch( ), runSource("stock_purchase", "Stock Orders", () => - searchReferenceCollection>( - pb, - { - type: "stock_purchase", - label: "Stock Orders", - collection: "stock_purchases", - fields: "id,purchase_number,status", - score: (r, q) => - bestScore( - [ - [String(r.purchase_number ?? ""), "id"], - [String(r.status ?? ""), "body"], - ], - q, - ), - build: (r) => ({ - id: String(r.id), - title: String(r.purchase_number ?? r.id), - subtitle: `Stock PO · ${r.status ?? ""}`, - href: `/stock-orders/${r.id}`, - matchedOn: "PO number", - }), - }, - ql, - ), + searchStockPurchases(pb, ql), ), runSource("supplier", "Suppliers", () =>