feat(orders): add Duplicates tab surfacing flagged duplicate orders

Cross-cutting review tab at the end of the strip — lists every order
with any of the three duplicate-detection flags set:
  - order_number_duplicate
  - potential_duplicate_order
  - potential_delivery_note

Implementation:
  - src/lib/item-status-ui.ts: add 'duplicates' to TabGroup + visual +
    TAB_GROUP_ORDER (placed last so it doesn't compete with
    active-workflow tabs). Empty array in TAB_GROUP_ITEM_STATUSES
    (it's not an item-status group — the duplicates tab is
    cross-cutting on order-header fields).
  - src/lib/server/orders-service.ts: add duplicateOrderFilter() +
    orderHasAnyDuplicateFlag() helpers. listOrdersWithItems routes
    tabGroup='duplicates' to a PB filter on the orders collection
    (separate from the item-level tabGroupFilter the other tabs use).
    getOrderItemStatusTabCounts counts duplicates independently so
    the badge stays accurate even when the active-workflow counts
    are filtered out.
  - src/app/(app)/orders/page.tsx: add the tab to the strip + an
    empty state with the purple #a855f7 visual.
This commit is contained in:
Dave 2026-08-31 11:30:09 +02:00
parent f1d23da685
commit b4855a9c9f
3 changed files with 220 additions and 12 deletions

View file

@ -72,6 +72,11 @@ const EMPTY_COUNTS: Record<TabGroup, number> = {
completed: 0,
delivered: 0,
cancelled: 0,
// Cross-cutting review tab — counted separately by
// `getOrderItemStatusTabCounts` (a PB filter on order-header
// duplicate fields, not the item-status scan). Always present in
// the shape so `Record<TabGroup, number>` stays satisfied.
duplicates: 0,
};
const SEARCH_DEBOUNCE_MS = 250;
@ -348,11 +353,18 @@ export default function OrdersPage() {
slice (last COMPLETED_WINDOW_DAYS, see orders-service.ts) —
orders that fall out of the window roll down to
/orders/archive automatically. */}
{/* The `duplicates` tab is the cross-cutting review surface —
every order with any of the three duplicate-detection flags
set. Sits at the end of the strip (not in TAB_GROUP_ORDER's
status-driven slots) so it doesn't visually compete with the
active-workflow tabs. See src/lib/item-status-ui.ts for the
purple #a855f7 visual + the `duplicates: []` placeholder in
`TAB_GROUP_ITEM_STATUSES`. */}
<ItemStatusTabs
active={activeTab}
onChange={handleTabChange}
counts={counts}
tabKeys={["new", "processing", "post_processing", "collection_pool", "collected", "completed"]}
tabKeys={["new", "processing", "post_processing", "collection_pool", "collected", "completed", "duplicates"]}
/>
{/* Search — matches order number, company, customer, item description, or item price */}
@ -387,6 +399,32 @@ export default function OrdersPage() {
<p className="text-sm text-destructive">{error}</p>
</CardContent>
</Card>
) : orders.length === 0 && activeTab === "duplicates" && !searching ? (
// Duplicates empty state — no flags set. Distinct from the
// generic "no orders" copy so the operator knows the matcher
// is healthy (i.e. it ran and found nothing) rather than
// silently broken. The "Re-run backfill" link is intentionally
// a no-op for now (the matchers run on every order write and
// on intake); kept as a future hook for a manual batched
// backfill tool.
<Card>
<CardContent className="py-10 text-center text-muted-foreground">
<Copy className="h-8 w-8 mx-auto mb-3" style={{ color: TAB_GROUP_VISUAL.duplicates.hex }} />
<p className="font-medium text-foreground">No duplicates detected</p>
<p className="text-sm mt-1">
The matcher runs on every new order. If you expected a duplicate, run the
backfill to re-scan existing orders.
</p>
<Button
variant="link"
className="mt-2"
disabled
title="Re-run backfill is not yet wired up — matchers run automatically on every order write."
>
Re-run backfill
</Button>
</CardContent>
</Card>
) : orders.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">

View file

