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:
parent
918f73ea25
commit
2736ebd7dc
3 changed files with 397 additions and 66 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue