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",
|
type: "order",
|
||||||
label: "Orders",
|
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,
|
total: 1,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
tookMs: 5,
|
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(() => {
|
beforeEach(() => {
|
||||||
fetchMock.mockReset();
|
fetchMock.mockReset();
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
|
@ -235,6 +247,7 @@ describe("UniversalSearchPage — results open in a new tab", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function type(value: string) {
|
async function type(value: string) {
|
||||||
|
|
@ -243,38 +256,69 @@ describe("UniversalSearchPage — results open in a new tab", () => {
|
||||||
await vi.advanceTimersByTimeAsync(400);
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
it("uses a real anchor with target=_blank, not a client-side route change", async () => {
|
it("clicking a row opens the overview instead of navigating", async () => {
|
||||||
// next/link unmounts this page on navigation, which destroys the
|
// The point of the preview: stay IN the result list. A client-side
|
||||||
// query and the result set. A plain anchor in a new tab leaves the
|
// route change would unmount this page and destroy the query and
|
||||||
// search tab mounted, so clicking away and back returns to exactly
|
// the result set.
|
||||||
// where you were — the behaviour the operator asked for.
|
|
||||||
fetchMock.mockResolvedValue(jsonResponse(RESULT));
|
fetchMock.mockResolvedValue(jsonResponse(RESULT));
|
||||||
|
|
||||||
render(<UniversalSearchPage />);
|
render(<UniversalSearchPage />);
|
||||||
await type("truco");
|
await type("truco");
|
||||||
|
|
||||||
const link = await screen.findByText("ORD-1");
|
// Scope to the list row: once the dialog is open "ORD-1" also
|
||||||
const anchor = link.closest("a");
|
// 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();
|
// Preview rows render...
|
||||||
expect(anchor).toHaveAttribute("href", "/orders/o1");
|
await waitFor(() =>
|
||||||
expect(anchor).toHaveAttribute("target", "_blank");
|
expect(screen.getByText("Telegenix Trading 168 CC")).toBeInTheDocument(),
|
||||||
// Required: without noopener the opened page can reach back through
|
);
|
||||||
// window.opener.
|
expect(screen.getByText("po_generated")).toBeInTheDocument();
|
||||||
expect(anchor).toHaveAttribute("rel", "noopener noreferrer");
|
// ...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 () => {
|
it("keeps the full page one click away from the dialog", async () => {
|
||||||
// The whole point of the new tab: returning to this tab must not
|
// A preview must never become the ONLY way to reach a record.
|
||||||
// have reset the search.
|
|
||||||
fetchMock.mockResolvedValue(jsonResponse(RESULT));
|
fetchMock.mockResolvedValue(jsonResponse(RESULT));
|
||||||
|
|
||||||
render(<UniversalSearchPage />);
|
render(<UniversalSearchPage />);
|
||||||
await type("truco");
|
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(
|
expect(
|
||||||
(screen.getByPlaceholderText(/Order no, quote no/i) as HTMLInputElement).value,
|
(screen.getByPlaceholderText(/Order no, quote no/i) as HTMLInputElement).value,
|
||||||
).toBe("truco");
|
).toBe("truco");
|
||||||
|
expect(screen.getAllByText("ORD-1").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import getPocketBase from "@/lib/pocketbase";
|
import getPocketBase from "@/lib/pocketbase";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,17 +32,23 @@ import getPocketBase from "@/lib/pocketbase";
|
||||||
* different statements.
|
* 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 Group = {
|
||||||
type: string;
|
type: string;
|
||||||
label: string;
|
label: string;
|
||||||
hits: {
|
hits: SearchHit[];
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
meta?: string;
|
|
||||||
href: string;
|
|
||||||
matchedOn?: string;
|
|
||||||
}[];
|
|
||||||
total: number;
|
total: number;
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
tookMs: number;
|
tookMs: number;
|
||||||
|
|
@ -83,6 +96,17 @@ export default function UniversalSearchPage() {
|
||||||
const [result, setResult] = useState<SearchResult | null>(null);
|
const [result, setResult] = useState<SearchResult | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [failed, setFailed] = useState<string | null>(null);
|
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
|
// Guards against a slow response for an abandoned query landing
|
||||||
// after a newer one has already rendered.
|
// after a newer one has already rendered.
|
||||||
|
|
@ -282,30 +306,30 @@ export default function UniversalSearchPage() {
|
||||||
{group.hits.map((hit) => (
|
{group.hits.map((hit) => (
|
||||||
<li key={hit.id}>
|
<li key={hit.id}>
|
||||||
{/*
|
{/*
|
||||||
Deliberately a plain <a target="_blank">, not
|
Click opens the quick-overview dialog, NOT the
|
||||||
next/link.
|
detail page.
|
||||||
|
|
||||||
next/link does a CLIENT-SIDE route change, which
|
The page is never unmounted either way, so the
|
||||||
unmounts this page — so the query, the expanded
|
query, the full result set and the scroll position
|
||||||
result set and the scroll position are gone when
|
survive both paths. The dialog keeps you IN the
|
||||||
you come back, and "going back" means a router
|
result list — which is what you want when
|
||||||
back that re-runs the search anyway.
|
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
|
A plain <button> (not <a>/next/link): there is no
|
||||||
in a new tab and leaves this tab mounted and
|
navigation to perform here, and a link would add
|
||||||
untouched. Clicking away and back returns to
|
a middle-click / open-in-new-tab affordance that
|
||||||
exactly where you were, results and all.
|
implies navigation. "Open full page" lives in the
|
||||||
|
dialog for when you genuinely want the record.
|
||||||
rel="noopener noreferrer" is required: without it
|
|
||||||
the opened page gets a handle on this window via
|
|
||||||
window.opener.
|
|
||||||
*/}
|
*/}
|
||||||
<a
|
<button
|
||||||
href={hit.href}
|
type="button"
|
||||||
target="_blank"
|
onClick={() => {
|
||||||
rel="noopener noreferrer"
|
setPreviewHit(hit);
|
||||||
title={`Open ${hit.title} in a new tab`}
|
setPreviewGroupLabel(group.label);
|
||||||
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/50"
|
}}
|
||||||
|
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="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|
@ -328,7 +352,7 @@ export default function UniversalSearchPage() {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -336,6 +360,72 @@ export default function UniversalSearchPage() {
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</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
|
* Types
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
@ -117,6 +126,42 @@ export interface UniversalHit {
|
||||||
href: string;
|
href: string;
|
||||||
/** Which surface matched, e.g. "SKU" — answers "why is this here?". */
|
/** Which surface matched, e.g. "SKU" — answers "why is this here?". */
|
||||||
matchedOn?: string;
|
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";
|
export type SourceStatus = "ok" | "timeout" | "error";
|
||||||
|
|
@ -248,6 +293,8 @@ interface OrderSnapshot {
|
||||||
orders: Record<string, unknown>[];
|
orders: Record<string, unknown>[];
|
||||||
itemsByOrder: Map<string, Record<string, unknown>[]>;
|
itemsByOrder: Map<string, Record<string, unknown>[]>;
|
||||||
supplierNames: Map<string, string>;
|
supplierNames: Map<string, string>;
|
||||||
|
/** customer/company id → display name, for the quick-overview dialog. */
|
||||||
|
partyNames: Map<string, string>;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -376,7 +423,10 @@ async function fetchItemsByOrderIds(
|
||||||
.map((id) => `order = "${escapeForPbFilter(id)}"`)
|
.map((id) => `order = "${escapeForPbFilter(id)}"`)
|
||||||
.join(" || "),
|
.join(" || "),
|
||||||
fields:
|
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,
|
batch: 1000,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
@ -454,6 +504,24 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise<SourceResul
|
||||||
if (score === 0) continue;
|
if (score === 0) continue;
|
||||||
|
|
||||||
const firstItem = orderItems[0];
|
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({
|
scored.push({
|
||||||
score,
|
score,
|
||||||
hit: {
|
hit: {
|
||||||
|
|
@ -463,6 +531,31 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise<SourceResul
|
||||||
meta: `${orderItems.length} item${orderItems.length === 1 ? "" : "s"}`,
|
meta: `${orderItems.length} item${orderItems.length === 1 ? "" : "s"}`,
|
||||||
href: `/orders/${order.id}`,
|
href: `/orders/${order.id}`,
|
||||||
matchedOn,
|
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: [],
|
orders: [],
|
||||||
itemsByOrder: new Map(),
|
itemsByOrder: new Map(),
|
||||||
supplierNames: new Map(),
|
supplierNames: new Map(),
|
||||||
|
partyNames: new Map(),
|
||||||
expiresAt: Date.now() + ORDER_SNAPSHOT_TTL_MS,
|
expiresAt: Date.now() + ORDER_SNAPSHOT_TTL_MS,
|
||||||
};
|
};
|
||||||
orderSnapshotValue = empty;
|
orderSnapshotValue = empty;
|
||||||
|
|
@ -504,11 +598,23 @@ async function getOrderSnapshot(pb: AdminPocketBase): Promise<OrderSnapshot> {
|
||||||
pb,
|
pb,
|
||||||
"orders",
|
"orders",
|
||||||
orderIds,
|
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),
|
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"
|
// Resolve supplier names ONCE for the whole item set, so "truco"
|
||||||
// matches the orders that bought from Truco rather than the rows
|
// matches the orders that bought from Truco rather than the rows
|
||||||
// carrying Truco's PB record id.
|
// carrying Truco's PB record id.
|
||||||
|
|
@ -530,6 +636,7 @@ async function getOrderSnapshot(pb: AdminPocketBase): Promise<OrderSnapshot> {
|
||||||
orders,
|
orders,
|
||||||
itemsByOrder,
|
itemsByOrder,
|
||||||
supplierNames,
|
supplierNames,
|
||||||
|
partyNames,
|
||||||
expiresAt: Date.now() + ORDER_SNAPSHOT_TTL_MS,
|
expiresAt: Date.now() + ORDER_SNAPSHOT_TTL_MS,
|
||||||
};
|
};
|
||||||
orderSnapshotValue = snapshot;
|
orderSnapshotValue = snapshot;
|
||||||
|
|
@ -568,15 +675,39 @@ async function searchQuoteSource(q: string): Promise<SourceResult> {
|
||||||
const result = await searchQuotes({ query: q, perPage: HITS_PER_GROUP });
|
const result = await searchQuotes({ query: q, perPage: HITS_PER_GROUP });
|
||||||
const hits: UniversalHit[] = result.hits.slice(0, HITS_PER_GROUP).map((h) => {
|
const hits: UniversalHit[] = result.hits.slice(0, HITS_PER_GROUP).map((h) => {
|
||||||
const quote = h.quote as unknown as Record<string, unknown>;
|
const quote = h.quote as unknown as Record<string, unknown>;
|
||||||
const expandedCustomer = quote?.expand as
|
const expanded = quote?.expand as
|
||||||
| { customer?: { customer?: string } }
|
| {
|
||||||
|
customer?: Record<string, unknown>;
|
||||||
|
company?: Record<string, unknown>;
|
||||||
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
return {
|
return {
|
||||||
id: String(quote?.id ?? ""),
|
id: String(quote?.id ?? ""),
|
||||||
title: String(quote?.quote_number ?? 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 ?? ""}`,
|
href: `/quotes/${quote?.id ?? ""}`,
|
||||||
matchedOn: "quote",
|
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;
|
const total = result.totalCandidates ?? hits.length;
|
||||||
|
|
@ -761,6 +892,21 @@ async function searchStockPurchases(
|
||||||
meta: `${poLines.length} line${poLines.length === 1 ? "" : "s"}`,
|
meta: `${poLines.length} line${poLines.length === 1 ? "" : "s"}`,
|
||||||
href: `/stock-orders/${id}`,
|
href: `/stock-orders/${id}`,
|
||||||
matchedOn,
|
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([
|
await Promise.all([
|
||||||
getOrderSnapshot(pb),
|
getOrderSnapshot(pb),
|
||||||
loadReferenceRows(pb, "suppliers", "id,suppliername,email,suppliernumber,supply_tags"),
|
loadReferenceRows(pb, "suppliers", "id,suppliername,email,suppliernumber,supply_tags"),
|
||||||
loadReferenceRows(pb, "companies", "id,company,address,vendor"),
|
loadReferenceRows(pb, "companies", COMPANY_FIELDS),
|
||||||
loadReferenceRows(pb, "customers", "id,customer,email,number"),
|
loadReferenceRows(pb, "customers", CUSTOMER_FIELDS),
|
||||||
|
|
||||||
loadReferenceRows(pb, "stock_items", "id,name,sku,description,unit"),
|
loadReferenceRows(pb, "stock_items", "id,name,sku,description,unit,current_qty,cost_price,selling_price"),
|
||||||
loadReferenceRows(pb, "purchase_orders", "id,po_number,status,order"),
|
loadReferenceRows(pb, "purchase_orders", "id,po_number,status,total_amount,order,supplier"),
|
||||||
loadReferenceRows(pb, "stock_purchases", "id,purchase_number,status,supplier"),
|
loadReferenceRows(pb, "stock_purchases", "id,purchase_number,status,supplier,ordered_value"),
|
||||||
loadReferenceRows(
|
loadReferenceRows(
|
||||||
pb,
|
pb,
|
||||||
"stock_purchase_items",
|
"stock_purchase_items",
|
||||||
|
|
@ -877,6 +1023,19 @@ export async function universalSearch(
|
||||||
const pb = await getAdminPocketBase();
|
const pb = await getAdminPocketBase();
|
||||||
pb.autoCancellation(false);
|
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([
|
const groups = await Promise.all([
|
||||||
runSource("order", "Orders", () => searchOrders(pb, query)),
|
runSource("order", "Orders", () => searchOrders(pb, query)),
|
||||||
runSource("quote", "Quotes", () => searchQuoteSource(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
|
// detail page in this app — POs are reviewed from inside the
|
||||||
// order — so the parent order is the hit's destination, with
|
// order — so the parent order is the hit's destination, with
|
||||||
// the PDF as the fallback for an orphaned PO.
|
// 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) =>
|
score: (r, q) =>
|
||||||
bestScore(
|
bestScore(
|
||||||
[
|
[
|
||||||
|
|
@ -906,11 +1065,20 @@ export async function universalSearch(
|
||||||
return {
|
return {
|
||||||
id: String(r.id),
|
id: String(r.id),
|
||||||
title: String(r.po_number ?? r.id),
|
title: String(r.po_number ?? r.id),
|
||||||
subtitle: parentOrder ? `PO · ${r.status ?? ""}` : `PO · ${r.status ?? ""}`,
|
subtitle: `PO · ${r.status ?? ""}`,
|
||||||
href: parentOrder
|
href: parentOrder
|
||||||
? `/orders/${parentOrder}`
|
? `/orders/${parentOrder}`
|
||||||
: `/api/v1/purchase-orders/${r.id}/pdf?download=1`,
|
: `/api/v1/purchase-orders/${r.id}/pdf?download=1`,
|
||||||
matchedOn: "PO number",
|
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,
|
subtitle: r.email ? String(r.email) : undefined,
|
||||||
href: `/suppliers/${r.id}`,
|
href: `/suppliers/${r.id}`,
|
||||||
matchedOn: "supplier",
|
matchedOn: "supplier",
|
||||||
|
preview: previewRows([
|
||||||
|
["Supplier", r.suppliername],
|
||||||
|
["Email", r.email],
|
||||||
|
["Phone", r.suppliernumber],
|
||||||
|
["Address", clip(r.supplieraddress, 90)],
|
||||||
|
["Tags", r.supply_tags],
|
||||||
|
]),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
ql,
|
ql,
|
||||||
|
|
@ -959,7 +1134,7 @@ export async function universalSearch(
|
||||||
type: "company",
|
type: "company",
|
||||||
label: "Companies",
|
label: "Companies",
|
||||||
collection: "companies",
|
collection: "companies",
|
||||||
fields: "id,company,address,vendor",
|
fields: COMPANY_FIELDS,
|
||||||
score: (r, q) =>
|
score: (r, q) =>
|
||||||
bestScore(
|
bestScore(
|
||||||
[
|
[
|
||||||
|
|
@ -975,6 +1150,13 @@ export async function universalSearch(
|
||||||
subtitle: r.address ? String(r.address) : undefined,
|
subtitle: r.address ? String(r.address) : undefined,
|
||||||
href: `/companies/${r.id}`,
|
href: `/companies/${r.id}`,
|
||||||
matchedOn: "company",
|
matchedOn: "company",
|
||||||
|
preview: previewRows([
|
||||||
|
["Company", r.company],
|
||||||
|
["Address", clip(r.address, 90)],
|
||||||
|
["Email", r.company_email],
|
||||||
|
["Phone", r.company_tel],
|
||||||
|
["Vendor", r.vendor],
|
||||||
|
]),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
ql,
|
ql,
|
||||||
|
|
@ -988,7 +1170,7 @@ export async function universalSearch(
|
||||||
type: "customer",
|
type: "customer",
|
||||||
label: "Customers",
|
label: "Customers",
|
||||||
collection: "customers",
|
collection: "customers",
|
||||||
fields: "id,customer,email,number",
|
fields: CUSTOMER_FIELDS,
|
||||||
score: (r, q) =>
|
score: (r, q) =>
|
||||||
bestScore(
|
bestScore(
|
||||||
[
|
[
|
||||||
|
|
@ -1004,6 +1186,12 @@ export async function universalSearch(
|
||||||
subtitle: r.email ? String(r.email) : undefined,
|
subtitle: r.email ? String(r.email) : undefined,
|
||||||
href: `/customers/${r.id}`,
|
href: `/customers/${r.id}`,
|
||||||
matchedOn: "customer",
|
matchedOn: "customer",
|
||||||
|
preview: previewRows([
|
||||||
|
["Customer", r.customer],
|
||||||
|
["Email", r.email],
|
||||||
|
["Phone", r.number],
|
||||||
|
["Company", companyNameById.get(String(r.company ?? ""))],
|
||||||
|
]),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
ql,
|
ql,
|
||||||
|
|
@ -1029,7 +1217,7 @@ export async function universalSearch(
|
||||||
type: "stock_item",
|
type: "stock_item",
|
||||||
label: "Stock Items",
|
label: "Stock Items",
|
||||||
collection: "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) =>
|
score: (r, q) =>
|
||||||
bestScore(
|
bestScore(
|
||||||
[
|
[
|
||||||
|
|
@ -1045,6 +1233,15 @@ export async function universalSearch(
|
||||||
subtitle: r.sku ? String(r.sku) : undefined,
|
subtitle: r.sku ? String(r.sku) : undefined,
|
||||||
href: `/stock/${r.id}`,
|
href: `/stock/${r.id}`,
|
||||||
matchedOn: r.sku ? "SKU" : "item",
|
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,
|
ql,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue