"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; 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([]); 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("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>(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 ( Duplicate orders {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"}.`} {!loading && totalGroups > 0 && (
setActiveKind("order_number")} /> setActiveKind("line_aggregate")} hint="Heuristic — same item count, total qty, and total selling price. Descriptions may differ." />
)} {!loading && effectiveVisible.length > 0 && (
{effectiveVisible.map((group) => ( ))}
)} {!loading && totalGroups > 0 && effectiveVisible.length === 0 && (

No {activeKind === "order_number" ? "shared order numbers" : "matching line-aggregates"} on the order board.

)}
); } function TabButton({ label, count, active, onClick, hint, }: { label: string; count: number; active: boolean; onClick: () => void; hint?: string; }) { return ( ); } function DuplicateGroupRow({ group, canceling, onCancel, }: { group: DuplicateGroup; canceling: Set; 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 (
{group.orderNumber} {group.records.length} {group.kind === "order_number" ? "copies" : "matching"} {group.kind === "line_aggregate" && ( heuristic )}
    {sorted.map((r, idx) => { const isCanceling = canceling.has(r.id); return (
  • {r.orderNumber} {idx === 0 && ( oldest )} · {new Date(r.created).toLocaleString("en-ZA", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", })}
    {r.id} {" · "} {r.companyName || "(no company)"} {" · "} {r.customerName || "(no customer)"}
    {r.itemCount === 0 ? "no items" : `${r.itemCount} item${r.itemCount === 1 ? "" : "s"} · ${Object.entries(r.itemStatusSummary).map(([s, n]) => `${n} ${s}`).join(", ")}`}
    {r.items.length > 0 && (
      {r.items.slice(0, 4).map((it, i) => (
    • · {it.description} {it.quantity != null && ` ×${it.quantity}`} [{it.item_status}]
    • ))} {r.items.length > 4 && (
    • …and {r.items.length - 4} more
    • )}
    )}
    e.stopPropagation()} >
  • ); })}
); }