Files
acmcc/src/pages/accounting/AccountingBillsPage.tsx
T
admin b4014f378c Payment accounts can be equity; archived accounts excluded from all dropdowns
- Bill payment + expense paid-through pickers now offer bank OR equity
  accounts (reserve-component style payments); label updated to
  'Payment account'
- is_archived=false filter added to every accounting account dropdown:
  journal entries, bills (expense + payment), expenses, invoices/sales
  receipts/receive payments deposit-to, deposits (bank + line source),
  assessments, opening balances, budget detail, reconciliation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 20:38:00 -04:00

859 lines
42 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 ?? [],
});
// Single vendor roster = public.vendors, scoped to this company's association.
// The chosen public vendor is mapped to its accounting.vendors row on save.
const { data: vendors = [] } = useQuery({
queryKey: ["vendors-lookup", associationId],
enabled: !!associationId,
queryFn: async () => (await supabase.from("vendors")
.select("id,name")
.eq("is_active", true)
.or(`association_id.eq.${associationId},association_ids.cs.{${associationId}}`)
.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").eq("is_archived", false).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);
// The dropdown is keyed by public vendor id; map the stored accounting
// vendor back to its source public vendor when one exists.
let pubVendorId = "";
if (b.vendor_id) {
const { data: av } = await accounting.from("vendors").select("external_source, external_id").eq("id", b.vendor_id).maybeSingle();
if (av?.external_source === "acmacc_vendor" && av?.external_id) pubVendorId = String(av.external_id);
}
setVendorId(pubVendorId);
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);
// Resolve the chosen public vendor to its accounting.vendors row (find-or-create).
let acctVendorId: string | null = null;
if (vendorId) {
const { data: mapped, error: mapErr } = await supabase.rpc("ensure_accounting_vendor", {
_association_id: associationId,
_public_vendor_id: vendorId,
});
if (mapErr) return toast.error(mapErr.message);
acctVendorId = (mapped as string) ?? null;
}
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: acctVendorId, 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: acctVendorId, 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 () =>
// Payment accounts: banks plus equity accounts (e.g. reserve components
// paid directly from equity). Archived accounts never appear.
(await accounting.from("accounts").select("id,name,code,balance,is_bank,type").eq("company_id", cid).or("is_bank.eq.true,type.eq.equity").eq("is_archived", false).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;
// Return-address (payer) block comes from the company check layout the user
// configured in Settings → Check Layout. Without this, checks print no
// return address.
const { data: layout } = await supabase
.from("company_check_layouts")
.select("payer_name, payer_address, show_payer_block")
.maybeSingle();
// Fall back to the association's General Settings name/address (accounting.companies).
const { data: companyInfo } = await accounting.from("companies").select("name, address").eq("id", cid).maybeSingle();
const payerName = (layout?.payer_name || "").trim() || ((companyInfo as any)?.name ?? "");
const showPayer = layout?.show_payer_block !== false;
const returnAddress = showPayer ? (layout?.payer_address || (companyInfo as any)?.address || undefined) : undefined;
const dataUrl = generateCheckPDF(
[{
companyName: (payerName || associationName) ?? "Company",
companyAddress: returnAddress,
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,
micrGap1: (cs as any)?.micr_gap_1 ?? 1,
micrGap2: (cs as any)?.micr_gap_2 ?? 1,
fieldPositions: (cs as any)?.field_positions ?? {},
}
);
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("Payment 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();
// Expense category name(s) from the bill — shown on the payment line in the
// transaction journal for visibility (display only; does not affect the GL).
const { data: payItems } = await accounting
.from("bill_items").select("account_id").eq("bill_id", payBill.id).not("account_id", "is", null);
const payAcctIds = Array.from(new Set((payItems ?? []).map((i: any) => i.account_id)));
let categoryLabel = "Bill Payment";
if (payAcctIds.length) {
const { data: payAccs } = await accounting.from("accounts").select("name").in("id", payAcctIds);
const names = (payAccs ?? []).map((a: any) => a.name).filter(Boolean);
if (names.length) categoryLabel = names.join(", ");
}
// 1) Bank ledger transaction — debit = money OUT of the bank (payment to vendor).
// A bill payment must clear Accounts Payable, NOT re-hit the expense account:
// the expense was already recognized when the bill was entered (accrual).
// coa_account_id stays null with vendor_id set, so post_transaction_gl posts
// Dr Accounts Payable / Cr Bank (clears the payable, no second P&L hit). The
// expense category is stored in `category` for the journal view only.
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: categoryLabel,
reference: refLabel,
coa_account_id: null, // → posts against Accounts Payable (via vendor)
vendor_id: payBill.vendor_id ?? null, // required so the GL clears A/P
bill_id: payBill.id, // links the payment to the bill it settles
});
// 2) Bank balance auto-updated by DB trigger trg_sync_account_balance
// 3) Update bill paid amount (partial payments leave the bill partially_paid)
const newPaid = Number(payBill.paid_amount ?? 0) + Number(payAmount);
await accounting.from("bills").update({ paid_amount: newPaid, status: newPaid >= Number(payBill.total) - 0.005 ? "paid" : "partially_paid" }).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>Payment 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>
);
}