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:
parent
f1d23da685
commit
b4855a9c9f
3 changed files with 220 additions and 12 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
};
|
||||
|
||||
/* ============================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue