Files
acmcc/src/components/dashboard/BillApprovalsCard.tsx
T
admin 2c723410a4 Bill approvals: surface approvers, fix email path, schema cleanup
UI

- Dashboard BillApprovalsCard: render approver name chips (color-coded
  by vote status) per pending bill instead of leaving the approver
  identity invisible.
- BillDetailPage: collapse the duplicate "Requested Approvers" card
  into the existing "Approvers" table. Approve/deny handler now stamps
  approved_by = auth.uid() for audit trail.
- MasterBoardDashboardPage: the "pending approvals for me" count was
  filtering on a non-existent bill_approvals.approver_user_id column.
  Replaced with a board_members.member_name -> bill_approvals.approver_name
  join (matches the RLS policy).
- BillApprovalRequestDialog + AIInvoiceParserPage: bill_approvals inserts
  now set created_by.

Database

- Rename public.bill_approvals.vendor_name -> approver_name. RLS policies
  auto-rewritten by ALTER TABLE RENAME COLUMN; the column was misnamed
  (it stores the approver's board-member name, never a vendor).
- Restore the bill_approval_email_tokens table + lookup_/record_
  bill_approval_by_token RPCs. The original 20260520153409 migration
  was never applied successfully; rewrote it to use approver_name and
  to populate approved_by/created_by from board_members.user_id on
  token-driven votes. Added the v2 migration that matches the live DB
  state.
- accounting trigger: void on accounting.bills cascades to
  public.bills.status='cancelled' (existing forward sync then drops the
  accounting row per accounting.bill_should_mirror).

Edge function

- send-transactional-email: add bill-approval-request and
  bill-approval-vote-invite templates (caller paths in BillApprovalsPage
  + send-bill-approval-invites referenced templates that weren't in the
  registry, so every email 404'd). Restored the local copies of
  election-invite, board-vote-invite, and the missing registry.ts so the
  repo matches what's deployed. Deployed to send-transactional-email v35.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 17:17:05 -04:00

413 lines
17 KiB
TypeScript

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<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; 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<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,
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<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, 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<string, string[]>();
const approversByBill = new Map<string, BillApprover[]>();
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 (
<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>
{bill.approvers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1 mt-1">
{bill.approvers.map((ap, i) => (
<Badge
key={i}
variant="outline"
className={`text-2xs px-1.5 py-0 h-4 font-normal ${approverBadgeClass(ap.status)}`}
>
{ap.name}
</Badge>
))}
</div>
) : (
<p className="text-2xs italic text-muted-foreground mt-1">
No approvers requested
</p>
)}
</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>
);
}