Files
acmcc/src/pages/accounting/AccountingBillsPage.tsx
T
admin e302fb91f0 Accounting platform: remove Zoho, unify reports, board access, vendor sharing
- Remove the Zoho Books integration (edge functions, sync libs, settings,
  reports/overview, banking links, fees tab, import dialog); preserve fee
  rules as a standalone FeesTab and the COA accounting_system classification.
- Financial Overview/Reports (staff + board) render the Accounting dashboard
  and reports; board reports mirror the rich Accounting Reports.
- New Reserve Fund Schedule report + an is_reserve flag on accounts.
- Unify all report exports to a branded format (logo + centered header +
  footer): shared ReportSheet (on-screen) and reportHeader (PDF). Budget vs
  Actuals and Bank Reconciliation PDFs now match the reference layout.
- Render financial reports inline (no preview pop-up).
- Budget Management mirrors Accounting Budgeting (staff-accessible) with SPA
  navigation; editable bills in the Accounting Bills page.
- Negative opening balances flow through to the GL and reports (allow negative
  input; keep non-zero on save; signed CSV import).
- Upload a per-account trial balance via CSV on Opening Balances.
- Board members: read-only RLS access to their association's accounting ledger;
  editable board-members panel on the association page; share vendor contacts
  with the board (toggle + directory section).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:29:31 -04:00

