import { useState, useEffect, useRef, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { supabase } from "@/integrations/supabase/client"; import { useToast } from "@/hooks/use-toast"; import { UserCheck, Plus, Search, Eye, Upload, X, ArrowUpDown, Edit, Trash2, MoreHorizontal, AlertTriangle, Loader2, Bell, Printer, Sparkles, Download } from "lucide-react"; import { downloadChecksPdf, type CheckData } from "@/utils/checkPdfGenerator"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; const statusColors: Record = { pending: "bg-amber-100 text-amber-700", approved: "bg-emerald-100 text-emerald-700", rejected: "bg-red-100 text-red-700", paid: "bg-emerald-100 text-emerald-700", overdue: "bg-red-100 text-red-700", draft: "bg-muted text-muted-foreground", }; type SortField = "id" | "association_id" | "vendor_name" | "amount" | "status" | "created_at" | "bill_date"; type SortDir = "asc" | "desc"; export default function BillApprovalsPage({ boardAssociationIds }: { boardAssociationIds?: string[] } = {}) { const { toast } = useToast(); const isBoardView = !!boardAssociationIds?.length; const navigate = useNavigate(); const [bills, setBills] = useState([]); const [associations, setAssociations] = useState([]); const [accounts, setAccounts] = useState([]); const [vendors, setVendors] = useState([]); const [boardMembers, setBoardMembers] = useState([]); const [vendorNotFound, setVendorNotFound] = useState(null); const [approvalsByBill, setApprovalsByBill] = useState>({}); const [loading, setLoading] = useState(true); // Filters const [search, setSearch] = useState(""); const [clientFilter, setClientFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all"); const [glFilter, setGlFilter] = useState("all"); const [activeTab, setActiveTab] = useState<"pending" | "approved">("pending"); const monthStart = (() => { const d = new Date(); return new Date(d.getFullYear(), d.getMonth(), 1).toISOString().split("T")[0]; })(); const monthEnd = (() => { const d = new Date(); return new Date(d.getFullYear(), d.getMonth() + 1, 0).toISOString().split("T")[0]; })(); const [approvedFrom, setApprovedFrom] = useState(monthStart); const [approvedTo, setApprovedTo] = useState(monthEnd); // Sorting const [sortField, setSortField] = useState("created_at"); const [sortDir, setSortDir] = useState("desc"); // Dialog const [dialogOpen, setDialogOpen] = useState(false); const [detailBill, setDetailBill] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [editingBill, setEditingBill] = useState(null); const [editOpen, setEditOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); // Notify Board const [notifyOpen, setNotifyOpen] = useState(false); const [notifyAssociationId, setNotifyAssociationId] = useState(""); const [notifyBoardMembers, setNotifyBoardMembers] = useState([]); const [notifyLoadingMembers, setNotifyLoadingMembers] = useState(false); const [notifySending, setNotifySending] = useState(false); // Bulk check printing const [selectedBillIds, setSelectedBillIds] = useState>(new Set()); const [printing, setPrinting] = useState(false); const [printDialogOpen, setPrintDialogOpen] = useState(false); const [printBanks, setPrintBanks] = useState([]); const [printAssocBankMap, setPrintAssocBankMap] = useState>({}); const [printIncludeMicr, setPrintIncludeMicr] = useState(false); const [combinePerVendor, setCombinePerVendor] = useState(true); const [printLoadingBanks, setPrintLoadingBanks] = useState(false); // Form const [form, setForm] = useState({ association_id: "", expense_account_id: "", approval_member_ids: [] as string[], vendor_id: "", vendor_name: "", invoice_number: "", bill_date: new Date().toISOString().split("T")[0], due_date: new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0], amount: "", description: "", }); const [uploadFile, setUploadFile] = useState(null); const [submitting, setSubmitting] = useState(false); const fileRef = useRef(null); const normalizeApprovalRecord = (record: any) => { const linkedInvoice = record.source_invoice_id ? record.invoices : null; return { ...record, amount: linkedInvoice?.amount ?? record.amount, due_date: linkedInvoice?.due_date ?? record.due_date, bill_date: linkedInvoice?.issue_date ?? record.bill_date, invoice_number: linkedInvoice?.invoice_number ?? record.invoice_number, description: linkedInvoice?.description ?? record.description, attachment_url: linkedInvoice?.raw_pdf_url ?? record.attachment_url, notes: linkedInvoice?.vendor_name ?? record.notes, }; }; // Buildium import state const [buildiumOpen, setBuildiumOpen] = useState(false); const [buildiumAssoc, setBuildiumAssoc] = useState("all"); const [buildiumFrom, setBuildiumFrom] = useState(""); const [buildiumTo, setBuildiumTo] = useState(""); const [buildiumImporting, setBuildiumImporting] = useState(false); const importFromBuildium = async () => { setBuildiumImporting(true); try { const { data, error } = await supabase.functions.invoke("buildium-sync", { body: { syncType: "bills", selectedAssociationIds: buildiumAssoc === "all" ? [] : [buildiumAssoc], dateFrom: buildiumFrom || undefined, dateTo: buildiumTo || undefined, }, }); if (error) throw error; const r = data?.results?.bills || {}; toast({ title: "Buildium import complete", description: `Fetched ${r.fetched || 0} • Created ${r.created || 0} • Updated ${r.updated || 0} • Linked duplicates ${r.linked_duplicates || 0} • Vendors created ${r.vendors_created || 0}`, }); setBuildiumOpen(false); fetchData(); } catch (err: any) { toast({ variant: "destructive", title: "Import failed", description: err?.message || String(err) }); } finally { setBuildiumImporting(false); } }; const fetchData = useCallback(async () => { setLoading(true); let assignedBillIds: string[] | null = null; if (isBoardView) { // Restrict to bills where the current user is an assigned approver const { data: userData } = await supabase.auth.getUser(); const uid = userData?.user?.id; assignedBillIds = []; if (uid) { const { data: myMemberships } = await supabase .from("board_members") .select("member_name, association_id") .eq("user_id", uid) .in("association_id", boardAssociationIds!); const names = Array.from(new Set((myMemberships || []).map((m: any) => m.member_name).filter(Boolean))); if (names.length > 0) { const { data: myApprovals } = await supabase .from("bill_approvals") .select("bill_id") .in("association_id", boardAssociationIds!) .in("vendor_name", names) .not("bill_id", "is", null); assignedBillIds = Array.from(new Set((myApprovals || []).map((a: any) => a.bill_id).filter(Boolean))); } } if (assignedBillIds.length === 0) { setBills([]); setApprovalsByBill({}); const [aRes2, coaRes2, vRes2] = await Promise.all([ supabase.from("associations").select("id, name").eq("status", "active").order("name"), supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type").eq("account_type", "expense").order("account_number"), supabase.from("vendors").select("id, name, address, association_id, association_ids").eq("is_active", true).order("name"), ]); setAssociations(aRes2.data || []); setAccounts(coaRes2.data || []); setVendors(vRes2.data || []); setLoading(false); return; } } // Paginate bills (Supabase caps default queries at 1,000 rows) const PAGE = 1000; const allBills: any[] = []; for (let from = 0; ; from += PAGE) { let q = supabase .from("bills") .select("*, associations(name), chart_of_accounts(account_name, account_number), invoices:source_invoice_id(id, vendor_name, invoice_number, issue_date, due_date, amount, description, raw_pdf_url)") .order("created_at", { ascending: false }); if (isBoardView) { q = q.in("association_id", boardAssociationIds!).in("id", assignedBillIds!); } const { data, error } = await q.range(from, from + PAGE - 1); if (error || !data) break; allBills.push(...data); if (data.length < PAGE) break; } const [aRes, coaRes, vRes] = await Promise.all([ supabase.from("associations").select("id, name").eq("status", "active").order("name"), supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type").eq("account_type", "expense").order("account_number"), supabase.from("vendors").select("id, name, address, association_id, association_ids").eq("is_active", true).order("name"), ]); const billsList = allBills.map(normalizeApprovalRecord); setBills(billsList); setAssociations(aRes.data || []); setAccounts(coaRes.data || []); setVendors(vRes.data || []); // Fetch approvals for all bills if (billsList.length > 0) { const billIds = billsList.map((b: any) => b.id); const { data: approvals } = await supabase .from("bill_approvals") .select("id, bill_id, vendor_name, status") .in("bill_id", billIds); const grouped: Record = {}; (approvals || []).forEach((a: any) => { if (!grouped[a.bill_id]) grouped[a.bill_id] = []; grouped[a.bill_id].push(a); }); setApprovalsByBill(grouped); } else { setApprovalsByBill({}); } setLoading(false); }, [isBoardView, boardAssociationIds]); useEffect(() => { fetchData(); }, [fetchData]); // Fetch board members when client changes useEffect(() => { if (!form.association_id) { setBoardMembers([]); return; } supabase .from("board_members") .select("id, member_name, member_email, approval_authority, user_id") .eq("association_id", form.association_id) .eq("approval_authority", true) .order("member_name") .then(({ data }) => setBoardMembers(data || [])); }, [form.association_id]); // Fetch board members for the Notify Board dialog useEffect(() => { if (!notifyAssociationId) { setNotifyBoardMembers([]); return; } setNotifyLoadingMembers(true); supabase .from("board_members") .select("id, member_name, member_email, approval_authority, user_id") .eq("association_id", notifyAssociationId) .eq("approval_authority", true) .order("member_name") .then(({ data }) => { setNotifyBoardMembers(data || []); setNotifyLoadingMembers(false); }); }, [notifyAssociationId]); const openNotifyBoard = () => { setNotifyAssociationId(associations[0]?.id || ""); setNotifyOpen(true); }; const handleSendNotifyBoard = async () => { if (!notifyAssociationId) { toast({ variant: "destructive", title: "Select a client", description: "Please choose an association to notify." }); return; } if (notifyBoardMembers.length === 0) { toast({ variant: "destructive", title: "No board members", description: "No board members with approval authority found for this association." }); return; } setNotifySending(true); try { const association = associations.find((a: any) => a.id === notifyAssociationId); const associationName = association?.name || "your association"; const link = "https://avria.cloud/dashboard/bill-approvals"; // In-app notifications for board members with linked user accounts const inAppPromises = notifyBoardMembers .filter((bm: any) => bm.user_id) .map((bm: any) => supabase.rpc("insert_notification", { p_user_id: bm.user_id, p_type: "bill_approval", p_title: "Bills Awaiting Your Approval", p_message: `One or more bills have been uploaded for ${associationName} and require your approval.`, p_related_item_type: "bill", p_link: "/homeowner/board/bill-approvals", }) ); // Email notifications via transactional email const stamp = Date.now(); const emailPromises = notifyBoardMembers .filter((bm: any) => bm.member_email) .map((bm: any) => supabase.functions.invoke("send-transactional-email", { body: { templateName: "bill-approval-request", recipientEmail: bm.member_email, idempotencyKey: `bill-approval-notify-${notifyAssociationId}-${bm.id}-${stamp}`, templateData: { recipientName: bm.member_name, associationName, link, }, }, }) ); const results = await Promise.allSettled([...inAppPromises, ...emailPromises]); const failed = results.filter((r) => r.status === "rejected").length; const emailCount = emailPromises.length; const inAppCount = inAppPromises.length; // Also send per-bill secure approve/deny links (one email per pending bill per board member). let voteSent = 0, voteFailed = 0; try { const { data: voteRes, error: voteErr } = await supabase.functions.invoke( "send-bill-approval-invites", { body: { association_id: notifyAssociationId, base_url: window.location.origin } } ); if (voteErr) { console.warn("send-bill-approval-invites error:", voteErr); } else { voteSent = voteRes?.sent ?? 0; voteFailed = voteRes?.failed ?? 0; } } catch (e) { console.warn("send-bill-approval-invites threw:", e); } if (failed > 0) { toast({ variant: "destructive", title: "Some notifications failed", description: `${failed} of ${results.length} notifications could not be sent.`, }); } else { toast({ title: "Board Notified", description: `Sent ${emailCount} summary email${emailCount !== 1 ? "s" : ""}, ${inAppCount} in-app notification${inAppCount !== 1 ? "s" : ""}, and ${voteSent} per-bill approve/deny email${voteSent !== 1 ? "s" : ""}${voteFailed ? ` (${voteFailed} failed)` : ""}.`, }); } setNotifyOpen(false); } catch (err: any) { toast({ variant: "destructive", title: "Error", description: err.message }); } finally { setNotifySending(false); } }; // Filtering const filtered = bills.filter((b) => { // "All" tab: pending + approved together (everything except paid) // "Approved" tab: only paid bills, filtered by date range const isPaid = b.status === "paid"; if (activeTab === "approved") { if (!isPaid) return false; const ref = b.bill_date || b.created_at; if (ref) { const refStr = String(ref).slice(0, 10); if (approvedFrom && refStr < approvedFrom) return false; if (approvedTo && refStr > approvedTo) return false; } } else { if (isPaid) return false; } if (clientFilter !== "all" && b.association_id !== clientFilter) return false; if (statusFilter !== "all" && b.status !== statusFilter) return false; if (glFilter !== "all" && b.expense_account_id !== glFilter) return false; if (search) { const q = search.toLowerCase(); return ( (b.vendor_name || b.description || "").toLowerCase().includes(q) || (b.id || "").toLowerCase().includes(q) || (b.invoice_number || "").toLowerCase().includes(q) ); } return true; }); // Sorting const sorted = [...filtered].sort((a, b) => { let aVal = a[sortField]; let bVal = b[sortField]; if (sortField === "amount") { aVal = Number(aVal); bVal = Number(bVal); } if (sortField === "association_id") { aVal = a.associations?.name || ""; bVal = b.associations?.name || ""; } if (aVal < bVal) return sortDir === "asc" ? -1 : 1; if (aVal > bVal) return sortDir === "asc" ? 1 : -1; return 0; }); const toggleSort = (field: SortField) => { if (sortField === field) setSortDir(d => d === "asc" ? "desc" : "asc"); else { setSortField(field); setSortDir("asc"); } }; const SortHeader = ({ field, children }: { field: SortField; children: React.ReactNode }) => ( toggleSort(field)}> {children} ); // Create bill const handleCreate = async () => { if (!form.association_id || !form.vendor_id || !form.amount) { toast({ variant: "destructive", title: "Missing fields", description: "Client, Vendor, and Amount are required." }); return; } const selectedVendor = vendors.find((v: any) => v.id === form.vendor_id); const vendorDisplayName = selectedVendor?.name || form.vendor_name || "Unknown Vendor"; setSubmitting(true); try { let attachmentUrl: string | null = null; if (uploadFile) { const path = `bill-approvals/${Date.now()}_${uploadFile.name}`; const { data: upData } = await supabase.storage.from("files").upload(path, uploadFile); if (upData) { const { data: { publicUrl } } = supabase.storage.from("files").getPublicUrl(upData.path); attachmentUrl = publicUrl; } } const { data: userData } = await supabase.auth.getUser(); const { data: newBill, error } = await supabase.from("bills").insert({ association_id: form.association_id, vendor_id: form.vendor_id || null, description: form.description || `Bill from ${vendorDisplayName}`, amount: parseFloat(form.amount) || 0, bill_date: form.bill_date, due_date: form.due_date || null, invoice_number: form.invoice_number || null, expense_account_id: form.expense_account_id || null, attachment_url: attachmentUrl, status: "pending", created_by: userData?.user?.id || null, notes: vendorDisplayName, }).select("id").single(); if (error) throw error; // If board members were selected for approval, create approval requests for each if (form.approval_member_ids.length > 0) { const approvalRows = form.approval_member_ids.map((memberId) => { const bm = boardMembers.find((m: any) => m.id === memberId); return { association_id: form.association_id, bill_id: newBill?.id || null, vendor_name: bm?.member_name || vendorDisplayName, amount: parseFloat(form.amount) || 0, status: "pending", notes: form.description || null, created_by: userData?.user?.id || null, }; }); await supabase.from("bill_approvals").insert(approvalRows); // Send in-app notifications to selected board members const notifyPromises = form.approval_member_ids .map((memberId) => boardMembers.find((m: any) => m.id === memberId)) .filter((bm: any) => bm?.user_id) .map((bm: any) => supabase.rpc("insert_notification", { p_user_id: bm.user_id, p_type: "bill_approval", p_title: "New Bill Awaiting Your Approval", p_message: `A bill for $${parseFloat(form.amount).toFixed(2)} from ${vendorDisplayName} requires your approval.`, p_related_item_type: "bill", p_link: "/homeowner/board/bill-approvals", }) ); await Promise.allSettled(notifyPromises); } toast({ title: "Bill Created", description: "Bill has been submitted for approval." }); setDialogOpen(false); setUploadFile(null); fetchData(); } catch (err: any) { toast({ variant: "destructive", title: "Error", description: err.message }); } finally { setSubmitting(false); } }; const openNew = () => { setForm({ association_id: associations[0]?.id || "", expense_account_id: "", approval_member_ids: [], vendor_id: "", vendor_name: "", invoice_number: "", bill_date: new Date().toISOString().split("T")[0], due_date: new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0], amount: "", description: "", }); setVendorNotFound(null); setUploadFile(null); setDialogOpen(true); }; const openEdit = (bill: any) => { setEditingBill(bill); setForm({ association_id: bill.association_id || "", expense_account_id: bill.expense_account_id || "", approval_member_ids: [], vendor_id: bill.vendor_id || "", vendor_name: bill.notes || "", invoice_number: bill.invoice_number || "", bill_date: bill.bill_date || new Date().toISOString().split("T")[0], due_date: bill.due_date || "", amount: bill.amount?.toString() || "", description: bill.description || "", }); setVendorNotFound(null); setUploadFile(null); setEditOpen(true); }; const handleUpdate = async () => { if (!editingBill) return; if (!form.association_id || !form.vendor_id || !form.amount) { toast({ variant: "destructive", title: "Missing fields", description: "Client, Vendor, and Amount are required." }); return; } const selectedVendor = vendors.find((v: any) => v.id === form.vendor_id); const vendorDisplayName = selectedVendor?.name || form.vendor_name || "Unknown Vendor"; setSubmitting(true); try { let attachmentUrl: string | null = editingBill.attachment_url || null; if (uploadFile) { const path = `bill-approvals/${Date.now()}_${uploadFile.name}`; const { data: upData } = await supabase.storage.from("files").upload(path, uploadFile); if (upData) { const { data: { publicUrl } } = supabase.storage.from("files").getPublicUrl(upData.path); attachmentUrl = publicUrl; } } const { error } = await supabase.from("bills").update({ association_id: form.association_id, vendor_id: form.vendor_id || null, description: form.description || `Bill from ${vendorDisplayName}`, amount: parseFloat(form.amount) || 0, bill_date: form.bill_date, due_date: form.due_date || null, invoice_number: form.invoice_number || null, expense_account_id: form.expense_account_id || null, notes: vendorDisplayName, attachment_url: attachmentUrl, }).eq("id", editingBill.id); if (error) throw error; // Also update linked invoice's raw_pdf_url if a new file was uploaded if (uploadFile && attachmentUrl && editingBill.source_invoice_id) { await supabase.from("invoices").update({ raw_pdf_url: attachmentUrl }).eq("id", editingBill.source_invoice_id); } toast({ title: "Bill Updated", description: "Bill changes have been saved." }); setEditOpen(false); setEditingBill(null); setUploadFile(null); fetchData(); } catch (err: any) { toast({ variant: "destructive", title: "Error", description: err.message }); } finally { setSubmitting(false); } }; const handleDelete = async () => { if (!deleteTarget) return; try { await supabase.from("bill_approvals").delete().eq("bill_id", deleteTarget.id); const { error } = await supabase.from("bills").delete().eq("id", deleteTarget.id); if (error) throw error; toast({ title: "Bill Deleted", description: "Bill has been removed." }); setDeleteTarget(null); fetchData(); } catch (err: any) { toast({ variant: "destructive", title: "Error", description: err.message }); setDeleteTarget(null); } }; const getVendorName = (bill: any) => bill.notes || bill.description || "—"; const getClientName = (bill: any) => bill.associations?.name || "—"; const getGlLabel = (bill: any) => bill.chart_of_accounts ? `${bill.chart_of_accounts.account_number} - ${bill.chart_of_accounts.account_name}` : "—"; const getStatusDisplay = (bill: any) => { // Check overdue if (bill.status === "pending" && bill.due_date && new Date(bill.due_date) < new Date()) { return "overdue"; } return bill.status; }; const filteredAccounts = form.association_id ? accounts.filter((a: any) => !a.association_id || a.association_id === form.association_id) : []; const filteredVendors = form.association_id ? vendors.filter((v: any) => v.association_id === form.association_id || (Array.isArray(v.association_ids) && v.association_ids.includes(form.association_id))) : []; // Open the print dialog: load active bank accounts for the selected bills' associations const openPrintDialog = async () => { const selectedBills = bills.filter((b) => selectedBillIds.has(b.id)); if (selectedBills.length === 0) { toast({ variant: "destructive", title: "No bills selected", description: "Select one or more bills to print checks for." }); return; } const assocIds = Array.from(new Set(selectedBills.map((b) => b.association_id).filter(Boolean))); if (assocIds.length === 0) { toast({ variant: "destructive", title: "Missing association", description: "Selected bills have no association." }); return; } setPrintLoadingBanks(true); setPrintDialogOpen(true); try { const { data: banks, error } = await supabase .from("bank_accounts") .select("id, account_name, bank_name, routing_number, account_number, next_check_number, association_id") .in("association_id", assocIds) .eq("status", "active") .order("account_name"); if (error) throw error; setPrintBanks(banks || []); // Default each association to its first bank const defaultMap: Record = {}; for (const id of assocIds) { const first = (banks || []).find((b: any) => b.association_id === id); if (first) defaultMap[id] = first.id; } setPrintAssocBankMap(defaultMap); } catch (err: any) { console.error(err); toast({ variant: "destructive", title: "Failed to load bank accounts", description: err.message }); setPrintDialogOpen(false); } finally { setPrintLoadingBanks(false); } }; // Bulk print checks for selected bills, using user-chosen bank accounts; supports reprint const handlePrintChecksForSelected = async () => { const selectedBills = bills.filter((b) => selectedBillIds.has(b.id)); if (selectedBills.length === 0) return; const byAssoc: Record = {}; for (const b of selectedBills) { if (!b.association_id) continue; (byAssoc[b.association_id] ||= []).push(b); } const assocIds = Object.keys(byAssoc); // Verify a bank account is chosen for every association const missing = assocIds.filter((id) => !printAssocBankMap[id]); if (missing.length > 0) { const names = missing.map((id) => associations.find((a: any) => a.id === id)?.name || id).join(", "); toast({ variant: "destructive", title: "Choose a bank account", description: `Select a bank account for: ${names}.` }); return; } const banksByAssoc: Record = {}; for (const id of assocIds) { const bank = printBanks.find((b: any) => b.id === printAssocBankMap[id]); if (bank) banksByAssoc[id] = bank; } if (printIncludeMicr) { const badBank = Object.values(banksByAssoc).find((b: any) => !b.routing_number || !b.account_number); if (badBank) { toast({ variant: "destructive", title: "Missing routing/account number", description: `Add them on "${badBank.account_name}" before printing MICR.` }); return; } } setPrinting(true); try { const { data: userData } = await supabase.auth.getUser(); // Load per-association check layouts (optional) const { data: layoutsData } = await supabase .from("check_layouts") .select("*") .in("association_id", assocIds); const layoutsByAssoc: Record = {}; (layoutsData || []).forEach((l: any) => { layoutsByAssoc[l.association_id] = l; }); let totalPrinted = 0; let totalReprinted = 0; for (const assocId of assocIds) { const bank = banksByAssoc[assocId]; const groupBills = byAssoc[assocId]; let nextNum = bank.next_check_number || 1001; const checkDataList: CheckData[] = []; const billStatusUpdates: Array<{ billId: string; checkId: string; checkNumber: string }> = []; const txInserts: any[] = []; const today = new Date().toISOString().slice(0, 10); const assocName = associations.find((a: any) => a.id === assocId)?.name || ""; // Group bills by payee (vendor) when combining is enabled. // Otherwise treat each bill as its own group. const billGroups: Array<{ payeeKey: string; payee: string; bills: any[] }> = []; if (combinePerVendor) { const map = new Map(); for (const bill of groupBills) { const payee = getVendorName(bill); const key = (payee || "").trim().toLowerCase(); if (!map.has(key)) map.set(key, { payee, bills: [] }); map.get(key)!.bills.push(bill); } for (const [payeeKey, v] of map) billGroups.push({ payeeKey, payee: v.payee, bills: v.bills }); } else { for (const bill of groupBills) { const payee = getVendorName(bill); billGroups.push({ payeeKey: bill.id, payee, bills: [bill] }); } } for (const grp of billGroups) { const payee = grp.payee; const firstBill = grp.bills[0]; const vendorRow: any = firstBill.vendor_id ? vendors.find((v: any) => v.id === firstBill.vendor_id) : null; const payeeAddress = vendorRow?.address || null; const totalAmount = grp.bills.reduce((s, b) => s + (Number(b.amount) || 0), 0); // Build line items for the stub (one row per bill in the group) const lineItems = grp.bills.map((b) => ({ invoice_number: b.invoice_number || null, date: b.bill_date || today, description: b.description || null, amount: Number(b.amount) || 0, })); const memo = grp.bills.length > 1 ? `Combined payment (${grp.bills.length} invoice${grp.bills.length === 1 ? "" : "s"})` : (firstBill.invoice_number ? `Inv #${firstBill.invoice_number}` : (firstBill.description || null)); // Identify any already-printed bills in the group so we can reprint // under the existing check number when the entire group shares one. const existingCheckIds = Array.from(new Set(grp.bills.map((b) => b.check_id).filter(Boolean))); let existingCheck: any = null; if (existingCheckIds.length === 1) { const { data: existing } = await supabase .from("checks") .select("id, check_number, bank_account_id") .eq("id", existingCheckIds[0]) .maybeSingle(); existingCheck = existing; } let checkNumber: string; let checkId: string; if (existingCheck) { // REPRINT: reuse existing check, do not advance counter or insert tx checkId = existingCheck.id; checkNumber = existingCheck.check_number || String(nextNum++); await supabase .from("checks") .update({ check_number: checkNumber, check_date: today, amount: totalAmount, payee, memo, printed: true, status: "printed", bank_account_id: bank.id, }) .eq("id", existingCheck.id); totalReprinted += 1; } else { // FIRST PRINT: create one check + one bank transaction for the whole group checkNumber = String(nextNum++); const { data: chk, error: chkErr } = await supabase .from("checks") .insert({ association_id: assocId, bank_account_id: bank.id, payee, amount: totalAmount, check_number: checkNumber, check_date: today, memo, status: "printed", printed: true, created_by: userData?.user?.id || null, }) .select("id") .single(); if (chkErr) throw chkErr; checkId = chk.id; for (const b of grp.bills) { billStatusUpdates.push({ billId: b.id, checkId, checkNumber }); } txInserts.push({ association_id: assocId, bank_account_id: bank.id, date: today, debit: totalAmount, credit: 0, description: `Check #${checkNumber} to ${payee}` + (grp.bills.length > 1 ? ` (combined ${grp.bills.length} bills)` : (memo ? ` — ${memo}` : "")), reference_number: checkNumber, related_entity_id: checkId, related_entity_type: "check", transaction_type: "check", }); totalPrinted += 1; } // Always make sure every bill in the group is linked to this check // (covers the reprint case where some bills may have been added later) for (const b of grp.bills) { if (b.check_id !== checkId) { billStatusUpdates.push({ billId: b.id, checkId, checkNumber }); } } checkDataList.push({ check_number: checkNumber, check_date: today, payee, payee_address: payeeAddress, amount: totalAmount, memo, line_items: lineItems.length > 1 ? lineItems : null, bank_account_name: bank.account_name, bank_routing_number: bank.routing_number, bank_account_number: bank.account_number, association_name: assocName, layout: (layoutsByAssoc[assocId] as any) || null, }); } for (const u of billStatusUpdates) { await supabase .from("bills") .update({ status: "paid", paid_date: today, payment_method: "check", check_id: u.checkId, }) .eq("id", u.billId); } if (txInserts.length > 0) await supabase.from("bank_transactions").insert(txInserts); if (nextNum > (bank.next_check_number || 1001)) { await supabase .from("bank_accounts") .update({ next_check_number: nextNum }) .eq("id", bank.id); } const safeName = (assocName || "association").replace(/[^a-z0-9-_]+/gi, "_"); await downloadChecksPdf(checkDataList, `checks-${safeName}-${today}.pdf`, { includeMicr: printIncludeMicr }); } const parts: string[] = []; if (totalPrinted) parts.push(`${totalPrinted} new`); if (totalReprinted) parts.push(`${totalReprinted} reprinted`); toast({ title: "Checks generated", description: `${parts.join(", ")} across ${assocIds.length} association(s).` }); setSelectedBillIds(new Set()); setPrintDialogOpen(false); fetchData(); } catch (err: any) { console.error("Bulk print failed:", err); toast({ variant: "destructive", title: "Print failed", description: err.message }); } finally { setPrinting(false); } }; // A bill is selectable if it has an association and isn't rejected. Paid bills with a check_id are reprintable. const isPrintable = (bill: any) => !!bill.association_id && bill.status !== "rejected"; return (
{/* Header */}

Bill Approvals List

Manage and track vendor bills requiring approval.

{!isBoardView && (
)}
{/* Tabs: Pending vs Approved */} setActiveTab(v as "pending" | "approved")}> Pending & Approved Paid {activeTab === "approved" && (
setApprovedFrom(e.target.value)} className="w-[160px]" />
setApprovedTo(e.target.value)} className="w-[160px]" />
)} {/* Search & Filters */}
setSearch(e.target.value)} className="pl-9" />
{/* Table */} {loading ? (
) : sorted.length === 0 ? ( No bills found. ) : ( {!isBoardView && ( 0 && sorted.filter(isPrintable).every((b) => selectedBillIds.has(b.id)) } onCheckedChange={(checked) => { const printable = sorted.filter(isPrintable).map((b) => b.id); setSelectedBillIds(checked ? new Set(printable) : new Set()); }} aria-label="Select all printable bills" /> )} Bill ID Client Vendor Amount Bill Date Approvers Status Created Date Actions {sorted.map((b) => { const displayStatus = getStatusDisplay(b); return ( navigate(isBoardView ? `/homeowner/board/bill-approvals/${b.id}` : `/dashboard/bill-approvals/${b.id}`)}> {!isBoardView && ( e.stopPropagation()}> { setSelectedBillIds((prev) => { const s = new Set(prev); if (checked) s.add(b.id); else s.delete(b.id); return s; }); }} aria-label={`Select bill ${b.id}`} /> )} {b.id.slice(0, 8)}... {getClientName(b)} {getVendorName(b)} ${Number(b.amount).toLocaleString("en-US", { minimumFractionDigits: 2 })} {b.bill_date ? new Date(b.bill_date + "T12:00:00").toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }) : "—"} {(approvalsByBill[b.id] || []).length > 0 ? (
{approvalsByBill[b.id].map((a: any) => (
{a.status === 'approved' ? '✓' : a.status === 'denied' || a.status === 'rejected' ? '✗' : '⏳'} {a.vendor_name}
))}
) : ( None )}
{displayStatus.charAt(0).toUpperCase() + displayStatus.slice(1)} {new Date(b.created_at).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })} e.stopPropagation()}> { setDetailBill(b); setDetailOpen(true); }}> View Details {!isBoardView && ( <> openEdit(b)}> Edit setDeleteTarget(b)}> Delete )}
); })}
)} {/* Create Bill Dialog */} Bill Details
{/* Client / Association */}
{/* GL Account */}
{/* Request Approval From */}
{!form.association_id ? (

Select a client first.

) : boardMembers.length === 0 ? (

No board members with approval authority found.

) : ( boardMembers.map((bm: any) => (
{ setForm(prev => ({ ...prev, approval_member_ids: checked ? [...prev.approval_member_ids, bm.id] : prev.approval_member_ids.filter((id: string) => id !== bm.id), })); }} />
)) )}
{form.approval_member_ids.length > 0 && (

{form.approval_member_ids.length} approver(s) selected

)}
{/* Vendor Not Found Warning */} {vendorNotFound && ( Vendor "{vendorNotFound}" was not recognized. Please{" "} add this vendor {" "} first, then retry. )} {/* Vendor & Invoice */}
setForm({ ...form, invoice_number: e.target.value })} />
{/* Dates */}
setForm({ ...form, bill_date: e.target.value })} />
setForm({ ...form, due_date: e.target.value })} />
{/* Amount */}
$ setForm({ ...form, amount: e.target.value })} />
{/* Description */}