From d33bb41a59c3d0482b33d0b954cdf8e3764c76ff Mon Sep 17 00:00:00 2001
From: SJCoetz
Date: Tue, 4 Aug 2026 19:58:43 +0200
Subject: [PATCH] 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
---
src/app/(app)/orders/archive/page.tsx | 410 +++++++++++++++++++++++++
src/app/(app)/orders/page.tsx | 86 +++++-
src/app/api/v1/orders/archive/route.ts | 50 +++
src/components/item-status-tabs.tsx | 18 +-
src/lib/server/orders-service.test.ts | 123 ++++++++
src/lib/server/orders-service.ts | 266 ++++++++++++++++
6 files changed, 936 insertions(+), 17 deletions(-)
create mode 100644 src/app/(app)/orders/archive/page.tsx
create mode 100644 src/app/api/v1/orders/archive/route.ts
diff --git a/src/app/(app)/orders/archive/page.tsx b/src/app/(app)/orders/archive/page.tsx
new file mode 100644
index 0000000..bdd8d40
--- /dev/null
+++ b/src/app/(app)/orders/archive/page.tsx
@@ -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([]);
+ 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 (
+
+
+ );
+}
+
+// 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;
diff --git a/src/app/(app)/orders/page.tsx b/src/app/(app)/orders/page.tsx
index fdc5c40..15a08db 100644
--- a/src/app/(app)/orders/page.tsx
+++ b/src/app/(app)/orders/page.tsx
@@ -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;
+ 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.
-
+
+ {/*
+ * 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.
+ */}
+
+
+
- {/* Status tabs */}
-
+ {/* 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). */}
+
{/* Search — matches order number, company, customer, item description, or item price */}
diff --git a/src/app/api/v1/orders/archive/route.ts b/src/app/api/v1/orders/archive/route.ts
new file mode 100644
index 0000000..38dea9f
--- /dev/null
+++ b/src/app/api/v1/orders/archive/route.ts
@@ -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 });
+ }
+}
diff --git a/src/components/item-status-tabs.tsx b/src/components/item-status-tabs.tsx
index f1b60d7..ed860f7 100644
--- a/src/components/item-status-tabs.tsx
+++ b/src/components/item-status-tabs.tsx
@@ -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,
diff --git a/src/lib/server/orders-service.test.ts b/src/lib/server/orders-service.test.ts
index 4a69443..6eb454b 100644
--- a/src/lib/server/orders-service.test.ts
+++ b/src/lib/server/orders-service.test.ts
@@ -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);
+ });
+});
diff --git a/src/lib/server/orders-service.ts b/src/lib/server/orders-service.ts
index bb9ea1e..14a9143 100644
--- a/src/lib/server/orders-service.ts
+++ b/src/lib/server/orders-service.ts
@@ -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 {
+ 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();
+ const distinctByStatus: Record> = {
+ 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({
+ 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)[sortKey];
+ const bv = (b as unknown as Record)[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 = {};
+ 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();
+ 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;