"use client"; import { Check, CreditCard, Inbox, KeyRound, Plane, RotateCcw, Rocket, ShoppingCart, X, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react"; import { type ReactNode, type Ref, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { AnimatedToastStack, useAnimatedToastStack, } from "@/components/motion/animated-toast-stack"; import { Button } from "@/components/motion/button"; import { Checkbox } from "@/components/motion/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/motion/dialog"; import { EmptyState } from "@/components/motion/empty-state"; import { Kbd } from "@/components/motion/kbd"; import { NumberTicker } from "@/components/motion/number-ticker"; import { Tabs, TabsList, TabsTrigger } from "@/components/motion/tabs"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type ApprovalStatus = "pending" | "approved" | "rejected"; export type ApprovalPriority = "low" | "normal" | "high" | "urgent"; export type ApprovalKind = "expense" | "access" | "deploy" | "purchase" | "time-off"; export interface ApprovalRequest { id: string; kind: ApprovalKind; title: string; requester: { name: string; avatar?: string }; /** Amount in major units (e.g. dollars). Omit for non-monetary requests. */ amount?: number; /** ISO 4217 code. Default: the component's `currency`. */ currency?: string; /** One-line context, e.g. "prod · v2.14.0" or "Admin on billing". */ details?: string; /** Date, epoch ms or ISO string — drives the age column. */ createdAt: Date | number | string; priority: ApprovalPriority; status: ApprovalStatus; /** Set when rejected. */ reason?: string; } export interface ApprovalQueueProps { /** Controlled list. Decisions call `onRequestsChange` with updated statuses. */ requests?: ApprovalRequest[]; /** Uncontrolled initial list. */ defaultRequests?: ApprovalRequest[]; onRequestsChange?: (requests: ApprovalRequest[]) => void; onApprove?: (ids: string[]) => void; onReject?: (ids: string[], reason: string) => void; /** Fired when a decision is undone from the toast or reopened. */ onUndo?: (ids: string[]) => void; /** Default currency for amounts. Default "USD". */ currency?: string; /** Locale for numbers. Default "en-US". */ locale?: string; /** Height of the scrolling list. Default "16rem". */ listHeight?: string; /** Show the j/k/a/r keyboard legend. Default true. */ showShortcuts?: boolean; className?: string; } const KIND_ICON: Record = { expense: CreditCard, access: KeyRound, deploy: Rocket, purchase: ShoppingCart, "time-off": Plane, }; const PRIORITY_CLASS: Record = { low: "bg-foreground/5 text-muted-foreground", normal: "bg-[color-mix(in_oklab,var(--chart-1)_12%,transparent)] text-[color-mix(in_oklab,var(--chart-1)_70%,var(--foreground))]", high: "bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[color-mix(in_oklab,var(--warning)_50%,var(--foreground))]", urgent: "bg-[color-mix(in_oklab,var(--destructive)_14%,transparent)] text-[color-mix(in_oklab,var(--destructive)_75%,var(--foreground))]", }; const REASONS = ["Over budget", "Needs more detail", "Duplicate request", "Not now"]; function toMs(t: ApprovalRequest["createdAt"]) { return t instanceof Date ? t.getTime() : typeof t === "number" ? t : Date.parse(t); } function age(ms: number) { const m = Math.max(0, Math.round(ms / 60_000)); if (m < 60) return `${m}m`; const h = Math.round(m / 60); if (h < 24) return `${h}h`; return `${Math.round(h / 24)}d`; } function useControllable(value: T | undefined, defaultValue: T, onChange?: (next: T) => void) { const [internal, setInternal] = useState(defaultValue); const controlled = value !== undefined; const current = controlled ? value : internal; const set = useCallback( (next: T) => { if (!controlled) setInternal(next); onChange?.(next); }, [controlled, onChange], ); return [current, set] as const; } /** * Review queue for pending requests (expenses, access grants, deploys…). * Approve or reject one row or a selection: rows swipe out optimistically — * right for approve, left for reject — and an undo toast puts them back. * Rejecting asks for a reason in a Dialog. Pending/Approved/Rejected tabs * roll their counts. Keyboard: j/k move, x selects, a approves, r rejects. */ export function ApprovalQueue({ requests, defaultRequests = [], onRequestsChange, onApprove, onReject, onUndo, currency = "USD", locale = "en-US", listHeight = "16rem", showShortcuts = true, className, }: ApprovalQueueProps) { const reduce = useReducedMotion() ?? false; const baseId = useId(); const [list, setList] = useControllable(requests, defaultRequests, onRequestsChange); const [tab, setTab] = useState("pending"); const [selected, setSelected] = useState>(() => new Set()); const [activeId, setActiveId] = useState(null); // Direction the next exiting rows swipe: 1 = approve (right), -1 = reject. const [exitDir, setExitDir] = useState<1 | -1>(1); const [rejecting, setRejecting] = useState(null); const [reason, setReason] = useState(""); const { toasts, showToast, dismissToast } = useAnimatedToastStack({ defaultDuration: 5000, limit: 3 }); const rootRef = useRef(null); const rowRefs = useRef(new Map()); // Latest list for toast callbacks fired after later decisions. const listRef = useRef(list); listRef.current = list; const [now, setNow] = useState(null); useEffect(() => { setNow(Date.now()); const t = window.setInterval(() => setNow(Date.now()), 60_000); return () => window.clearInterval(t); }, []); const counts = useMemo(() => { const c = { pending: 0, approved: 0, rejected: 0 }; for (const r of list) c[r.status]++; return c; }, [list]); const visible = useMemo( () => list .filter((r) => r.status === tab) .sort((a, b) => toMs(a.createdAt) - toMs(b.createdAt)), [list, tab], ); const pendingTab = tab === "pending"; const selectedVisible = visible.filter((r) => selected.has(r.id)).map((r) => r.id); const money = useMemo(() => { const cache = new Map(); return (amount: number, code: string) => { let f = cache.get(code); if (!f) { f = new Intl.NumberFormat(locale, { style: "currency", currency: code, maximumFractionDigits: 0 }); cache.set(code, f); } return f.format(amount); }; }, [locale]); const focusRow = (id: string | null) => { setActiveId(id); if (id) requestAnimationFrame(() => rowRefs.current.get(id)?.focus({ preventScroll: false })); }; /** After rows leave, keep the cursor on the next remaining row. */ const nextActive = (leaving: string[]) => { if (!activeId || !leaving.includes(activeId)) return activeId; const idx = visible.findIndex((r) => r.id === activeId); const rest = visible.filter((r) => !leaving.includes(r.id)); return rest[Math.min(idx, rest.length - 1)]?.id ?? null; }; const decide = (ids: string[], status: "approved" | "rejected", why?: string) => { if (!ids.length) return; const before = new Map(list.filter((r) => ids.includes(r.id)).map((r) => [r.id, r])); setExitDir(status === "approved" ? 1 : -1); const next = nextActive(ids); // Moving focus before the rows leave keeps it from landing on . const leavingHasFocus = ids.some((id) => rowRefs.current.get(id)?.contains(document.activeElement)); setList(list.map((r) => (ids.includes(r.id) ? { ...r, status, reason: why } : r))); setSelected((prev) => { const s = new Set(prev); for (const id of ids) s.delete(id); return s; }); if (leavingHasFocus) { if (next) focusRow(next); else rootRef.current?.focus(); } else setActiveId(next); if (status === "approved") onApprove?.(ids); else onReject?.(ids, why ?? ""); const first = before.values().next().value; const label = ids.length === 1 && first ? `“${first.title}”` : `${ids.length} requests`; const toastId = showToast({ title: `${status === "approved" ? "Approved" : "Rejected"} ${label}`, description: why ? `Reason: ${why}` : undefined, status: status === "approved" ? "success" : "error", action: { label: "Undo", onClick: () => { setExitDir(status === "approved" ? -1 : 1); setList(listRef.current.map((r) => before.get(r.id) ?? r)); onUndo?.(ids); dismissToast(toastId); }, }, }); }; const reopen = (id: string) => { setExitDir(tab === "approved" ? -1 : 1); setList(list.map((r) => (r.id === id ? { ...r, status: "pending", reason: undefined } : r))); onUndo?.([id]); }; const askReject = (ids: string[]) => { if (!ids.length) return; setReason(""); setRejecting(ids); }; const confirmReject = () => { if (!rejecting) return; decide(rejecting, "rejected", reason.trim() || undefined); setRejecting(null); }; const toggleSelect = (id: string) => setSelected((prev) => { const s = new Set(prev); if (s.has(id)) s.delete(id); else s.add(id); return s; }); const allSelected = visible.length > 0 && selectedVisible.length === visible.length; const toggleAll = () => setSelected((prev) => { const s = new Set(prev); if (allSelected) for (const r of visible) s.delete(r.id); else for (const r of visible) s.add(r.id); return s; }); // j/k/x/a/r while focus is anywhere inside the queue (not in a text field). // Attached natively so the wrapper stays a plain, non-interactive element. const keyState = useRef({ visible, activeId, selectedVisible, pendingTab, decide, askReject, toggleSelect, focusRow }); keyState.current = { visible, activeId, selectedVisible, pendingTab, decide, askReject, toggleSelect, focusRow }; useEffect(() => { const root = rootRef.current; if (!root) return; const onKey = (e: KeyboardEvent) => { if (e.metaKey || e.ctrlKey || e.altKey) return; const t = e.target as HTMLElement; if (t.closest("input, textarea, select, [contenteditable='true']")) return; const s = keyState.current; const idx = s.visible.findIndex((r) => r.id === s.activeId); const key = e.key.toLowerCase(); if (key === "j" || key === "k") { e.preventDefault(); if (!s.visible.length) return; const next = idx === -1 ? 0 : Math.min(s.visible.length - 1, Math.max(0, idx + (key === "j" ? 1 : -1))); s.focusRow(s.visible[next].id); return; } if (!s.pendingTab) return; const targets = s.selectedVisible.length ? s.selectedVisible : s.activeId && idx !== -1 ? [s.activeId] : []; if (key === "x" && s.activeId && idx !== -1) { e.preventDefault(); s.toggleSelect(s.activeId); } else if (key === "a" && targets.length) { e.preventDefault(); s.decide(targets, "approved"); } else if (key === "r" && targets.length) { e.preventDefault(); s.askReject(targets); } }; root.addEventListener("keydown", onKey); return () => root.removeEventListener("keydown", onKey); }, []); const rowVariants: Variants = { initial: { opacity: 0, y: reduce ? 0 : 6 }, animate: { opacity: 1, x: 0, y: 0 }, exit: (dir: number) => ({ opacity: 0, x: reduce ? 0 : dir * 96, transition: { duration: 0.2, ease: EASE_OUT }, }), }; return (
setTab(v as ApprovalStatus)} variant="underline"> {(["pending", "approved", "rejected"] as const).map((s) => ( {s} ))} {pendingTab && visible.length > 0 ? ( 0} onCheckedChange={toggleAll} aria-label="Select all pending requests" label="All" className="gap-2 text-xs" /> ) : null}
{/* Bulk bar slides down when rows are selected. */} {pendingTab && selectedVisible.length > 0 ? ( {selectedVisible.length} selected ) : null}
{visible.length === 0 ? ( } title={pendingTab ? "Queue is clear" : `Nothing ${tab} yet`} description={pendingTab ? "New requests will show up here." : undefined} /> ) : (
    {visible.map((r) => ( { if (el) rowRefs.current.set(r.id, el); else rowRefs.current.delete(r.id); }} onFocusRow={() => setActiveId(r.id)} onToggle={() => toggleSelect(r.id)} onApprove={() => decide([r.id], "approved")} onReject={() => askReject([r.id])} onReopen={() => reopen(r.id)} /> ))}
)}
{showShortcuts ? (

j k move x select a approve r reject

) : null} !o && setRejecting(null)}> Reject {rejecting && rejecting.length > 1 ? `${rejecting.length} requests` : "request"} The requester sees this reason. You can undo right after.
{REASONS.map((r) => ( ))}