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:
parent
292cf2468e
commit
d33bb41a59
6 changed files with 936 additions and 17 deletions
410
src/app/(app)/orders/archive/page.tsx
Normal file
410
src/app/(app)/orders/archive/page.tsx
Normal 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">
|
||||
“{search}”
|
||||
</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;
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
50
src/app/api/v1/orders/archive/route.ts
Normal file
50
src/app/api/v1/orders/archive/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue