Spaces:
Running
Running
| "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<AnomalyType, { label: string; icon: React.ElementType }> = { | |
| 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<AnomalySeverity, string> = { | |
| 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 ( | |
| <div className="flex items-baseline justify-between gap-3"> | |
| <span className="text-[11px] text-ink-faint">{label}</span> | |
| <span className="font-mono tabular text-[11px] text-ink-muted">{value}</span> | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div className="mt-3 space-y-1 border-l border-hairline-strong bg-black/25 px-3 py-2"> | |
| {rows.map(([label, value]) => ( | |
| <EvidenceRow key={label} label={label} value={value} /> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| export interface AnomalyQueueProps { | |
| anomalies: Anomaly[]; | |
| loading: boolean; | |
| onResolve: (id: string, action: "approve" | "reject") => Promise<void>; | |
| 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<Record<string, boolean>>({}); | |
| 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 ( | |
| <Panel edgeLight className="flex h-full flex-col overflow-hidden"> | |
| <PanelHeader | |
| eyebrow="Human in the loop" | |
| title="Anomaly review queue" | |
| action={ | |
| anomalies.length > 0 ? ( | |
| <Badge tone="flag">{anomalies.length} open</Badge> | |
| ) : 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 ? ( | |
| <div | |
| role="status" | |
| className="flex items-start gap-3 border-l-2 border-warn bg-warn/[0.07] px-5 py-3 text-[13px] text-warn/90" | |
| > | |
| <span className="flex-1">{notice}</span> | |
| {onDismissNotice ? ( | |
| <button | |
| type="button" | |
| onClick={onDismissNotice} | |
| aria-label="Dismiss" | |
| className="shrink-0 text-warn/60 transition-colors hover:text-warn" | |
| > | |
| <X className="size-4" /> | |
| </button> | |
| ) : null} | |
| </div> | |
| ) : null} | |
| <div className="flex-1 space-y-3 overflow-y-auto px-5 py-4"> | |
| {loading ? ( | |
| [0, 1].map((index) => ( | |
| <div key={index} className="bevel-sm border border-hairline p-4"> | |
| <Skeleton className="h-3 w-32" /> | |
| <Skeleton className="mt-3 h-3 w-full" /> | |
| <Skeleton className="mt-2 h-3 w-4/5" /> | |
| </div> | |
| )) | |
| ) : anomalies.length === 0 ? ( | |
| <EmptyState | |
| icon={<ShieldCheck className="size-6" />} | |
| title="Nothing waiting for review" | |
| hint="Every processed document reconciled against its vendor history." | |
| /> | |
| ) : ( | |
| <AnimatePresence mode="popLayout"> | |
| {anomalies.map((anomaly) => { | |
| const meta = TYPE_META[anomaly.anomaly_type]; | |
| const Icon = meta.icon; | |
| const busy = pending[anomaly.id] === true; | |
| return ( | |
| <motion.article | |
| key={anomaly.id} | |
| layout | |
| initial={{ opacity: 0, y: 12 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, x: 40, transition: { duration: 0.22 } }} | |
| transition={{ duration: 0.28, ease: "easeOut" }} | |
| className={cn( | |
| "bevel-sm border bg-white/[0.025] p-4", | |
| SEVERITY_GLOW[anomaly.severity], | |
| )} | |
| > | |
| <div className="flex items-start justify-between gap-3"> | |
| <div className="flex min-w-0 items-center gap-2"> | |
| <Icon | |
| className={cn( | |
| "size-4 shrink-0", | |
| anomaly.severity === "HIGH" | |
| ? "text-flag" | |
| : anomaly.severity === "MEDIUM" | |
| ? "text-warn" | |
| : "text-accent", | |
| )} | |
| /> | |
| <p className="truncate text-[13px] font-semibold text-ink"> | |
| {meta.label} | |
| </p> | |
| </div> | |
| <SeverityBadge severity={anomaly.severity} /> | |
| </div> | |
| {/* The plain-English reason — spec §2: every flag carries one. */} | |
| <p className="mt-2.5 text-[13px] leading-relaxed text-ink-muted"> | |
| {anomaly.reason} | |
| </p> | |
| <Evidence anomaly={anomaly} /> | |
| <div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-ink-faint"> | |
| {anomaly.vendor ? <span className="truncate">{anomaly.vendor}</span> : null} | |
| {anomaly.total !== null ? ( | |
| <span className="font-mono tabular"> | |
| {formatMoney(anomaly.total, anomaly.currency)} | |
| </span> | |
| ) : null} | |
| <span className="ml-auto">{relativeTime(anomaly.created_at)}</span> | |
| </div> | |
| <div className="mt-3 flex items-center gap-2"> | |
| <Button | |
| variant="approve" | |
| size="sm" | |
| disabled={busy} | |
| onClick={() => void resolve(anomaly.id, "approve")} | |
| > | |
| {busy ? <Loader2 className="size-3.5 animate-spin" /> : null} | |
| Approve | |
| </Button> | |
| <Button | |
| variant="reject" | |
| size="sm" | |
| disabled={busy} | |
| onClick={() => void resolve(anomaly.id, "reject")} | |
| > | |
| <X className="size-3.5" /> | |
| Reject | |
| </Button> | |
| {onInspect ? ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="ml-auto" | |
| onClick={() => onInspect(anomaly.document_id)} | |
| > | |
| Audit trail | |
| </Button> | |
| ) : null} | |
| </div> | |
| </motion.article> | |
| ); | |
| })} | |
| </AnimatePresence> | |
| )} | |
| </div> | |
| </Panel> | |
| ); | |
| } | |