From a1a2cc73e2aadee9d2b515d2c7935e6fabbd5186 Mon Sep 17 00:00:00 2001
From: Dave
Date: Fri, 16 Oct 2026 18:45:44 +0200
Subject: [PATCH] feat(search): invoice + delivery-note search, richer
colour-coded preview
- Orders now match on customer invoice numbers and delivery-note
numbers carried on their lines (tagged "invoice" / "delivery note").
- New Delivery Notes source: DN number, customer PO, customer and
company names; preview pulls lines and invoices for visible hits only.
- Quick-overview dialog moved to search-preview.tsx and redesigned:
type-tinted header, status pills, stat tiles, invoice chips with
QuickBooks links, line list, icon timeline with dates and gaps.
Fixes the dialog being pinned at 24rem by the primitive's sm:max-w-sm.
- Result list: per-type colour (icon tile, accent border, badge, row
hover), status pills per row, and a jump bar; mobile-tightened.
- Stock-order preview now fetches ordered_value/created (Value row and
age chip were always empty); supplier names keep their original case.
Co-Authored-By: Claude Opus 5.5
---
src/app/(app)/search/page.test.tsx | 51 +-
src/app/(app)/search/page.tsx | 272 ++++-----
src/app/(app)/search/search-preview.tsx | 575 +++++++++++++++++
src/lib/server/quotes-search-service.ts | 4 +
.../server/universal-search-service.test.ts | 38 +-
src/lib/server/universal-search-service.ts | 578 ++++++++++++++++--
6 files changed, 1296 insertions(+), 222 deletions(-)
create mode 100644 src/app/(app)/search/search-preview.tsx
diff --git a/src/app/(app)/search/page.test.tsx b/src/app/(app)/search/page.test.tsx
index 16c6128..b40ea04 100644
--- a/src/app/(app)/search/page.test.tsx
+++ b/src/app/(app)/search/page.test.tsx
@@ -358,7 +358,56 @@ describe("UniversalSearchPage — timeline rendering", () => {
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();
+ // Dates render as "12 Oct 2026", with the raw value on .
+ expect(screen.getByText("12 Oct 2026")).toBeInTheDocument();
+ expect(screen.getByText("12 Oct 2026").getAttribute("datetime")).toBe("2026-10-12");
+ // Gap to the next step is spelled out.
+ expect(screen.getByText(/\+2 days/)).toBeInTheDocument();
+ });
+
+ it("shows invoice numbers, delivery notes and coloured status pills", async () => {
+ fetchMock.mockResolvedValue(
+ jsonResponse({
+ ...RESULT,
+ groups: [
+ {
+ ...RESULT.groups[0],
+ hits: [
+ {
+ ...RESULT.groups[0].hits[0],
+ statuses: [{ label: "Invoiced", hex: "#047857", count: 2 }],
+ stats: [{ label: "Value (excl.)", value: "R 1 250.00" }],
+ invoices: [
+ { number: "INV10729", date: "2026-10-14", lines: 2, href: "https://qbo.example/inv/1" },
+ ],
+ deliveryNotes: ["DN26101401"],
+ lines: [{ description: "M16 bolts", quantity: "10", invoice: "INV10729" }],
+ lineTotal: 2,
+ },
+ ],
+ },
+ ],
+ }),
+ );
+
+ render( );
+ const input = screen.getByPlaceholderText(/Order no, quote no/i);
+ fireEvent.change(input, { target: { value: "INV10729" } });
+ 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(/^Invoices$/)).toBeInTheDocument());
+ const invoice = screen.getAllByText("INV10729")[0];
+ expect(invoice.closest("a")).toHaveAttribute("href", "https://qbo.example/inv/1");
+ expect(screen.getByText("DN26101401")).toBeInTheDocument();
+ expect(screen.getByText("Invoiced")).toHaveStyle({ backgroundColor: "#047857" });
+ expect(screen.getByText("R 1 250.00")).toBeInTheDocument();
+ expect(screen.getByText(/showing 1 of 2/)).toBeInTheDocument();
});
it("omits the timeline section entirely when the service sends none", async () => {
diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx
index 08bd733..4d00e3d 100644
--- a/src/app/(app)/search/page.tsx
+++ b/src/app/(app)/search/page.tsx
@@ -1,18 +1,17 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
-import { Search as SearchIcon, X, Loader2, AlertTriangle, ArrowUpRight, ExternalLink } from "lucide-react";
+import { Search as SearchIcon, X, Loader2, AlertTriangle, ArrowUpRight } from "lucide-react";
import { Input } from "@/components/ui/input";
-import { Badge } from "@/components/ui/badge";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
import getPocketBase from "@/lib/pocketbase";
import { cn } from "@/lib/utils";
import { ageClass, ageLabel } from "./age";
+import {
+ FALLBACK_THEME,
+ SearchPreviewDialog,
+ TYPE_THEME,
+ type SearchHit,
+} from "./search-preview";
/**
* Universal search — one box across orders, quotes, POs, suppliers,
@@ -33,24 +32,6 @@ import { ageClass, ageLabel } from "./age";
* different statements.
*/
-type PreviewRow = { label: string; value: string };
-type TimelineEvent = { label: string; at: 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[];
- /** Dated lifecycle steps, oldest first, where timestamps exist. */
- timeline?: TimelineEvent[];
- /** Record creation date, YYYY-MM-DD — drives the age chip + ordering. */
- createdAt?: string;
-};
-
type Group = {
type: string;
label: string;
@@ -113,6 +94,7 @@ export default function UniversalSearchPage() {
*/
const [previewHit, setPreviewHit] = useState(null);
const [previewGroupLabel, setPreviewGroupLabel] = useState("");
+ const [previewGroupType, setPreviewGroupType] = useState("");
// Guards against a slow response for an abandoned query landing
// after a newer one has already rendered.
@@ -208,8 +190,9 @@ export default function UniversalSearchPage() {
Search
- One box across orders, quotes, purchase orders, suppliers, companies,
- customers, reps and stock.
+ One box across orders (incl. invoice numbers), delivery notes,
+ quotes, purchase orders, suppliers, companies, customers, reps and
+ stock.
@@ -227,7 +210,7 @@ export default function UniversalSearchPage() {
submitNow();
}
}}
- placeholder="Order no, quote no, supplier, customer, rep, SKU, description…"
+ placeholder="Order no, quote no, invoice no, DN no, supplier, customer, rep, SKU…"
className="h-11 pl-9 pr-9 text-base"
/>
{input && (
@@ -280,17 +263,67 @@ export default function UniversalSearchPage() {
)}
+ {/* Jump bar — one coloured chip per section. Most useful on a
+ phone, where the sections stack into a long scroll. */}
+ {result && result.groups.length > 1 && (
+
+ {result.groups.map((group) => {
+ const theme = TYPE_THEME[group.type] ?? FALLBACK_THEME;
+ const Icon = theme.icon;
+ return (
+
+ document
+ .getElementById(`search-group-${group.type}`)
+ ?.scrollIntoView({ behavior: "smooth", block: "start" })
+ }
+ className={cn(
+ "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80",
+ theme.badge,
+ )}
+ >
+
+ {group.label}
+ {group.total}
+
+ );
+ })}
+
+ )}
+
- {result?.groups.map((group) => (
-
-
+ {result?.groups.map((group) => {
+ const theme = TYPE_THEME[group.type] ?? FALLBACK_THEME;
+ const GroupIcon = theme.icon;
+ return (
+
+
+
+
+
{group.label}
-
+
{group.total}
-
+
{/* Rule 1: the cap is always stated. */}
{group.truncated && (
-
+
showing {group.hits.length} of {group.total} — refine to see
the rest
@@ -308,7 +341,12 @@ export default function UniversalSearchPage() {
) : (
-
+
{group.hits.map((hit) => (
{/*
@@ -334,12 +372,33 @@ export default function UniversalSearchPage() {
onClick={() => {
setPreviewHit(hit);
setPreviewGroupLabel(group.label);
+ setPreviewGroupType(group.type);
}}
- className="flex w-full items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/50"
+ className={cn(
+ "flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors",
+ theme.hover,
+ )}
>
-
+
{hit.title}
+ {/* Status pills — the same colours as the
+ /orders board, so a row reads at a glance. */}
+ {hit.statuses?.slice(0, 2).map((chip) => (
+
+ {chip.label}
+ {chip.count > 1 ? ` ${chip.count}` : ""}
+
+ ))}
+ {hit.statuses && hit.statuses.length > 2 && (
+
+ +{hit.statuses.length - 2}
+
+ )}
{hit.matchedOn && (
{hit.matchedOn}
@@ -354,7 +413,7 @@ export default function UniversalSearchPage() {
{hit.meta && (
-
+
{hit.meta}
)}
@@ -376,139 +435,24 @@ export default function UniversalSearchPage() {
)}
-
+
))}
)}
- ))}
+ );
+ })}
- {/*
- 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.
- */}
-
{
- if (!open) setPreviewHit(null);
- }}
- >
-
- {previewHit && (
- <>
-
- {previewGroupLabel && (
-
- {previewGroupLabel}
-
- )}
-
- {previewHit.title}
-
-
- {previewHit.matchedOn && (
-
- matched: {previewHit.matchedOn}
-
- )}
- {previewHit.meta && (
-
- {previewHit.meta}
-
- )}
-
-
-
- {previewHit.preview && previewHit.preview.length > 0 ? (
-
- {previewHit.preview.map((row) => (
-
-
- {row.label}
-
-
- {row.value}
-
-
- ))}
-
- ) : (
-
- No details available for this result.
-
- )}
-
- {/*
- 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 && (
-
-
- Timeline
-
-
- {previewHit.timeline.map((event, index) => {
- const isLast = index === previewHit!.timeline!.length - 1;
- return (
-
-
-
- {!isLast && (
-
- )}
-
-
-
{event.label}
-
- {event.at}
-
-
-
- );
- })}
-
-
- )}
-
- {/* The escape hatch: the preview must never be the only
- way to reach a record. */}
-
-
- Open full page
-
- >
- )}
-
-
+ {/* Quick overview — see `search-preview.tsx`. */}
+
setPreviewHit(null)}
+ />
);
}
\ No newline at end of file
diff --git a/src/app/(app)/search/search-preview.tsx b/src/app/(app)/search/search-preview.tsx
new file mode 100644
index 0000000..7fcf3c9
--- /dev/null
+++ b/src/app/(app)/search/search-preview.tsx
@@ -0,0 +1,575 @@
+"use client";
+
+import type { LucideIcon } from "lucide-react";
+import {
+ Boxes,
+ Building2,
+ CirclePlus,
+ ClipboardList,
+ ExternalLink,
+ Factory,
+ FileCheck2,
+ FileText,
+ MailOpen,
+ MessageSquareReply,
+ Package,
+ PackageCheck,
+ Receipt,
+ Send,
+ Truck,
+ User,
+ UserCheck,
+ Warehouse,
+} from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { cn } from "@/lib/utils";
+import { ageClass, ageLabel } from "./age";
+
+/**
+ * Quick overview for one universal-search hit.
+ *
+ * Mirrors the hit contract in `universal-search-service.ts`. Every
+ * richer block (statuses, stats, invoices, lines) is optional: a source
+ * that only sends `preview` rows still renders a complete dialog, so a
+ * new source needs no UI work here.
+ *
+ * Lives outside `page.tsx` because App Router pages may only export a
+ * fixed set of names.
+ */
+
+export type PreviewRow = { label: string; value: string };
+export type TimelineEvent = {
+ label: string;
+ at: string;
+ kind?: string;
+ detail?: string;
+};
+export type StatusChip = { label: string; hex: string; count: number };
+export type PreviewStat = { label: string; value: string };
+export type InvoiceRef = { number: string; date?: string; href?: string; lines: number };
+export type PreviewLine = {
+ description: string;
+ quantity?: string;
+ amount?: string;
+ status?: string;
+ statusHex?: string;
+ supplier?: string;
+ invoice?: string;
+};
+
+export 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[];
+ /** Dated lifecycle steps, oldest first, where timestamps exist. */
+ timeline?: TimelineEvent[];
+ /** Record creation date, YYYY-MM-DD — drives the age chip + ordering. */
+ createdAt?: string;
+ statuses?: StatusChip[];
+ stats?: PreviewStat[];
+ invoices?: InvoiceRef[];
+ deliveryNotes?: string[];
+ lines?: PreviewLine[];
+ lineTotal?: number;
+};
+
+/* ============================================================================
+ * Visual tokens
+ * ========================================================================== */
+
+export type TypeTheme = {
+ icon: LucideIcon;
+ /** Dialog header wash. */
+ band: string;
+ /** Solid icon tile. */
+ tile: string;
+ /** Result-list section: left accent, count badge, row hover. */
+ accent: string;
+ badge: string;
+ hover: string;
+};
+
+/**
+ * One hue per result type, so a section or a dialog says "this is a
+ * quote" before the text does. Shared by the result list and the
+ * dialog so the two always agree. Light tints with a solid icon tile;
+ * dark mode drops the tint to a low-alpha wash so it doesn't glow.
+ *
+ * Every class is written out in full — Tailwind only ships classes it
+ * can find verbatim in the source.
+ */
+export const TYPE_THEME: Record = {
+ order: {
+ icon: Package,
+ band: "from-blue-50 to-transparent dark:from-blue-500/10",
+ tile: "bg-blue-600 text-white",
+ accent: "border-l-blue-500",
+ badge: "bg-blue-100 text-blue-800 dark:bg-blue-500/15 dark:text-blue-300",
+ hover: "hover:bg-blue-50/70 dark:hover:bg-blue-500/10",
+ },
+ quote: {
+ icon: FileText,
+ band: "from-violet-50 to-transparent dark:from-violet-500/10",
+ tile: "bg-violet-600 text-white",
+ accent: "border-l-violet-500",
+ badge: "bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-300",
+ hover: "hover:bg-violet-50/70 dark:hover:bg-violet-500/10",
+ },
+ delivery_note: {
+ icon: Truck,
+ band: "from-cyan-50 to-transparent dark:from-cyan-500/10",
+ tile: "bg-cyan-600 text-white",
+ accent: "border-l-cyan-500",
+ badge: "bg-cyan-100 text-cyan-800 dark:bg-cyan-500/15 dark:text-cyan-300",
+ hover: "hover:bg-cyan-50/70 dark:hover:bg-cyan-500/10",
+ },
+ purchase_order: {
+ icon: ClipboardList,
+ band: "from-amber-50 to-transparent dark:from-amber-500/10",
+ tile: "bg-amber-600 text-white",
+ accent: "border-l-amber-500",
+ badge: "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300",
+ hover: "hover:bg-amber-50/70 dark:hover:bg-amber-500/10",
+ },
+ stock_purchase: {
+ icon: Warehouse,
+ band: "from-teal-50 to-transparent dark:from-teal-500/10",
+ tile: "bg-teal-600 text-white",
+ accent: "border-l-teal-500",
+ badge: "bg-teal-100 text-teal-800 dark:bg-teal-500/15 dark:text-teal-300",
+ hover: "hover:bg-teal-50/70 dark:hover:bg-teal-500/10",
+ },
+ supplier: {
+ icon: Factory,
+ band: "from-orange-50 to-transparent dark:from-orange-500/10",
+ tile: "bg-orange-600 text-white",
+ accent: "border-l-orange-500",
+ badge: "bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-300",
+ hover: "hover:bg-orange-50/70 dark:hover:bg-orange-500/10",
+ },
+ company: {
+ icon: Building2,
+ band: "from-indigo-50 to-transparent dark:from-indigo-500/10",
+ tile: "bg-indigo-600 text-white",
+ accent: "border-l-indigo-500",
+ badge: "bg-indigo-100 text-indigo-800 dark:bg-indigo-500/15 dark:text-indigo-300",
+ hover: "hover:bg-indigo-50/70 dark:hover:bg-indigo-500/10",
+ },
+ customer: {
+ icon: User,
+ band: "from-rose-50 to-transparent dark:from-rose-500/10",
+ tile: "bg-rose-600 text-white",
+ accent: "border-l-rose-500",
+ badge: "bg-rose-100 text-rose-800 dark:bg-rose-500/15 dark:text-rose-300",
+ hover: "hover:bg-rose-50/70 dark:hover:bg-rose-500/10",
+ },
+ stock_item: {
+ icon: Boxes,
+ band: "from-emerald-50 to-transparent dark:from-emerald-500/10",
+ tile: "bg-emerald-600 text-white",
+ accent: "border-l-emerald-500",
+ badge: "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300",
+ hover: "hover:bg-emerald-50/70 dark:hover:bg-emerald-500/10",
+ },
+};
+
+export const FALLBACK_THEME: TypeTheme = {
+ icon: FileText,
+ band: "from-slate-50 to-transparent dark:from-slate-500/10",
+ tile: "bg-slate-600 text-white",
+ accent: "border-l-slate-400",
+ badge: "bg-slate-100 text-slate-700 dark:bg-slate-500/15 dark:text-slate-300",
+ hover: "hover:bg-muted/50",
+};
+
+/** Icon + colour per lifecycle step. Unknown kinds fall back to neutral. */
+const STEP_THEME: Record = {
+ created: { icon: CirclePlus, dot: "bg-blue-600" },
+ assigned: { icon: UserCheck, dot: "bg-violet-600" },
+ collected: { icon: PackageCheck, dot: "bg-teal-600" },
+ delivered: { icon: Truck, dot: "bg-emerald-700" },
+ invoiced: { icon: Receipt, dot: "bg-green-600" },
+ pdf: { icon: FileCheck2, dot: "bg-slate-600" },
+ sent: { icon: Send, dot: "bg-sky-600" },
+ opened: { icon: MailOpen, dot: "bg-indigo-600" },
+ replied: { icon: MessageSquareReply, dot: "bg-amber-600" },
+};
+
+const FALLBACK_STEP = { icon: CirclePlus, dot: "bg-slate-500" };
+
+/* ============================================================================
+ * Formatting
+ * ========================================================================== */
+
+const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+
+/**
+ * `2026-10-12` → `12 Oct 2026`. Parsed by hand rather than through
+ * `Date`/`Intl` so a date-only value can't shift a day across a
+ * timezone, and the output doesn't vary by runtime locale.
+ */
+export function formatDay(ymd: string): string {
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(ymd);
+ if (!m) return ymd;
+ const month = MONTHS[Number(m[2]) - 1];
+ return month ? `${Number(m[3])} ${month} ${m[1]}` : ymd;
+}
+
+/** Whole days between two YYYY-MM-DD values, or null if either is bad. */
+export function daysBetween(from: string, to: string): number | null {
+ const a = Date.parse(`${from.slice(0, 10)}T00:00:00Z`);
+ const b = Date.parse(`${to.slice(0, 10)}T00:00:00Z`);
+ if (Number.isNaN(a) || Number.isNaN(b)) return null;
+ return Math.round((b - a) / 86_400_000);
+}
+
+function gapLabel(days: number): string {
+ if (days <= 0) return "same day";
+ if (days === 1) return "+1 day";
+ return `+${days} days`;
+}
+
+/* ============================================================================
+ * Component
+ * ========================================================================== */
+
+function SectionTitle({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function SearchPreviewDialog({
+ hit,
+ groupType,
+ groupLabel,
+ onClose,
+}: {
+ hit: SearchHit | null;
+ groupType: string;
+ groupLabel: string;
+ onClose: () => void;
+}) {
+ return (
+ {
+ if (!open) onClose();
+ }}
+ >
+ {/*
+ `sm:max-w-2xl` (not plain `max-w-*`): the primitive ships
+ `sm:max-w-sm`, which out-ranks an unprefixed width at every
+ breakpoint above mobile and pinned this dialog at 24rem.
+ */}
+
+ {hit && }
+
+
+ );
+}
+
+function PreviewBody({
+ hit,
+ groupType,
+ groupLabel,
+}: {
+ hit: SearchHit;
+ groupType: string;
+ groupLabel: string;
+}) {
+ const theme = TYPE_THEME[groupType] ?? FALLBACK_THEME;
+ const TypeIcon = theme.icon;
+ const age = ageLabel(hit.createdAt);
+ const showInvoices = groupType === "order" || groupType === "delivery_note";
+
+ return (
+ <>
+ {/* Header band — tinted by result type. */}
+
+
+
+
+
+
+ {groupLabel && (
+
+ {groupLabel}
+
+ )}
+
+ {hit.title}
+
+ {hit.subtitle && (
+ {hit.subtitle}
+ )}
+
+
+
+
+ {hit.statuses?.map((chip) => (
+
+ {chip.label}
+ {chip.count > 1 && (
+ {chip.count}
+ )}
+
+ ))}
+ {hit.matchedOn && (
+
+ matched: {hit.matchedOn}
+
+ )}
+ {age && (
+
+ {age === "today" ? "created today" : `${age} old`}
+
+ )}
+
+
+
+ {/* Scrollable body. */}
+
+ {hit.stats && hit.stats.length > 0 && (
+
+ {hit.stats.map((stat) => (
+
+
{stat.label}
+
{stat.value}
+
+ ))}
+
+ )}
+
+ {/* Invoices — the first thing billing asks about an order. */}
+ {showInvoices && hit.invoices !== undefined && (
+
+ Invoices
+ {hit.invoices.length > 0 ? (
+
+ {hit.invoices.map((inv) => {
+ const body = (
+ <>
+
+
{inv.number}
+
+ {[inv.date ? formatDay(inv.date) : "", `${inv.lines} line${inv.lines === 1 ? "" : "s"}`]
+ .filter(Boolean)
+ .join(" · ")}
+
+ {inv.href &&
}
+ >
+ );
+ const className =
+ "inline-flex max-w-full flex-wrap items-center gap-x-2 gap-y-0.5 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-1.5 text-emerald-950 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100";
+ return inv.href ? (
+
+ {body}
+
+ ) : (
+
+ {body}
+
+ );
+ })}
+
+ ) : (
+
+ Not invoiced yet.
+
+ )}
+ {hit.deliveryNotes && hit.deliveryNotes.length > 0 && (
+
+ Delivery notes:
+ {hit.deliveryNotes.map((dn) => (
+
+ {dn}
+
+ ))}
+
+ )}
+
+ )}
+
+ {hit.preview && hit.preview.length > 0 ? (
+
+ Details
+
+ {hit.preview.map((row) => (
+
+
{row.label}
+ {row.value}
+
+ ))}
+
+
+ ) : (
+ !hit.lines?.length && (
+
No details available for this result.
+ )
+ )}
+
+ {hit.lines && hit.lines.length > 0 && (
+
+
+ Lines
+ {hit.lineTotal && hit.lineTotal > hit.lines.length
+ ? ` · showing ${hit.lines.length} of ${hit.lineTotal}`
+ : ""}
+
+
+ {hit.lines.map((line, index) => (
+
+
+
+
{line.description}
+ {(line.status || line.supplier || line.invoice) && (
+
+ {line.status && (
+
+ {line.status}
+
+ )}
+ {line.supplier && {line.supplier} }
+ {line.invoice && (
+ {line.invoice}
+ )}
+
+ )}
+
+ {(line.quantity || line.amount) && (
+
+ {line.quantity &&
× {line.quantity}
}
+ {line.amount &&
{line.amount}
}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/*
+ 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.
+ */}
+ {hit.timeline && hit.timeline.length > 0 && (
+
+ Timeline
+
+ {hit.timeline.map((event, index) => {
+ const timeline = hit.timeline!;
+ const isLast = index === timeline.length - 1;
+ const step = STEP_THEME[event.kind ?? ""] ?? FALLBACK_STEP;
+ const StepIcon = step.icon;
+ const next = timeline[index + 1];
+ const gap = next ? daysBetween(event.at, next.at) : null;
+ return (
+
+
+
+
+
+ {!isLast && }
+
+
+
+
+ {event.label}
+ {isLast && (
+
+ Latest
+
+ )}
+
+
+ {formatDay(event.at)}
+
+
+ {event.detail && (
+
{event.detail}
+ )}
+ {gap !== null && (
+
{gapLabel(gap)} →
+ )}
+
+
+ );
+ })}
+
+
+ )}
+
+
+ {/* Footer. The escape hatch: the preview must never be the only
+ way to reach a record. */}
+
+ >
+ );
+}
diff --git a/src/lib/server/quotes-search-service.ts b/src/lib/server/quotes-search-service.ts
index cc5b593..170f64a 100644
--- a/src/lib/server/quotes-search-service.ts
+++ b/src/lib/server/quotes-search-service.ts
@@ -1327,6 +1327,10 @@ const QUOTE_SEARCH_FIELDS = [
"is_template",
"follow_up_due",
"last_reply_at",
+ // Lifecycle stamps for the universal-search quick-overview timeline.
+ "pdf_generated_at",
+ "email_delivered_at",
+ "email_opened_at",
"customer",
"company",
"delivery_note",
diff --git a/src/lib/server/universal-search-service.test.ts b/src/lib/server/universal-search-service.test.ts
index 326b030..a16cdcc 100644
--- a/src/lib/server/universal-search-service.test.ts
+++ b/src/lib/server/universal-search-service.test.ts
@@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
import {
UNIVERSAL_SEARCH_WINDOW_DAYS,
bestScore,
+ buildOrderTimeline,
+ buildQuoteTimeline,
daysAgoIso,
escapeForPbFilter,
orderGroups,
@@ -212,4 +214,38 @@ describe("the never-hide-a-cap invariant", () => {
expect(failed.status).not.toBe("ok");
expect(failed.error).toBeTruthy();
});
-});
\ No newline at end of file
+});
+describe("buildOrderTimeline", () => {
+ it("emits one Invoiced step per invoice, carrying its number", () => {
+ // A partial pickup bills one order across two invoices; a single
+ // "Invoiced" step would hide which number went out when.
+ const events = buildOrderTimeline({ created: "2026-10-01 08:00:00.000Z", rep: "Tiaan" }, [
+ { customer_invoice_number: "INV1", invoiced_date: "2026-10-05" },
+ { customer_invoice_number: "INV1", invoiced_date: "2026-10-05" },
+ { customer_invoice_number: "INV2", invoiced_date: "2026-10-09", drv_collected_at: "2026-10-08" },
+ ]);
+ expect(events.map((e) => [e.label, e.at, e.detail])).toEqual([
+ ["Order raised", "2026-10-01", "Rep: Tiaan"],
+ ["Invoiced", "2026-10-05", "INV1 · 2 lines"],
+ ["Collected", "2026-10-08", undefined],
+ ["Invoiced", "2026-10-09", "INV2"],
+ ]);
+ });
+
+ it("omits steps that have no timestamp", () => {
+ const events = buildOrderTimeline({ created: "2026-10-01" }, [{ item_status: "pending" }]);
+ expect(events).toEqual([{ label: "Order raised", at: "2026-10-01", kind: "created" }]);
+ });
+});
+
+describe("buildQuoteTimeline", () => {
+ it("orders the email lifecycle and skips what didn't happen", () => {
+ const events = buildQuoteTimeline({
+ created: "2026-10-01 07:00:00.000Z",
+ email_delivered_at: "2026-10-02 09:00:00.000Z",
+ pdf_generated_at: "2026-10-02 08:00:00.000Z",
+ email_opened_at: "",
+ });
+ expect(events.map((e) => e.kind)).toEqual(["created", "pdf", "sent"]);
+ });
+});
diff --git a/src/lib/server/universal-search-service.ts b/src/lib/server/universal-search-service.ts
index 00eaeed..338eb9d 100644
--- a/src/lib/server/universal-search-service.ts
+++ b/src/lib/server/universal-search-service.ts
@@ -60,7 +60,18 @@
import { getAdminPocketBase } from "./pocketbase-admin";
import { searchQuotes } from "./quotes-search-service";
-import { TAB_GROUP_ITEM_STATUSES, TAB_GROUP_ORDER } from "../item-status-ui";
+import {
+ TAB_GROUP_ITEM_STATUSES,
+ TAB_GROUP_ORDER,
+ getItemStatusVisual,
+} from "../item-status-ui";
+import {
+ readItemInvoiceNumber,
+ summarizeOrderInvoices,
+ type InvoiceBearingItem,
+ type OrderInvoiceSummary,
+} from "../order-invoices";
+import { formatZAR } from "../pdf/format";
type AdminPocketBase = Awaited>;
@@ -119,6 +130,7 @@ const COMPANY_FIELDS = "id,company,address,vendor,company_email,company_tel";
export type UniversalHitType =
| "order"
| "quote"
+ | "delivery_note"
| "purchase_order"
| "stock_purchase"
| "supplier"
@@ -161,6 +173,22 @@ export interface UniversalHit {
* value — the chip is then omitted rather than showing a blank.
*/
createdAt?: string;
+ /**
+ * Optional richer blocks for the quick-overview dialog. All of these
+ * are additive: a source that sends none still gets the generic
+ * `preview` rows, so the dialog never depends on them.
+ */
+ /** Coloured status pills, e.g. the status mix across an order's lines. */
+ statuses?: StatusChip[];
+ /** Headline figures shown as tiles across the top of the dialog. */
+ stats?: PreviewStat[];
+ /** Customer invoices raised against the record. */
+ invoices?: InvoiceRef[];
+ /** Delivery-note numbers the record's lines went out on. */
+ deliveryNotes?: string[];
+ /** The record's lines, capped — `lineTotal` says how many exist. */
+ lines?: PreviewLine[];
+ lineTotal?: number;
}
export interface PreviewRow {
@@ -168,6 +196,38 @@ export interface PreviewRow {
value: string;
}
+export interface StatusChip {
+ label: string;
+ /** Hex fill, from the same palette as the /orders pills. */
+ hex: string;
+ count: number;
+}
+
+export interface PreviewStat {
+ label: string;
+ value: string;
+}
+
+export interface InvoiceRef {
+ number: string;
+ /** YYYY-MM-DD when known. */
+ date?: string;
+ /** QuickBooks link, when the invoice came through QuickBooks. */
+ href?: string;
+ /** How many of the record's lines are on this invoice. */
+ lines: number;
+}
+
+export interface PreviewLine {
+ description: string;
+ quantity?: string;
+ amount?: string;
+ status?: string;
+ statusHex?: string;
+ supplier?: string;
+ invoice?: string;
+}
+
/**
* One dated step in a record's lifecycle, e.g. "Collected · 12 Oct".
*
@@ -180,8 +240,23 @@ export interface TimelineEvent {
label: string;
/** YYYY-MM-DD, as stored. */
at: string;
+ /** Picks the step's icon and colour in the dialog. */
+ kind?: TimelineKind;
+ /** Short extra, e.g. the invoice number on an "Invoiced" step. */
+ detail?: string;
}
+export type TimelineKind =
+ | "created"
+ | "assigned"
+ | "collected"
+ | "delivered"
+ | "invoiced"
+ | "pdf"
+ | "sent"
+ | "opened"
+ | "replied";
+
/**
* Builds a lifecycle timeline for an order from timestamps that are
* actually present on its rows.
@@ -196,19 +271,20 @@ export interface TimelineEvent {
*
* Steps with no timestamp are OMITTED rather than rendered blank.
*/
-function buildOrderTimeline(
+export function buildOrderTimeline(
order: Record,
items: Record[],
+ invoices: OrderInvoiceSummary[] = summarizeOrderInvoices(items as InvoiceBearingItem[]),
): TimelineEvent[] {
const events: TimelineEvent[] = [];
- const add = (label: string, value: unknown) => {
+ const add = (label: string, value: unknown, kind: TimelineKind, detail?: string) => {
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) });
+ events.push({ label, at: raw.slice(0, 10), kind, ...(detail ? { detail } : {}) });
};
- add("Order raised", order.created);
+ add("Order raised", order.created, "created", order.rep ? `Rep: ${String(order.rep)}` : undefined);
// Earliest non-empty value per milestone across the order's lines.
const earliest = (
@@ -224,14 +300,105 @@ function buildOrderTimeline(
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"]));
+ add("Driver assigned", earliest(["drv_assigned_at", "assigned_at"]), "assigned");
+ add("Collected", earliest(["drv_collected_at", "collected_date"]), "collected");
+ add("Delivered", earliest(["delivered_at"]), "delivered");
+
+ // One step PER INVOICE, carrying its number — a partial pickup bills
+ // an order across several invoices, and "Invoiced" alone hides which
+ // one went out when. Falls back to the bare earliest `invoiced_date`
+ // when the lines carry a date but no number.
+ const datedInvoices = invoices.filter((inv) => inv.date && inv.date.length >= 10);
+ if (datedInvoices.length > 0) {
+ for (const inv of datedInvoices) {
+ add(
+ "Invoiced",
+ inv.date,
+ "invoiced",
+ `${inv.number}${inv.lineCount > 1 ? ` · ${inv.lineCount} lines` : ""}`,
+ );
+ }
+ } else {
+ add("Invoiced", earliest(["invoiced_date"]), "invoiced");
+ }
return events.sort((a, b) => a.at.localeCompare(b.at));
}
+/**
+ * Lifecycle for a quote, from the email/PDF timestamps on the header.
+ * Same omit-what-didn't-happen rule as the order timeline.
+ */
+export function buildQuoteTimeline(quote: Record): TimelineEvent[] {
+ const events: TimelineEvent[] = [];
+ const add = (label: string, value: unknown, kind: TimelineKind) => {
+ const raw = value === null || value === undefined ? "" : String(value).trim();
+ if (raw.length < 10) return;
+ events.push({ label, at: raw.slice(0, 10), kind });
+ };
+ add("Quote received", quote.created, "created");
+ add("PDF generated", quote.pdf_generated_at, "pdf");
+ add("Sent to customer", quote.email_delivered_at, "sent");
+ add("Opened by customer", quote.email_opened_at, "opened");
+ add("Customer replied", quote.last_reply_at, "replied");
+ return events.sort((a, b) => a.at.localeCompare(b.at));
+}
+
+/**
+ * Quote status colours. Same 600/700-shade family as the item-status
+ * palette so white text clears contrast on every pill.
+ */
+const QUOTE_STATUS_VISUAL: Record = {
+ new: { hex: "#2563eb", label: "New" },
+ claimed: { hex: "#4f46e5", label: "Claimed" },
+ processed: { hex: "#b45309", label: "Processed" },
+ sending: { hex: "#0e7490", label: "Sending" },
+ emailed: { hex: "#0f766e", label: "Emailed" },
+ approval_pending: { hex: "#b45309", label: "Approval Pending" },
+ partial_approval_pending: { hex: "#b45309", label: "Partial Approval" },
+ approved: { hex: "#047857", label: "Approved" },
+ partial_approved: { hex: "#047857", label: "Partially Approved" },
+ complete: { hex: "#064e3b", label: "Complete" },
+ follow_up: { hex: "#7c3aed", label: "Follow Up" },
+ delivery_note: { hex: "#7c2d12", label: "Delivery Note" },
+ rejected: { hex: "#be123c", label: "Rejected" },
+ cancelled: { hex: "#e11d48", label: "Cancelled" },
+ problem: { hex: "#be123c", label: "Problem" },
+ email_failed: { hex: "#be123c", label: "Email Failed" },
+ mail_failed: { hex: "#be123c", label: "Mail Failed" },
+ unmatched_bounce: { hex: "#be123c", label: "Bounced" },
+ template: { hex: "#475569", label: "Template" },
+};
+
+/** Statuses whose lines no longer count toward an order's value. */
+const DEAD_LINE_STATUSES = new Set(["cancelled", "removed"]);
+
+function num(value: unknown): number {
+ const n = typeof value === "number" ? value : Number(value);
+ return Number.isFinite(n) ? n : 0;
+}
+
+/** Trims "12.000" → "12" so quantities read like a person wrote them. */
+function qty(value: unknown): string {
+ const n = num(value);
+ return n ? String(Number(n.toFixed(3))) : "";
+}
+
+/** Status mix as coloured chips, lifecycle-ordered by first appearance. */
+function orderStatusChips(items: Record[]): StatusChip[] {
+ const byLabel = new Map();
+ for (const item of items) {
+ const raw = String(item.item_status ?? "");
+ if (!raw) continue;
+ const visual = getItemStatusVisual(raw);
+ const label = visual.label === "Unknown" ? raw : visual.label;
+ const existing = byLabel.get(label);
+ if (existing) existing.count += 1;
+ else byLabel.set(label, { label, hex: visual.hex, count: 1 });
+ }
+ return [...byLabel.values()];
+}
+
/** Drops rows whose value is empty so previews don't render blank lines. */
function previewRows(rows: Array<[string, unknown]>): PreviewRow[] {
return rows
@@ -544,7 +711,11 @@ async function fetchItemsByOrderIds(
// summarises an order's status mix, and omitting it produced
// 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",
+ // The invoice columns (`customer_invoice_number`,
+ // `order_item_details` — which only carries the small
+ // invoice/print/split blobs) and `delivery_note_number` make
+ // those numbers searchable and show in the dialog.
+ "id,order,item_status,description,po_description,sku,notes,quantity,selling_price,cost_price,supplier,created,assigned_at,drv_assigned_at,collected_date,drv_collected_at,delivered_at,invoiced_date,customer_invoice_number,order_item_details,delivery_note_number",
batch: 1000,
}),
),
@@ -618,6 +789,33 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise String(i.delivery_note_number ?? "").trim())
+ .filter(Boolean),
+ ),
+ );
+ for (const inv of invoices) {
+ const s = scoreField(inv.number, ql, "id");
+ if (s > score) {
+ score = s;
+ matchedOn = "invoice";
+ }
+ }
+ for (const dn of deliveryNotes) {
+ const s = scoreField(dn, ql, "id");
+ if (s > score) {
+ score = s;
+ matchedOn = "delivery note";
+ }
+ }
+
if (score === 0) {
for (const item of orderItems) {
const supplierName = snapshot.supplierNames.get(String(item.supplier ?? ""));
@@ -646,16 +844,6 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise();
- 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
@@ -664,6 +852,19 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise !DEAD_LINE_STATUSES.has(String(i.item_status ?? "")),
+ );
+ const orderValue = liveItems.reduce(
+ (sum, i) => sum + num(i.selling_price) * (num(i.quantity) || 1),
+ 0,
+ );
+ const invoicedLines = orderItems.filter((i) =>
+ readItemInvoiceNumber(i as InvoiceBearingItem),
+ ).length;
+
scored.push({
score,
hit: {
@@ -674,31 +875,49 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise clip(i.description, 48))
- .filter(Boolean)
- .join(" · "),
- ],
["Notes", clip(order.notes, 140)],
]),
- timeline: buildOrderTimeline(order, orderItems),
+ // Status breakdown is the fastest read on an order: an operator
+ // asking "is this live or done?" shouldn't have to open it.
+ statuses: orderStatusChips(orderItems),
+ stats: [
+ { label: "Lines", value: String(orderItems.length) },
+ { label: "Value (excl.)", value: orderValue > 0 ? formatZAR(orderValue) : "—" },
+ {
+ label: "Invoiced",
+ value: orderItems.length ? `${invoicedLines} / ${orderItems.length}` : "—",
+ },
+ ],
+ invoices: invoices.map((inv) => ({
+ number: inv.number,
+ lines: inv.lineCount,
+ ...(inv.date ? { date: inv.date.slice(0, 10) } : {}),
+ ...(inv.qbInvoiceLink ? { href: inv.qbInvoiceLink } : {}),
+ })),
+ deliveryNotes,
+ lines: orderItems.slice(0, 8).map((i) => {
+ const status = String(i.item_status ?? "");
+ const visual = status ? getItemStatusVisual(status) : null;
+ const price = num(i.selling_price);
+ const lineQty = num(i.quantity) || 1;
+ return {
+ description: clip(i.description, 90) || "(no description)",
+ quantity: qty(i.quantity) || undefined,
+ amount: price > 0 ? formatZAR(price * lineQty) : undefined,
+ status: visual ? (visual.label === "Unknown" ? status : visual.label) : undefined,
+ statusHex: visual?.hex,
+ supplier: snapshot.supplierNames.get(String(i.supplier ?? "")) || undefined,
+ invoice: readItemInvoiceNumber(i as InvoiceBearingItem) ?? undefined,
+ };
+ }),
+ lineTotal: orderItems.length,
+ timeline: buildOrderTimeline(order, orderItems, invoices),
createdAt: createdYmd(order.created),
},
});
@@ -801,8 +1020,10 @@ async function fetchSupplierNameMap(
filter: chunk.map((id) => `id = "${escapeForPbFilter(id)}"`).join(" || "),
fields: "id,suppliername",
});
+ // Kept in its original case: it's displayed in the dialog, and every
+ // matching site lowercases on its own (`scoreField` does both sides).
for (const row of rows) {
- names.set(row.id, (row.suppliername ?? "").toLowerCase());
+ names.set(row.id, (row.suppliername ?? "").trim());
}
}
return names;
@@ -825,6 +1046,16 @@ async function searchQuoteSource(q: string): Promise {
company?: Record;
}
| undefined;
+ const items = (h.items ?? []) as unknown as Record[];
+ const status = String(quote?.status ?? "");
+ const visual = QUOTE_STATUS_VISUAL[status];
+ const quoteValue = items.reduce(
+ (sum, i) => sum + num(i.selling_price) * (num(i.quantity) || 1),
+ 0,
+ );
+ const pricedLines = items.filter((i) => num(i.selling_price) > 0).length;
+ const rep = (expanded?.customer as { expand?: { rep?: { name?: unknown } } } | undefined)
+ ?.expand?.rep?.name;
return {
id: String(quote?.id ?? ""),
title: String(quote?.quote_number ?? quote?.id ?? ""),
@@ -835,24 +1066,39 @@ async function searchQuoteSource(q: string): Promise {
matchedOn: "quote",
createdAt: createdYmd(quote?.created),
preview: previewRows([
- ["Quote", quote?.quote_number],
["Customer", expanded?.customer?.customer],
["Company", expanded?.company?.company],
- ["Status", quote?.status],
+ ["Rep", rep],
["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).description, 48),
- )
- .filter(Boolean)
- .join(" · "),
- ],
+ ["Reference", quote?.quote_reference],
]),
+ ...(status
+ ? {
+ statuses: [
+ {
+ label: visual?.label ?? status,
+ hex: visual?.hex ?? "#475569",
+ count: 1,
+ },
+ ],
+ }
+ : {}),
+ stats: [
+ { label: "Lines", value: String(items.length) },
+ { label: "Value (excl.)", value: quoteValue > 0 ? formatZAR(quoteValue) : "—" },
+ { label: "Priced", value: items.length ? `${pricedLines} / ${items.length}` : "—" },
+ ],
+ lines: items.slice(0, 8).map((i) => {
+ const price = num(i.selling_price);
+ return {
+ description: clip(i.description, 90) || "(no description)",
+ quantity: qty(i.quantity) || undefined,
+ amount: price > 0 ? formatZAR(price * (num(i.quantity) || 1)) : undefined,
+ };
+ }),
+ lineTotal: items.length,
+ timeline: buildQuoteTimeline(quote ?? {}),
};
});
const total = result.totalCandidates ?? hits.length;
@@ -946,7 +1192,9 @@ async function searchStockPurchases(
const headers = await loadReferenceRows>(
pb,
"stock_purchases",
- "id,purchase_number,status,supplier",
+ // Same field string as the warm-up, so both share one cache entry;
+ // `ordered_value` / `created` feed the preview's Value row and age chip.
+ "id,purchase_number,status,supplier,ordered_value,created",
);
if (headers.length === 0) {
return { type: "stock_purchase", label: "Stock Orders", hits: [], total: 0, truncated: false };
@@ -1067,6 +1315,209 @@ async function searchStockPurchases(
};
}
+/* ============================================================================
+ * Delivery notes source
+ * ========================================================================== */
+
+const DELIVERY_NOTE_FIELDS =
+ "id,delivery_note_number,customer,company,order,status,delivery_date,customer_po,notes,created";
+
+const DELIVERY_NOTE_STATUS_VISUAL: Record = {
+ draft: { hex: "#475569", label: "Draft" },
+ sent: { hex: "#0e7490", label: "Sent" },
+ loaded: { hex: "#b45309", label: "Loaded" },
+ po_received: { hex: "#7c3aed", label: "PO received" },
+ invoiced: { hex: "#047857", label: "Invoiced" },
+ completed: { hex: "#064e3b", label: "Completed" },
+ cancelled: { hex: "#e11d48", label: "Cancelled" },
+};
+
+/**
+ * Delivery notes — the `delivery_notes` headers, matched in memory.
+ *
+ * Not windowed: DNs are created a handful a day, so the whole collection
+ * sits comfortably in the reference cache, and an operator chasing a DN
+ * number off a signed paper copy expects it at any age.
+ *
+ * Matches the DN number and customer PO (identifiers), and the resolved
+ * customer / company names. The lines (and their invoice numbers) are
+ * fetched ONLY for the hits that survive the cap — one chunked read by
+ * `delivery_note_number`, never a scan of every DN's items.
+ */
+async function searchDeliveryNotes(
+ pb: AdminPocketBase,
+ ql: string,
+ partyNames: { customers: Map; companies: Map },
+): Promise {
+ const rows = await loadReferenceRows>(
+ pb,
+ "delivery_notes",
+ DELIVERY_NOTE_FIELDS,
+ );
+
+ const scored: { hit: UniversalHit; score: number; row: Record }[] = [];
+ for (const row of rows) {
+ const number = String(row.delivery_note_number ?? "");
+ const po = String(row.customer_po ?? "");
+ const customer = partyNames.customers.get(String(row.customer ?? ""));
+ const company = partyNames.companies.get(String(row.company ?? ""));
+ const score = bestScore(
+ [
+ [number, "id"],
+ [po, "id"],
+ [customer, "name"],
+ [company, "name"],
+ [String(row.notes ?? ""), "body"],
+ ],
+ ql,
+ );
+ if (score === 0) continue;
+ const matchedOn =
+ scoreField(number, ql, "id") > 0
+ ? "DN number"
+ : scoreField(po, ql, "id") > 0
+ ? "customer PO"
+ : scoreField(customer, ql, "name") > 0
+ ? "customer"
+ : scoreField(company, ql, "name") > 0
+ ? "company"
+ : "notes";
+ const status = String(row.status ?? "");
+ const visual = DELIVERY_NOTE_STATUS_VISUAL[status];
+ scored.push({
+ score,
+ row,
+ hit: {
+ id: String(row.id),
+ title: number || String(row.id),
+ subtitle: [customer, company && company !== customer ? company : ""]
+ .filter(Boolean)
+ .join(" · ") || undefined,
+ meta: po ? `PO ${po}` : undefined,
+ href: `/delivery-notes/${row.id}`,
+ matchedOn,
+ createdAt: createdYmd(row.created),
+ preview: previewRows([
+ ["Customer", customer],
+ ["Company", company],
+ ["Customer PO", po],
+ ["Delivery date", createdYmd(row.delivery_date)],
+ ["Notes", clip(row.notes, 140)],
+ ]),
+ ...(status
+ ? {
+ statuses: [
+ { label: visual?.label ?? status, hex: visual?.hex ?? "#475569", count: 1 },
+ ],
+ }
+ : {}),
+ },
+ });
+ }
+ scored.sort(compareHits);
+ const kept = scored.slice(0, HITS_PER_GROUP);
+
+ // Lines for the visible hits only.
+ const numbers = kept.map((s) => s.hit.title).filter(Boolean);
+ const itemsByDn = new Map[]>();
+ const orderIds = Array.from(
+ new Set(kept.map((s) => String(s.row.order ?? "")).filter(Boolean)),
+ );
+ const [items, orders] = await Promise.all([
+ (async () => {
+ const out: Record[] = [];
+ for (let i = 0; i < numbers.length; i += FILTER_CHUNK) {
+ const chunk = numbers.slice(i, i + FILTER_CHUNK);
+ out.push(
+ ...(await pb.collection("order_items").getFullList>({
+ filter: chunk
+ .map((n) => `delivery_note_number = "${escapeForPbFilter(n)}"`)
+ .join(" || "),
+ fields:
+ "id,delivery_note_number,description,quantity,selling_price,item_status,supplier,invoiced_date,customer_invoice_number,order_item_details",
+ })),
+ );
+ }
+ return out;
+ })(),
+ fetchByRecordIds<{ id: string; order_number?: string }>(
+ pb,
+ "orders",
+ orderIds,
+ "id,order_number",
+ ),
+ ]);
+ for (const item of items) {
+ const key = String(item.delivery_note_number ?? "");
+ const bucket = itemsByDn.get(key);
+ if (bucket) bucket.push(item);
+ else itemsByDn.set(key, [item]);
+ }
+ const orderNumbers = new Map(orders.map((o) => [o.id, String(o.order_number ?? "")]));
+
+ for (const { hit, row } of kept) {
+ const dnItems = itemsByDn.get(hit.title) ?? [];
+ const invoices = summarizeOrderInvoices(dnItems as InvoiceBearingItem[]);
+ const value = dnItems
+ .filter((i) => !DEAD_LINE_STATUSES.has(String(i.item_status ?? "")))
+ .reduce((sum, i) => sum + num(i.selling_price) * (num(i.quantity) || 1), 0);
+ const orderNumber = orderNumbers.get(String(row.order ?? ""));
+ if (orderNumber) {
+ hit.preview = [{ label: "Order", value: orderNumber }, ...(hit.preview ?? [])];
+ }
+ hit.stats = [
+ { label: "Lines", value: String(dnItems.length) },
+ { label: "Value (excl.)", value: value > 0 ? formatZAR(value) : "—" },
+ {
+ label: "Invoiced",
+ value: dnItems.length
+ ? `${dnItems.filter((i) => readItemInvoiceNumber(i as InvoiceBearingItem)).length} / ${dnItems.length}`
+ : "—",
+ },
+ ];
+ hit.invoices = invoices.map((inv) => ({
+ number: inv.number,
+ lines: inv.lineCount,
+ ...(inv.date ? { date: inv.date.slice(0, 10) } : {}),
+ ...(inv.qbInvoiceLink ? { href: inv.qbInvoiceLink } : {}),
+ }));
+ hit.lines = dnItems.slice(0, 8).map((i) => {
+ const status = String(i.item_status ?? "");
+ const visual = status ? getItemStatusVisual(status) : null;
+ const price = num(i.selling_price);
+ return {
+ description: clip(i.description, 90) || "(no description)",
+ quantity: qty(i.quantity) || undefined,
+ amount: price > 0 ? formatZAR(price * (num(i.quantity) || 1)) : undefined,
+ status: visual ? (visual.label === "Unknown" ? status : visual.label) : undefined,
+ statusHex: visual?.hex,
+ invoice: readItemInvoiceNumber(i as InvoiceBearingItem) ?? undefined,
+ };
+ });
+ hit.lineTotal = dnItems.length;
+
+ const timeline: TimelineEvent[] = [];
+ const created = createdYmd(row.created);
+ if (created) timeline.push({ label: "Delivery note raised", at: created, kind: "created" });
+ const delivery = createdYmd(row.delivery_date);
+ if (delivery) timeline.push({ label: "Delivery date", at: delivery, kind: "delivered" });
+ for (const inv of invoices) {
+ if (inv.date && inv.date.length >= 10) {
+ timeline.push({ label: "Invoiced", at: inv.date.slice(0, 10), kind: "invoiced", detail: inv.number });
+ }
+ }
+ hit.timeline = timeline.sort((a, b) => a.at.localeCompare(b.at));
+ }
+
+ return {
+ type: "delivery_note",
+ label: "Delivery Notes",
+ hits: kept.map((s) => s.hit),
+ total: scored.length,
+ truncated: scored.length > HITS_PER_GROUP,
+ };
+}
+
/* ============================================================================
* Orchestrator
* ========================================================================== */
@@ -1125,6 +1576,7 @@ export async function warmUniversalSearchCaches(): Promise {
loadReferenceRows(pb, "suppliers", "id,suppliername,email,suppliernumber,supply_tags"),
loadReferenceRows(pb, "companies", COMPANY_FIELDS),
loadReferenceRows(pb, "customers", CUSTOMER_FIELDS),
+ loadReferenceRows(pb, "delivery_notes", DELIVERY_NOTE_FIELDS),
loadReferenceRows(pb, "stock_items", "id,name,sku,description,unit,current_qty,cost_price,selling_price,created"),
loadReferenceRows(pb, "purchase_orders", "id,po_number,status,total_amount,order,supplier,created"),
@@ -1181,10 +1633,24 @@ export async function universalSearch(
const companyNameById = new Map(
companyRows.map((row) => [String(row.id), String(row.company ?? "")]),
);
+ const customerRows = await loadReferenceRows>(
+ pb,
+ "customers",
+ CUSTOMER_FIELDS,
+ );
+ const customerNameById = new Map(
+ customerRows.map((row) => [String(row.id), String(row.customer ?? "")]),
+ );
const groups = await Promise.all([
runSource("order", "Orders", () => searchOrders(pb, query)),
runSource("quote", "Quotes", () => searchQuoteSource(query)),
+ runSource("delivery_note", "Delivery Notes", () =>
+ searchDeliveryNotes(pb, ql, {
+ customers: customerNameById,
+ companies: companyNameById,
+ }),
+ ),
runSource("purchase_order", "Purchase Orders", () =>
searchReferenceCollection>(