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:
parent
a3a831d183
commit
a1a2cc73e2
6 changed files with 1296 additions and 222 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
575
src/app/(app)/search/search-preview.tsx
Normal file
575
src/app/(app)/search/search-preview.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>>(
|
||||
|
|
|
|||
Loading…
Reference in a new issue