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>
This commit is contained in:
2026-06-04 17:17:05 -04:00
parent bd5caf5415
commit 2c723410a4
18 changed files with 796 additions and 95 deletions
+5 -1
View File
@@ -60,14 +60,18 @@ export default function BillApprovalRequestDialog({ open, onOpenChange, billId,
try {
await supabase.from('bills').update({ status: 'pending' }).eq('id', billId);
const { data: userData } = await supabase.auth.getUser();
const createdBy = userData?.user?.id ?? null;
const approvalRows = selectedMembers.map((memberId) => {
const bm = boardMembers.find(b => b.id === memberId);
return {
association_id: clientId,
bill_id: billId,
vendor_name: bm?.member_name || 'Approver',
approver_name: bm?.member_name || 'Approver',
status: 'pending',
notes: comment || null,
created_by: createdBy,
};
});
+48 -2
View File
@@ -8,6 +8,11 @@ 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;
@@ -17,6 +22,7 @@ interface PendingBill {
created_at: string;
associations?: { name: string } | null;
vendor_name?: string | null;
approvers: BillApprover[];
source: "bill" | "invoice";
}
@@ -41,7 +47,7 @@ type PendingBillDbRow = Omit<PendingBill, "source" | "vendor_name"> & {
created_at: string | null;
} | null;
};
type BillApprovalStatusRow = { bill_id: string | null; status: string | null };
type BillApprovalStatusRow = { bill_id: string | null; status: string | null; approver_name: string | null };
const fmtMoney = (n: number | null) =>
typeof n === "number"
@@ -83,9 +89,23 @@ const normalizePendingBill = (bill: PendingBillDbRow): PendingBill => ({
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");
@@ -115,7 +135,7 @@ export function BillApprovalsCard() {
// approved/denied). Those should not contribute to the badge.
supabase
.from("bill_approvals")
.select("bill_id, status")
.select("bill_id, status, approver_name")
.not("bill_id", "is", null),
]);
@@ -126,11 +146,20 @@ export function BillApprovalsCard() {
// 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;
@@ -312,6 +341,23 @@ export function BillApprovalsCard() {
{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>
))}
+2 -2
View File
@@ -64,7 +64,7 @@ export function useBillApprovals() {
approved_by: userData?.user?.id,
status: 'approved',
notes: comment,
vendor_name: voterName || 'Unknown',
approver_name: voterName || 'Unknown',
association_id: (bills.find(b => b.id === billId))?.association_id
})
.select()
@@ -76,7 +76,7 @@ export function useBillApprovals() {
approved_by: userData?.user?.id,
status: 'denied',
notes: comment,
vendor_name: voterName || 'Unknown',
approver_name: voterName || 'Unknown',
association_id: (bills.find(b => b.id === billId))?.association_id
});
if (approvalError) throw approvalError;
+3 -3
View File
@@ -1772,6 +1772,7 @@ export type Database = {
amount: number
approved_by: string | null
approved_date: string | null
approver_name: string
association_id: string
bill_id: string | null
created_at: string
@@ -1781,12 +1782,12 @@ export type Database = {
notes: string | null
status: string
updated_at: string
vendor_name: string
}
Insert: {
amount?: number
approved_by?: string | null
approved_date?: string | null
approver_name: string
association_id: string
bill_id?: string | null
created_at?: string
@@ -1796,12 +1797,12 @@ export type Database = {
notes?: string | null
status?: string
updated_at?: string
vendor_name: string
}
Update: {
amount?: number
approved_by?: string | null
approved_date?: string | null
approver_name?: string
association_id?: string
bill_id?: string | null
created_at?: string
@@ -1811,7 +1812,6 @@ export type Database = {
notes?: string | null
status?: string
updated_at?: string
vendor_name?: string
}
Relationships: [
{
+2 -1
View File
@@ -370,11 +370,12 @@ export default function AIInvoiceParserPage() {
return {
association_id: reviewForm.association_id,
bill_id: newBill?.id || null,
vendor_name: bm?.member_name || reviewData.vendor_name,
approver_name: bm?.member_name || reviewData.vendor_name,
amount: reviewData.total_amount || 0,
invoice_id: invoice.id,
status: "pending",
notes: description || null,
created_by: userData?.user?.id || null,
};
});
await supabase.from("bill_approvals").insert(approvalRows);
+4 -4
View File
@@ -163,7 +163,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
.from("bill_approvals")
.select("bill_id")
.in("association_id", boardAssociationIds!)
.in("vendor_name", names)
.in("approver_name", names)
.not("bill_id", "is", null);
assignedBillIds = Array.from(new Set((myApprovals || []).map((a: any) => a.bill_id).filter(Boolean)));
}
@@ -217,7 +217,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
const billIds = billsList.map((b: any) => b.id);
const { data: approvals } = await supabase
.from("bill_approvals")
.select("id, bill_id, vendor_name, status")
.select("id, bill_id, approver_name, status")
.in("bill_id", billIds);
const grouped: Record<string, any[]> = {};
(approvals || []).forEach((a: any) => {
@@ -455,7 +455,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
return {
association_id: form.association_id,
bill_id: newBill?.id || null,
vendor_name: bm?.member_name || vendorDisplayName,
approver_name: bm?.member_name || vendorDisplayName,
amount: parseFloat(form.amount) || 0,
status: "pending",
notes: form.description || null,
@@ -1084,7 +1084,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
<Badge variant="outline" className={`text-xs ${a.status === 'approved' ? 'border-emerald-300 text-emerald-700' : a.status === 'denied' || a.status === 'rejected' ? 'border-red-300 text-red-700' : 'border-amber-300 text-amber-700'}`}>
{a.status === 'approved' ? '✓' : a.status === 'denied' || a.status === 'rejected' ? '✗' : '⏳'}
</Badge>
<span className="text-xs truncate max-w-[120px]">{a.vendor_name}</span>
<span className="text-xs truncate max-w-[120px]">{a.approver_name}</span>
</div>
))}
</div>
+12 -57
View File
@@ -133,9 +133,14 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
const handleApprovalAction = async (approvalId: string, action: "approved" | "denied") => {
try {
const { data: userData } = await supabase.auth.getUser();
const { error } = await supabase
.from("bill_approvals")
.update({ status: action, approved_date: new Date().toISOString() })
.update({
status: action,
approved_date: new Date().toISOString(),
approved_by: userData?.user?.id ?? null,
})
.eq("id", approvalId);
if (error) throw error;
@@ -170,7 +175,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
// Notify staff (admins/managers) that a board member has voted
try {
const approvalRecord = (bill?.bill_approvals || []).find((a: any) => a.id === approvalId);
const voterName = approvalRecord?.vendor_name || "A board member";
const voterName = approvalRecord?.approver_name || "A board member";
const billLabel = bill?.invoice_number ? `Bill #${bill.invoice_number}` : `Bill ${(id || "").slice(0, 8)}`;
const associationName = bill?.associations?.name || "an association";
const verb = action === "approved" ? "approved" : "denied";
@@ -445,56 +450,6 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
</CardContent>
</Card>
{/* Requested Approvers */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Clock className="h-5 w-5 text-primary" /> Requested Approvers
</CardTitle>
</CardHeader>
<CardContent>
{approvals.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No specific approvers requested during creation. You can request one above.
</p>
) : (
<div className="space-y-3">
{approvals.map((a) => (
<div key={a.id} className="flex items-center justify-between border rounded-lg p-3">
<div className="flex-1">
<p className="text-sm font-medium">{a.vendor_name}</p>
<p className="text-xs text-muted-foreground">
${Number(a.amount).toFixed(2)} · {new Date(a.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</p>
{a.notes && <p className="text-xs text-muted-foreground mt-1">{a.notes}</p>}
</div>
<div className="flex items-center gap-2">
<Badge className={statusColors[a.status] || "bg-muted text-muted-foreground"}>
{a.status.charAt(0).toUpperCase() + a.status.slice(1)}
</Badge>
{a.status === "pending" && (boardAssociationIds ? userBoardMemberNames.includes(a.vendor_name) : true) && (
<>
<Button size="sm" variant="outline" onClick={() => handleApprovalAction(a.id, "approved")} className="h-7 px-2 text-xs gap-1">
<CheckCircle className="h-3 w-3" /> Approve
</Button>
<Button size="sm" variant="outline" onClick={() => handleApprovalAction(a.id, "denied")} className="h-7 px-2 text-xs gap-1 text-destructive">
<XCircle className="h-3 w-3" /> Deny
</Button>
</>
)}
{!boardAssociationIds && (
<Button size="sm" variant="ghost" onClick={() => handleDeleteApproval(a.id)} className="h-7 px-2 text-xs text-destructive">
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{(lineItems.length > 0 || canEditLineItems) && (
<Card>
<CardHeader>
@@ -674,17 +629,17 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
</Card>
)}
{/* Approval History */}
{/* Approvers */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Clock className="h-5 w-5 text-primary" /> Approval History
<Clock className="h-5 w-5 text-primary" /> Approvers
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{approvals.length === 0 ? (
<div className="px-6 pb-6 text-sm text-muted-foreground italic">
No approval records yet.
No approvers requested yet. Use the &ldquo;Request Approval&rdquo; button above to add one.
</div>
) : (
<Table>
@@ -702,7 +657,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
<TableRow key={a.id}>
<TableCell>
<div>
<p className="text-sm font-medium">{a.vendor_name || "—"}</p>
<p className="text-sm font-medium">{a.approver_name || "—"}</p>
</div>
</TableCell>
<TableCell>
@@ -724,7 +679,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
</TableCell>
<TableCell>
<div className="flex gap-1">
{a.status === "pending" && (isBoardView ? userBoardMemberNames.includes(a.vendor_name) : true) && (
{a.status === "pending" && (isBoardView ? userBoardMemberNames.includes(a.approver_name) : true) && (
<>
<Button
variant="ghost"
@@ -52,26 +52,36 @@ export default function MasterBoardDashboardPage() {
.then(({ data }) => setAnnouncements(data || []));
}, [associationIds]);
// Pending bill approvals count for current user across assigned assocs
// Pending bill approvals count for current user across assigned assocs.
// bill_approvals doesn't store the approver's user_id directly — it stores
// the board member's display name (approver_name). We map auth.uid() to
// the board_member rows for the user, then match by member_name.
useEffect(() => {
if (!user || !associationIds.length) { setPendingBillCount(0); return; }
(async () => {
const billsQ: any = supabase
.from("bills")
.select("id")
.in("association_id", associationIds)
.in("status", ["pending", "pending_approval", "submitted"]);
const { data: bills } = await billsQ;
const [{ data: bills }, { data: myMemberships }] = await Promise.all([
supabase
.from("bills")
.select("id")
.in("association_id", associationIds)
.in("status", ["pending", "pending_approval", "submitted"]),
supabase
.from("board_members")
.select("member_name")
.eq("user_id", user.id)
.in("association_id", associationIds),
]);
const billIds = (bills || []).map((b: any) => b.id);
if (!billIds.length) { setPendingBillCount(0); return; }
const client: any = supabase;
const q = client
const myNames = Array.from(
new Set((myMemberships || []).map((m: any) => m.member_name).filter(Boolean))
);
if (!billIds.length || !myNames.length) { setPendingBillCount(0); return; }
const { count } = await supabase
.from("bill_approvals")
.select("id", { count: "exact", head: true })
.in("bill_id", billIds)
.eq("approver_user_id", user.id)
.in("approver_name", myNames)
.eq("status", "pending");
const { count } = await q;
setPendingBillCount(count ?? 0);
})();
}, [user, associationIds]);