import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo, useRef, useState } from "react"; import { accounting } from "@/lib/accountingClient"; import { supabase } from "@/integrations/supabase/client"; import { useCompanyId } from "./lib/useCompanyId"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Plus, Trash2, Search, Receipt, Upload, Sparkles, FileText, X, AlertCircle, Printer, Loader2, Pencil } from "lucide-react"; import { toast } from "sonner"; import { money, fmtDate } from "./lib/format"; import { StatusBadge } from "./components/StatusBadge"; import { EmptyState } from "./components/EmptyState"; import { TableSkeleton } from "./components/TableSkeleton"; import { parseBill } from "./lib/parseBill"; import { cn } from "@/lib/utils"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { generateCheckPDF } from "./lib/checkPdf"; type Item = { description: string; quantity: number; rate: number; account_id?: string | null }; function deriveStatus(b: any): string { if (b.status === "void" || b.status === "draft") return b.status; const paid = Number(b.paid_amount ?? 0); const total = Number(b.total ?? 0); if (paid >= total && total > 0) return "paid"; if (paid > 0 && paid < total) return "partially_paid"; if (b.due_date && new Date(b.due_date) < new Date() && paid < total) return "overdue"; return "open"; } const ACCEPT = "application/pdf,image/jpeg,image/jpg,image/png,image/heic,image/heif"; const HL = "bg-sky-50 border-sky-300"; const ERR = "bg-red-50 border-red-400"; export default function AccountingBillsPage() { const { companyId, loading: companyLoading, error: companyError, associationId, associationName } = useCompanyId(); const cid = companyId ?? ""; const cur = "USD"; const qc = useQueryClient(); const parseFn = parseBill; const [open, setOpen] = useState(false); const [editId, setEditId] = useState(null); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [autoOnly, setAutoOnly] = useState(false); const [vendorId, setVendorId] = useState(""); const [number, setNumber] = useState(""); const [issueDate, setIssueDate] = useState(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" })); const [dueDate, setDueDate] = useState(""); const [taxPct, setTaxPct] = useState(0); const [notes, setNotes] = useState(""); const [items, setItems] = useState([{ description: "", quantity: 1, rate: 0, account_id: null }]); // Upload + parse state const [file, setFile] = useState(null); const [filePreview, setFilePreview] = useState(null); const [uploadedUrl, setUploadedUrl] = useState(null); const [parsing, setParsing] = useState(false); const [aiFilled, setAiFilled] = useState>(new Set()); // field keys that were AI-filled const [missingFields, setMissingFields] = useState>(new Set()); const [dragOver, setDragOver] = useState(false); const [pageDragOver, setPageDragOver] = useState(false); const fileRef = useRef(null); const { data: bills = [], isLoading } = useQuery({ queryKey: ["bills", cid], enabled: !!cid, queryFn: async () => (await accounting.from("bills").select("*, vendors(name,address)").eq("company_id", cid).order("issue_date", { ascending: false })).data ?? [], }); const { data: vendors = [] } = useQuery({ queryKey: ["vendors-lookup", cid], enabled: !!cid, queryFn: async () => (await accounting.from("vendors").select("id,name").eq("company_id", cid).order("name")).data ?? [], }); const { data: expenseAccounts = [] } = useQuery({ queryKey: ["expense-accounts", cid], enabled: !!cid, queryFn: async () => (await accounting.from("accounts").select("id,name,code,type").eq("company_id", cid).eq("type", "expense").order("code")).data ?? [], }); const enriched = useMemo( () => (bills as any[]).map((b: any) => ({ ...b, derivedStatus: deriveStatus(b), balance: Math.max(0, Number(b.total) - Number(b.paid_amount ?? 0)) })), [bills] ); const filtered = enriched.filter((b: any) => { if (statusFilter !== "all" && b.derivedStatus !== statusFilter) return false; if (autoOnly && !b.auto_created) return false; if (search && !`${b.number} ${b.vendors?.name ?? ""}`.toLowerCase().includes(search.toLowerCase())) return false; return true; }); const aging = useMemo(() => { const buckets = { current: 0, "0-30": 0, "31-60": 0, "60+": 0 }; const today = new Date(); enriched.forEach((b: any) => { if (b.derivedStatus === "paid" || b.derivedStatus === "void" || b.derivedStatus === "draft") return; if (!b.due_date) { buckets.current += b.balance; return; } const days = Math.floor((today.getTime() - new Date(b.due_date).getTime()) / 86400000); if (days < 0) buckets.current += b.balance; else if (days <= 30) buckets["0-30"] += b.balance; else if (days <= 60) buckets["31-60"] += b.balance; else buckets["60+"] += b.balance; }); return buckets; }, [enriched]); const totalDue = aging.current + aging["0-30"] + aging["31-60"] + aging["60+"]; const subtotal = useMemo(() => items.reduce((s, i) => s + Number(i.quantity) * Number(i.rate), 0), [items]); const tax = +(subtotal * (taxPct / 100)).toFixed(2); const total = +(subtotal + tax).toFixed(2); const resetForm = () => { setEditId(null); setVendorId(""); setNumber(""); setDueDate(""); setTaxPct(0); setNotes(""); setItems([{ description: "", quantity: 1, rate: 0, account_id: null }]); setFile(null); setFilePreview(null); setUploadedUrl(null); setAiFilled(new Set()); setMissingFields(new Set()); }; const openEdit = async (b: any) => { setEditId(b.id); setVendorId(b.vendor_id ?? ""); setNumber(b.number ?? ""); setIssueDate(b.issue_date ?? issueDate); setDueDate(b.due_date ?? ""); const sub = Number(b.subtotal || 0); setTaxPct(sub > 0 ? +((Number(b.tax || 0) / sub) * 100).toFixed(4) : 0); setNotes(b.notes ?? ""); setUploadedUrl(b.attachment_url ?? null); setFile(null); setFilePreview(null); setAiFilled(new Set()); setMissingFields(new Set()); const { data: its } = await accounting.from("bill_items").select("*").eq("bill_id", b.id); setItems(its && its.length ? its.map((i: any) => ({ description: i.description ?? "", quantity: Number(i.quantity ?? 1), rate: Number(i.rate ?? 0), account_id: i.account_id ?? null })) : [{ description: "", quantity: 1, rate: 0, account_id: null }]); setOpen(true); }; const handleFile = (f: File) => { setFile(f); setUploadedUrl(null); if (f.type.startsWith("image/")) { setFilePreview(URL.createObjectURL(f)); } else { setFilePreview(null); } // Open dialog immediately and auto-parse setOpen(true); runParse(f); }; const uploadFileObj = async (f: File): Promise => { if (!cid) return null; const ext = f.name.split(".").pop() || "bin"; const path = `${cid}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; const { error } = await supabase.storage.from("bill-attachments").upload(path, f, { contentType: f.type || "application/octet-stream", upsert: false, }); if (error) { toast.error(`Upload failed: ${error.message}`); return null; } const { data } = supabase.storage.from("bill-attachments").getPublicUrl(path); setUploadedUrl(data.publicUrl); return data.publicUrl; }; const matchAccount = (desc: string): string | null => { if (!desc || (expenseAccounts as any[]).length === 0) return null; const d = desc.toLowerCase(); let best: { id: string; score: number } | null = null; for (const a of expenseAccounts as any[]) { const name = String(a.name).toLowerCase(); let score = 0; if (d.includes(name) || name.includes(d)) score = 100; else { const words = name.split(/\s+/).filter((w) => w.length > 3); score = words.reduce((s, w) => (d.includes(w) ? s + 10 : s), 0); } if (score > 0 && (!best || score > best.score)) best = { id: a.id, score }; } return best?.id ?? null; }; const runParse = async (f: File) => { setParsing(true); try { const url = await uploadFileObj(f); if (!url) { setParsing(false); return; } const parsed = await parseFn({ data: { fileUrl: url, mimeType: f.type || "application/octet-stream" } }); const filled = new Set(); const missing = new Set(); if (parsed.bill_number) { setNumber(String(parsed.bill_number)); filled.add("number"); } else missing.add("number"); if (parsed.bill_date) { setIssueDate(String(parsed.bill_date).slice(0, 10)); filled.add("issueDate"); } if (parsed.due_date) { setDueDate(String(parsed.due_date).slice(0, 10)); filled.add("dueDate"); } if (parsed.notes) { setNotes(String(parsed.notes)); filled.add("notes"); } // Vendor match if (parsed.vendor_name) { const vn = String(parsed.vendor_name).toLowerCase(); const v = (vendors as any[]).find((x) => String(x.name).toLowerCase() === vn || String(x.name).toLowerCase().includes(vn) || vn.includes(String(x.name).toLowerCase())); if (v) { setVendorId(v.id); filled.add("vendor"); } else filled.add("vendor_unmatched"); } // Line items if (Array.isArray(parsed.line_items) && parsed.line_items.length > 0) { const newItems: Item[] = parsed.line_items.map((li: any) => { const desc = String(li.description ?? ""); const qty = Number(li.quantity ?? 1) || 1; const rate = Number(li.unit_price ?? li.amount ?? 0) || 0; return { description: desc, quantity: qty, rate, account_id: matchAccount(desc) }; }); setItems(newItems); filled.add("items"); } // Tax % from subtotal/tax const sub = Number(parsed.subtotal ?? 0); const taxAmt = Number(parsed.tax_amount ?? 0); if (sub > 0 && taxAmt > 0) { setTaxPct(+((taxAmt / sub) * 100).toFixed(2)); filled.add("tax"); } setAiFilled(filled); setMissingFields(missing); toast.success("Bill parsed — please review highlighted fields."); } catch (e: any) { toast.error(e?.message ?? "Failed to parse bill"); } finally { setParsing(false); } }; const save = async () => { if (!number.trim()) return toast.error("Bill number required"); let attachmentUrl = uploadedUrl; if (file && !attachmentUrl) attachmentUrl = await uploadFileObj(file); const itemRows = (billId: string) => items.map(i => ({ bill_id: billId, description: i.description, quantity: i.quantity, rate: i.rate, amount: +(Number(i.quantity) * Number(i.rate)).toFixed(2), account_id: i.account_id || null, })); if (editId) { const { error } = await accounting.from("bills").update({ vendor_id: vendorId || null, number, issue_date: issueDate, due_date: dueDate || null, subtotal, tax, total, notes: notes || null, attachment_url: attachmentUrl, }).eq("id", editId); if (error) return toast.error(error.message); await accounting.from("bill_items").delete().eq("bill_id", editId); await accounting.from("bill_items").insert(itemRows(editId)); toast.success("Bill updated"); } else { const { data: bill, error } = await accounting.from("bills").insert({ company_id: cid, vendor_id: vendorId || null, number, issue_date: issueDate, due_date: dueDate || null, subtotal, tax, total, status: "open", notes: notes || null, attachment_url: attachmentUrl, }).select().single(); if (error || !bill) return toast.error(error?.message ?? "Failed"); await accounting.from("bill_items").insert(itemRows(bill.id)); toast.success("Bill recorded"); } setOpen(false); resetForm(); qc.invalidateQueries({ queryKey: ["bills", cid] }); }; // Payment dialog state const [payBill, setPayBill] = useState(null); const [payAmount, setPayAmount] = useState(0); const [payDate, setPayDate] = useState(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" })); const [payMethod, setPayMethod] = useState<"check" | "ach" | "wire" | "credit_card" | "cash" | "other">("check"); const [payAccountId, setPayAccountId] = useState(""); const [payReference, setPayReference] = useState(""); const [payMemo, setPayMemo] = useState(""); const [printCheck, setPrintCheck] = useState(true); const [paying, setPaying] = useState(false); const { data: bankAccounts = [] } = useQuery({ queryKey: ["bank-accounts", cid], enabled: !!cid, queryFn: async () => (await accounting.from("accounts").select("id,name,code,balance,is_bank").eq("company_id", cid).eq("is_bank", true).order("code")).data ?? [], }); const openPayment = async (b: any) => { setPayBill(b); setPayAmount(b.balance); setPayDate(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" })); setPayMethod("check"); setPayMemo(""); setPrintCheck(true); const def = (bankAccounts as any[])[0]?.id ?? ""; setPayAccountId(def); const { data: cs } = await accounting.from("check_settings").select("next_check_number").eq("company_id", cid).maybeSingle(); setPayReference(String(cs?.next_check_number ?? 1001)); }; const onMethodChange = async (m: typeof payMethod) => { setPayMethod(m); if (m === "check") { setPrintCheck(true); const { data: cs } = await accounting.from("check_settings").select("next_check_number").eq("company_id", cid).maybeSingle(); setPayReference(String(cs?.next_check_number ?? 1001)); } else { setPrintCheck(false); setPayReference(""); } }; const printCheckPDF = async (checkNumber: number, vendorName: string, amount: number, date: string, memo: string, bankAccount: any) => { const { data: cs } = await accounting.from("check_settings").select("*").eq("company_id", cid).maybeSingle(); const { data: billItems } = await accounting.from("bill_items").select("description,amount").eq("bill_id", payBill?.id ?? ""); const vendorAddress = payBill?.vendors?.address ?? undefined; const dataUrl = generateCheckPDF( [{ companyName: associationName ?? "Company", companyAddress: undefined, bankName: cs?.bank_name ?? bankAccount?.name ?? undefined, bankAddress: cs?.bank_address ?? undefined, routingNumber: cs?.routing_number ?? undefined, accountNumber: cs?.account_number ?? undefined, fractionalRouting: (cs as any)?.fractional_routing ?? undefined, checkNumber, date: fmtDate(date), payee: vendorName, payeeAddress: vendorAddress, amount, memo: memo || undefined, lineItems: (billItems ?? []).map((i: any) => ({ description: i.description, amount: Number(i.amount) })), printSignature: cs?.print_signature ?? false, signatureDataUrl: cs?.signature_url ?? undefined, }], { style: (cs?.default_style as any) ?? "voucher", position: (cs?.default_position as any) ?? "top", fontSize: (cs?.font_size as any) ?? "medium", offsetX: (cs as any)?.offset_x ?? 0, offsetY: (cs as any)?.offset_y ?? 0, micrOffsetY: (cs as any)?.micr_offset_y ?? 0, } ); const w = window.open(""); if (w) { w.document.write(``); } }; const savePayment = async () => { if (!payBill || !payAccountId) return toast.error("Bank account required"); if (!payAmount || payAmount <= 0) return toast.error("Invalid amount"); setPaying(true); try { const bankAccount = (bankAccounts as any[]).find((a) => a.id === payAccountId); const vendorName = payBill.vendors?.name ?? "Vendor"; const refLabel = payReference || payMethod.toUpperCase(); // 1) Bank ledger transaction — debit = money OUT of the bank (payment to vendor) // Pull the primary expense account from the first bill item so the COA balance updates const { data: billItems } = await accounting .from("bill_items") .select("account_id") .eq("bill_id", payBill.id) .not("account_id", "is", null) .limit(1); const primaryCoa = billItems?.[0]?.account_id ?? null; await accounting.from("transactions").insert({ company_id: cid, account_id: payAccountId, date: payDate, type: "debit", amount: payAmount, description: `Bill Payment · ${vendorName} · Bill ${payBill.number}`, category: "Bill Payment", reference: refLabel, coa_account_id: primaryCoa, // links to expense account → updates COA balance vendor_id: payBill.vendor_id ?? null, // link to vendor for reporting }); // 2) Bank balance auto-updated by DB trigger trg_sync_account_balance // 3) Update bill paid amount const newPaid = Number(payBill.paid_amount ?? 0) + Number(payAmount); await accounting.from("bills").update({ paid_amount: newPaid, status: newPaid >= Number(payBill.total) ? "paid" : "open" }).eq("id", payBill.id); // 4) If check + print: insert check record, print, mark printed, bump next # if (payMethod === "check") { const checkNumber = parseInt(payReference, 10) || 1001; const { data: chk } = await accounting.from("checks").insert({ company_id: cid, bank_account_id: payAccountId, check_number: checkNumber, date: payDate, payee_vendor_id: payBill.vendor_id, payee_name: vendorName, amount: payAmount, memo: payMemo || `Bill ${payBill.number}`, source_bill_id: payBill.id, status: printCheck ? "printed" : "unprinted", printed_at: printCheck ? new Date().toISOString() : null, }).select().single(); // bump next check number const { data: csRow } = await accounting.from("check_settings").select("id,next_check_number").eq("company_id", cid).maybeSingle(); if (csRow) { await accounting.from("check_settings").update({ next_check_number: Math.max(csRow.next_check_number, checkNumber + 1) }).eq("id", csRow.id); } else { await accounting.from("check_settings").insert({ company_id: cid, next_check_number: checkNumber + 1 }); } if (printCheck && chk) { printCheckPDF(checkNumber, vendorName, payAmount, payDate, payMemo, bankAccount); } } toast.success("Payment recorded"); setPayBill(null); qc.invalidateQueries({ queryKey: ["bills", cid] }); qc.invalidateQueries({ queryKey: ["bank-accounts", cid] }); qc.invalidateQueries({ queryKey: ["transactions", cid] }); qc.invalidateQueries({ queryKey: ["accounts", cid] }); } catch (e: any) { toast.error(e?.message ?? "Failed to record payment"); } finally { setPaying(false); } }; const remove = async (id: string) => { await accounting.from("bills").delete().eq("id", id); qc.invalidateQueries({ queryKey: ["bills", cid] }); }; const hl = (key: string) => cn( aiFilled.has(key) && HL, missingFields.has(key) && ERR, ); if (!associationId) return

Select an association.

; if (companyLoading) return
; if (companyError || !companyId) return

{companyError || "Accounting setup is not ready."}

; return (
{ e.preventDefault(); setPageDragOver(true); }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setPageDragOver(false); }} onDrop={(e) => { e.preventDefault(); setPageDragOver(false); const f = e.dataTransfer.files?.[0]; if (f) handleFile(f); }} > {/* Full-page drop overlay */} {pageDragOver && (
Drop bill to parse
PDF, JPG, PNG, HEIC accepted
)}

Bills

{ setOpen(o); if (!o) resetForm(); }}> {editId ? "Edit bill" : "New bill"} {aiFilled.size > 0 && (
AI has filled in this bill — please review all fields before saving.
)}
{/* Form */}
{/* Upload zone */}
{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={(e) => { e.preventDefault(); setDragOver(false); const f = e.dataTransfer.files?.[0]; if (f) handleFile(f); }} className={cn( "rounded-lg border-2 border-dashed p-4 text-center text-sm cursor-pointer transition-colors", dragOver ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50" )} onClick={() => fileRef.current?.click()} > { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
{parsing ? "Parsing with AI…" : file ? file.name : "Upload & Parse Bill"}
{parsing ? "Extracting fields from your bill…" : "Drag & drop or click — PDF, JPG, PNG, HEIC · auto-parses on drop"}
{file && !parsing && (
e.stopPropagation()}>
)}
setNumber(e.target.value)} className={hl("number")} />
setIssueDate(e.target.value)} className={hl("issueDate")} />
setDueDate(e.target.value)} className={hl("dueDate")} />
{items.map((it, idx) => (
{ const a = [...items]; a[idx] = { ...it, description: e.target.value }; setItems(a); }} /> { const a = [...items]; a[idx] = { ...it, quantity: Number(e.target.value) }; setItems(a); }} /> { const a = [...items]; a[idx] = { ...it, rate: Number(e.target.value) }; setItems(a); }} />
))}
setTaxPct(Number(e.target.value))} className={hl("tax")} />
Subtotal: {money(subtotal, cur)}
Tax: {money(tax, cur)}
Total: {money(total, cur)}
{(notes || aiFilled.has("notes")) && (
setNotes(e.target.value)} className={hl("notes")} />
)}
{/* Preview pane */}
{filePreview ? ( Bill preview ) : file ? (
{file.name}
{(file.size / 1024).toFixed(1)} KB
) : (
No file uploaded
)}
{/* Aging summary */} Payables aging · {money(totalDue, cur)} due
{[ { label: "Not yet due", value: aging.current, tone: "bg-slate-50 text-slate-700 border-slate-200" }, { label: "0 – 30 days", value: aging["0-30"], tone: "bg-amber-50 text-amber-700 border-amber-200" }, { label: "31 – 60 days", value: aging["31-60"], tone: "bg-orange-50 text-orange-700 border-orange-200" }, { label: "60+ days", value: aging["60+"], tone: "bg-red-50 text-red-700 border-red-200" }, ].map((b) => (
{b.label}
{money(b.value, cur)}
))}
setSearch(e.target.value)} />
All bills Bill # Vendor Issued Due Total Balance Status {isLoading && } {!isLoading && filtered.map((b: any) => ( {b.attachment_url ? ( {b.number} ) : b.number} {b.auto_created && Auto} {b.vendors?.name ?? "—"} {fmtDate(b.issue_date)} {fmtDate(b.due_date)} {money(b.total, cur)} {money(b.balance, cur)} {b.derivedStatus !== "paid" && b.derivedStatus !== "void" && ( )} {b.derivedStatus !== "void" && ( )} ))} {!isLoading && filtered.length === 0 && ( )}
{/* Payment dialog */} !o && setPayBill(null)}> Record payment {payBill ? `· Bill ${payBill.number}` : ""} {payBill && (
Vendor: {payBill.vendors?.name ?? "—"} · Balance:{" "} {money(payBill.balance, cur)}
setPayDate(e.target.value)} />
setPayAmount(Number(e.target.value))} />
setPayReference(e.target.value)} maxLength={40} />