- 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.
411 lines
No EOL
14 KiB
TypeScript
411 lines
No EOL
14 KiB
TypeScript
"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>
|
||
);
|
||
} |