From b4855a9c9ff8aacedd44a5f13d9aed226ae70fa0 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 31 Aug 2026 11:30:09 +0200 Subject: [PATCH] feat(orders): add Duplicates tab surfacing flagged duplicate orders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/(app)/orders/page.tsx | 40 +++++++- src/lib/item-status-ui.ts | 30 +++++- src/lib/server/orders-service.ts | 162 +++++++++++++++++++++++++++++-- 3 files changed, 220 insertions(+), 12 deletions(-) diff --git a/src/app/(app)/orders/page.tsx b/src/app/(app)/orders/page.tsx index b321ff6..61080df 100644 --- a/src/app/(app)/orders/page.tsx +++ b/src/app/(app)/orders/page.tsx @@ -72,6 +72,11 @@ const EMPTY_COUNTS: Record = { 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` 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`. */} {/* Search — matches order number, company, customer, item description, or item price */} @@ -387,6 +399,32 @@ export default function OrdersPage() {

{error}

+ ) : 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. + + + +

No duplicates detected

+

+ The matcher runs on every new order. If you expected a duplicate, run the + backfill to re-scan existing orders. +

+ +
+
) : orders.length === 0 ? ( diff --git a/src/lib/item-status-ui.ts b/src/lib/item-status-ui.ts index ff8a84c..0746633 100644 --- a/src/lib/item-status-ui.ts +++ b/src/lib/item-status-ui.ts @@ -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 = { @@ -166,6 +178,14 @@ export const TAB_GROUP_ITEM_STATUSES: Record = { // 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` 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 = { 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" }, }; /* ============================================================================ diff --git a/src/lib/server/orders-service.ts b/src/lib/server/orders-service.ts index 70dfa3b..7f55d16 100644 --- a/src/lib/server/orders-service.ts +++ b/src/lib/server/orders-service.ts @@ -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 = 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( + 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( + 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,