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 ( +
+ {/* Header — back link, title, summary counts */} +
+
+ +

+ + Orders Archive +

+

+ Historical orders — every order with at least one item in + delivered or cancelled status. The main orders page shows + only the active workflow. +

+
+
+ + + {counts.delivered.toLocaleString()} delivered + + + + {counts.cancelled.toLocaleString()} cancelled + +
+
+ + {/* Search */} +
+ + setSearchInput(e.target.value)} + className="pl-9 pr-9" + /> + {searchInput && ( + + )} +
+ + {loading ? ( +
+ +
+ ) : error ? ( + + + +

{error}

+
+
+ ) : orders.length === 0 ? ( + + + +

+ {search.trim() + ? `No archive orders match "${search}".` + : "No archived orders yet."} +

+ {search && ( + + )} +
+
+ ) : ( + <> +

+ {search.trim() ? ( + <> + {orders.length} match{orders.length === 1 ? "" : "es"} for{" "} + + “{search}” + + + ) : ( + <> + Showing {(page - 1) * 20 + 1}–{Math.min(page * 20, totalItems)}{" "} + of {totalItems.toLocaleString()} archived orders + + )} +

+
+ {orders.map(({ order, items, repName }) => ( + router.push(`/orders/${order.id}`)} + /> + ))} +
+ + )} + + {!search.trim() && totalPages > 1 && ( +
+
+ + + Page {page} of {totalPages} + + +
+
+ )} +
+ ); +} + +/* ============================================================================ + * 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 ( + +
+ +
+
+ {orderNumber} + {isComplete ? ( + + + Complete + + ) : ( + + + Cancelled + + )} + · + + {companyName || "—"} + + {customerName && ( + <> + · + + {customerName} + + + )} + {contactName && ( + <> + · + + {contactName} + + + )} + {repName && ( + <> + · + REP: {repName} + + )} + + {formatDate(created)} + +
+
+ {itemCount} item{itemCount === 1 ? "" : "s"} +
+
+ +
+
+ ); +} + +// 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;