"use client"; import { AnimatePresence, motion } from "framer-motion"; import { CopyCheck, Hash, Loader2, ShieldCheck, TrendingUp, X } from "lucide-react"; import * as React from "react"; import { Button } from "@/components/ui/button"; import { Badge, EmptyState, Panel, PanelHeader, SeverityBadge, Skeleton, } from "@/components/ui/primitives"; import { cn, formatMoney, relativeTime } from "@/lib/utils"; import type { Anomaly, AnomalySeverity, AnomalyType } from "@/types/api"; const TYPE_META: Record = { duplicate: { label: "Possible duplicate payment", icon: CopyCheck }, amount_zscore: { label: "Amount outlier", icon: TrendingUp }, term_drift: { label: "Payment terms changed", icon: Hash }, round_number: { label: "Suspiciously round total", icon: Hash }, }; const SEVERITY_GLOW: Record = { HIGH: "glow-flag border-flag/30", MEDIUM: "glow-warn border-warn/25", LOW: "border-accent/20", }; function EvidenceRow({ label, value }: { label: string; value: React.ReactNode }) { return (
{label} {value}
); } function Evidence({ anomaly }: { anomaly: Anomaly }) { const evidence = anomaly.evidence; const rows: Array<[string, React.ReactNode]> = []; if (anomaly.anomaly_type === "duplicate") { if (typeof evidence.matched_invoice_number === "string") { rows.push(["Matched invoice", evidence.matched_invoice_number]); } if (typeof evidence.amount_gap_pct === "number") { rows.push(["Amount gap", `${evidence.amount_gap_pct.toFixed(2)}%`]); } if (typeof evidence.day_gap === "number") { rows.push(["Days apart", evidence.day_gap]); } if (typeof evidence.vendor_similarity === "number") { rows.push(["Vendor match", `${evidence.vendor_similarity.toFixed(0)}%`]); } } else if (anomaly.anomaly_type === "amount_zscore") { if (typeof evidence.history_mean === "number") { rows.push(["Vendor average", formatMoney(evidence.history_mean)]); } if (typeof evidence.zscore === "number") { rows.push(["Z-score", evidence.zscore.toFixed(2)]); } if (typeof evidence.history_count === "number") { rows.push(["History size", `${evidence.history_count} invoices`]); } } else if (anomaly.anomaly_type === "term_drift") { if (typeof evidence.modal_terms === "string") { rows.push(["Usual terms", evidence.modal_terms]); } if (typeof evidence.candidate_terms === "string") { rows.push(["This invoice", evidence.candidate_terms]); } } else if (typeof evidence.multiple_of === "number") { rows.push(["Multiple of", evidence.multiple_of.toLocaleString()]); } if (rows.length === 0) return null; return (
{rows.map(([label, value]) => ( ))}
); } export interface AnomalyQueueProps { anomalies: Anomaly[]; loading: boolean; onResolve: (id: string, action: "approve" | "reject") => Promise; onInspect?: (documentId: string) => void; /** Why the last decision did not apply. Shown above the queue it refers to. */ notice?: string | null; onDismissNotice?: () => void; } export function AnomalyQueue({ anomalies, loading, onResolve, onInspect, notice, onDismissNotice, }: AnomalyQueueProps) { const [pending, setPending] = React.useState>({}); const resolve = React.useCallback( async (id: string, action: "approve" | "reject") => { setPending((current) => ({ ...current, [id]: true })); try { await onResolve(id, action); } finally { setPending((current) => { const next = { ...current }; delete next[id]; return next; }); } }, [onResolve], ); return ( 0 ? ( {anomalies.length} open ) : null } /> {/* Sits above the queue rather than in a corner toast: the sentence is about these rows, and the row the reviewer just acted on has already left. */} {notice ? (
{notice} {onDismissNotice ? ( ) : null}
) : null}
{loading ? ( [0, 1].map((index) => (
)) ) : anomalies.length === 0 ? ( } title="Nothing waiting for review" hint="Every processed document reconciled against its vendor history." /> ) : ( {anomalies.map((anomaly) => { const meta = TYPE_META[anomaly.anomaly_type]; const Icon = meta.icon; const busy = pending[anomaly.id] === true; return (

{meta.label}

{/* The plain-English reason — spec §2: every flag carries one. */}

{anomaly.reason}

{anomaly.vendor ? {anomaly.vendor} : null} {anomaly.total !== null ? ( {formatMoney(anomaly.total, anomaly.currency)} ) : null} {relativeTime(anomaly.created_at)}
{onInspect ? ( ) : null}
); })}
)}
); }