+
+ );
+}
+
+// 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;