import { useState, useEffect } from "react"; import { supabase } from "@/integrations/supabase/client"; import { useToast } from "@/hooks/use-toast"; import { FileText, Plus, Search, MoreHorizontal, Edit, Trash2, CheckCircle, XCircle, DollarSign, Download, Loader2 } from "lucide-react"; import RecordImportButton from "@/components/RecordImportButton"; import QuickAddVendorDialog from "@/components/vendors/QuickAddVendorDialog"; 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 } 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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; const statusColors: Record = { draft: "bg-muted text-muted-foreground", pending_approval: "bg-amber-100 text-amber-700", approved: "bg-blue-100 text-blue-700", rejected: "bg-red-100 text-red-700", paid: "bg-emerald-100 text-emerald-700", voided: "bg-gray-100 text-gray-500", partial: "bg-purple-100 text-purple-700", }; export default function BillsPage() { const { toast } = useToast(); const [bills, setBills] = useState([]); const [vendors, setVendors] = useState([]); const [accounts, setAccounts] = useState([]); const [associations, setAssociations] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [associationFilter, setAssociationFilter] = useState("all"); const [paidTab, setPaidTab] = useState<"unpaid" | "paid" | "all">("unpaid"); const [dialogOpen, setDialogOpen] = useState(false); const [editing, setEditing] = useState(null); const [buildiumOpen, setBuildiumOpen] = useState(false); const [buildiumAssoc, setBuildiumAssoc] = useState("all"); const [buildiumFrom, setBuildiumFrom] = useState(""); const [buildiumTo, setBuildiumTo] = useState(""); const [buildiumImporting, setBuildiumImporting] = useState(false); const [quickVendorOpen, setQuickVendorOpen] = useState(false); const [form, setForm] = useState({ vendor_id: "", invoice_number: "", bill_date: new Date().toISOString().split("T")[0], due_date: "", amount: "", expense_account_id: "", description: "", notes: "", association_id: "" }); 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 = async () => { setLoading(true); // Paginate bills (Supabase caps default queries at 1,000 rows) const PAGE = 1000; const allBills: any[] = []; for (let from = 0; ; from += PAGE) { const { data, error } = await supabase .from("bills") .select("*, vendors(name), chart_of_accounts(account_name, account_number)") .order("bill_date", { ascending: false }) .range(from, from + PAGE - 1); if (error) break; allBills.push(...(data || [])); if (!data || data.length < PAGE) break; } const [vRes, assocRes] = await Promise.all([ supabase.from("vendors").select("id, name, association_id, association_ids").eq("is_active", true).order("name"), supabase.from("associations").select("id, name, zoho_organization_id").eq("status", "active").order("name"), ]); setBills(allBills); setVendors(vRes.data || []); setAssociations(assocRes.data || []); setLoading(false); }; useEffect(() => { fetchData(); }, []); // Re-fetch the chart of accounts whenever the selected association changes, // scoped to that association's accounting system (zoho vs buildium). useEffect(() => { if (!form.association_id) { setAccounts([]); return; } const assoc = associations.find(a => a.id === form.association_id); const system = assoc?.zoho_organization_id ? "zoho" : "buildium"; supabase.from("chart_of_accounts") .select("id, account_name, account_number, account_type") .eq("is_active", true) .eq("accounting_system", system) .order("account_number") .then(({ data }) => setAccounts(data || [])); }, [form.association_id, associations]); const filtered = bills.filter(b => { // Tabs split: paid vs unpaid (everything not paid except voided counts as unpaid) if (paidTab === "paid" && b.status !== "paid") return false; if (paidTab === "unpaid" && (b.status === "paid" || b.status === "voided")) return false; if (statusFilter !== "all" && b.status !== statusFilter) return false; if (associationFilter !== "all" && b.association_id !== associationFilter) return false; if (search) { const q = search.toLowerCase(); return (b.vendors?.name || "").toLowerCase().includes(q) || (b.invoice_number || "").toLowerCase().includes(q) || (b.description || "").toLowerCase().includes(q); } return true; }); const tabCounts = bills.reduce((acc, b) => { if (b.status === "paid") acc.paid++; else if (b.status !== "voided") acc.unpaid++; acc.all++; return acc; }, { unpaid: 0, paid: 0, all: 0 }); const totals = bills.reduce((acc, b) => { acc.total += Number(b.amount); if (b.status === "paid") acc.paid += Number(b.amount); if (["draft", "pending_approval", "approved"].includes(b.status)) acc.unpaid += Number(b.amount) - Number(b.amount_paid); return acc; }, { total: 0, paid: 0, unpaid: 0 }); const openNew = () => { setEditing(null); setForm({ vendor_id: "", invoice_number: "", bill_date: new Date().toISOString().split("T")[0], due_date: "", amount: "", expense_account_id: "", description: "", notes: "", association_id: associations[0]?.id || "" }); setDialogOpen(true); }; const openEdit = (b: any) => { setEditing(b); setForm({ vendor_id: b.vendor_id || "", invoice_number: b.invoice_number || "", bill_date: b.bill_date, due_date: b.due_date || "", amount: b.amount?.toString() || "", expense_account_id: b.expense_account_id || "", description: b.description || "", notes: b.notes || "", association_id: b.association_id }); setDialogOpen(true); }; const handleSave = async () => { if (!form.amount || !form.association_id) { toast({ variant: "destructive", title: "Amount and association are required" }); return; } const payload = { ...form, amount: parseFloat(form.amount) || 0, vendor_id: form.vendor_id || null, expense_account_id: form.expense_account_id || null, due_date: form.due_date || null }; if (editing) { await supabase.from("bills").update(payload).eq("id", editing.id); toast({ title: "Bill updated" }); } else { await supabase.from("bills").insert(payload).select("id").single(); toast({ title: "Bill created" }); } setDialogOpen(false); fetchData(); }; const updateStatus = async (id: string, status: string) => { // Look up the current bill so we can void/clear the linked check when reverting to unpaid const current = bills.find((b) => b.id === id); const wasPaid = current?.status === "paid"; const revertingToUnpaid = wasPaid && (status === "approved" || status === "pending_approval" || status === "draft"); const updates: any = { status }; if (status === "approved") updates.approved_date = new Date().toISOString().split("T")[0]; if (status === "paid") updates.paid_date = new Date().toISOString().split("T")[0]; // Reverting to unpaid clears paid date, payment amount, and the linked check reference if (status === "approved" || status === "pending_approval" || status === "draft") { updates.paid_date = null; updates.amount_paid = 0; updates.payment_method = null; updates.check_id = null; } if (revertingToUnpaid && current?.check_id) { // Void the linked check await supabase .from("checks") .update({ status: "voided", printed: false }) .eq("id", current.check_id); // Remove the matching bank ledger entry (vendor ledger) created when the check was issued await supabase .from("bank_transactions") .delete() .eq("related_entity_type", "check") .eq("related_entity_id", current.check_id); } await supabase.from("bills").update(updates).eq("id", id); toast({ title: `Bill ${status.replace("_", " ")}` }); fetchData(); }; const handleDelete = async (id: string) => { await supabase.from("bills").delete().eq("id", id); toast({ title: "Bill deleted" }); fetchData(); }; return (

Bills

Manage accounts payable and vendor bills.

{ const assocId = associations[0]?.id; if (!assocId) throw new Error("Create an association first"); const vendorMap = new Map(vendors.map(v => [v.name.toLowerCase(), v.id])); const payload = rows.map(r => ({ vendor_id: vendorMap.get((r.vendor_name || "").toLowerCase()) || null, invoice_number: r.invoice_number || null, amount: parseFloat(r.amount) || 0, bill_date: r.bill_date || new Date().toISOString().split("T")[0], due_date: r.due_date || null, description: r.description || null, association_id: assocId, })); const { error } = await supabase.from("bills").insert(payload); if (error) throw error; toast({ title: `Imported ${rows.length} bills` }); fetchData(); }} templateFileName="bills_template.xlsx" />

Total Bills

${totals.total.toLocaleString("en-US", { minimumFractionDigits: 2 })}

Paid

${totals.paid.toLocaleString("en-US", { minimumFractionDigits: 2 })}

Outstanding

${totals.unpaid.toLocaleString("en-US", { minimumFractionDigits: 2 })}

setPaidTab(v as "unpaid" | "paid" | "all")}> Unpaid ({tabCounts.unpaid}) Paid ({tabCounts.paid}) All ({tabCounts.all})
setSearch(e.target.value)} />
{loading ? (
) : filtered.length === 0 ? ( No bills found. ) : ( Vendor Invoice # Bill Date Due Date Expense Account Amount Status {filtered.map((b) => ( {b.vendors?.name || "—"} {b.invoice_number || "—"} {b.bill_date} {b.due_date || "—"} {b.chart_of_accounts ? `${b.chart_of_accounts.account_number} - ${b.chart_of_accounts.account_name}` : "—"} ${Number(b.amount).toLocaleString("en-US", { minimumFractionDigits: 2 })} {b.status.replace("_", " ")} openEdit(b)}> Edit {b.status === "draft" && updateStatus(b.id, "pending_approval")}> Submit for Approval} {b.status === "pending_approval" && ( <> updateStatus(b.id, "approved")}> Approve updateStatus(b.id, "rejected")}> Reject )} {b.status === "approved" && updateStatus(b.id, "paid")}> Mark Paid} {b.status === "paid" && updateStatus(b.id, "approved")}> Mark Unpaid} {b.status !== "voided" && updateStatus(b.id, "voided")}> Void} handleDelete(b.id)}> Delete ))}
)} {editing ? "Edit" : "New"} Bill
setForm({ ...form, invoice_number: e.target.value })} />
setForm({ ...form, bill_date: e.target.value })} />
setForm({ ...form, due_date: e.target.value })} />
setForm({ ...form, amount: e.target.value })} />