feat(search): quick-overview dialog per search hit

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
    <Sidebar /> 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 <button>, not <a>/next/link: there is no navigation to
perform, and a link would imply a middle-click affordance that doesn't
apply. "Open full page" in the dialog is the escape hatch — a preview
must never be the only way to reach a record.

Tests: the two new-tab tests are replaced by three dialog tests (opens
instead of navigating, escape hatch present with correct attrs,
dismissing leaves query and results untouched).
This commit is contained in:
Dave 2026-10-14 13:17:08 +02:00
parent 918f73ea25
commit 2736ebd7dc
3 changed files with 397 additions and 66 deletions

View file

@ -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(<UniversalSearchPage />);
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(<UniversalSearchPage />);
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(<UniversalSearchPage />);
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);
});
});

View file

@ -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 Group = {
type: string;
label: string;
hits: {
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: SearchHit[];
total: number;
truncated: boolean;
tookMs: number;
@ -83,6 +96,17 @@ export default function UniversalSearchPage() {
const [result, setResult] = useState<SearchResult | null>(null);
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState<string | null>(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<SearchHit | null>(null);
const [previewGroupLabel, setPreviewGroupLabel] = useState<string>("");
// 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) => (
<li key={hit.id}>
{/*
Deliberately a plain <a target="_blank">, 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 <button> (not <a>/next/link): there is no
navigation to perform here, and a link would add
a middle-click / open-in-new-tab affordance that
implies navigation. "Open full page" lives in the
dialog for when you genuinely want the record.
*/}
<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"
<button
type="button"
onClick={() => {
setPreviewHit(hit);
setPreviewGroupLabel(group.label);
}}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/50"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
@ -328,7 +352,7 @@ export default function UniversalSearchPage() {
</span>
)}
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</a>
</button>
</li>
))}
</ul>
@ -336,6 +360,72 @@ export default function UniversalSearchPage() {
</section>
))}
</div>
{/*
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.
*/}
<Dialog
open={previewHit !== null}
onOpenChange={(open) => {
if (!open) setPreviewHit(null);
}}
>
<DialogContent className="max-w-lg">
{previewHit && (
<>
<DialogHeader>
<DialogTitle className="flex flex-wrap items-center gap-2">
{previewHit.title}
{previewHit.matchedOn && (
<span className="rounded border px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{previewHit.matchedOn}
</span>
)}
</DialogTitle>
{previewGroupLabel && (
<DialogDescription>{previewGroupLabel}</DialogDescription>
)}
</DialogHeader>
{previewHit.preview && previewHit.preview.length > 0 ? (
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
{previewHit.preview.map((row) => (
<div key={row.label} className="contents">
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="min-w-0 break-words">{row.value}</dd>
</div>
))}
</dl>
) : (
<p className="text-sm text-muted-foreground">
No details available for this result.
</p>
)}
{/* The escape hatch: the preview must never be the only
way to reach a record. */}
<a
href={previewHit.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary underline-offset-4 hover:underline"
>
<ExternalLink className="h-3.5 w-3.5" />
Open full page
</a>
</>
)}
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -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<string, unknown>[];
itemsByOrder: Map<string, Record<string, unknown>[]>;
supplierNames: Map<string, string>;
/** customer/company id → display name, for the quick-overview dialog. */
partyNames: Map<string, string>;
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<SourceResul
if (score === 0) continue;
const firstItem = orderItems[0];
// Status breakdown is the fastest read on an order: an operator
// asking "is this live or done?" shouldn't have to open it.
const statusCounts = new Map<string, number>();
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<SourceResul
meta: `${orderItems.length} item${orderItems.length === 1 ? "" : "s"}`,
href: `/orders/${order.id}`,
matchedOn,
preview: previewRows([
["Order", order.order_number],
[
"Customer",
snapshot.partyNames.get(`c:${String(order.customer ?? "")}`),
],
[
"Company",
snapshot.partyNames.get(`k:${String(order.company ?? "")}`),
],
["Rep", order.rep],
["Status", statusSummary],
["Items", orderItems.length],
["Created", order.created ? String(order.created).slice(0, 10) : ""],
["Suppliers", supplierNamesForOrder.join(", ")],
[
"Lines",
orderItems
.slice(0, 3)
.map((i) => clip(i.description, 48))
.filter(Boolean)
.join(" · "),
],
["Notes", clip(order.notes, 140)],
]),
},
});
}
@ -493,6 +586,7 @@ async function getOrderSnapshot(pb: AdminPocketBase): Promise<OrderSnapshot> {
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<OrderSnapshot> {
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<Record<string, unknown>>(pb, "customers", CUSTOMER_FIELDS),
loadReferenceRows<Record<string, unknown>>(pb, "companies", COMPANY_FIELDS),
]);
const partyNames = new Map<string, string>();
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<OrderSnapshot> {
orders,
itemsByOrder,
supplierNames,
partyNames,
expiresAt: Date.now() + ORDER_SNAPSHOT_TTL_MS,
};
orderSnapshotValue = snapshot;
@ -568,15 +675,39 @@ async function searchQuoteSource(q: string): Promise<SourceResult> {
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<string, unknown>;
const expandedCustomer = quote?.expand as
| { customer?: { customer?: string } }
const expanded = quote?.expand as
| {
customer?: Record<string, unknown>;
company?: Record<string, unknown>;
}
| 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<string, unknown>).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<void> {
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<Record<string, unknown>>(
pb,
"companies",
COMPANY_FIELDS,
);
const companyNameById = new Map<string, string>(
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,