mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
2c723410a4
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>
413 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|