import { useEffect, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { ArrowRight, FileCheck, Inbox, Loader2, Receipt } from "lucide-react"; import { supabase } from "@/integrations/supabase/client"; import { useNavigate } from "react-router-dom"; import { formatDistanceToNow } from "date-fns"; interface BillApprover { name: string; status: string; } interface PendingBill { id: string; source_invoice_id?: string | null; invoice_number: string | null; amount: number | null; due_date: string | null; created_at: string; associations?: { name: string } | null; vendor_name?: string | null; approvers: BillApprover[]; source: "bill" | "invoice"; } interface InboundEmail { id: string; subject: string | null; from_email: string | null; status: string; created_at: string; associations?: { name: string } | null; } type PendingBillRow = Omit; type PendingBillDbRow = Omit & { notes?: string | null; vendors?: { name: string | null } | null; invoices?: { vendor_name: string | null; invoice_number: string | null; amount: number | null; due_date: string | null; created_at: string | null; } | null; }; type BillApprovalStatusRow = { bill_id: string | null; status: string | null; approver_name: string | null }; const fmtMoney = (n: number | null) => typeof n === "number" ? n.toLocaleString("en-US", { style: "currency", currency: "USD" }) : "—"; const getBillDisplayKey = (bill: PendingBill) => { if (bill.source === "invoice") return `invoice:${bill.id}`; if (bill.source_invoice_id) return `invoice:${bill.source_invoice_id}`; const associationName = bill.associations?.name || ""; const vendorName = bill.vendor_name || ""; const invoiceNumber = bill.invoice_number || ""; const amount = typeof bill.amount === "number" ? bill.amount.toFixed(2) : ""; if (associationName || vendorName || invoiceNumber || amount) { return [associationName, vendorName, invoiceNumber, amount].join("|").toLowerCase(); } return `bill:${bill.id}`; }; const dedupeBillsForDisplay = (items: PendingBill[]) => { const seen = new Set(); return items.filter((bill) => { const key = getBillDisplayKey(bill); if (seen.has(key)) return false; seen.add(key); return true; }); }; const normalizePendingBill = (bill: PendingBillDbRow): PendingBill => ({ id: bill.id, source_invoice_id: bill.source_invoice_id, invoice_number: bill.invoices?.invoice_number ?? bill.invoice_number, amount: bill.invoices?.amount ?? bill.amount, due_date: bill.invoices?.due_date ?? bill.due_date, created_at: bill.created_at, associations: bill.associations, vendor_name: bill.invoices?.vendor_name ?? bill.vendors?.name ?? bill.notes ?? null, approvers: [], source: "bill", }); const approverBadgeClass = (status: string) => { switch (status) { case "approved": return "border-emerald-300 text-emerald-700 bg-emerald-50"; case "denied": return "border-red-300 text-red-700 bg-red-50"; case "paid": return "border-sky-300 text-sky-700 bg-sky-50"; default: return "border-amber-300 text-amber-700 bg-amber-50"; } }; export function BillApprovalsCard() { const navigate = useNavigate(); const [tab, setTab] = useState<"approvals" | "inbox">("approvals"); const [bills, setBills] = useState([]); const [pendingCount, setPendingCount] = useState(0); const [emails, setEmails] = useState([]); const [inboxCount, setInboxCount] = useState(0); const [loading, setLoading] = useState(true); useEffect(() => { const fetchAll = async () => { setLoading(true); // Pending bills (source of truth — what BillApprovalsPage shows) const [ { data: pendingBillsData, count: pendingBillsCount }, { data: allBillApprovals }, ] = await Promise.all([ supabase .from("bills") .select("id, source_invoice_id, invoice_number, amount, due_date, created_at, notes, associations(name), vendors(name), invoices:source_invoice_id(vendor_name, invoice_number, amount, due_date, created_at)", { count: "exact" }) .eq("status", "pending") .order("created_at", { ascending: false }) .limit(10), // Pull every approval row keyed to a pending bill so we can detect // "stuck" bills (status=pending but every approval is already // approved/denied). Those should not contribute to the badge. supabase .from("bill_approvals") .select("bill_id, status, approver_name") .not("bill_id", "is", null), ]); const pendingBillRows = (pendingBillsData || []) as unknown as PendingBillDbRow[]; const billApprovalStatusRows = (allBillApprovals || []) as BillApprovalStatusRow[]; const collected: PendingBill[] = pendingBillRows.map(normalizePendingBill); // Group approvals by bill_id and figure out which pending bills are // truly actionable (have ≥1 pending approval, or none at all). const approvalsByBill = new Map(); const approversByBill = new Map(); billApprovalStatusRows.forEach((a) => { if (!a.bill_id) return; const arr = approvalsByBill.get(a.bill_id) || []; if (a.status) arr.push(a.status); approvalsByBill.set(a.bill_id, arr); const approvers = approversByBill.get(a.bill_id) || []; approvers.push({ name: a.approver_name || "Unknown", status: a.status || "pending" }); approversByBill.set(a.bill_id, approvers); }); collected.forEach((bill) => { bill.approvers = approversByBill.get(bill.id) || []; }); const stuckBillIds: string[] = []; let actionablePendingBills = 0; pendingBillRows.forEach((b) => { const statuses = approvalsByBill.get(b.id); if (!statuses || statuses.length === 0) { // No approvers configured — still counts as actionable actionablePendingBills += 1; return; } const hasPending = statuses.some((s) => s === "pending"); if (hasPending) { actionablePendingBills += 1; } else { stuckBillIds.push(b.id); } }); // Use the full bills-pending count when only the first 10 were // fetched, minus the stuck rows we identified in that page. const totalPending = pendingBillsCount || 0; const inferredActionable = totalPending - (pendingBillsData || []).length + actionablePendingBills; setPendingCount(Math.max(0, inferredActionable)); // Heal stuck pending bills in the background (no await) so the badge // matches reality on the next render. if (stuckBillIds.length > 0) { const healed: { id: string; newStatus: string }[] = []; stuckBillIds.forEach((id) => { const statuses = approvalsByBill.get(id) || []; const newStatus = statuses.some((s) => s === "denied") ? "denied" : "approved"; healed.push({ id, newStatus }); }); const approvedIds = healed.filter((h) => h.newStatus === "approved").map((h) => h.id); const deniedIds = healed.filter((h) => h.newStatus === "denied").map((h) => h.id); if (approvedIds.length) { supabase .from("bills") .update({ status: "approved", updated_at: new Date().toISOString() }) .in("id", approvedIds); } if (deniedIds.length) { supabase .from("bills") .update({ status: "denied", updated_at: new Date().toISOString() }) .in("id", deniedIds); } } collected.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); const stuckSet = new Set(stuckBillIds); const displayBills = dedupeBillsForDisplay(collected.filter((b) => !stuckSet.has(b.id))); setBills(displayBills.slice(0, 5)); if ((pendingBillsData || []).length < 10) { setPendingCount(displayBills.length); } // Inbound bill emails (inbox) const [{ data: inboxData }, { count }] = await Promise.all([ supabase .from("inbound_bill_emails") .select("id, subject, from_email, status, created_at, associations(name)") .neq("status", "rejected") .neq("status", "processed") .order("created_at", { ascending: false }) .limit(5), supabase .from("inbound_bill_emails") .select("*", { count: "exact", head: true }) .eq("status", "pending"), ]); setEmails((inboxData || []) as InboundEmail[]); setInboxCount(count || 0); setLoading(false); }; fetchAll(); const ch = supabase .channel("bill-approvals-card") .on("postgres_changes", { event: "*", schema: "public", table: "bills" }, fetchAll) .on("postgres_changes", { event: "*", schema: "public", table: "bill_approvals" }, fetchAll) .on("postgres_changes", { event: "*", schema: "public", table: "inbound_bill_emails" }, fetchAll) .subscribe(); return () => { supabase.removeChannel(ch); }; }, []); return (
Bill Approvals {pendingCount + inboxCount > 0 && ( {pendingCount + inboxCount} )} Pending approvals & inbound bills
setTab(v as "approvals" | "inbox")} className="h-full flex flex-col"> Approvals {pendingCount > 0 && ( {pendingCount} )} Inbox {inboxCount > 0 && ( {inboxCount} )} {loading ? (
) : bills.length === 0 ? (

No pending approvals

) : (
{bills.map((bill) => (
navigate( bill.source === "invoice" ? "/dashboard/bill-approvals-list" : `/dashboard/bill-approvals/${bill.id}`, ) } >

{bill.vendor_name || bill.invoice_number || "Bill"} {bill.invoice_number && bill.vendor_name && ( · #{bill.invoice_number} )}

{fmtMoney(bill.amount)} {bill.associations?.name || "—"} ·{" "} {formatDistanceToNow(new Date(bill.created_at), { addSuffix: true })}
{bill.approvers.length > 0 ? (
{bill.approvers.map((ap, i) => ( {ap.name} ))}
) : (

No approvers requested

)}
))}
)}
{loading ? (
) : emails.length === 0 ? (

Inbox is empty

) : (
{emails.map((email) => (
navigate("/dashboard/inbound-bills")} >

{email.subject || "(no subject)"}

{email.status} {email.from_email || "Unknown"} ·{" "} {formatDistanceToNow(new Date(email.created_at), { addSuffix: true })}
))}
)}
); }