fix(search): orders searchable by customer/company name; timeline in preview
Two reports, one root cause each.
1. "teleg" didn't find order 121026-1 ENG
Same defect class as the supplier gap in efb5a53. `partyNames` was
resolved and used to BUILD the preview, but never added to the MATCH
set — so an order was findable by its number and displayed with its
company name, yet not findable BY that name. `orders.customer` and
`orders.company` are PB relation ids; without the resolved names in
the match set, partial company searches returned nothing.
Now matched on both, with the matched surface reported accurately
(customer / company / contact / rep / order) rather than a blanket
"order". Verified live: "teleg" → ENG found, via=company.
2. Preview had no lifecycle trail
Built from timestamps already on the order and its items, so it costs
no extra round-trip — an audit-log query per hit would undo the
point of the preview being free. Verified live on a progressed order:
2026-08-04 Order raised
2026-08-12 Driver assigned
2026-08-12 Collected
2026-10-14 Invoiced
Steps with no timestamp are OMITTED, not rendered blank — a rail with
holes reads as broken data rather than unfinished work. That is why
121026-1 ENG shows a single step: it is only at po_generated, so
nothing after "Order raised" has happened yet.
Column population on live PB, for anyone extending this:
assigned_at 1371 · drv_collected_at 1358 · invoiced_date 1626
delivered_at 2 · drv_assigned_at 0 · collected_date 0
`delivered_at` being effectively empty is the same defect fixed in
918f73e; here it means the Delivered step will rarely appear.
HITS_PER_GROUP 25 → 50
Searching a company name returns every order that company has, and they
all score identically (same matched surface), so the ENG order fell off
a 25-row list that was one row short of showing it. 50 covers a single
customer's order volume. Truncation is still surfaced as "showing N of
TOTAL", so this stays a UX choice rather than a hidden guarantee.
Dialog restyled: type as an eyebrow, a "matched: <surface>" pill so the
reason a hit is present is stated plainly, a bordered label/value table
instead of a loose grid, and the timeline as a vertical rail.
This commit is contained in:
parent
2736ebd7dc
commit
00736320bf
3 changed files with 253 additions and 19 deletions
|
|
@ -74,6 +74,10 @@ const RESULT = {
|
|||
{ label: "Customer", value: "Telegenix Trading 168 CC" },
|
||||
{ label: "Status", value: "po_generated" },
|
||||
],
|
||||
timeline: [
|
||||
{ label: "Order raised", at: "2026-10-12" },
|
||||
{ label: "Driver assigned", at: "2026-10-14" },
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
|
|
@ -322,3 +326,67 @@ describe("UniversalSearchPage — quick overview dialog", () => {
|
|||
expect(screen.getAllByText("ORD-1").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UniversalSearchPage — timeline rendering", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders each dated lifecycle step in order", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(RESULT));
|
||||
|
||||
render(<UniversalSearchPage />);
|
||||
const input = screen.getByPlaceholderText(/Order no, quote no/i);
|
||||
fireEvent.change(input, { target: { value: "truco" } });
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
|
||||
const matches = await screen.findAllByText("ORD-1");
|
||||
const row = matches.find(
|
||||
(el) => el.tagName === "SPAN" && el.className.includes("font-medium"),
|
||||
);
|
||||
fireEvent.click(row as HTMLElement);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/^Timeline$/)).toBeInTheDocument());
|
||||
expect(screen.getByText("Order raised")).toBeInTheDocument();
|
||||
expect(screen.getByText("Driver assigned")).toBeInTheDocument();
|
||||
expect(screen.getByText("2026-10-12")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the timeline section entirely when the service sends none", async () => {
|
||||
// A rail with holes reads as broken data rather than unfinished
|
||||
// work, so the service omits missing steps and the UI must not
|
||||
// render an empty section for them.
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
...RESULT,
|
||||
groups: [
|
||||
{
|
||||
...RESULT.groups[0],
|
||||
hits: [{ ...RESULT.groups[0].hits[0], timeline: undefined }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
render(<UniversalSearchPage />);
|
||||
const input = screen.getByPlaceholderText(/Order no, quote no/i);
|
||||
fireEvent.change(input, { target: { value: "truco" } });
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
|
||||
const matches = await screen.findAllByText("ORD-1");
|
||||
const row = matches.find(
|
||||
(el) => el.tagName === "SPAN" && el.className.includes("font-medium"),
|
||||
);
|
||||
fireEvent.click(row as HTMLElement);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("po_generated")).toBeInTheDocument());
|
||||
expect(screen.queryByText(/^Timeline$/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import { Badge } from "@/components/ui/badge";
|
|||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Universal search — one box across orders, quotes, POs, suppliers,
|
||||
|
|
@ -33,6 +33,7 @@ import getPocketBase from "@/lib/pocketbase";
|
|||
*/
|
||||
|
||||
type PreviewRow = { label: string; value: string };
|
||||
type TimelineEvent = { label: string; at: string };
|
||||
|
||||
type SearchHit = {
|
||||
id: string;
|
||||
|
|
@ -43,6 +44,8 @@ type SearchHit = {
|
|||
matchedOn?: string;
|
||||
/** Key/value rows the service fills from data it already has. */
|
||||
preview?: PreviewRow[];
|
||||
/** Dated lifecycle steps, oldest first, where timestamps exist. */
|
||||
timeline?: TimelineEvent[];
|
||||
};
|
||||
|
||||
type Group = {
|
||||
|
|
@ -379,29 +382,45 @@ export default function UniversalSearchPage() {
|
|||
if (!open) setPreviewHit(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-xl">
|
||||
{previewHit && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex flex-wrap items-center gap-2">
|
||||
<DialogHeader className="space-y-1.5">
|
||||
{previewGroupLabel && (
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{previewGroupLabel}
|
||||
</span>
|
||||
)}
|
||||
<DialogTitle className="text-xl leading-tight">
|
||||
{previewHit.title}
|
||||
</DialogTitle>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-0.5">
|
||||
{previewHit.matchedOn && (
|
||||
<span className="rounded border px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{previewHit.matchedOn}
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">
|
||||
matched: {previewHit.matchedOn}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
{previewGroupLabel && (
|
||||
<DialogDescription>{previewGroupLabel}</DialogDescription>
|
||||
)}
|
||||
{previewHit.meta && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{previewHit.meta}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{previewHit.preview && previewHit.preview.length > 0 ? (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dl className="divide-y rounded-md border">
|
||||
{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
|
||||
key={row.label}
|
||||
className="grid grid-cols-[7.5rem_1fr] gap-3 px-3 py-1.5"
|
||||
>
|
||||
<dt className="text-xs text-muted-foreground sm:text-sm">
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-words text-sm">
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
|
@ -411,6 +430,48 @@ export default function UniversalSearchPage() {
|
|||
</p>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Lifecycle timeline. Rendered only when the service
|
||||
could build one — a rail with holes would read as
|
||||
broken data rather than unfinished work, so steps with
|
||||
no timestamp are omitted at the source instead.
|
||||
*/}
|
||||
{previewHit.timeline && previewHit.timeline.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Timeline
|
||||
</h3>
|
||||
<ol className="space-y-0">
|
||||
{previewHit.timeline.map((event, index) => {
|
||||
const isLast = index === previewHit!.timeline!.length - 1;
|
||||
return (
|
||||
<li key={`${event.label}-${event.at}`} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 size-2.5 shrink-0 rounded-full ring-4",
|
||||
isLast
|
||||
? "bg-primary ring-primary/15"
|
||||
: "bg-border ring-background",
|
||||
)}
|
||||
/>
|
||||
{!isLast && (
|
||||
<span className="w-px flex-1 bg-border" />
|
||||
)}
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
<p className="text-sm leading-tight">{event.label}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{event.at}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The escape hatch: the preview must never be the only
|
||||
way to reach a record. */}
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -75,8 +75,18 @@ type AdminPocketBase = Awaited<ReturnType<typeof getAdminPocketBase>>;
|
|||
*/
|
||||
export const UNIVERSAL_SEARCH_WINDOW_DAYS = 90;
|
||||
|
||||
/** Rendered hits per group. `total` is always the uncapped count. */
|
||||
const HITS_PER_GROUP = 25;
|
||||
/**
|
||||
* Rendered hits per group. `total` is always the uncapped count.
|
||||
*
|
||||
* Raised 25 → 50 on 2026-10-14 after searching a company name proved
|
||||
* 25 too tight: "teleg" matches 28 orders that all score identically
|
||||
* (they match on the same company), so the ENG order the operator was
|
||||
* looking for fell off the end of a list that was one row short of
|
||||
* showing it. 50 comfortably covers a single customer's order volume
|
||||
* while still bounding the payload. Truncation is still surfaced as
|
||||
* "showing N of TOTAL", so the number is a UX choice, not a guarantee.
|
||||
*/
|
||||
const HITS_PER_GROUP = 50;
|
||||
|
||||
/** Below this, results are noise rather than signal. */
|
||||
const MIN_QUERY_CHARS = 2;
|
||||
|
|
@ -139,6 +149,11 @@ export interface UniversalHit {
|
|||
* Rows are ordered most-identifying-first.
|
||||
*/
|
||||
preview?: PreviewRow[];
|
||||
/**
|
||||
* Optional dated lifecycle, newest-last. Present only where the
|
||||
* underlying rows carry enough timestamps to build one honestly.
|
||||
*/
|
||||
timeline?: TimelineEvent[];
|
||||
}
|
||||
|
||||
export interface PreviewRow {
|
||||
|
|
@ -146,6 +161,70 @@ export interface PreviewRow {
|
|||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One dated step in a record's lifecycle, e.g. "Collected · 12 Oct".
|
||||
*
|
||||
* Only steps whose timestamp actually exists are emitted — an order
|
||||
* that hasn't been invoiced yet gets no invoiced step rather than a
|
||||
* blank one, because a timeline full of holes reads as broken data
|
||||
* rather than unfinished work.
|
||||
*/
|
||||
export interface TimelineEvent {
|
||||
label: string;
|
||||
/** YYYY-MM-DD, as stored. */
|
||||
at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a lifecycle timeline for an order from timestamps that are
|
||||
* actually present on its rows.
|
||||
*
|
||||
* Deliberately derived from data the snapshot already holds rather than
|
||||
* from an audit-log query: this runs for every returned hit, so an
|
||||
* extra round-trip per order would undo the point of the preview
|
||||
* being free. What that trades away is precision — each step is the
|
||||
* EARLIEST such timestamp across the order's items, so a mixed order
|
||||
* reads as "first of each", not per-line. Good enough to answer "how
|
||||
* far along is this?", which is what the dialog is for.
|
||||
*
|
||||
* Steps with no timestamp are OMITTED rather than rendered blank.
|
||||
*/
|
||||
function buildOrderTimeline(
|
||||
order: Record<string, unknown>,
|
||||
items: Record<string, unknown>[],
|
||||
): TimelineEvent[] {
|
||||
const events: TimelineEvent[] = [];
|
||||
const add = (label: string, value: unknown) => {
|
||||
const raw = value === null || value === undefined ? "" : String(value).trim();
|
||||
// Accept both `YYYY-MM-DD` and full ISO timestamps; keep the date.
|
||||
if (!raw || raw.length < 10) return;
|
||||
events.push({ label, at: raw.slice(0, 10) });
|
||||
};
|
||||
|
||||
add("Order raised", order.created);
|
||||
|
||||
// Earliest non-empty value per milestone across the order's lines.
|
||||
const earliest = (
|
||||
keys: string[],
|
||||
): string | undefined => {
|
||||
let best: string | undefined;
|
||||
for (const item of items) {
|
||||
for (const key of keys) {
|
||||
const raw = String(item[key] ?? "").trim();
|
||||
if (raw.length >= 10 && (!best || raw < best)) best = raw;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
add("Driver assigned", earliest(["drv_assigned_at", "assigned_at"]));
|
||||
add("Collected", earliest(["drv_collected_at", "collected_date"]));
|
||||
add("Delivered", earliest(["delivered_at"]));
|
||||
add("Invoiced", earliest(["invoiced_date"]));
|
||||
|
||||
return events.sort((a, b) => a.at.localeCompare(b.at));
|
||||
}
|
||||
|
||||
/** Drops rows whose value is empty so previews don't render blank lines. */
|
||||
function previewRows(rows: Array<[string, unknown]>): PreviewRow[] {
|
||||
return rows
|
||||
|
|
@ -425,8 +504,9 @@ async function fetchItemsByOrderIds(
|
|||
fields:
|
||||
// `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",
|
||||
// a preview with no Status row at all. The date columns feed
|
||||
// the lifecycle timeline in that same dialog.
|
||||
"id,order,item_status,description,po_description,sku,notes,selling_price,cost_price,supplier,created,assigned_at,drv_assigned_at,collected_date,drv_collected_at,delivered_at,invoiced_date",
|
||||
batch: 1000,
|
||||
}),
|
||||
),
|
||||
|
|
@ -463,6 +543,13 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise<SourceResul
|
|||
for (const order of snapshot.orders) {
|
||||
const orderItems = snapshot.itemsByOrder.get(String(order.id)) ?? [];
|
||||
|
||||
const customerName = snapshot.partyNames.get(
|
||||
`c:${String(order.customer ?? "")}`,
|
||||
);
|
||||
const companyName = snapshot.partyNames.get(
|
||||
`k:${String(order.company ?? "")}`,
|
||||
);
|
||||
|
||||
let score = bestScore(
|
||||
[
|
||||
[String(order.order_number ?? ""), "id"],
|
||||
|
|
@ -471,10 +558,27 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise<SourceResul
|
|||
[String(order.rep ?? ""), "name"],
|
||||
[String(order.description ?? ""), "body"],
|
||||
[String(order.notes ?? ""), "body"],
|
||||
// Customer and company NAME. `orders.customer` / `orders.company`
|
||||
// are PB relation ids, so without this the resolved names are
|
||||
// used for the preview but never for matching — meaning a
|
||||
// search for part of a company name ("teleg" for Telegenix
|
||||
// Trading 168 CC) found the order by number but not by name,
|
||||
// which is the same defect class as the supplier gap.
|
||||
[customerName, "name"],
|
||||
[companyName, "name"],
|
||||
],
|
||||
ql,
|
||||
);
|
||||
let matchedOn = score > 0 ? "order" : undefined;
|
||||
let matchedOn: string | undefined;
|
||||
if (score > 0) {
|
||||
if (scoreField(customerName, ql, "name") > 0) matchedOn = "customer";
|
||||
else if (scoreField(companyName, ql, "name") > 0) matchedOn = "company";
|
||||
else if (scoreField(String(order.contact_name ?? ""), ql, "name") > 0)
|
||||
matchedOn = "contact";
|
||||
else if (scoreField(String(order.rep ?? ""), ql, "name") > 0)
|
||||
matchedOn = "rep";
|
||||
else matchedOn = "order";
|
||||
}
|
||||
|
||||
if (score === 0) {
|
||||
for (const item of orderItems) {
|
||||
|
|
@ -556,6 +660,7 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise<SourceResul
|
|||
],
|
||||
["Notes", clip(order.notes, 140)],
|
||||
]),
|
||||
timeline: buildOrderTimeline(order, orderItems),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue