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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-16 18:45:44 +02:00
parent a3a831d183
commit a1a2cc73e2
6 changed files with 1296 additions and 222 deletions

View file

@ -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 <time>.
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(<UniversalSearchPage />);
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 () => {

View file

@ -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<SearchHit | null>(null);
const [previewGroupLabel, setPreviewGroupLabel] = useState<string>("");
const [previewGroupType, setPreviewGroupType] = useState<string>("");
// 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() {
<div>
<h1 className="text-2xl font-bold tracking-tight">Search</h1>
<p className="text-muted-foreground">
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.
</p>
</div>
@ -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() {
</p>
)}
{/* 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 && (
<nav aria-label="Jump to section" className="-mx-1 flex flex-wrap gap-1.5 px-1">
{result.groups.map((group) => {
const theme = TYPE_THEME[group.type] ?? FALLBACK_THEME;
const Icon = theme.icon;
return (
<button
key={group.type}
type="button"
onClick={() =>
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,
)}
>
<Icon className="size-3.5" />
{group.label}
<span className="tabular-nums opacity-70">{group.total}</span>
</button>
);
})}
</nav>
)}
<div className="space-y-6">
{result?.groups.map((group) => (
<section key={group.type}>
<div className="mb-2 flex items-center gap-2">
{result?.groups.map((group) => {
const theme = TYPE_THEME[group.type] ?? FALLBACK_THEME;
const GroupIcon = theme.icon;
return (
<section
key={group.type}
id={`search-group-${group.type}`}
className="scroll-mt-4"
>
<div className="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1">
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md shadow-sm",
theme.tile,
)}
>
<GroupIcon className="size-4" />
</span>
<h2 className="text-sm font-semibold">{group.label}</h2>
<Badge variant="secondary" className="tabular-nums">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums",
theme.badge,
)}
>
{group.total}
</Badge>
</span>
{/* Rule 1: the cap is always stated. */}
{group.truncated && (
<span className="text-xs text-muted-foreground">
<span className="basis-full text-xs text-muted-foreground sm:basis-auto">
showing {group.hits.length} of {group.total} — refine to see
the rest
</span>
@ -308,7 +341,12 @@ export default function UniversalSearchPage() {
</span>
</div>
) : (
<ul className="divide-y rounded-md border">
<ul
className={cn(
"divide-y overflow-hidden rounded-lg border border-l-4 bg-card shadow-sm",
theme.accent,
)}
>
{group.hits.map((hit) => (
<li key={hit.id}>
{/*
@ -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,
)}
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium">{hit.title}</span>
{/* Status pills — the same colours as the
/orders board, so a row reads at a glance. */}
{hit.statuses?.slice(0, 2).map((chip) => (
<span
key={chip.label}
className="rounded-full px-1.5 py-px text-[10px] font-semibold text-white"
style={{ backgroundColor: chip.hex }}
>
{chip.label}
{chip.count > 1 ? ` ${chip.count}` : ""}
</span>
))}
{hit.statuses && hit.statuses.length > 2 && (
<span className="text-[10px] text-muted-foreground">
+{hit.statuses.length - 2}
</span>
)}
{hit.matchedOn && (
<span className="rounded border px-1 text-[10px] uppercase tracking-wide text-muted-foreground">
{hit.matchedOn}
@ -354,7 +413,7 @@ export default function UniversalSearchPage() {
</div>
<div className="flex shrink-0 items-center gap-2">
{hit.meta && (
<span className="text-xs text-muted-foreground">
<span className="hidden text-xs text-muted-foreground sm:inline">
{hit.meta}
</span>
)}
@ -376,139 +435,24 @@ export default function UniversalSearchPage() {
</span>
)}
</div>
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" />
<ArrowUpRight className="hidden h-4 w-4 shrink-0 text-muted-foreground sm:block" />
</button>
</li>
))}
</ul>
)}
</section>
))}
);
})}
</div>
{/*
Quick overview.
One generic renderer for all eight result types: the service
returns a flat label/value list per hit, so a new source gets a
preview without any new UI here.
`key={previewHit.id}` is deliberate — it remounts the content
when the hit changes, so switching results re-runs the
initialisers against the new record instead of syncing props
into existing state.
*/}
<Dialog
open={previewHit !== null}
onOpenChange={(open) => {
if (!open) setPreviewHit(null);
}}
>
<DialogContent className="max-w-xl">
{previewHit && (
<>
<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-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">
matched: {previewHit.matchedOn}
</span>
)}
{previewHit.meta && (
<span className="text-xs text-muted-foreground">
{previewHit.meta}
</span>
)}
</div>
</DialogHeader>
{previewHit.preview && previewHit.preview.length > 0 ? (
<dl className="divide-y rounded-md border">
{previewHit.preview.map((row) => (
<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>
) : (
<p className="text-sm text-muted-foreground">
No details available for this result.
</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
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>
{/* Quick overview — see `search-preview.tsx`. */}
<SearchPreviewDialog
hit={previewHit}
groupType={previewGroupType}
groupLabel={previewGroupLabel}
onClose={() => setPreviewHit(null)}
/>
</div>
);
}

View file

@ -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<string, TypeTheme> = {
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<string, { icon: LucideIcon; dot: string }> = {
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 (
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</h3>
);
}
export function SearchPreviewDialog({
hit,
groupType,
groupLabel,
onClose,
}: {
hit: SearchHit | null;
groupType: string;
groupLabel: string;
onClose: () => void;
}) {
return (
<Dialog
open={hit !== null}
onOpenChange={(open) => {
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.
*/}
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-h-[90vh] sm:max-w-2xl">
{hit && <PreviewBody hit={hit} groupType={groupType} groupLabel={groupLabel} />}
</DialogContent>
</Dialog>
);
}
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. */}
<DialogHeader
className={cn(
"gap-3 border-b bg-gradient-to-b px-4 pt-4 pb-3 pr-12 sm:px-5 sm:pt-5 sm:pb-4",
theme.band,
)}
>
<div className="flex items-start gap-3">
<span
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-lg shadow-sm",
theme.tile,
)}
>
<TypeIcon className="size-5" />
</span>
<div className="min-w-0 flex-1 space-y-0.5">
{groupLabel && (
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{groupLabel}
</span>
)}
<DialogTitle className="text-xl leading-tight break-words">
{hit.title}
</DialogTitle>
{hit.subtitle && (
<DialogDescription className="truncate">{hit.subtitle}</DialogDescription>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{hit.statuses?.map((chip) => (
<span
key={chip.label}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold text-white shadow-sm"
style={{ backgroundColor: chip.hex }}
>
{chip.label}
{chip.count > 1 && (
<span className="rounded-full bg-white/25 px-1 tabular-nums">{chip.count}</span>
)}
</span>
))}
{hit.matchedOn && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">
matched: {hit.matchedOn}
</span>
)}
{age && (
<span
title={hit.createdAt ? `Created ${formatDay(hit.createdAt)}` : undefined}
className={cn(
"rounded-full px-2 py-0.5 text-[11px] font-medium tabular-nums",
ageClass(hit.createdAt),
)}
>
{age === "today" ? "created today" : `${age} old`}
</span>
)}
</div>
</DialogHeader>
{/* Scrollable body. */}
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-4 py-4 sm:px-5">
{hit.stats && hit.stats.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{hit.stats.map((stat) => (
<div key={stat.label} className="min-w-0 rounded-lg border bg-muted/40 px-2.5 py-2 sm:px-3">
<p className="truncate text-[11px] text-muted-foreground">{stat.label}</p>
<p className="truncate text-sm font-semibold tabular-nums sm:text-base">{stat.value}</p>
</div>
))}
</div>
)}
{/* Invoices — the first thing billing asks about an order. */}
{showInvoices && hit.invoices !== undefined && (
<section>
<SectionTitle>Invoices</SectionTitle>
{hit.invoices.length > 0 ? (
<div className="flex flex-wrap gap-2">
{hit.invoices.map((inv) => {
const body = (
<>
<Receipt className="size-4 shrink-0 text-emerald-700 dark:text-emerald-400" />
<span className="font-mono text-sm font-semibold">{inv.number}</span>
<span className="text-xs text-emerald-800/80 dark:text-emerald-300/80">
{[inv.date ? formatDay(inv.date) : "", `${inv.lines} line${inv.lines === 1 ? "" : "s"}`]
.filter(Boolean)
.join(" · ")}
</span>
{inv.href && <ExternalLink className="size-3.5 text-emerald-700 dark:text-emerald-400" />}
</>
);
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 ? (
<a
key={inv.number}
href={inv.href}
target="_blank"
rel="noopener noreferrer"
title="Open in QuickBooks"
className={cn(className, "hover:bg-emerald-100 dark:hover:bg-emerald-500/20")}
>
{body}
</a>
) : (
<span key={inv.number} className={className}>
{body}
</span>
);
})}
</div>
) : (
<p className="rounded-lg border border-dashed px-3 py-2 text-sm text-muted-foreground">
Not invoiced yet.
</p>
)}
{hit.deliveryNotes && hit.deliveryNotes.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<span className="text-xs text-muted-foreground">Delivery notes:</span>
{hit.deliveryNotes.map((dn) => (
<span
key={dn}
className="rounded-md border border-orange-200 bg-orange-50 px-1.5 py-0.5 font-mono text-xs text-orange-900 dark:border-orange-500/30 dark:bg-orange-500/10 dark:text-orange-200"
>
{dn}
</span>
))}
</div>
)}
</section>
)}
{hit.preview && hit.preview.length > 0 ? (
<section>
<SectionTitle>Details</SectionTitle>
<dl className="grid gap-x-6 gap-y-2 rounded-lg border px-3 py-2.5 sm:grid-cols-2">
{hit.preview.map((row) => (
<div key={row.label} className="min-w-0">
<dt className="text-[11px] text-muted-foreground">{row.label}</dt>
<dd className="break-words text-sm">{row.value}</dd>
</div>
))}
</dl>
</section>
) : (
!hit.lines?.length && (
<p className="text-sm text-muted-foreground">No details available for this result.</p>
)
)}
{hit.lines && hit.lines.length > 0 && (
<section>
<SectionTitle>
Lines
{hit.lineTotal && hit.lineTotal > hit.lines.length
? ` · showing ${hit.lines.length} of ${hit.lineTotal}`
: ""}
</SectionTitle>
<ul className="divide-y rounded-lg border">
{hit.lines.map((line, index) => (
<li key={index} className="flex items-start gap-2.5 px-3 py-2">
<span
className="mt-1.5 size-2 shrink-0 rounded-full bg-muted-foreground/40"
style={line.statusHex ? { backgroundColor: line.statusHex } : undefined}
title={line.status}
/>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug">{line.description}</p>
{(line.status || line.supplier || line.invoice) && (
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
{line.status && (
<span style={line.statusHex ? { color: line.statusHex } : undefined} className="font-medium">
{line.status}
</span>
)}
{line.supplier && <span>{line.supplier}</span>}
{line.invoice && (
<span className="font-mono text-emerald-700 dark:text-emerald-400">{line.invoice}</span>
)}
</p>
)}
</div>
{(line.quantity || line.amount) && (
<div className="shrink-0 text-right text-xs tabular-nums">
{line.quantity && <p className="text-muted-foreground">× {line.quantity}</p>}
{line.amount && <p className="font-medium">{line.amount}</p>}
</div>
)}
</li>
))}
</ul>
</section>
)}
{/*
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 && (
<section>
<SectionTitle>Timeline</SectionTitle>
<ol>
{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 (
<li key={`${event.label}-${event.at}-${index}`} className="flex gap-3">
<div className="flex flex-col items-center">
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full text-white shadow-sm",
step.dot,
isLast && "ring-4 ring-primary/15",
)}
>
<StepIcon className="size-3.5" />
</span>
{!isLast && <span className="my-1 w-px flex-1 bg-border" />}
</div>
<div className={cn("min-w-0 flex-1", isLast ? "pb-1" : "pb-3")}>
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<p className="text-sm font-medium leading-7">
{event.label}
{isLast && (
<span className="ml-2 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
Latest
</span>
)}
</p>
<time dateTime={event.at} className="text-xs tabular-nums text-muted-foreground">
{formatDay(event.at)}
</time>
</div>
{event.detail && (
<p className="font-mono text-xs text-muted-foreground">{event.detail}</p>
)}
{gap !== null && (
<p className="mt-1 text-[11px] text-muted-foreground/80">{gapLabel(gap)} →</p>
)}
</div>
</li>
);
})}
</ol>
</section>
)}
</div>
{/* Footer. The escape hatch: the preview must never be the only
way to reach a record. */}
<div className="flex items-center justify-between gap-3 border-t bg-muted/30 px-4 py-3 sm:px-5">
<span className="min-w-0 truncate text-xs text-muted-foreground">{hit.meta}</span>
<a
href={hit.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90"
>
<ExternalLink className="h-3.5 w-3.5" />
Open full page
</a>
</div>
</>
);
}

