WFDASH-WEBAPP/src/components/duplicate-orders-dialog.tsx
Dave 46e0683635 feat(duplicate-orders): enhance duplicate order detection and UI
- Introduced a new `DuplicateKind` type to differentiate between "order_number" and "line_aggregate" duplicates.
- Updated the `DuplicateOrdersDialog` component to support tabbed navigation between order number and line aggregate views, improving user experience.
- Enhanced the data structure for duplicate records to include `totalQuantity` and `totalSellingPrice`, facilitating more accurate duplicate detection.
- Implemented logic to auto-flip to the tab with available content, ensuring operators do not encounter empty views.
- Expanded test coverage for the `findDuplicateOrders` function to validate new line-aggregate matching logic and ensure accurate detection of duplicates across different scenarios.
- Improved documentation for the duplicate order handling process, clarifying the criteria for identifying duplicates.
2026-08-16 11:25:00 +02:00

411 lines
No EOL
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2, Copy, Ban, ExternalLink } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import getPocketBase from "@/lib/pocketbase";
interface DuplicateItem {
description: string;
quantity: number | null;
item_status: string;
}
interface DuplicateRecord {
id: string;
orderNumber: string;
created: string;
customer: string | null;
company: string | null;
customerName: string | null;
companyName: string | null;
itemCount: number;
itemStatusSummary: Record<string, number>;
items: DuplicateItem[];
totalQuantity: number | null;
totalSellingPrice: number | null;
}
type DuplicateKind = "order_number" | "line_aggregate";
interface DuplicateGroup {
kind: DuplicateKind;
orderNumber: string;
aggregateKey?: string;
records: DuplicateRecord[];
}
interface FindDuplicatesResponse {
groups: DuplicateGroup[];
}
/**
* "Find Duplicates" panel for /orders.
*
* Lists every order_number that appears on more than one order record
* AND where at least one record has active items (the operator can
* see it on the order board). Each record under a shared number gets
* its own Cancel button — the operator walks the list and cancels
* the duplicates one at a time.
*
* What "cancel" does: soft-cancel via the existing cancelOrder
* service. Every line item's `item_status` flips to `cancelled`, the
* order drops out of the active workflow tabs, and surfaces on
* /orders/archive under "Cancelled". Audit history (POs, invoices,
* supplier replies) stays attached — the record is not deleted.
*/
export function DuplicateOrdersDialog({
open,
onOpenChange,
onCancelled,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCancelled?: () => void;
}) {
const [groups, setGroups] = useState<DuplicateGroup[]>([]);
const [loading, setLoading] = useState(false);
// "order_number" is the surer signal (same number = certainly
// duplicated at intake); "line_aggregate" is a heuristic. Default
// to the surer tab, then auto-flip if it's empty so the operator
// doesn't see a blank list when only the heuristic tab has hits.
const [activeKind, setActiveKind] = useState<DuplicateKind>("order_number");
// Track which specific record ids are mid-cancel so the operator
// sees a spinner on the exact row they're cancelling, not on the
// whole dialog.
const [canceling, setCanceling] = useState<Set<string>>(new Set());
const load = useCallback(async () => {
setLoading(true);
try {
// PB authStore isn't sent by the browser automatically — we have
// to forward the bearer token explicitly. Mirrors the
// `fetchDashboardJson` helper on /orders/page.tsx:91-104.
const token = getPocketBase().authStore.token;
const res = await fetch("/api/v1/orders/duplicates", {
method: "GET",
headers: token ? { Authorization: token } : {},
});
const body = (await res.json().catch(() => ({}))) as
| FindDuplicatesResponse
| { error?: string };
if (!res.ok) {
toast.error(
(body as { error?: string }).error || `Failed (${res.status})`,
);
return;
}
setGroups((body as FindDuplicatesResponse).groups);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to load duplicates");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- load() owns its own async setState; mount-time trigger only
void load();
}
}, [open, load]);
const refresh = useCallback(async () => {
await load();
onCancelled?.();
}, [load, onCancelled]);
const cancelOne = async (recordId: string) => {
if (canceling.has(recordId)) return;
setCanceling((prev) => {
const next = new Set(prev);
next.add(recordId);
return next;
});
try {
const token = getPocketBase().authStore.token;
const res = await fetch(
`/api/v1/orders/${encodeURIComponent(recordId)}/cancel`,
{
method: "POST",
headers: token ? { Authorization: token } : {},
},
);
const body = (await res.json().catch(() => ({}))) as {
itemsCancelled?: number;
itemsSkipped?: Array<{ item_status: string }>;
error?: string;
};
if (!res.ok) {
toast.error(body.error || `Cancel failed (${res.status})`);
return;
}
const cancelled = body.itemsCancelled ?? 0;
const skipped = body.itemsSkipped?.length ?? 0;
toast.success(
skipped > 0
? `Cancelled (${cancelled} items, ${skipped} skipped — already in terminal state)`
: `Cancelled (${cancelled} item${cancelled === 1 ? "" : "s"})`,
);
// Re-fetch the list so the cancelled record disappears (or
// drops to the next-active scenario if the order still has
// items not yet on the board).
await refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Cancel failed");
} finally {
setCanceling((prev) => {
const next = new Set(prev);
next.delete(recordId);
return next;
});
}
};
const numberGroups = groups.filter((g) => g.kind === "order_number");
const aggregateGroups = groups.filter((g) => g.kind === "line_aggregate");
const totalGroups = groups.length;
// Auto-flip to whichever tab has content — the operator shouldn't
// land on an empty first tab when the heuristic tab is the only one
// with hits. The first render after `load()` runs from the same
// effect, so this is safe to compute inline.
const effectiveKind: DuplicateKind =
activeKind === "order_number" && numberGroups.length === 0 && aggregateGroups.length > 0
? "line_aggregate"
: activeKind;
const effectiveVisible =
effectiveKind === "order_number" ? numberGroups : aggregateGroups;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Copy className="h-4 w-4" />
Duplicate orders
</DialogTitle>
<DialogDescription>
{loading
? "Loading…"
: totalGroups === 0
? "No duplicate orders on the order board."
: `${numberGroups.length} shared order number${numberGroups.length === 1 ? "" : "s"} · ${aggregateGroups.length} matching line-aggregate${aggregateGroups.length === 1 ? "" : "s"}.`}
</DialogDescription>
</DialogHeader>
{!loading && totalGroups > 0 && (
<div className="flex items-center gap-1 border-b border-border/60">
<TabButton
label="Order number"
count={numberGroups.length}
active={effectiveKind === "order_number"}
onClick={() => setActiveKind("order_number")}
/>
<TabButton
label="Same line items"
count={aggregateGroups.length}
active={effectiveKind === "line_aggregate"}
onClick={() => setActiveKind("line_aggregate")}
hint="Heuristic — same item count, total qty, and total selling price. Descriptions may differ."
/>
</div>
)}
{!loading && effectiveVisible.length > 0 && (
<div className="space-y-3">
{effectiveVisible.map((group) => (
<DuplicateGroupRow
key={`${group.kind}-${group.orderNumber}`}
group={group}
canceling={canceling}
onCancel={cancelOne}
/>
))}
</div>
)}
{!loading && totalGroups > 0 && effectiveVisible.length === 0 && (
<p className="text-sm text-muted-foreground italic">
No {activeKind === "order_number" ? "shared order numbers" : "matching line-aggregates"} on the order board.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function TabButton({
label,
count,
active,
onClick,
hint,
}: {
label: string;
count: number;
active: boolean;
onClick: () => void;
hint?: string;
}) {
return (
<button
type="button"
onClick={onClick}
title={hint}
className={`px-3 py-1.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
active
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<span>{label}</span>
<Badge variant="secondary" className="ml-1.5 text-[10px]">
{count}
</Badge>
</button>
);
}
function DuplicateGroupRow({
group,
canceling,
onCancel,
}: {
group: DuplicateGroup;
canceling: Set<string>;
onCancel: (recordId: string) => void;
}) {
// Server returns records sorted by created asc; place the oldest
// first as the visual cue. The operator picks which row to cancel.
const sorted = group.records;
return (
<div className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm font-semibold truncate">
{group.orderNumber}
</span>
<Badge variant="secondary" className="text-xs shrink-0">
{group.records.length} {group.kind === "order_number" ? "copies" : "matching"}
</Badge>
{group.kind === "line_aggregate" && (
<Badge variant="outline" className="text-[10px] shrink-0">
heuristic
</Badge>
)}
</div>
<ul className="space-y-1">
{sorted.map((r, idx) => {
const isCanceling = canceling.has(r.id);
return (
<li
key={r.id}
className={`flex items-start gap-2 text-xs rounded px-2 py-1.5 ${
idx === 0
? "bg-success/5 border border-success/20"
: "bg-background"
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-mono font-semibold">
{r.orderNumber}
</span>
{idx === 0 && (
<Badge variant="outline" className="text-[10px] bg-success/10 text-success border-success/30 shrink-0">
oldest
</Badge>
)}
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
{new Date(r.created).toLocaleString("en-ZA", {
year: "numeric", month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", second: "2-digit",
})}
</span>
</div>
<div className="text-muted-foreground text-[11px] mt-0.5">
<span className="font-mono">{r.id}</span>
{" · "}
{r.companyName || "(no company)"}
{" · "}
{r.customerName || "(no customer)"}
</div>
<div className="text-muted-foreground text-[11px] mt-0.5">
{r.itemCount === 0
? "no items"
: `${r.itemCount} item${r.itemCount === 1 ? "" : "s"} · ${Object.entries(r.itemStatusSummary).map(([s, n]) => `${n} ${s}`).join(", ")}`}
</div>
{r.items.length > 0 && (
<ul className="mt-1.5 space-y-0.5 text-[11px]">
{r.items.slice(0, 4).map((it, i) => (
<li key={i} className="text-foreground flex items-start gap-1.5">
<span className="text-muted-foreground">·</span>
<span className="break-words">
{it.description}
{it.quantity != null && ` ×${it.quantity}`}
<span className="text-muted-foreground"> [{it.item_status}]</span>
</span>
</li>
))}
{r.items.length > 4 && (
<li className="text-muted-foreground italic">
…and {r.items.length - 4} more
</li>
)}
</ul>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Link
href={`/orders/${r.id}`}
target="_blank"
rel="noopener"
className="text-muted-foreground hover:text-foreground p-1"
title="Open order in new tab"
aria-label={`Open order ${r.orderNumber} in new tab`}
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="h-3.5 w-3.5" />
</Link>
<Button
size="sm"
variant="outline"
onClick={() => onCancel(r.id)}
disabled={isCanceling}
title="Cancel this order — items move to 'cancelled' status, the order drops off the active board and surfaces on /orders/archive."
className="h-7 text-xs"
>
{isCanceling ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Ban className="mr-1 h-3 w-3" />
)}
Cancel
</Button>
</div>
</li>
);
})}
</ul>
</div>
);
}