808 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string | null>(null);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("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<Item[]>([{ description: "", quantity: 1, rate: 0, account_id: null }]);
// Upload + parse state
const [file, setFile] = useState<File | null>(null);
const [filePreview, setFilePreview] = useState<string | null>(null);
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null);
const [parsing, setParsing] = useState(false);
const [aiFilled, setAiFilled] = useState<Set<string>>(new Set()); // field keys that were AI-filled
const [missingFields, setMissingFields] = useState<Set<string>>(new Set());
const [dragOver, setDragOver] = useState(false);
const [pageDragOver, setPageDragOver] = useState(false);
const fileRef = useRef<HTMLInputElement>(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<string | null> => {
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<string>();
const missing = new Set<string>();
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<any | null>(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(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh" onload="this.contentWindow.print()"></iframe>`);
}
};
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 <p className="text-sm text-muted-foreground">Select an association.</p>;
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
return (
<div
className="space-y-6"
onDragOver={(e) => { 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 && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-primary/10 pointer-events-none">
<div className="rounded-2xl border-4 border-dashed border-primary bg-white shadow-2xl px-12 py-10 text-center">
<Upload className="mx-auto h-14 w-14 text-primary mb-4" />
<div className="text-xl font-semibold">Drop bill to parse</div>
<div className="text-sm text-muted-foreground mt-1">PDF, JPG, PNG, HEIC accepted</div>
</div>
</div>
)}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Bills</h1>
<Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) resetForm(); }}>
<DialogTrigger asChild><Button><Plus className="mr-1 h-4 w-4" /> New Bill</Button></DialogTrigger>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>{editId ? "Edit bill" : "New bill"}</DialogTitle></DialogHeader>
{aiFilled.size > 0 && (
<div className="rounded-md border border-amber-300 bg-amber-50 text-amber-900 px-3 py-2 text-sm flex items-start gap-2">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<span>AI has filled in this bill please review all fields before saving.</span>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Form */}
<div className="md:col-span-2 space-y-4">
{/* Upload zone */}
<div
onDragOver={(e) => { 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()}
>
<input
ref={fileRef} type="file" accept={ACCEPT} className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
<Upload className="mx-auto h-5 w-5 text-muted-foreground" />
<div className="mt-1 font-medium">
{parsing ? "Parsing with AI…" : file ? file.name : "Upload & Parse Bill"}
</div>
<div className="text-xs text-muted-foreground">
{parsing ? "Extracting fields from your bill…" : "Drag & drop or click — PDF, JPG, PNG, HEIC · auto-parses on drop"}
</div>
{file && !parsing && (
<div className="mt-2 flex justify-center gap-2" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="outline" onClick={() => runParse(file)}>
<Sparkles className="mr-1 h-3.5 w-3.5" /> Re-parse
</Button>
<Button size="sm" variant="ghost" onClick={() => { setFile(null); setFilePreview(null); setUploadedUrl(null); setAiFilled(new Set()); setMissingFields(new Set()); }}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Vendor</Label>
<Select value={vendorId} onValueChange={setVendorId}>
<SelectTrigger className={hl("vendor") || (aiFilled.has("vendor_unmatched") ? ERR : "")}>
<SelectValue placeholder={aiFilled.has("vendor_unmatched") ? "Could not match — pick vendor" : "Select vendor"} />
</SelectTrigger>
<SelectContent>{(vendors as any[]).map((v: any) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label>Bill #{missingFields.has("number") && <span className="text-red-600 text-xs ml-1">Could not detect enter manually</span>}</Label>
<Input maxLength={40} value={number} onChange={(e) => setNumber(e.target.value)} className={hl("number")} />
</div>
<div>
<Label>Issue date</Label>
<Input type="date" value={issueDate} onChange={(e) => setIssueDate(e.target.value)} className={hl("issueDate")} />
</div>
<div>
<Label>Due date</Label>
<Input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} className={hl("dueDate")} />
</div>
</div>
<div>
<Label>Line items</Label>
{items.map((it, idx) => (
<div key={idx} className={cn("mt-2 grid grid-cols-12 gap-2 p-2 rounded", aiFilled.has("items") && "bg-sky-50/60")}>
<Input className="col-span-5" placeholder="Description" maxLength={200} value={it.description}
onChange={(e) => { const a = [...items]; a[idx] = { ...it, description: e.target.value }; setItems(a); }} />
<Input className="col-span-1" type="number" min={0} step="0.01" value={it.quantity}
onChange={(e) => { const a = [...items]; a[idx] = { ...it, quantity: Number(e.target.value) }; setItems(a); }} />
<Input className="col-span-2" type="number" min={0} step="0.01" value={it.rate}
onChange={(e) => { const a = [...items]; a[idx] = { ...it, rate: Number(e.target.value) }; setItems(a); }} />
<Select value={it.account_id ?? ""} onValueChange={(v) => { const a = [...items]; a[idx] = { ...it, account_id: v || null }; setItems(a); }}>
<SelectTrigger className="col-span-3 text-xs"><SelectValue placeholder="Account" /></SelectTrigger>
<SelectContent>
{(expenseAccounts as any[]).map((a) => (
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
))}
</SelectContent>
</Select>
<Button className="col-span-1" variant="ghost" size="icon" onClick={() => setItems(items.filter((_, i) => i !== idx))}><Trash2 className="h-4 w-4" /></Button>
</div>
))}
<Button className="mt-2" variant="outline" size="sm" onClick={() => setItems([...items, { description: "", quantity: 1, rate: 0, account_id: null }])}><Plus className="mr-1 h-3 w-3" /> Add item</Button>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label>Tax %</Label>
<Input type="number" min={0} value={taxPct} onChange={(e) => setTaxPct(Number(e.target.value))} className={hl("tax")} />
</div>
<div className="col-span-2 text-right text-sm">
<div>Subtotal: <b>{money(subtotal, cur)}</b></div>
<div>Tax: <b>{money(tax, cur)}</b></div>
<div className="text-lg">Total: <b>{money(total, cur)}</b></div>
</div>
</div>
{(notes || aiFilled.has("notes")) && (
<div>
<Label>Notes</Label>
<Input value={notes} onChange={(e) => setNotes(e.target.value)} className={hl("notes")} />
</div>
)}
</div>
{/* Preview pane */}
<div className="md:col-span-1">
<Label>Attachment</Label>
<div className="mt-1 rounded-md border bg-muted/30 p-2 min-h-[200px] flex items-center justify-center">
{filePreview ? (
<img src={filePreview} alt="Bill preview" className="max-h-[400px] w-full object-contain rounded" />
) : file ? (
<div className="text-center text-sm text-muted-foreground">
<FileText className="mx-auto h-8 w-8 mb-2" />
<div className="font-medium">{file.name}</div>
<div className="text-xs">{(file.size / 1024).toFixed(1)} KB</div>
</div>
) : (
<div className="text-xs text-muted-foreground text-center">No file uploaded</div>
)}
</div>
</div>
</div>
<DialogFooter><Button onClick={save}>Save bill</Button></DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Aging summary */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Payables aging · {money(totalDue, cur)} due</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{[
{ 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) => (
<div key={b.label} className={`rounded-lg border p-4 ${b.tone}`}>
<div className="text-xs font-medium uppercase tracking-wide opacity-75">{b.label}</div>
<div className="mt-2 text-2xl font-semibold">{money(b.value, cur)}</div>
</div>
))}
</div>
</CardContent>
</Card>
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[220px]">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input className="pl-9" placeholder="Search bill # or vendor" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="overdue">Overdue</SelectItem>
<SelectItem value="partially_paid">Partially Paid</SelectItem>
<SelectItem value="paid">Paid</SelectItem>
</SelectContent>
</Select>
<label className="inline-flex items-center gap-2 text-sm">
<input type="checkbox" checked={autoOnly} onChange={(e) => setAutoOnly(e.target.checked)} className="rounded border-border" />
Show Auto-Created Bills
</label>
</div>
<Card>
<CardHeader><CardTitle>All bills</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Bill #</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Issued</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-right">Balance</TableHead>
<TableHead>Status</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && <TableSkeleton columns={8} />}
{!isLoading && filtered.map((b: any) => (
<TableRow key={b.id}>
<TableCell className="font-medium">
{b.attachment_url ? (
<a href={b.attachment_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 hover:underline">
<FileText className="h-3 w-3" /> {b.number}
</a>
) : b.number}
{b.auto_created && <span className="ml-2 inline-flex items-center rounded bg-amber-100 text-amber-700 px-1.5 py-0.5 text-[10px] font-semibold">Auto</span>}
</TableCell>
<TableCell>{b.vendors?.name ?? "—"}</TableCell>
<TableCell>{fmtDate(b.issue_date)}</TableCell>
<TableCell>{fmtDate(b.due_date)}</TableCell>
<TableCell className="text-right">{money(b.total, cur)}</TableCell>
<TableCell className="text-right">{money(b.balance, cur)}</TableCell>
<TableCell><StatusBadge status={b.derivedStatus.replace("_", " ")} /></TableCell>
<TableCell className="flex gap-1">
{b.derivedStatus !== "paid" && b.derivedStatus !== "void" && (
<Button size="sm" variant="outline" onClick={() => openPayment(b)}>Record payment</Button>
)}
{b.derivedStatus !== "void" && (
<Button size="icon" variant="ghost" onClick={() => openEdit(b)} title="Edit bill"><Pencil className="h-4 w-4" /></Button>
)}
<Button size="icon" variant="ghost" onClick={() => remove(b.id)}><Trash2 className="h-4 w-4" /></Button>
</TableCell>
</TableRow>
))}
{!isLoading && filtered.length === 0 && (
<TableRow className="hover:bg-transparent"><TableCell colSpan={8} className="p-0">
<EmptyState icon={Receipt} title="No bills match" description="Record a bill from a vendor to track what you owe." />
</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Payment dialog */}
<Dialog open={!!payBill} onOpenChange={(o) => !o && setPayBill(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Record payment {payBill ? `· Bill ${payBill.number}` : ""}</DialogTitle>
</DialogHeader>
{payBill && (
<div className="space-y-3">
<div className="text-sm text-muted-foreground">
Vendor: <b>{payBill.vendors?.name ?? "—"}</b> · Balance:{" "}
<b>{money(payBill.balance, cur)}</b>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Payment date</Label>
<Input type="date" value={payDate} onChange={(e) => setPayDate(e.target.value)} />
</div>
<div>
<Label>Amount</Label>
<Input type="number" min={0} step="0.01" value={payAmount} onChange={(e) => setPayAmount(Number(e.target.value))} />
</div>
<div>
<Label>Payment method</Label>
<Select value={payMethod} onValueChange={(v) => onMethodChange(v as any)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="check">Check</SelectItem>
<SelectItem value="ach">ACH</SelectItem>
<SelectItem value="wire">Wire</SelectItem>
<SelectItem value="credit_card">Credit Card</SelectItem>
<SelectItem value="cash">Cash</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Bank account</Label>
<Select value={payAccountId} onValueChange={setPayAccountId}>
<SelectTrigger><SelectValue placeholder="Select account" /></SelectTrigger>
<SelectContent>
{(bankAccounts as any[]).map((a) => (
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="col-span-2">
<Label>{payMethod === "check" ? "Check number" : "Reference"}</Label>
<Input value={payReference} onChange={(e) => setPayReference(e.target.value)} maxLength={40} />
</div>
<div className="col-span-2">
<Label>Memo</Label>
<Textarea rows={2} value={payMemo} onChange={(e) => setPayMemo(e.target.value)} maxLength={200} />
</div>
{payMethod === "check" && (
<div className="col-span-2 flex items-center justify-between rounded-md border p-3 bg-muted/30">
<div className="flex items-center gap-2">
<Printer className="h-4 w-4" />
<div>
<div className="text-sm font-medium">Print check after saving</div>
<div className="text-xs text-muted-foreground">Opens print dialog & marks as printed</div>
</div>
</div>
<Switch checked={printCheck} onCheckedChange={setPrintCheck} />
</div>
)}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPayBill(null)}>Cancel</Button>
<Button onClick={savePayment} disabled={paying}>
{paying ? "Saving…" : payMethod === "check" && printCheck ? "Save & Print" : "Save payment"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}