mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 “Request Approval” 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]);
|
||||
|
||||
Reference in New Issue
Block a user