feat(search): stock orders by product/supplier; results open in a new tab

STOCK ORDERS

The Stock Orders source matched only `purchase_number` and `status`,
which made stock POs findable only by our own internal sequence number.
Live probe on 2026-10-14: searching "saw chain", "protech" or any line
description returned ZERO stock-order hits, while the literal
"PO26101404STK" matched. An operator hunting a stock order thinks in
product or supplier, not in our numbering, so the source now also
matches the resolved supplier name and every line's description, sku,
supplier_sku and product_code.

Verified live:
  "pro-tech"     -> 13 stock orders  (supplier name)
  "clamp"        -> 1                (line description)
  "chelsea boot" -> 4                (line description)
  "M36 NUT"      -> 2                (line description)

Also fixed the matched-surface label: a header match was always tagged
"PO number", so a supplier-name hit claimed to have matched the PO
number. A "why did this match" tag that's wrong teaches the operator the
tags are noise and stops them reading any of them.

Both new collections join the cache warm so the first search doesn't pay
for them.

RESULTS OPEN IN A NEW TAB

Switched from next/link to a plain `<a target="_blank" rel="noopener
noreferrer">`.

next/link does a client-side route change, which UNMOUNTS the search
page — so the query, the expanded result set and the scroll position are
all gone when you come back. A real anchor in a new tab leaves this tab
mounted and untouched, which is the behaviour that was asked for:
click a result, look at it, click back to the search tab, and you are
exactly where you were.

Verified by test: dropping `target` fails the new-tab assertion while
the rest of the suite stays green.
This commit is contained in:
Dave 2026-10-14 12:25:18 +02:00
parent f355895c75
commit f1dc78c9a0
3 changed files with 220 additions and 30 deletions

View file

@ -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(<UniversalSearchPage />);
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(<UniversalSearchPage />);
await type("truco");
await screen.findByText("ORD-1");
expect(
(screen.getByPlaceholderText(/Order no, quote no/i) as HTMLInputElement).value,
).toBe("truco");
});
});

View file

@ -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() {
<ul className="divide-y rounded-md border">
{group.hits.map((hit) => (
<li key={hit.id}>
<Link
{/*
Deliberately a plain <a target="_blank">, not
next/link.
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.
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
href={hit.href}
target="_blank"
rel="noopener noreferrer"
title={`Open ${hit.title} in a new tab`}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/50"
>
<div className="min-w-0 flex-1">
@ -307,7 +328,7 @@ export default function UniversalSearchPage() {
</span>
)}
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</Link>
</a>
</li>
))}
</ul>

View file

@ -605,6 +605,135 @@ async function searchReferenceCollection<T extends Record<string, unknown>>(
};
}
/**
* 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<SourceResult> {
const headers = await loadReferenceRows<Record<string, unknown>>(
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<Record<string, unknown>>(
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<string, Record<string, unknown>[]>();
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<void> {
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<Record<string, unknown>>(
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", () =>