Add ACMCC app source, Supabase backend, and project config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:19:26 -04:00
parent 313b51b412
commit 183fe0a93c
1422 changed files with 259271 additions and 0 deletions
@@ -0,0 +1,366 @@
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 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;
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<PendingBill, "source">;
type PendingBillDbRow = Omit<PendingBill, "source" | "vendor_name"> & {
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 };
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<string>();
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,
source: "bill",
});
export function BillApprovalsCard() {
const navigate = useNavigate();
const [tab, setTab] = useState<"approvals" | "inbox">("approvals");
const [bills, setBills] = useState<PendingBill[]>([]);
const [pendingCount, setPendingCount] = useState(0);
const [emails, setEmails] = useState<InboundEmail[]>([]);
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")
.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<string, string[]>();
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 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 (
<Card className="border shadow-sm h-full flex flex-col">
<CardHeader className="pb-2 px-4 pt-4">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<FileCheck className="h-4 w-4 text-primary" /> Bill Approvals
{pendingCount + inboxCount > 0 && (
<Badge variant="destructive" className="text-2xs px-1.5 py-0 h-4 min-w-4 flex items-center justify-center">
{pendingCount + inboxCount}
</Badge>
)}
</CardTitle>
<CardDescription className="text-[12px]">Pending approvals & inbound bills</CardDescription>
</div>
<Button
variant="ghost"
size="sm"
className="text-[12px] h-7 gap-1 text-primary"
onClick={() =>
navigate(tab === "approvals" ? "/dashboard/bill-approvals-list" : "/dashboard/inbound-bills")
}
>
View all <ArrowRight className="h-3 w-3" />
</Button>
</div>
</CardHeader>
<CardContent className="px-0 pb-0 flex-1 overflow-hidden">
<Tabs value={tab} onValueChange={(v) => setTab(v as "approvals" | "inbox")} className="h-full flex flex-col">
<TabsList className="mx-4 grid w-auto grid-cols-2 h-8">
<TabsTrigger value="approvals" className="text-xs gap-1.5">
<Receipt className="h-3 w-3" /> Approvals
{pendingCount > 0 && (
<Badge variant="secondary" className="text-2xs px-1 py-0 h-3.5 min-w-3.5">
{pendingCount}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="inbox" className="text-xs gap-1.5">
<Inbox className="h-3 w-3" /> Inbox
{inboxCount > 0 && (
<Badge variant="secondary" className="text-2xs px-1 py-0 h-3.5 min-w-3.5">
{inboxCount}
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="approvals" className="mt-2 flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : bills.length === 0 ? (
<div className="text-center py-8 px-4">
<Receipt className="h-6 w-6 text-muted-foreground/20 mx-auto mb-2" />
<p className="text-[13px] text-muted-foreground">No pending approvals</p>
</div>
) : (
<div className="divide-y divide-border">
{bills.map((bill) => (
<div
key={bill.id}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() =>
navigate(
bill.source === "invoice"
? "/dashboard/bill-approvals-list"
: `/dashboard/bill-approvals/${bill.id}`,
)
}
>
<div className="h-7 w-7 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<Receipt className="h-3.5 w-3.5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium text-foreground truncate">
{bill.vendor_name || bill.invoice_number || "Bill"}
{bill.invoice_number && bill.vendor_name && (
<span className="text-muted-foreground font-normal"> · #{bill.invoice_number}</span>
)}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-2xs font-medium text-foreground">{fmtMoney(bill.amount)}</span>
<span className="text-2xs text-muted-foreground truncate">
{bill.associations?.name || "—"} ·{" "}
{formatDistanceToNow(new Date(bill.created_at), { addSuffix: true })}
</span>
</div>
</div>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="inbox" className="mt-2 flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : emails.length === 0 ? (
<div className="text-center py-8 px-4">
<Inbox className="h-6 w-6 text-muted-foreground/20 mx-auto mb-2" />
<p className="text-[13px] text-muted-foreground">Inbox is empty</p>
</div>
) : (
<div className="divide-y divide-border">
{emails.map((email) => (
<div
key={email.id}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => navigate("/dashboard/inbound-bills")}
>
<div className="h-7 w-7 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<Inbox className="h-3.5 w-3.5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium text-foreground truncate">
{email.subject || "(no subject)"}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="cc-badge-warning inline-flex items-center rounded-full px-2 py-0.5 text-2xs font-medium border capitalize">
{email.status}
</span>
<span className="text-2xs text-muted-foreground truncate">
{email.from_email || "Unknown"} ·{" "}
{formatDistanceToNow(new Date(email.created_at), { addSuffix: true })}
</span>
</div>
</div>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}