@ -112,8 +112,19 @@ export type TabGroup =
| "collected"
| "completed"
| "delivered"
| "cancelled";
| "cancelled"
| "duplicates";
// Display order on /orders. Status tabs come first (the operator's
// primary workflow), then `duplicates` last so the cross-cutting
// review tab sits at the end of the strip and doesn't visually
// compete with the active-workflow tabs.
//
// Why "last" and not "first": the duplicates tab is a manual-review
// surface, not an action queue. Operators reach for it when an
// investigation warrants it — not as part of the daily
// "what's next to process?" sweep. Sinking it to the end matches
// the actual scan order.
export const TAB_GROUP_ORDER: TabGroup[] = [
"new",
"processing",
@ -123,6 +134,7 @@ export const TAB_GROUP_ORDER: TabGroup[] = [
"completed",
"delivered",
"cancelled",
"duplicates",
];
export const TAB_GROUP_ITEM_STATUSES: Record<TabGroup, ItemStatus[]> = {
@ -166,6 +178,14 @@ export const TAB_GROUP_ITEM_STATUSES: Record<TabGroup, ItemStatus[]> = {
// the other non-completions so the active workflow tabs don't carry
// them. The /orders/archive page surfaces both buckets.
cancelled: ["cancelled", "removed"],
// `duplicates` is NOT an item-status group — it's a cross-cutting
// filter on order-header fields (order_number_duplicate /
// potential_duplicate_order / potential_delivery_note). The empty
// array keeps `Record<TabGroup, ItemStatus[]>` satisfied; the
// service layer (orders-service.ts) intercepts `tabGroup ===
// "duplicates"` and routes to a PB filter on the orders
// collection instead of `tabGroupFilter` against order_items.
duplicates: [],
};
/**
@ -229,6 +249,14 @@ export const TAB_GROUP_VISUAL: Record<TabGroup, TabGroupVisual> = {
completed: { hex: "#064e3b", label: "Completed" },
delivered: { hex: "#047857", label: "Delivered" },
cancelled: { hex: "#e11d48", label: "Cancelled" },
// Duplicates: deliberately a vibrant purple (#a855f7) that doesn't
// collide with collection_pool's #7c3aed — the collection_pool
// purple is deeper and reserved for "items waiting in the pool",
// while duplicates' brighter purple signals "investigate me" without
// matching any operational state. Sits at the end of the tab strip
// (see TAB_GROUP_ORDER) so the cross-cutting review surface doesn't
// visually compete with the active-workflow tabs.
duplicates: { hex: "#a855f7", label: "Duplicates" },
};
/* ============================================================================

View file

@ -122,6 +122,62 @@ export function completedTabFilter(now: Date = new Date()): string {
].join(" || ");
}
/**
* PB filter fragment for the `duplicates` tab.
*
* Three independent flag columns on the `orders` collection capture
* the duplicate-detection matchers (see the *-link-service.ts files):
* - `order_number_duplicate` — exact-order-number collision (any
* status, no time limit). Stamped by `suggestOrderNumberDuplicate`.
* - `potential_duplicate_order` — another order with matching line
* items for the same company, last 60 days, regardless of either
* order's status. Stamped by `suggestOrderDuplicate`.
* - `potential_delivery_note` — a past delivery note with matching
* items for the same company, last 60 days. Stamped by
* `suggestDeliveryNoteForOrder`.
*
* Each field is set to either a record id (when there's a match) or
* `null` (when the matcher ran and found nothing — or hasn't run yet).
* We OR three `!= null && != ""` predicates so an order with ANY of
* the three flags set qualifies. The `!= ""` half is belt-and-braces
* safety against a stray empty-string write (PB stores `""` for
* "checked, no match" on text fields like `potential_duplicate_order_number`
* but the relation fields themselves use `null`).
*
* The fragment lives at the orders-collection level (the duplicate
* flags are header fields on orders, not on items), so it's a PB
* `getList` filter — not the item-level `tabGroupFilter` used by
* every other tab. `listOrdersWithItems` and
* `getOrderItemStatusTabCounts` both branch on `tabGroup ===
* "duplicates"` to route here.
*/
export function duplicateOrderFilter(): string {
return [
`(order_number_duplicate != "" && order_number_duplicate != null)`,
`(potential_duplicate_order != "" && potential_duplicate_order != null)`,
`(potential_delivery_note != "" && potential_delivery_note != null)`,
].join(" || ");
}
/**
* True when the order has any of the three duplicate-detection flags
* set. Mirrors `duplicateOrderFilter`'s OR — used by the search-match
* loop to fold the duplicates bucket into `tabGroups` so the
* `searchCounts[tab]++` tally reports hits on the duplicates tab when
* the operator searches while standing on it.
*
* Empty strings are treated as "unset" (matches PB filter semantics
* — `!= ""` excludes them). A relation field set to a 15-char id is
* a positive match.
*/
export function orderHasAnyDuplicateFlag(order: Order): boolean {
return Boolean(
(order.order_number_duplicate && order.order_number_duplicate !== "") ||
(order.potential_duplicate_order && order.potential_duplicate_order !== "") ||
(order.potential_delivery_note && order.potential_delivery_note !== ""),
);
}
/** Zero-filled per-tab counts. Spread it; never mutate the original. */
const EMPTY_TAB_COUNTS: Record<TabGroup, number> = Object.freeze(
Object.fromEntries(TAB_GROUP_ORDER.map((tab) => [tab, 0])),
@ -276,24 +332,65 @@ export async function listOrdersWithItems(
// permanently trim the payload.
const expand = "company,customer,customer.rep,quote";
// Tab filter path — three sub-cases:
// Tab filter path — four sub-cases:
// (a) no tab, no search → original PB-paginated path
// (b) tab + no search → resolve matching order ids, then
// PB-paginate the filtered set (small tabs
// fall through to `getFullList`+slice when
// the `id = "..." || ...` filter would
// exceed PB's URL length cap)
// (c) any search → candidate set is the ACTIVE order set
// (union of the four workflow tabs), which
// is ~91 orders on live PB. We fetch those,
// match in memory, and collapse to a single
// page. Scoping by active id set (rather
// than "first 500 by sort") is what makes
// search independent of sort direction —
// see ORDER_SEARCH_HARD_CAP.
// (c) any search (non-duplicates tab) → candidate set is the ACTIVE
// order set (union of the four workflow
// tabs), which is ~91 orders on live PB.
// We fetch those, match in memory, and
// collapse to a single page. Scoping by
// active id set (rather than "first 500
// by sort") is what makes search
// independent of sort direction — see
// ORDER_SEARCH_HARD_CAP.
// (d) duplicates tab → header-field filter on the orders
// collection (`duplicateOrderFilter`).
// Not item-status-driven, so the
// `getOrderIdsForTabGroup` /
// `getActiveOrderIds` machinery doesn't
// apply. With search active, scope to the
// duplicate set first (small on live PB —
// dozens, not thousands) and filter in
// memory; without search, pass the filter
// straight to PB and let it paginate.
let ordersPage: { items: Order[]; totalItems: number; totalPages: number; page: number; perPage: number };
if (search) {
if (tabGroup === "duplicates") {
// (d) Duplicates tab — the cross-cutting review surface that lists
// every order with any of the three duplicate-detection flags set.
// PB's own filter+pagination handles the non-search case; the
// search case fetches the full duplicate set (typically small) and
// narrows in memory.
const dupFilter = duplicateOrderFilter();
if (!search) {
ordersPage = await pb.collection(ORDER_COLLECTION).getList<Order>(
page,
perPage,
{ filter: dupFilter, sort, expand },
);
} else {
// Cap mirrors ORDER_SEARCH_HARD_CAP — duplicates are a small
// set on live PB but the cap is the same defence-in-depth
// ceiling used by the active-set search path above.
const allDupOrders = await pb.collection(ORDER_COLLECTION).getList<Order>(
1,
ORDER_SEARCH_HARD_CAP,
{ filter: dupFilter, sort, expand },
);
ordersPage = {
items: allDupOrders.items,
totalItems: allDupOrders.items.length,
totalPages: 1,
page: 1,
perPage: ORDER_SEARCH_HARD_CAP,
};
}
} else if (search) {
// (c) Search — over the active set only. `tabGroup` is NOT applied
// here: we need every active match in order to compute the per-tab
// match counts below. The tab narrowing happens after counting.
@ -384,6 +481,16 @@ export async function listOrdersWithItems(
for (const item of orderItems) {
tabGroups.add(itemStatusToTabGroup(item.item_status));
}
// Cross-cutting `duplicates` bucket — the duplicates tab isn't
// item-status-driven, so we add it to `tabGroups` based on the
// order-header duplicate flags. This is what lets the search
// match counts (and the per-tab badge) report a hit on the
// duplicates tab when the user actively searches while standing
// on it. Without this, `searchCounts.duplicates` would always
// be 0 and the tab badge would mislead the operator.
if (orderHasAnyDuplicateFlag(order)) {
tabGroups.add("duplicates");
}
// Tally the match into every tab it appears in, then narrow. Order
// matters — counting after the `continue` below would only ever
@ -717,6 +824,41 @@ export async function getOrderItemStatusTabCounts(
counts.cancelled = 0;
}
// Duplicates count — separate query because the duplicate-detection
// flags live on `orders` (header), not `order_items` (line). The
// count query is a fields-only `getList(1, 1, ...)` so PB returns
// just the `totalItems` we need without materialising the rows.
//
// Always counted regardless of `includeArchivedTabs` — the
// duplicates tab is a cross-cutting review surface, not an
// active-workflow tab. A duplicate flag on a delivered or cancelled
// order is just as worth surfacing as one on a `new` order (the
// historical-order version is what tells the operator "we
// accidentally booked this twice, the second one is sitting on the
// archive").
//
// Independent of the item-status scan above — both populate the
// same `counts` record but draw from disjoint sources.
try {
const dupTotal = await pb
.collection(ORDER_COLLECTION)
.getList<{ id: string }>(1, 1, {
filter: duplicateOrderFilter(),
fields: "id",
});
counts.duplicates = dupTotal.totalItems;
} catch (err) {
// Don't let a count-query failure poison the rest of the
// tab badges — the item-status counts are still useful.
// Logged so a future regression (e.g. a typo'd field name in
// duplicateOrderFilter) is visible without breaking the page.
console.error(
"getOrderItemStatusTabCounts: duplicates count query failed:",
err instanceof Error ? err.message : String(err),
);
counts.duplicates = 0;
}
cachedTabCounts.set(cacheKey, {
value: counts,
expiresAt: Date.now() + TAB_COUNTS_CACHE_TTL_MS,