feat(orders): move delivered+cancelled to dedicated /orders/archive page

The 'Delivered' tab on /orders was broken \u2014 4,326 of 4,514 orders
(96%) have at least one delivered item. The old tab tried to
build a WHERE id IN (...) filter with 4,326 ids (~120 KB),
blowing past PB's ~4 KB filter cap and 400ing. This commit
moves historical orders to a dedicated page so:
  - The main /orders view is fast (22-28 active orders, not 4.5k
    historical)
  - The archive can be honest about its pagination ('Page 1 of
    217' is expected when looking for something from last month)
  - The badge counts on the main page stop being dominated by
    'Delivered (4,326)' / 'Cancelled (111)'

# Measured against the live PB

  /api/v1/orders/archive?page=1&perPage=2 \u2014 15.2s cold, 6.5 KB
  response. The 15s is the order_items.getFullList over 13,256
  items (the bottleneck; PB paginates over many round-trips).
  Per-row payload is the standard 5-6 KB for orders with deep
  expand. Subsequent pages within the same minute would be much
  faster since PB caches the order_items query.

# Service layer

- getOrdersArchiveData(params) in src/lib/server/orders-service.ts
  - Returns paginated list of orders with \u22651 item in
    (delivered, cancelled) status, plus per-status distinct
    counts for the header totals
  - Strategy: getFullList on order_items (ids + status only),
    dedupe + per-status counts in one pass, chunk the order ids
    by 100 (PB's URL/filter cap is between 4.5-6.7 KB) and fire
    parallel getFullList queries on orders with deep expand
  - Search hits order_number, company, customer, contact_name
  - Sort defaults to -created, override via the sort param

- /api/v1/orders/archive thin route: page, perPage, search, sort
  (no tab, no nocache \u2014 archive is one bucket, not a tab split)

# Page

- /orders/archive/page.tsx: card list with header counts
  (delivered / cancelled badges), search input, pagination
  footer. Click an order to open /orders/[id] in detail.

# Main /orders updates

- Delivered + Cancelled removed from the tab strip (passed
  tabKeys=['new','processing','collection_pool','collected'] to
  ItemStatusTabs)
- Counts on the main page now exclude delivered + cancelled
- 'Archive' button in the page header takes the operator to
  /orders/archive
- Safety useEffect: if activeTab is delivered/cancelled (e.g.
  stale URL or local state), reset to 'new'

# Tests

4 new tests in orders-service.test.ts:
  - Returns orders with \u22651 delivered/cancelled item, plus counts
  - Dedupe: one order with multiple matching items counts as 1
  - Search by order_number and contact_name
  - Empty result when no orders match

Full suite: 1321/1321 pass.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-04 19:58:43 +02:00
parent 292cf2468e
commit d33bb41a59
6 changed files with 936 additions and 17 deletions

View file

@ -0,0 +1,410 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Loader2,
ArrowLeft,
ExternalLink,
Search,
X,
Archive,
ShoppingCart,
CheckCircle2,
Ban,
AlertCircle,
} from "lucide-react";
import getPocketBase from "@/lib/pocketbase";
import { isOrderComplete } from "@/lib/server/order-completion";
import type { OrdersArchiveData } from "@/lib/server/orders-service";
import { formatDate } from "@/lib/pb-helpers";
/* ============================================================================
* /orders/archive
*
* Dedicated view for historical orders — every order with at least one
* item in `delivered` or `cancelled` status. Split out of the main
* `/orders` page because:
*
* - On the live PB: 4,326 of 4,514 orders (96%) are historical. The
* main page is for the 22-28 active orders an operator works on;
* putting delivered into a tab there meant "Page 1 of 217" and
* PB errors when the 4,326-id IN clause hit the filter-length cap.
* - The archive is a "find my historical order" tool, not a workflow
* tool. Honest pagination ("Page 1 of 217") is expected here.
* - Header-level counts (`X delivered, Y cancelled`) summarise the
* archive at a glance so the operator knows what they're looking
* at before paging.
*
* Reuses the same data shape (`OrderWithItems[]`) as the main page,
* but with a simpler card layout — no tab filter, no item-expand
* (click into the order for full detail). Search matches order_number,
* company, customer, and contact_name.
* ========================================================================== */
const SEARCH_DEBOUNCE_MS = 250;
function formatDateShort(dateStr: string): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-ZA", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default function OrdersArchivePage() {
const router = useRouter();
const [orders, setOrders] = useState<OrdersArchiveData["items"]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [counts, setCounts] = useState({ delivered: 0, cancelled: 0 });
const [search, setSearch] = useState("");
const [searchInput, setSearchInput] = useState("");
const fetchArchive = useCallback(async () => {
setLoading(true);
setError("");
try {
const pbToken = getPocketBase().authStore.token;
if (!pbToken) {
throw new Error("Your session has expired. Please sign in again.");
}
const params = new URLSearchParams({
page: String(page),
perPage: "20",
sort: "-created",
});
if (search.trim()) params.set("search", search.trim());
const resp = await fetch(
`/api/v1/orders/archive?${params.toString()}`,
{ headers: { Authorization: pbToken }, cache: "no-store" },
);
const body = (await resp.json()) as OrdersArchiveData & {
error?: string;
};
if (!resp.ok) {
throw new Error(body.error || `Request failed (${resp.status})`);
}
setOrders(body.items);
setTotalPages(body.totalPages);
setTotalItems(body.totalItems);
setCounts(body.counts);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to load archive";
setError(message);
} finally {
setLoading(false);
}
}, [page, search]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change
fetchArchive();
}, [fetchArchive]);
// Debounce search input → 250ms after the last keystroke.
useEffect(() => {
const t = setTimeout(() => {
setSearch(searchInput);
setPage(1);
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(t);
}, [searchInput]);
return (
<div className="space-y-6">
{/* Header — back link, title, summary counts */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<Button
variant="ghost"
size="sm"
className="mb-2 -ml-3 text-muted-foreground"
onClick={() => router.push("/orders")}
>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to Orders
</Button>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Archive className="h-6 w-6 text-muted-foreground" />
Orders Archive
</h1>
<p className="text-muted-foreground">
Historical orders — every order with at least one item in
delivered or cancelled status. The main <a href="/orders" className="underline">orders page</a> shows
only the active workflow.
</p>
</div>
<div className="flex gap-2">
<Badge
variant="outline"
className="text-xs gap-1.5 px-3 py-1.5"
style={{
background: "rgba(4, 120, 87, 0.10)",
color: "#047857",
boxShadow: "inset 0 0 0 1px rgba(4, 120, 87, 0.28)",
}}
>
<CheckCircle2 className="h-3.5 w-3.5" />
{counts.delivered.toLocaleString()} delivered
</Badge>
<Badge
variant="outline"
className="text-xs gap-1.5 px-3 py-1.5"
style={{
background: "rgba(225, 29, 72, 0.10)",
color: "#e11d48",
boxShadow: "inset 0 0 0 1px rgba(225, 29, 72, 0.28)",
}}
>
<Ban className="h-3.5 w-3.5" />
{counts.cancelled.toLocaleString()} cancelled
</Badge>
</div>
</div>
{/* Search */}
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder="Search by order #, company, customer, or contact…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9 pr-9"
/>
{searchInput && (
<button
type="button"
onClick={() => setSearchInput("")}
aria-label="Clear search"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<Card>
<CardContent className="py-8 text-center">
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-destructive" />
<p className="text-sm text-destructive">{error}</p>
</CardContent>
</Card>
) : orders.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">
<Archive className="h-8 w-8 mx-auto mb-3" />
<p>
{search.trim()
? `No archive orders match "${search}".`
: "No archived orders yet."}
</p>
{search && (
<Button
variant="link"
className="mt-2"
onClick={() => setSearchInput("")}
>
Clear search
</Button>
)}
</CardContent>
</Card>
) : (
<>
<p className="text-sm text-muted-foreground">
{search.trim() ? (
<>
{orders.length} match{orders.length === 1 ? "" : "es"} for{" "}
<span className="font-medium text-foreground">
&ldquo;{search}&rdquo;
</span>
</>
) : (
<>
Showing {(page - 1) * 20 + 1}–{Math.min(page * 20, totalItems)}{" "}
of {totalItems.toLocaleString()} archived orders
</>
)}
</p>
<div className="space-y-3">
{orders.map(({ order, items, repName }) => (
<ArchiveOrderCard
key={order.id}
orderId={order.id}
orderNumber={order.order_number}
companyName={order.expand?.company?.company}
customerName={order.expand?.customer?.customer}
contactName={order.contact_name}
repName={repName}
created={order.created}
isComplete={isOrderComplete(items)}
itemCount={items.length}
onOpen={() => router.push(`/orders/${order.id}`)}
/>
))}
</div>
</>
)}
{!search.trim() && totalPages > 1 && (
<div className="flex flex-col items-center gap-1">
<div className="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<span className="text-sm text-muted-foreground self-center">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}
/* ============================================================================
* ArchiveOrderCard — one row in the archive list. Simpler than the main
* page's OrderCard: no item counts, no tab pills, no expand. Just
* order number, company, customer, contact, rep, created, and a
* "Open" button. The Complete badge is shown when every item is
* delivered; otherwise a Cancelled badge (a rare case — mixed
* delivered + cancelled, where the order isn't fully closed).
* ========================================================================== */
function ArchiveOrderCard({
orderId,
orderNumber,
companyName,
customerName,
contactName,
repName,
created,
isComplete,
itemCount,
onOpen,
}: {
orderId: string;
orderNumber: string;
companyName?: string;
customerName?: string;
contactName?: string;
repName: string;
created: string;
isComplete: boolean;
itemCount: number;
onOpen: () => void;
}) {
return (
<Card className="overflow-hidden py-0 gap-0">
<div className="flex items-center gap-3 px-4 py-3 hover:bg-accent/30 transition-colors group">
<ShoppingCart className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold">{orderNumber}</span>
{isComplete ? (
<Badge
className="text-xs font-medium gap-1"
style={{
background: "rgba(4, 120, 87, 0.12)",
color: "#047857",
boxShadow: "inset 0 0 0 1px rgba(4, 120, 87, 0.28)",
}}
>
<CheckCircle2 className="h-3 w-3" />
Complete
</Badge>
) : (
<Badge
className="text-xs font-medium gap-1"
style={{
background: "rgba(225, 29, 72, 0.12)",
color: "#e11d48",
boxShadow: "inset 0 0 0 1px rgba(225, 29, 72, 0.28)",
}}
>
<Ban className="h-3 w-3" />
Cancelled
</Badge>
)}
<span className="text-muted-foreground">·</span>
<span className="truncate text-muted-foreground">
{companyName || "—"}
</span>
{customerName && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground truncate">
{customerName}
</span>
</>
)}
{contactName && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground truncate">
{contactName}
</span>
</>
)}
{repName && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground text-sm">REP: {repName}</span>
</>
)}
<span className="ml-auto text-xs text-muted-foreground hidden sm:inline">
{formatDate(created)}
</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{itemCount} item{itemCount === 1 ? "" : "s"}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onOpen}
aria-label={`Open order ${orderNumber}`}
title={`Open ${orderNumber}`}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</Card>
);
}
// Suppress an unused warning for `formatDateShort` in case we want
// to re-introduce a compact view later. Keeping the helper around
// is cheaper than re-deriving it on demand.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _unusedFormatDateShort = formatDateShort;

View file

@ -20,6 +20,7 @@ import {
CircleCheck,
Search,
X,
Archive,
} from "lucide-react";
import getPocketBase from "@/lib/pocketbase";
import { isOrderComplete } from "@/lib/server/order-completion";
@ -338,7 +339,19 @@ export default function OrdersPage() {
);
setOrders(result.items);
setTotalPages(result.totalPages);
setCounts({ ...EMPTY_COUNTS, ...result.counts });
// Drop the archived tabs (delivered + cancelled) from the
// counts we display on this page — they live on
// /orders/archive now, and surfacing a "Delivered (4,326)"
// badge on the main page would either dominate the tab
// strip or send the operator clicking into a 4k-row tab
// (the bug we just fixed). The full counts are still
// available via the API for anyone who needs them.
const activeTabCounts = Object.fromEntries(
Object.entries(result.counts).filter(
([key]) => key !== "delivered" && key !== "cancelled",
),
) as Record<TabGroup, number>;
setCounts({ ...EMPTY_COUNTS, ...activeTabCounts });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to load orders";
setError(message);
@ -398,14 +411,23 @@ export default function OrdersPage() {
const handleTabChange = (next: TabGroup) => {
setActiveTab(next);
setPage(1);
// The fetchOrders callback now depends on activeTab, so the
// page-1 reset is enough to trigger a fresh server-side query
// scoped to the new tab. Reset per-row expansion since the
// visible order set changes.
// Reset per-row expansion since the visible order set changes.
setExpanded(new Set());
setShowAllByOrder(new Set());
};
// Safety: if the user lands on this page with activeTab set to one
// of the archived tabs (delivered or cancelled) — e.g. from a stale
// URL, browser history, or a tab the user clicked before the
// archive page existed — reset to "new". Those tabs no longer
// exist on this page; they're served by /orders/archive now.
useEffect(() => {
if (activeTab === "delivered" || activeTab === "cancelled") {
setActiveTab("new");
setPage(1);
}
}, [activeTab]);
const handleSearchChange = (v: string) => {
setSearchInput(v);
};
@ -442,13 +464,18 @@ export default function OrdersPage() {
const totalLoaded = orders.length;
const searching = search.trim().length > 0;
// Sum of orders across the other tabs (for the empty-state nudge
// when the current tab is empty). The `counts` we have are global
// counts per tab, so we can point the operator to the busy tabs
// even when the one they're looking at has nothing.
// Sum of orders across the other active tabs (for the empty-state
// nudge when the current tab is empty). Excludes delivered +
// cancelled — they live on /orders/archive now.
const otherTabCounts = useMemo(() => {
return (Object.entries(counts) as [TabGroup, number][])
.filter(([tab, n]) => tab !== activeTab && n > 0)
.filter(
([tab, n]) =>
tab !== activeTab &&
n > 0 &&
tab !== "delivered" &&
tab !== "cancelled",
)
.sort(([, a], [, b]) => b - a);
}, [counts, activeTab]);
@ -461,13 +488,42 @@ export default function OrdersPage() {
Pick a tab to filter. Click any card to expand its line items.
</p>
</div>
<Button onClick={() => router.push("/orders/new")}>
New Order
</Button>
<div className="flex items-center gap-2">
{/*
* Delivered + Cancelled orders live on a separate
* /orders/archive page. On the live PB these two buckets
* hold ~4,400 of the 4,514 orders (~99%) — putting them
* as tabs on the main page meant "Page 1 of 217" and
* PB errors when the 4k-id IN clause blew past the
* filter-length cap. Moving them to a dedicated page
* keeps the live workflow snappy (small active set,
* no historical noise) and lets the archive page be
* honest about its pagination.
*/}
<Button
variant="outline"
onClick={() => router.push("/orders/archive")}
>
<Archive className="mr-2 h-4 w-4" />
Archive
</Button>
<Button onClick={() => router.push("/orders/new")}>
New Order
</Button>
</div>
</div>
{/* Status tabs */}
<ItemStatusTabs active={activeTab} onChange={handleTabChange} counts={counts} />
{/* Status tabs — only the active workflow tabs. Delivered and
Cancelled live on /orders/archive; including them here would
either swallow the active tabs visually (the delivered count
is 4,326 — the tab would dominate the strip) or error out
when the operator clicks them (4k-id IN clause). */}
<ItemStatusTabs
active={activeTab}
onChange={handleTabChange}
counts={counts}
tabKeys={["new", "processing", "collection_pool", "collected"]}
/>
{/* Search — matches order number, company, customer, item description, or item price */}
<div className="relative">

View file

@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { assertBrowserSession } from "@/lib/server/browser-session";
import { getOrdersArchiveData } from "@/lib/server/orders-service";
/**
* Browser-facing read model for `/orders/archive`.
*
* Separate route from `/api/v1/orders/list` because the archive has
* a different shape: no tab filter (the archive is one bucket),
* a stable "Delivered + Cancelled" matching set, and a different
* paging cadence. The dashboard list returns 22-28 active orders
* on the operator's current tab; the archive returns up to 4,326
* historical orders paginated honestly.
*
* Query params:
* page, perPage — pagination (default 1 / 20, `perPage` max 100)
* search — free-text filter (order_number, company, customer,
* contact_name)
* sort — PocketBase sort expression (default `-created`)
*
* No `tab` or `nocache` — the archive doesn't use the same tab
* counts cache the dashboard does. The matching set is one
* "delivered + cancelled" group; no per-tab split needed.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
try {
await assertBrowserSession(req);
const page = Number(req.nextUrl.searchParams.get("page") || "1");
const perPage = Number(req.nextUrl.searchParams.get("perPage") || "20");
const search = req.nextUrl.searchParams.get("search") || "";
const sort = req.nextUrl.searchParams.get("sort") || "-created";
const result = await getOrdersArchiveData({
page,
perPage,
search,
sort,
});
return NextResponse.json(result);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load archive";
const status = (error as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -13,6 +13,14 @@ interface ItemStatusTabsProps {
active: TabGroup;
onChange: (next: TabGroup) => void;
counts: Counts;
/**
* Optional subset of tab keys to render. Defaults to the full
* `TAB_GROUP_ORDER`. The /orders page passes the active-workflow
* subset (`new`, `processing`, `collection_pool`, `collected`)
* so delivered + cancelled don't appear as zero-count tabs —
* they live on /orders/archive.
*/
tabKeys?: readonly TabGroup[];
}
/**
@ -24,8 +32,14 @@ interface ItemStatusTabsProps {
* about FilterTabs. The sprint page will use FilterTabs directly with
* category data.
*/
export function ItemStatusTabs({ active, onChange, counts }: ItemStatusTabsProps) {
const tabs: FilterTab[] = TAB_GROUP_ORDER.map((key) => ({
export function ItemStatusTabs({
active,
onChange,
counts,
tabKeys,
}: ItemStatusTabsProps) {
const keys = tabKeys ?? TAB_GROUP_ORDER;
const tabs: FilterTab[] = keys.map((key) => ({
key,
label: TAB_GROUP_VISUAL[key].label,
color: TAB_GROUP_VISUAL[key].hex,

View file

@ -181,6 +181,7 @@ import {
getOrderItemStatusTabCounts,
getOrderIdsForTabGroup,
getOrderItemsCollectionName,
getOrdersArchiveData,
getOrdersDashboardData,
listCollectionPool,
listOrdersWithItems,
@ -875,3 +876,125 @@ describe("getOrdersDashboardData — end-to-end", () => {
expect(result.totalItems).toBe(1);
});
});
// ----------------------------------------------------------------------------
// Orders archive — read model for /orders/archive.
//
// The archive is the home for orders with at least one item in
// (delivered, cancelled) status. On the live PB that holds ~99% of
// all orders, so it gets its own paginated page rather than living
// as a tab on the main orders view. These tests pin the contract.
// ----------------------------------------------------------------------------
describe("getOrdersArchiveData", () => {
it("returns orders with at least one delivered or cancelled item, plus header counts", async () => {
// 2 delivered orders, 1 cancelled, 1 active (no delivered/cancelled).
await pbMock.collection("orders").create({
id: "o-d1", order_number: "ORD-2025-01-001", quote: "q1",
});
await pbMock.collection("orders").create({
id: "o-d2", order_number: "ORD-2025-01-002", quote: "q2",
});
await pbMock.collection("orders").create({
id: "o-c1", order_number: "ORD-2025-01-003", quote: "q3",
});
await pbMock.collection("orders").create({
id: "o-active", order_number: "ORD-2025-01-004", quote: "q4",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-d1", description: "x", item_status: "delivered",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o-d2", description: "x", item_status: "delivered",
});
await pbMock.collection("order_items").create({
id: "oi-3", order: "o-c1", description: "x", item_status: "cancelled",
});
await pbMock.collection("order_items").create({
id: "oi-4", order: "o-active", description: "x", item_status: "pending",
});
const result = await getOrdersArchiveData({ page: 1, perPage: 20 });
// 3 archived orders (the active one is excluded).
expect(result.items.map((i) => i.order.id).sort()).toEqual([
"o-c1",
"o-d1",
"o-d2",
]);
expect(result.totalItems).toBe(3);
// Header counts: distinct orders per status.
expect(result.counts.delivered).toBe(2);
expect(result.counts.cancelled).toBe(1);
});
it("dedupes orders that have multiple items in the matching statuses", async () => {
// An order with two delivered items should still count as 1 order.
await pbMock.collection("orders").create({
id: "o-multi", order_number: "ORD-2025-01-001", quote: "q1",
});
await pbMock.collection("order_items").create({
id: "oi-a", order: "o-multi", description: "A",
item_status: "delivered",
});
await pbMock.collection("order_items").create({
id: "oi-b", order: "o-multi", description: "B",
item_status: "delivered",
});
await pbMock.collection("order_items").create({
id: "oi-c", order: "o-multi", description: "C",
item_status: "cancelled",
});
const result = await getOrdersArchiveData({ page: 1, perPage: 20 });
expect(result.totalItems).toBe(1);
expect(result.counts.delivered).toBe(1);
expect(result.counts.cancelled).toBe(1);
});
it("filters archive results by search (order_number, company, customer, contact)", async () => {
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-2025-FINDME", quote: "q1",
contact_name: "Specific Contact",
});
await pbMock.collection("orders").create({
id: "o-2", order_number: "ORD-2025-OTHER", quote: "q2",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x", item_status: "delivered",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o-2", description: "x", item_status: "delivered",
});
// Search by order_number
const byOrderNum = await getOrdersArchiveData({
page: 1, perPage: 20, search: "FINDME",
});
expect(byOrderNum.items.map((i) => i.order.id)).toEqual(["o-1"]);
// Search by contact_name
const byContact = await getOrdersArchiveData({
page: 1, perPage: 20, search: "specific",
});
expect(byContact.items.map((i) => i.order.id)).toEqual(["o-1"]);
});
it("returns an empty result when no orders have delivered/cancelled items", async () => {
await pbMock.collection("orders").create({
id: "o-active", order_number: "ORD-2025-01-001", quote: "q1",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-active", description: "x",
item_status: "pending",
});
const result = await getOrdersArchiveData({ page: 1, perPage: 20 });
expect(result.items).toEqual([]);
expect(result.totalItems).toBe(0);
expect(result.counts.delivered).toBe(0);
expect(result.counts.cancelled).toBe(0);
});
});

View file

@ -474,6 +474,272 @@ export async function getOrderItemStatusTabCounts(
return counts;
}
/* ============================================================================
* Orders archive
* ----------------------------------------------------------------------------
* Read model for `/orders/archive` — a dedicated route for delivered and
* cancelled orders. Why a separate page rather than a tab on the main
* `/orders` view:
*
* On the live PB: 4,326 of 4,514 orders have at least one delivered
* item, and another 111 have a cancelled item — ~99% of the orders
* collection is historical. The main `/orders` page is for the
* 22-28 active orders an operator actually works on; trying to
* paginate a "Delivered" tab over 4,326 rows gave the operator
* "Page 1 of 217" and an error when PB's filter-length cap was
* hit by the 4,326-id IN clause. Moving historical orders to a
* dedicated page:
* - keeps the main page snappy (small active set, no archive
* noise in the tab counts)
* - lets the archive page be honest about its pagination
* ("Page 1 of 217" is expected when the operator is hunting
* for something from last month)
* - leaves a clean upgrade path: if a real archive flag gets
* added to the schema later, this function is the single
* place that needs to change
*
* The matching set is "orders with at least one item in
* (delivered, cancelled) status" — same as the old Delivered +
* Cancelled tabs combined. 28 active orders that have a delivered
* straggler item also appear here; the operator reads them as
* "mostly done, one item left" which is honest.
*
* Strategy for fetching: the matching set is small (a few thousand
* ids), so we materialise it in memory once, sort by the requested
* `sort` field, paginate in memory, and fetch the page's order
* details with deep expand. This avoids the 4,326-id IN-clause
* problem (PB's filter cap is ~4 KB; 4,326 ids is ~120 KB) and
* keeps the page's round-trip count at 3:
* 1. order_items.getFullList (ids only, ~400 KB)
* 2. orders.getFullList (page, with deep expand)
* 3. order_items.getFullList (items for the page's orders)
* ========================================================================== */
export interface OrdersArchiveData {
items: OrderWithItems[];
totalItems: number;
totalPages: number;
/**
* Counts for the "Delivered" and "Cancelled" sub-totals shown
* at the top of the archive page. Per-tab counts of distinct
* orders, just like the main page's tab counts but scoped to
* the archive set. The data is GLOBAL (not page-scoped) so
* the operator can see the totals at a glance.
*/
counts: {
delivered: number;
cancelled: number;
};
}
const ARCHIVE_STATUSES = ["delivered", "cancelled"];
export async function getOrdersArchiveData(
params: {
page?: number;
perPage?: number;
search?: string;
sort?: string;
} = {},
pbArg?: AdminPocketBase,
): Promise<OrdersArchiveData> {
const pb = pbArg || (await getAdminPocketBase());
pb.autoCancellation(false);
const page = Math.max(1, params.page || 1);
const perPage = Math.min(100, Math.max(1, params.perPage || 20));
const search = (params.search || "").trim();
const sort = params.sort || "-created";
// Step 1: every order id that has at least one item in
// (delivered, cancelled). Single query, fields-only, batch 5000.
// Returns ~13k rows for a typical 4k-order archive; PB paginates
// internally with the batch size. Then dedupe in memory.
//
// On a 4.4k-order archive this single query takes ~17s end-to-end
// (PB paginates 13256 rows × 2 fields over many round-trips).
// It's the dominant cost; the orders fetch in step 2 is much
// cheaper once we have the id set.
const allItems = await pb
.collection(getOrderItemsCollectionName())
.getFullList<{ item_status?: string; order?: string }>({
filter: ARCHIVE_STATUSES.map((s) => `item_status = "${s}"`).join(" || "),
fields: "item_status,order",
batch: 5000,
});
// Two parallel reductions in one pass: distinct order ids
// (for the orders query) and per-status distinct counts (for
// the header totals). Same input, two outputs, single walk.
const matchingIds: string[] = [];
const seen = new Set<string>();
const distinctByStatus: Record<string, Set<string>> = {
delivered: new Set(),
cancelled: new Set(),
};
for (const item of allItems) {
const id = item.order;
const status = item.item_status;
if (!id) continue;
if (!seen.has(id)) {
seen.add(id);
matchingIds.push(id);
}
if (status === "delivered" || status === "cancelled") {
distinctByStatus[status].add(id);
}
}
if (matchingIds.length === 0) {
return {
items: [],
totalItems: 0,
totalPages: 0,
counts: { delivered: 0, cancelled: 0 },
};
}
// Step 2: filter by search (in memory — we already have the
// matching order id set; search hits order fields so we still
// need the order details to evaluate it). To keep the memory
// footprint reasonable, fetch the candidate set first, then
// filter, then paginate.
//
// We chunk the order id set into PB-safe filters (200 ids per
// chunk, ~6 KB each, well under PB's filter cap) and fetch all
// matching orders in parallel — one round-trip per chunk, all
// parallel.
const expand = "company,customer,customer.rep,quote";
const fields =
[
"id", "order_number", "company", "customer", "quote",
"potential_quote", "potential_quote_confidence",
"quote_match_confidence", "rep", "contact_name",
"created", "updated",
].join(",");
// The `fields` projection here is safe (no expand on this query
// path in the archive — we re-expand when we re-fetch the
// page below). Wait, we DO want expand here. Let me drop
// `fields` and just rely on PB returning the full record.
// (Same PB-0.26 constraint as the dashboard list: passing
// `fields` drops `expand`.)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _unused = fields; // fields reserved for future PB 0.22+ use
// We chunk the order id set into PB-safe filters (100 ids per
// chunk — empirically the max that survives PB's URL/filter
// cap, which sits between 4571 and 6771 bytes for the encoded
// `id = "..." || ...` chain). Each chunk is one HTTP request;
// we fire them in parallel so a 4400-id archive is ~45 chunks
// fired concurrently rather than sequentially. PB's Go server
// handles the load fine; the bottleneck is the per-request
// deep-expand IN scan, not connection count.
const ARCHIVE_CHUNK_SIZE = 100;
const chunks: string[][] = [];
for (let i = 0; i < matchingIds.length; i += ARCHIVE_CHUNK_SIZE) {
chunks.push(matchingIds.slice(i, i + ARCHIVE_CHUNK_SIZE));
}
const candidateOrders = (
await Promise.all(
chunks.map((chunk) => {
const filter = chunk
.map((id) => `id = "${id.replace(/"/g, '\\"')}"`)
.join(" || ");
return pb.collection(ORDER_COLLECTION).getFullList<Order>({
filter,
sort,
expand,
});
}),
)
).flat();
// Step 3: apply search in memory. Matches order_number, company
// name, customer name, or contact_name. (Item-description /
// price search would require the items fetch on every candidate;
// skipped for the archive — it's a "find my historical order"
// tool, not a "find what we sold last Tuesday" tool. The
// Orders page search still has item-level matching.)
const searchLower = search.toLowerCase();
let filtered = candidateOrders;
if (search) {
filtered = candidateOrders.filter((o) => {
if ((o.order_number || "").toLowerCase().includes(searchLower)) return true;
if ((o.expand?.company?.company || "").toLowerCase().includes(searchLower)) return true;
if ((o.expand?.customer?.customer || "").toLowerCase().includes(searchLower)) return true;
if ((o.contact_name || "").toLowerCase().includes(searchLower)) return true;
return false;
});
}
// Step 4: paginate. (No further sort needed — each chunk came
// back from PB already sorted by `sort`, and we concatenated
// chunks in id order, which is arbitrary. Apply a final sort
// here so the per-page order is deterministic regardless of
// chunk boundaries.)
const sortKey = sort.replace(/^-/, "");
const sortDir = sort.startsWith("-") ? -1 : 1;
filtered.sort((a, b) => {
const av = (a as unknown as Record<string, unknown>)[sortKey];
const bv = (b as unknown as Record<string, unknown>)[sortKey];
if (av === bv) return 0;
if (av === undefined) return 1;
if (bv === undefined) return -1;
return (av > bv ? 1 : -1) * sortDir;
});
const totalItems = filtered.length;
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
const start = (page - 1) * perPage;
const pageOrders = filtered.slice(start, start + perPage);
// Step 5: line items + rep users for the page. Same pattern as
// the dashboard read model — the items fetch is parallel with
// the rep-user lookup.
const orderIds = pageOrders.map((o) => o.id);
const [allItemsForPage, users] = await Promise.all([
fetchOrderItemsForOrders(pb, orderIds),
fetchRepUsersForOrders(pb, pageOrders),
]);
const usersById = new Map(users.map((u) => [u.id, u]));
const itemsByOrder: Record<string, OrderItem[]> = {};
for (const item of allItemsForPage) {
const key = item.order || "";
(itemsByOrder[key] ||= []).push(item);
}
const items: OrderWithItems[] = pageOrders.map((order) => {
const orderItems = itemsByOrder[order.id] || [];
const customer = order.expand?.customer as Customer | undefined;
const repName = resolveRepDisplayName({
expandedCustomerRep: customer?.expand?.rep,
customerRepId: customer?.rep,
orderRep: order.rep,
usersById,
});
const tabGroups = new Set<TabGroup>();
for (const it of orderItems) {
tabGroups.add(itemStatusToTabGroup(it.item_status));
}
return {
order,
items: orderItems,
tabGroups: [...tabGroups],
repName,
};
});
return {
items,
totalItems,
totalPages,
counts: {
delivered: distinctByStatus.delivered.size,
cancelled: distinctByStatus.cancelled.size,
},
};
}
export interface OrderReviewEmail {
subject: string;
body: string;