View file

@ -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",

View file

@ -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();
});
});
});
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"]);
});
});

View file

@ -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<ReturnType<typeof getAdminPocketBase>>;
@ -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<string, unknown>,
items: Record<string, unknown>[],
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<string, unknown>): 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<string, { hex: string; label: string }> = {
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<string, unknown>[]): StatusChip[] {
const byLabel = new Map<string, StatusChip>();
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<SourceResul
else matchedOn = "order";
}
// Invoice and delivery-note numbers. They live on the LINES (an
// order has no invoice column; a partial pickup spreads one order
// across several invoices), and both are identifiers, so an exact
// invoice number outranks an order that merely mentions it.
const invoices = summarizeOrderInvoices(orderItems as InvoiceBearingItem[]);
const deliveryNotes = Array.from(
new Set(
orderItems
.map((i) => 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<SourceResul
if (score === 0) continue;
const firstItem = orderItems[0];
// Status breakdown is the fastest read on an order: an operator
// asking "is this live or done?" shouldn't have to open it.
const statusCounts = new Map<string, number>();
for (const item of orderItems) {
const s = String(item.item_status ?? "");
if (s) statusCounts.set(s, (statusCounts.get(s) ?? 0) + 1);
}
const statusSummary = [...statusCounts.entries()]
.map(([s, n]) => (n > 1 ? `${s} (${n})` : s))
.join(", ");
const supplierNamesForOrder = Array.from(
new Set(
orderItems
@ -664,6 +852,19 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise<SourceResul
),
).slice(0, 4);
// Value counts live lines only — a cancelled or struck line was
// never going to be billed, so including it overstates the order.
const liveItems = orderItems.filter(
(i) => !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<SourceResul
href: `/orders/${order.id}`,
matchedOn,
preview: previewRows([
["Order", order.order_number],
[
"Customer",
snapshot.partyNames.get(`c:${String(order.customer ?? "")}`),
],
[
"Company",
snapshot.partyNames.get(`k:${String(order.company ?? "")}`),
],
["Customer", customerName],
["Company", companyName],
["Contact", order.contact_name],
["Rep", order.rep],
["Status", statusSummary],
["Items", orderItems.length],
["Created", order.created ? String(order.created).slice(0, 10) : ""],
["RFQ", order.rfq_number],
["Suppliers", supplierNamesForOrder.join(", ")],
[
"Lines",
orderItems
.slice(0, 3)
.map((i) => 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<SourceResult> {
company?: Record<string, unknown>;
}
| undefined;
const items = (h.items ?? []) as unknown as Record<string, unknown>[];
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<SourceResult> {
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<string, unknown>).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<Record<string, unknown>>(
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<string, { hex: string; label: string }> = {
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<string, string>; companies: Map<string, string> },
): Promise<SourceResult> {
const rows = await loadReferenceRows<Record<string, unknown>>(
pb,
"delivery_notes",
DELIVERY_NOTE_FIELDS,
);
const scored: { hit: UniversalHit; score: number; row: Record<string, unknown> }[] = [];
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<string, Record<string, unknown>[]>();
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<string, unknown>[] = [];
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<Record<string, unknown>>({
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<void> {
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<string, string>(
companyRows.map((row) => [String(row.id), String(row.company ?? "")]),
);
const customerRows = await loadReferenceRows<Record<string, unknown>>(
pb,
"customers",
CUSTOMER_FIELDS,
);
const customerNameById = new Map<string, string>(
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<Record<string, unknown>>(