mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user