mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
e302fb91f0
- 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>
808 lines
39 KiB
TypeScript
808 lines
39 KiB
TypeScript
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>
|
||
);
|
||
}
|