Files
acmcc/src/pages/BillApprovalsPage.tsx
T
admin 84c8483169 Accounting: unify vendor roster + COA across bill-approvals and accounting bills
Single vendor source (public.vendors) and single COA source (accounting.accounts)
across both bill flows:

- Forward sync now carries public.bills.expense_account_id into the mirrored
  accounting.bill_items.account_id (when it resolves to accounting.accounts).
- Reverse trigger flows a GL change on a mirrored accounting bill line back to
  public.bills.expense_account_id (loop-guarded).
- New public.ensure_accounting_vendor RPC resolves a chosen public vendor to its
  accounting.vendors row; one-time backfill of mirrored line account_id.
- BillApprovalsPage GL pickers now use ChartOfAccountsDropdown (accounting.accounts).
- AccountingBillsPage vendor picker now lists public.vendors scoped to the
  company's association and maps to accounting.vendors on save.

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

1741 lines
80 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { UserCheck, Plus, Search, Eye, Upload, X, ArrowUpDown, Edit, Trash2, MoreHorizontal, AlertTriangle, Loader2, Bell, Printer, Sparkles, Download } from "lucide-react";
import { downloadChecksPdf, type CheckData } from "@/utils/checkPdfGenerator";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import ChartOfAccountsDropdown from "@/components/ChartOfAccountsDropdown.jsx";
const statusColors: Record<string, string> = {
pending: "bg-amber-100 text-amber-700",
approved: "bg-emerald-100 text-emerald-700",
rejected: "bg-red-100 text-red-700",
paid: "bg-emerald-100 text-emerald-700",
overdue: "bg-red-100 text-red-700",
draft: "bg-muted text-muted-foreground",
};
type SortField = "id" | "association_id" | "vendor_name" | "amount" | "status" | "created_at" | "bill_date";
type SortDir = "asc" | "desc";
export default function BillApprovalsPage({ boardAssociationIds }: { boardAssociationIds?: string[] } = {}) {
const { toast } = useToast();
const isBoardView = !!boardAssociationIds?.length;
const navigate = useNavigate();
const [bills, setBills] = useState<any[]>([]);
const [associations, setAssociations] = useState<any[]>([]);
const [accounts, setAccounts] = useState<any[]>([]);
const [vendors, setVendors] = useState<any[]>([]);
const [boardMembers, setBoardMembers] = useState<any[]>([]);
const [vendorNotFound, setVendorNotFound] = useState<string | null>(null);
const [approvalsByBill, setApprovalsByBill] = useState<Record<string, any[]>>({});
const [loading, setLoading] = useState(true);
// Filters
const [search, setSearch] = useState("");
const [clientFilter, setClientFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [glFilter, setGlFilter] = useState("all");
const [activeTab, setActiveTab] = useState<"pending" | "approved">("pending");
const monthStart = (() => { const d = new Date(); return new Date(d.getFullYear(), d.getMonth(), 1).toISOString().split("T")[0]; })();
const monthEnd = (() => { const d = new Date(); return new Date(d.getFullYear(), d.getMonth() + 1, 0).toISOString().split("T")[0]; })();
const [approvedFrom, setApprovedFrom] = useState<string>(monthStart);
const [approvedTo, setApprovedTo] = useState<string>(monthEnd);
// Sorting
const [sortField, setSortField] = useState<SortField>("created_at");
const [sortDir, setSortDir] = useState<SortDir>("desc");
// Dialog
const [dialogOpen, setDialogOpen] = useState(false);
const [detailBill, setDetailBill] = useState<any>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [editingBill, setEditingBill] = useState<any>(null);
const [editOpen, setEditOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<any>(null);
// Notify Board
const [notifyOpen, setNotifyOpen] = useState(false);
const [notifyAssociationId, setNotifyAssociationId] = useState<string>("");
const [notifyBoardMembers, setNotifyBoardMembers] = useState<any[]>([]);
const [notifyLoadingMembers, setNotifyLoadingMembers] = useState(false);
const [notifySending, setNotifySending] = useState(false);
// Bulk check printing
const [selectedBillIds, setSelectedBillIds] = useState<Set<string>>(new Set());
const [printing, setPrinting] = useState(false);
const [printDialogOpen, setPrintDialogOpen] = useState(false);
const [printBanks, setPrintBanks] = useState<any[]>([]);
const [printAssocBankMap, setPrintAssocBankMap] = useState<Record<string, string>>({});
const [printIncludeMicr, setPrintIncludeMicr] = useState(false);
const [combinePerVendor, setCombinePerVendor] = useState(true);
const [printLoadingBanks, setPrintLoadingBanks] = useState(false);
// Form
const [form, setForm] = useState({
association_id: "", expense_account_id: "", approval_member_ids: [] as string[],
vendor_id: "", vendor_name: "", invoice_number: "", bill_date: new Date().toISOString().split("T")[0],
due_date: new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0],
amount: "", description: "",
});
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const normalizeApprovalRecord = (record: any) => {
const linkedInvoice = record.source_invoice_id ? record.invoices : null;
return {
...record,
amount: linkedInvoice?.amount ?? record.amount,
due_date: linkedInvoice?.due_date ?? record.due_date,
bill_date: linkedInvoice?.issue_date ?? record.bill_date,
invoice_number: linkedInvoice?.invoice_number ?? record.invoice_number,
description: linkedInvoice?.description ?? record.description,
attachment_url: linkedInvoice?.raw_pdf_url ?? record.attachment_url,
notes: linkedInvoice?.vendor_name ?? record.notes,
};
};
// Buildium import state
const [buildiumOpen, setBuildiumOpen] = useState(false);
const [buildiumAssoc, setBuildiumAssoc] = useState<string>("all");
const [buildiumFrom, setBuildiumFrom] = useState<string>("");
const [buildiumTo, setBuildiumTo] = useState<string>("");
const [buildiumImporting, setBuildiumImporting] = useState(false);
const importFromBuildium = async () => {
setBuildiumImporting(true);
try {
const { data, error } = await supabase.functions.invoke("buildium-sync", {
body: {
syncType: "bills",
selectedAssociationIds: buildiumAssoc === "all" ? [] : [buildiumAssoc],
dateFrom: buildiumFrom || undefined,
dateTo: buildiumTo || undefined,
},
});
if (error) throw error;
const r = data?.results?.bills || {};
toast({
title: "Buildium import complete",
description: `Fetched ${r.fetched || 0} • Created ${r.created || 0} • Updated ${r.updated || 0} • Linked duplicates ${r.linked_duplicates || 0} • Vendors created ${r.vendors_created || 0}`,
});
setBuildiumOpen(false);
fetchData();
} catch (err: any) {
toast({ variant: "destructive", title: "Import failed", description: err?.message || String(err) });
} finally {
setBuildiumImporting(false);
}
};
const fetchData = useCallback(async () => {
setLoading(true);
let assignedBillIds: string[] | null = null;
if (isBoardView) {
// Restrict to bills where the current user is an assigned approver
const { data: userData } = await supabase.auth.getUser();
const uid = userData?.user?.id;
assignedBillIds = [];
if (uid) {
const { data: myMemberships } = await supabase
.from("board_members")
.select("member_name, association_id")
.eq("user_id", uid)
.in("association_id", boardAssociationIds!);
const names = Array.from(new Set((myMemberships || []).map((m: any) => m.member_name).filter(Boolean)));
if (names.length > 0) {
const { data: myApprovals } = await supabase
.from("bill_approvals")
.select("bill_id")
.in("association_id", boardAssociationIds!)
.in("vendor_name", names)
.not("bill_id", "is", null);
assignedBillIds = Array.from(new Set((myApprovals || []).map((a: any) => a.bill_id).filter(Boolean)));
}
}
if (assignedBillIds.length === 0) {
setBills([]);
setApprovalsByBill({});
const [aRes2, coaRes2, vRes2] = await Promise.all([
supabase.from("associations").select("id, name, accounting_system").eq("status", "active").order("name"),
supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type, accounting_system, association_id").eq("account_type", "expense").eq("is_active", true).order("account_number"),
supabase.from("vendors").select("id, name, address, association_id, association_ids").eq("is_active", true).order("name"),
]);
setAssociations(aRes2.data || []);
setAccounts(coaRes2.data || []);
setVendors(vRes2.data || []);
setLoading(false);
return;
}
}
// Paginate bills (Supabase caps default queries at 1,000 rows)
const PAGE = 1000;
const allBills: any[] = [];
for (let from = 0; ; from += PAGE) {
let q = supabase
.from("bills")
.select("*, associations(name), chart_of_accounts(account_name, account_number), invoices:source_invoice_id(id, vendor_name, invoice_number, issue_date, due_date, amount, description, raw_pdf_url)")
.order("created_at", { ascending: false });
if (isBoardView) {
q = q.in("association_id", boardAssociationIds!).in("id", assignedBillIds!);
}
const { data, error } = await q.range(from, from + PAGE - 1);
if (error || !data) break;
allBills.push(...data);
if (data.length < PAGE) break;
}
const [aRes, coaRes, vRes] = await Promise.all([
supabase.from("associations").select("id, name, accounting_system").eq("status", "active").order("name"),
supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type, accounting_system, association_id").eq("account_type", "expense").eq("is_active", true).order("account_number"),
supabase.from("vendors").select("id, name, address, association_id, association_ids").eq("is_active", true).order("name"),
]);
const billsList = allBills.map(normalizeApprovalRecord);
setBills(billsList);
setAssociations(aRes.data || []);
setAccounts(coaRes.data || []);
setVendors(vRes.data || []);
// Fetch approvals for all bills
if (billsList.length > 0) {
const billIds = billsList.map((b: any) => b.id);
const { data: approvals } = await supabase
.from("bill_approvals")
.select("id, bill_id, vendor_name, status")
.in("bill_id", billIds);
const grouped: Record<string, any[]> = {};
(approvals || []).forEach((a: any) => {
if (!grouped[a.bill_id]) grouped[a.bill_id] = [];
grouped[a.bill_id].push(a);
});
setApprovalsByBill(grouped);
} else {
setApprovalsByBill({});
}
setLoading(false);
}, [isBoardView, boardAssociationIds]);
useEffect(() => { fetchData(); }, [fetchData]);
// Fetch board members when client changes
useEffect(() => {
if (!form.association_id) { setBoardMembers([]); return; }
supabase
.from("board_members")
.select("id, member_name, member_email, approval_authority, user_id")
.eq("association_id", form.association_id)
.eq("approval_authority", true)
.order("member_name")
.then(({ data }) => setBoardMembers(data || []));
}, [form.association_id]);
// Fetch board members for the Notify Board dialog
useEffect(() => {
if (!notifyAssociationId) { setNotifyBoardMembers([]); return; }
setNotifyLoadingMembers(true);
supabase
.from("board_members")
.select("id, member_name, member_email, approval_authority, user_id")
.eq("association_id", notifyAssociationId)
.eq("approval_authority", true)
.order("member_name")
.then(({ data }) => {
setNotifyBoardMembers(data || []);
setNotifyLoadingMembers(false);
});
}, [notifyAssociationId]);
const openNotifyBoard = () => {
setNotifyAssociationId(associations[0]?.id || "");
setNotifyOpen(true);
};
const handleSendNotifyBoard = async () => {
if (!notifyAssociationId) {
toast({ variant: "destructive", title: "Select a client", description: "Please choose an association to notify." });
return;
}
if (notifyBoardMembers.length === 0) {
toast({ variant: "destructive", title: "No board members", description: "No board members with approval authority found for this association." });
return;
}
setNotifySending(true);
try {
const association = associations.find((a: any) => a.id === notifyAssociationId);
const associationName = association?.name || "your association";
const link = "https://avria.cloud/dashboard/bill-approvals";
// In-app notifications for board members with linked user accounts
const inAppPromises = notifyBoardMembers
.filter((bm: any) => bm.user_id)
.map((bm: any) =>
supabase.rpc("insert_notification", {
p_user_id: bm.user_id,
p_type: "bill_approval",
p_title: "Bills Awaiting Your Approval",
p_message: `One or more bills have been uploaded for ${associationName} and require your approval.`,
p_related_item_type: "bill",
p_link: "/homeowner/board/bill-approvals",
})
);
// Email notifications via transactional email
const stamp = Date.now();
const emailPromises = notifyBoardMembers
.filter((bm: any) => bm.member_email)
.map((bm: any) =>
supabase.functions.invoke("send-transactional-email", {
body: {
templateName: "bill-approval-request",
recipientEmail: bm.member_email,
idempotencyKey: `bill-approval-notify-${notifyAssociationId}-${bm.id}-${stamp}`,
templateData: {
recipientName: bm.member_name,
associationName,
link,
},
},
})
);
const results = await Promise.allSettled([...inAppPromises, ...emailPromises]);
const failed = results.filter((r) => r.status === "rejected").length;
const emailCount = emailPromises.length;
const inAppCount = inAppPromises.length;
// Also send per-bill secure approve/deny links (one email per pending bill per board member).
let voteSent = 0, voteFailed = 0;
try {
const { data: voteRes, error: voteErr } = await supabase.functions.invoke(
"send-bill-approval-invites",
{ body: { association_id: notifyAssociationId, base_url: window.location.origin } }
);
if (voteErr) {
console.warn("send-bill-approval-invites error:", voteErr);
} else {
voteSent = voteRes?.sent ?? 0;
voteFailed = voteRes?.failed ?? 0;
}
} catch (e) {
console.warn("send-bill-approval-invites threw:", e);
}
if (failed > 0) {
toast({
variant: "destructive",
title: "Some notifications failed",
description: `${failed} of ${results.length} notifications could not be sent.`,
});
} else {
toast({
title: "Board Notified",
description: `Sent ${emailCount} summary email${emailCount !== 1 ? "s" : ""}, ${inAppCount} in-app notification${inAppCount !== 1 ? "s" : ""}, and ${voteSent} per-bill approve/deny email${voteSent !== 1 ? "s" : ""}${voteFailed ? ` (${voteFailed} failed)` : ""}.`,
});
}
setNotifyOpen(false);
} catch (err: any) {
toast({ variant: "destructive", title: "Error", description: err.message });
} finally {
setNotifySending(false);
}
};
// Filtering
const filtered = bills.filter((b) => {
// "All" tab: pending + approved together (everything except paid)
// "Approved" tab: only paid bills, filtered by date range
const isPaid = b.status === "paid";
if (activeTab === "approved") {
if (!isPaid) return false;
const ref = b.bill_date || b.created_at;
if (ref) {
const refStr = String(ref).slice(0, 10);
if (approvedFrom && refStr < approvedFrom) return false;
if (approvedTo && refStr > approvedTo) return false;
}
} else {
if (isPaid) return false;
}
if (clientFilter !== "all" && b.association_id !== clientFilter) return false;
if (statusFilter !== "all" && b.status !== statusFilter) return false;
if (glFilter !== "all" && b.expense_account_id !== glFilter) return false;
if (search) {
const q = search.toLowerCase();
return (
(b.vendor_name || b.description || "").toLowerCase().includes(q) ||
(b.id || "").toLowerCase().includes(q) ||
(b.invoice_number || "").toLowerCase().includes(q)
);
}
return true;
});
// Sorting
const sorted = [...filtered].sort((a, b) => {
let aVal = a[sortField];
let bVal = b[sortField];
if (sortField === "amount") { aVal = Number(aVal); bVal = Number(bVal); }
if (sortField === "association_id") { aVal = a.associations?.name || ""; bVal = b.associations?.name || ""; }
if (aVal < bVal) return sortDir === "asc" ? -1 : 1;
if (aVal > bVal) return sortDir === "asc" ? 1 : -1;
return 0;
});
const toggleSort = (field: SortField) => {
if (sortField === field) setSortDir(d => d === "asc" ? "desc" : "asc");
else { setSortField(field); setSortDir("asc"); }
};
const SortHeader = ({ field, children }: { field: SortField; children: React.ReactNode }) => (
<TableHead className="cursor-pointer select-none" onClick={() => toggleSort(field)}>
<span className="flex items-center gap-1">{children} <ArrowUpDown className="h-3 w-3 text-muted-foreground" /></span>
</TableHead>
);
// Create bill
const handleCreate = async () => {
if (!form.association_id || !form.vendor_id || !form.amount) {
toast({ variant: "destructive", title: "Missing fields", description: "Client, Vendor, and Amount are required." });
return;
}
const selectedVendor = vendors.find((v: any) => v.id === form.vendor_id);
const vendorDisplayName = selectedVendor?.name || form.vendor_name || "Unknown Vendor";
setSubmitting(true);
try {
let attachmentUrl: string | null = null;
if (uploadFile) {
const path = `bill-approvals/${Date.now()}_${uploadFile.name}`;
const { data: upData } = await supabase.storage.from("files").upload(path, uploadFile);
if (upData) {
const { data: { publicUrl } } = supabase.storage.from("files").getPublicUrl(upData.path);
attachmentUrl = publicUrl;
}
}
const { data: userData } = await supabase.auth.getUser();
const { data: newBill, error } = await supabase.from("bills").insert({
association_id: form.association_id,
vendor_id: form.vendor_id || null,
description: form.description || `Bill from ${vendorDisplayName}`,
amount: parseFloat(form.amount) || 0,
bill_date: form.bill_date,
due_date: form.due_date || null,
invoice_number: form.invoice_number || null,
expense_account_id: form.expense_account_id || null,
attachment_url: attachmentUrl,
status: "pending",
created_by: userData?.user?.id || null,
notes: vendorDisplayName,
}).select("id").single();
if (error) throw error;
// If board members were selected for approval, create approval requests for each
if (form.approval_member_ids.length > 0) {
const approvalRows = form.approval_member_ids.map((memberId) => {
const bm = boardMembers.find((m: any) => m.id === memberId);
return {
association_id: form.association_id,
bill_id: newBill?.id || null,
vendor_name: bm?.member_name || vendorDisplayName,
amount: parseFloat(form.amount) || 0,
status: "pending",
notes: form.description || null,
created_by: userData?.user?.id || null,
};
});
await supabase.from("bill_approvals").insert(approvalRows);
// Send in-app notifications to selected board members
const notifyPromises = form.approval_member_ids
.map((memberId) => boardMembers.find((m: any) => m.id === memberId))
.filter((bm: any) => bm?.user_id)
.map((bm: any) =>
supabase.rpc("insert_notification", {
p_user_id: bm.user_id,
p_type: "bill_approval",
p_title: "New Bill Awaiting Your Approval",
p_message: `A bill for $${parseFloat(form.amount).toFixed(2)} from ${vendorDisplayName} requires your approval.`,
p_related_item_type: "bill",
p_link: "/homeowner/board/bill-approvals",
})
);
await Promise.allSettled(notifyPromises);
}
toast({ title: "Bill Created", description: "Bill has been submitted for approval." });
setDialogOpen(false);
setUploadFile(null);
fetchData();
} catch (err: any) {
toast({ variant: "destructive", title: "Error", description: err.message });
} finally {
setSubmitting(false);
}
};
const openNew = () => {
setForm({
association_id: associations[0]?.id || "", expense_account_id: "", approval_member_ids: [],
vendor_id: "", vendor_name: "", invoice_number: "", bill_date: new Date().toISOString().split("T")[0],
due_date: new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0],
amount: "", description: "",
});
setVendorNotFound(null);
setUploadFile(null);
setDialogOpen(true);
};
const openEdit = (bill: any) => {
setEditingBill(bill);
setForm({
association_id: bill.association_id || "",
expense_account_id: bill.expense_account_id || "",
approval_member_ids: [],
vendor_id: bill.vendor_id || "",
vendor_name: bill.notes || "",
invoice_number: bill.invoice_number || "",
bill_date: bill.bill_date || new Date().toISOString().split("T")[0],
due_date: bill.due_date || "",
amount: bill.amount?.toString() || "",
description: bill.description || "",
});
setVendorNotFound(null);
setUploadFile(null);
setEditOpen(true);
};
const handleUpdate = async () => {
if (!editingBill) return;
if (!form.association_id || !form.vendor_id || !form.amount) {
toast({ variant: "destructive", title: "Missing fields", description: "Client, Vendor, and Amount are required." });
return;
}
const selectedVendor = vendors.find((v: any) => v.id === form.vendor_id);
const vendorDisplayName = selectedVendor?.name || form.vendor_name || "Unknown Vendor";
setSubmitting(true);
try {
let attachmentUrl: string | null = editingBill.attachment_url || null;
if (uploadFile) {
const path = `bill-approvals/${Date.now()}_${uploadFile.name}`;
const { data: upData } = await supabase.storage.from("files").upload(path, uploadFile);
if (upData) {
const { data: { publicUrl } } = supabase.storage.from("files").getPublicUrl(upData.path);
attachmentUrl = publicUrl;
}
}
const { error } = await supabase.from("bills").update({
association_id: form.association_id,
vendor_id: form.vendor_id || null,
description: form.description || `Bill from ${vendorDisplayName}`,
amount: parseFloat(form.amount) || 0,
bill_date: form.bill_date,
due_date: form.due_date || null,
invoice_number: form.invoice_number || null,
expense_account_id: form.expense_account_id || null,
notes: vendorDisplayName,
attachment_url: attachmentUrl,
}).eq("id", editingBill.id);
if (error) throw error;
// Also update linked invoice's raw_pdf_url if a new file was uploaded
if (uploadFile && attachmentUrl && editingBill.source_invoice_id) {
await supabase.from("invoices").update({ raw_pdf_url: attachmentUrl }).eq("id", editingBill.source_invoice_id);
}
toast({ title: "Bill Updated", description: "Bill changes have been saved." });
setEditOpen(false);
setEditingBill(null);
setUploadFile(null);
fetchData();
} catch (err: any) {
toast({ variant: "destructive", title: "Error", description: err.message });
} finally {
setSubmitting(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await supabase.from("bill_approvals").delete().eq("bill_id", deleteTarget.id);
const { error } = await supabase.from("bills").delete().eq("id", deleteTarget.id);
if (error) throw error;
toast({ title: "Bill Deleted", description: "Bill has been removed." });
setDeleteTarget(null);
fetchData();
} catch (err: any) {
toast({ variant: "destructive", title: "Error", description: err.message });
setDeleteTarget(null);
}
};
const getVendorName = (bill: any) => bill.notes || bill.description || "—";
const getClientName = (bill: any) => bill.associations?.name || "—";
const getGlLabel = (bill: any) => bill.chart_of_accounts ? `${bill.chart_of_accounts.account_number} - ${bill.chart_of_accounts.account_name}` : "—";
const getStatusDisplay = (bill: any) => {
// Check overdue
if (bill.status === "pending" && bill.due_date && new Date(bill.due_date) < new Date()) {
return "overdue";
}
return bill.status;
};
const filteredVendors = form.association_id
? vendors.filter((v: any) => v.association_id === form.association_id || (Array.isArray(v.association_ids) && v.association_ids.includes(form.association_id)))
: [];
// Open the print dialog: load active bank accounts for the selected bills' associations
const openPrintDialog = async () => {
const selectedBills = bills.filter((b) => selectedBillIds.has(b.id));
if (selectedBills.length === 0) {
toast({ variant: "destructive", title: "No bills selected", description: "Select one or more bills to print checks for." });
return;
}
const assocIds = Array.from(new Set(selectedBills.map((b) => b.association_id).filter(Boolean)));
if (assocIds.length === 0) {
toast({ variant: "destructive", title: "Missing association", description: "Selected bills have no association." });
return;
}
setPrintLoadingBanks(true);
setPrintDialogOpen(true);
try {
const { data: banks, error } = await supabase
.from("bank_accounts")
.select("id, account_name, bank_name, routing_number, account_number, next_check_number, association_id")
.in("association_id", assocIds)
.eq("status", "active")
.order("account_name");
if (error) throw error;
setPrintBanks(banks || []);
// Default each association to its first bank
const defaultMap: Record<string, string> = {};
for (const id of assocIds) {
const first = (banks || []).find((b: any) => b.association_id === id);
if (first) defaultMap[id] = first.id;
}
setPrintAssocBankMap(defaultMap);
} catch (err: any) {
console.error(err);
toast({ variant: "destructive", title: "Failed to load bank accounts", description: err.message });
setPrintDialogOpen(false);
} finally {
setPrintLoadingBanks(false);
}
};
// Bulk print checks for selected bills, using user-chosen bank accounts; supports reprint
const handlePrintChecksForSelected = async () => {
const selectedBills = bills.filter((b) => selectedBillIds.has(b.id));
if (selectedBills.length === 0) return;
const byAssoc: Record<string, any[]> = {};
for (const b of selectedBills) {
if (!b.association_id) continue;
(byAssoc[b.association_id] ||= []).push(b);
}
const assocIds = Object.keys(byAssoc);
// Verify a bank account is chosen for every association
const missing = assocIds.filter((id) => !printAssocBankMap[id]);
if (missing.length > 0) {
const names = missing.map((id) => associations.find((a: any) => a.id === id)?.name || id).join(", ");
toast({ variant: "destructive", title: "Choose a bank account", description: `Select a bank account for: ${names}.` });
return;
}
const banksByAssoc: Record<string, any> = {};
for (const id of assocIds) {
const bank = printBanks.find((b: any) => b.id === printAssocBankMap[id]);
if (bank) banksByAssoc[id] = bank;
}
if (printIncludeMicr) {
const badBank = Object.values(banksByAssoc).find((b: any) => !b.routing_number || !b.account_number);
if (badBank) {
toast({ variant: "destructive", title: "Missing routing/account number", description: `Add them on "${badBank.account_name}" before printing MICR.` });
return;
}
}
setPrinting(true);
try {
const { data: userData } = await supabase.auth.getUser();
// Load per-association check layouts (optional)
const { data: layoutsData } = await supabase
.from("check_layouts")
.select("*")
.in("association_id", assocIds);
const layoutsByAssoc: Record<string, any> = {};
(layoutsData || []).forEach((l: any) => { layoutsByAssoc[l.association_id] = l; });
let totalPrinted = 0;
let totalReprinted = 0;
for (const assocId of assocIds) {
const bank = banksByAssoc[assocId];
const groupBills = byAssoc[assocId];
let nextNum = bank.next_check_number || 1001;
const checkDataList: CheckData[] = [];
const billStatusUpdates: Array<{ billId: string; checkId: string; checkNumber: string }> = [];
const txInserts: any[] = [];
const today = new Date().toISOString().slice(0, 10);
const assocName = associations.find((a: any) => a.id === assocId)?.name || "";
// Group bills by payee (vendor) when combining is enabled.
// Otherwise treat each bill as its own group.
const billGroups: Array<{ payeeKey: string; payee: string; bills: any[] }> = [];
if (combinePerVendor) {
const map = new Map<string, { payee: string; bills: any[] }>();
for (const bill of groupBills) {
const payee = getVendorName(bill);
const key = (payee || "").trim().toLowerCase();
if (!map.has(key)) map.set(key, { payee, bills: [] });
map.get(key)!.bills.push(bill);
}
for (const [payeeKey, v] of map) billGroups.push({ payeeKey, payee: v.payee, bills: v.bills });
} else {
for (const bill of groupBills) {
const payee = getVendorName(bill);
billGroups.push({ payeeKey: bill.id, payee, bills: [bill] });
}
}
for (const grp of billGroups) {
const payee = grp.payee;
const firstBill = grp.bills[0];
const vendorRow: any = firstBill.vendor_id ? vendors.find((v: any) => v.id === firstBill.vendor_id) : null;
const payeeAddress = vendorRow?.address || null;
const totalAmount = grp.bills.reduce((s, b) => s + (Number(b.amount) || 0), 0);
// Build line items for the stub (one row per bill in the group)
const lineItems = grp.bills.map((b) => ({
invoice_number: b.invoice_number || null,
date: b.bill_date || today,
description: b.description || null,
amount: Number(b.amount) || 0,
}));
const memo = grp.bills.length > 1
? `Combined payment (${grp.bills.length} invoice${grp.bills.length === 1 ? "" : "s"})`
: (firstBill.invoice_number ? `Inv #${firstBill.invoice_number}` : (firstBill.description || null));
// Identify any already-printed bills in the group so we can reprint
// under the existing check number when the entire group shares one.
const existingCheckIds = Array.from(new Set(grp.bills.map((b) => b.check_id).filter(Boolean)));
let existingCheck: any = null;
if (existingCheckIds.length === 1) {
const { data: existing } = await supabase
.from("checks")
.select("id, check_number, bank_account_id")
.eq("id", existingCheckIds[0])
.maybeSingle();
existingCheck = existing;
}
let checkNumber: string;
let checkId: string;
if (existingCheck) {
// REPRINT: reuse existing check, do not advance counter or insert tx
checkId = existingCheck.id;
checkNumber = existingCheck.check_number || String(nextNum++);
await supabase
.from("checks")
.update({
check_number: checkNumber,
check_date: today,
amount: totalAmount,
payee,
memo,
printed: true,
status: "printed",
bank_account_id: bank.id,
})
.eq("id", existingCheck.id);
totalReprinted += 1;
} else {
// FIRST PRINT: create one check + one bank transaction for the whole group
checkNumber = String(nextNum++);
const { data: chk, error: chkErr } = await supabase
.from("checks")
.insert({
association_id: assocId,
bank_account_id: bank.id,
payee,
amount: totalAmount,
check_number: checkNumber,
check_date: today,
memo,
status: "printed",
printed: true,
created_by: userData?.user?.id || null,
})
.select("id")
.single();
if (chkErr) throw chkErr;
checkId = chk.id;
for (const b of grp.bills) {
billStatusUpdates.push({ billId: b.id, checkId, checkNumber });
}
txInserts.push({
association_id: assocId,
bank_account_id: bank.id,
date: today,
debit: totalAmount,
credit: 0,
description: `Check #${checkNumber} to ${payee}` +
(grp.bills.length > 1 ? ` (combined ${grp.bills.length} bills)` : (memo ? ` — ${memo}` : "")),
reference_number: checkNumber,
related_entity_id: checkId,
related_entity_type: "check",
transaction_type: "check",
});
totalPrinted += 1;
}
// Always make sure every bill in the group is linked to this check
// (covers the reprint case where some bills may have been added later)
for (const b of grp.bills) {
if (b.check_id !== checkId) {
billStatusUpdates.push({ billId: b.id, checkId, checkNumber });
}
}
checkDataList.push({
check_number: checkNumber,
check_date: today,
payee,
payee_address: payeeAddress,
amount: totalAmount,
memo,
line_items: lineItems.length > 1 ? lineItems : null,
bank_account_name: bank.account_name,
bank_routing_number: bank.routing_number,
bank_account_number: bank.account_number,
association_name: assocName,
layout: (layoutsByAssoc[assocId] as any) || null,
});
}
for (const u of billStatusUpdates) {
await supabase
.from("bills")
.update({
status: "paid",
paid_date: today,
payment_method: "check",
check_id: u.checkId,
})
.eq("id", u.billId);
}
if (txInserts.length > 0) await supabase.from("bank_transactions").insert(txInserts);
if (nextNum > (bank.next_check_number || 1001)) {
await supabase
.from("bank_accounts")
.update({ next_check_number: nextNum })
.eq("id", bank.id);
}
const safeName = (assocName || "association").replace(/[^a-z0-9-_]+/gi, "_");
await downloadChecksPdf(checkDataList, `checks-${safeName}-${today}.pdf`, { includeMicr: printIncludeMicr });
}
const parts: string[] = [];
if (totalPrinted) parts.push(`${totalPrinted} new`);
if (totalReprinted) parts.push(`${totalReprinted} reprinted`);
toast({ title: "Checks generated", description: `${parts.join(", ")} across ${assocIds.length} association(s).` });
setSelectedBillIds(new Set());
setPrintDialogOpen(false);
fetchData();
} catch (err: any) {
console.error("Bulk print failed:", err);
toast({ variant: "destructive", title: "Print failed", description: err.message });
} finally {
setPrinting(false);
}
};
// A bill is selectable if it has an association and isn't rejected. Paid bills with a check_id are reprintable.
const isPrintable = (bill: any) =>
!!bill.association_id && bill.status !== "rejected";
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<UserCheck className="h-6 w-6 text-primary" /> Bill Approvals List
</h1>
<p className="text-sm text-muted-foreground mt-1">Manage and track vendor bills requiring approval.</p>
</div>
{!isBoardView && (
<div className="flex gap-2 flex-wrap">
<Button onClick={openNotifyBoard} variant="outline" size="sm" className="gap-2">
<Bell className="w-4 h-4" /> Notify Board
</Button>
<Button
onClick={openPrintDialog}
disabled={selectedBillIds.size === 0 || printing}
variant="outline"
size="sm"
className="gap-2"
>
{printing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Printer className="w-4 h-4" />}
Print Checks ({selectedBillIds.size})
</Button>
<Button variant="outline" className="gap-2" onClick={() => setBuildiumOpen(true)}>
<Download className="h-4 w-4" /> Import from Buildium
</Button>
<Button variant="outline" className="gap-2" onClick={() => navigate("/dashboard/bill-approvals?tab=parser")}>
<Sparkles className="h-4 w-4" /> Submit Invoice
</Button>
<Button className="gap-2" onClick={openNew}>
<Plus className="h-4 w-4" /> Create Bill
</Button>
</div>
)}
</div>
{/* Tabs: Pending vs Approved */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "pending" | "approved")}>
<TabsList>
<TabsTrigger value="pending">Pending & Approved</TabsTrigger>
<TabsTrigger value="approved">Paid</TabsTrigger>
</TabsList>
</Tabs>
{activeTab === "approved" && (
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<Label className="text-xs">From</Label>
<Input type="date" value={approvedFrom} onChange={(e) => setApprovedFrom(e.target.value)} className="w-[160px]" />
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs">To</Label>
<Input type="date" value={approvedTo} onChange={(e) => setApprovedTo(e.target.value)} className="w-[160px]" />
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => { setApprovedFrom(monthStart); setApprovedTo(monthEnd); }}>This Month</Button>
<Button variant="outline" size="sm" onClick={() => {
const d = new Date(); const s = new Date(d.getFullYear(), d.getMonth() - 1, 1);
const e = new Date(d.getFullYear(), d.getMonth(), 0);
setApprovedFrom(s.toISOString().split("T")[0]); setApprovedTo(e.toISOString().split("T")[0]);
}}>Last Month</Button>
<Button variant="outline" size="sm" onClick={() => {
const d = new Date(); setApprovedFrom(new Date(d.getFullYear(), 0, 1).toISOString().split("T")[0]);
setApprovedTo(new Date(d.getFullYear(), 11, 31).toISOString().split("T")[0]);
}}>YTD</Button>
<Button variant="ghost" size="sm" onClick={() => { setApprovedFrom(""); setApprovedTo(""); }}>All Time</Button>
</div>
</div>
)}
{/* Search & Filters */}
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by Vendor Name or Bill ID..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex gap-2 ml-auto">
<Select value={clientFilter} onValueChange={setClientFilter}>
<SelectTrigger className="w-[160px]"><SelectValue placeholder="All Clients" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Clients</SelectItem>
{associations.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]"><SelectValue placeholder="All Statuses" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="paid">Paid</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
</SelectContent>
</Select>
<Select value={glFilter} onValueChange={setGlFilter}>
<SelectTrigger className="w-[170px]"><SelectValue placeholder="All GL Accounts" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All GL Accounts</SelectItem>
{accounts.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.account_number} - {a.account_name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{/* Table */}
{loading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : sorted.length === 0 ? (
<Card><CardContent className="py-12 text-center text-muted-foreground">No bills found.</CardContent></Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
{!isBoardView && (
<TableHead className="w-10">
<Checkbox
checked={
sorted.filter(isPrintable).length > 0 &&
sorted.filter(isPrintable).every((b) => selectedBillIds.has(b.id))
}
onCheckedChange={(checked) => {
const printable = sorted.filter(isPrintable).map((b) => b.id);
setSelectedBillIds(checked ? new Set(printable) : new Set());
}}
aria-label="Select all printable bills"
/>
</TableHead>
)}
<SortHeader field="id">Bill ID</SortHeader>
<SortHeader field="association_id">Client</SortHeader>
<TableHead>Vendor</TableHead>
<SortHeader field="amount">Amount</SortHeader>
<SortHeader field="bill_date">Bill Date</SortHeader>
<TableHead>Approvers</TableHead>
<SortHeader field="status">Status</SortHeader>
<SortHeader field="created_at">Created Date</SortHeader>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((b) => {
const displayStatus = getStatusDisplay(b);
return (
<TableRow key={b.id} className="cursor-pointer hover:bg-muted/50" onClick={() => navigate(isBoardView ? `/homeowner/board/bill-approvals/${b.id}` : `/dashboard/bill-approvals/${b.id}`)}>
{!isBoardView && (
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedBillIds.has(b.id)}
disabled={!isPrintable(b)}
onCheckedChange={(checked) => {
setSelectedBillIds((prev) => {
const s = new Set(prev);
if (checked) s.add(b.id); else s.delete(b.id);
return s;
});
}}
aria-label={`Select bill ${b.id}`}
/>
</TableCell>
)}
<TableCell className="font-mono text-xs text-muted-foreground">{b.id.slice(0, 8)}...</TableCell>
<TableCell>{getClientName(b)}</TableCell>
<TableCell className="font-medium">{getVendorName(b)}</TableCell>
<TableCell className="font-mono">${Number(b.amount).toLocaleString("en-US", { minimumFractionDigits: 2 })}</TableCell>
<TableCell className="text-sm">
{b.bill_date ? new Date(b.bill_date + "T12:00:00").toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }) : "—"}
</TableCell>
<TableCell className="text-sm">
{(approvalsByBill[b.id] || []).length > 0 ? (
<div className="space-y-0.5">
{approvalsByBill[b.id].map((a: any) => (
<div key={a.id} className="flex items-center gap-1.5">
<Badge variant="outline" className={`text-xs ${a.status === 'approved' ? 'border-emerald-300 text-emerald-700' : a.status === 'denied' || a.status === 'rejected' ? 'border-red-300 text-red-700' : 'border-amber-300 text-amber-700'}`}>
{a.status === 'approved' ? '✓' : a.status === 'denied' || a.status === 'rejected' ? '✗' : '⏳'}
</Badge>
<span className="text-xs truncate max-w-[120px]">{a.vendor_name}</span>
</div>
))}
</div>
) : (
<span className="text-muted-foreground italic">None</span>
)}
</TableCell>
<TableCell>
<Badge className={statusColors[displayStatus] || "bg-muted text-muted-foreground"}>
{displayStatus.charAt(0).toUpperCase() + displayStatus.slice(1)}
</Badge>
</TableCell>
<TableCell className="text-sm">
{new Date(b.created_at).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => { setDetailBill(b); setDetailOpen(true); }}>
<Eye className="h-4 w-4 mr-2" /> View Details
</DropdownMenuItem>
{!isBoardView && (
<>
<DropdownMenuItem onClick={() => openEdit(b)}>
<Edit className="h-4 w-4 mr-2" /> Edit
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => setDeleteTarget(b)}>
<Trash2 className="h-4 w-4 mr-2" /> Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
{/* Create Bill Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bill Details</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Client / Association */}
<div>
<Label>Client / Association <span className="text-destructive">*</span></Label>
<Select value={form.association_id} onValueChange={(v) => setForm({ ...form, association_id: v, expense_account_id: "", approval_member_ids: [] })}>
<SelectTrigger><SelectValue placeholder="Select Client" /></SelectTrigger>
<SelectContent>
{associations.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* GL Account */}
<div>
<Label>GL Account (Expense) <span className="text-destructive">*</span></Label>
<ChartOfAccountsDropdown
accountType="expense"
associationId={form.association_id || null}
value={form.expense_account_id}
onChange={(v: string) => setForm({ ...form, expense_account_id: v })}
disabled={!form.association_id}
placeholder={form.association_id ? "Select GL Account" : "Select a client first"}
/>
</div>
{/* Request Approval From */}
<div>
<Label>Request Approval From (Optional)</Label>
<div className="border rounded-md p-3 max-h-[150px] overflow-y-auto bg-muted/30 space-y-2 mt-1">
{!form.association_id ? (
<p className="text-sm text-muted-foreground italic">Select a client first.</p>
) : boardMembers.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No board members with approval authority found.</p>
) : (
boardMembers.map((bm: any) => (
<div key={bm.id} className="flex items-center space-x-2 bg-background p-2 rounded border">
<Checkbox
id={`approver-${bm.id}`}
checked={form.approval_member_ids.includes(bm.id)}
onCheckedChange={(checked) => {
setForm(prev => ({
...prev,
approval_member_ids: checked
? [...prev.approval_member_ids, bm.id]
: prev.approval_member_ids.filter((id: string) => id !== bm.id),
}));
}}
/>
<Label htmlFor={`approver-${bm.id}`} className="cursor-pointer flex-1">
<span className="font-medium text-sm">{bm.member_name}</span>
{bm.member_email && <span className="text-xs text-muted-foreground block">{bm.member_email}</span>}
</Label>
</div>
))
)}
</div>
{form.approval_member_ids.length > 0 && (
<p className="text-xs text-muted-foreground mt-1">{form.approval_member_ids.length} approver(s) selected</p>
)}
</div>
{/* Vendor Not Found Warning */}
{vendorNotFound && (
<Alert variant="destructive" className="border-amber-300 bg-amber-50 text-amber-900">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Vendor "<strong>{vendorNotFound}</strong>" was not recognized. Please{" "}
<a href="/dashboard/vendors" target="_blank" rel="noopener noreferrer" className="underline font-semibold text-primary">
add this vendor
</a>{" "}
first, then retry.
</AlertDescription>
</Alert>
)}
{/* Vendor & Invoice */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Vendor <span className="text-destructive">*</span></Label>
<Select
value={form.vendor_id}
onValueChange={(v) => {
const selected = filteredVendors.find((vn: any) => vn.id === v);
setForm({ ...form, vendor_id: v, vendor_name: selected?.name || "" });
setVendorNotFound(null);
}}
disabled={!form.association_id}
>
<SelectTrigger><SelectValue placeholder={form.association_id ? "Select vendor" : "Select a client first"} /></SelectTrigger>
<SelectContent>
{filteredVendors.map((v: any) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>Bill / Invoice Number</Label>
<Input placeholder="Optional" value={form.invoice_number} onChange={(e) => setForm({ ...form, invoice_number: e.target.value })} />
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Bill Date <span className="text-destructive">*</span></Label>
<Input type="date" value={form.bill_date} onChange={(e) => setForm({ ...form, bill_date: e.target.value })} />
</div>
<div>
<Label>Due Date <span className="text-destructive">*</span></Label>
<Input type="date" value={form.due_date} onChange={(e) => setForm({ ...form, due_date: e.target.value })} />
</div>
</div>
{/* Amount */}
<div>
<Label>Amount <span className="text-destructive">*</span></Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">$</span>
<Input
type="number"
step="0.01"
min="0"
placeholder="0.00"
className="pl-7"
value={form.amount}
onChange={(e) => setForm({ ...form, amount: e.target.value })}
/>
</div>
</div>
{/* Description */}
<div>
<Label>Description / Memo</Label>
<Textarea
placeholder="What was this expense for? (Optional)"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={3}
/>
</div>
{/* File Upload */}
<div>
<Label>Supporting Document (PDF)</Label>
<div
className="mt-1 border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors hover:bg-muted/50 border-border"
onClick={() => fileRef.current?.click()}
>
<input
ref={fileRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
className="hidden"
onChange={(e) => setUploadFile(e.target.files?.[0] || null)}
/>
{uploadFile ? (
<div className="flex items-center justify-center gap-2">
<span className="text-sm font-medium text-foreground">{uploadFile.name}</span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); setUploadFile(null); }}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<div className="mx-auto mb-2 bg-primary/10 rounded-full p-3 w-fit">
<Upload className="h-5 w-5 text-primary" />
</div>
<p className="text-sm text-foreground font-medium">Click to upload or drag and drop</p>
<p className="text-xs text-muted-foreground mt-1">PDF, JPG, PNG (max 10MB)</p>
</>
)}
</div>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting}>Cancel</Button>
<Button onClick={handleCreate} disabled={submitting} className="gap-2">
{submitting ? <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" /> : <Plus className="h-4 w-4" />}
Create Bill
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* View Details Dialog */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bill Details</DialogTitle>
</DialogHeader>
{detailBill && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><p className="text-xs text-muted-foreground uppercase">Bill ID</p><p className="text-sm font-mono">{detailBill.id.slice(0, 12)}...</p></div>
<div><p className="text-xs text-muted-foreground uppercase">Status</p><Badge className={statusColors[getStatusDisplay(detailBill)] || ""}>{getStatusDisplay(detailBill)}</Badge></div>
<div><p className="text-xs text-muted-foreground uppercase">Client</p><p className="text-sm font-medium">{getClientName(detailBill)}</p></div>
<div><p className="text-xs text-muted-foreground uppercase">Vendor</p><p className="text-sm font-medium">{getVendorName(detailBill)}</p></div>
<div><p className="text-xs text-muted-foreground uppercase">Amount</p><p className="text-sm font-bold">${Number(detailBill.amount).toLocaleString("en-US", { minimumFractionDigits: 2 })}</p></div>
<div><p className="text-xs text-muted-foreground uppercase">Invoice #</p><p className="text-sm">{detailBill.invoice_number || "—"}</p></div>
<div><p className="text-xs text-muted-foreground uppercase">Bill Date</p><p className="text-sm">{detailBill.bill_date}</p></div>
<div><p className="text-xs text-muted-foreground uppercase">Due Date</p><p className="text-sm">{detailBill.due_date || "—"}</p></div>
<div><p className="text-xs text-muted-foreground uppercase">GL Account</p><p className="text-sm">{getGlLabel(detailBill)}</p></div>
<div><p className="text-xs text-muted-foreground uppercase">Created</p><p className="text-sm">{new Date(detailBill.created_at).toLocaleDateString()}</p></div>
</div>
{detailBill.description && (
<div><p className="text-xs text-muted-foreground uppercase">Description</p><p className="text-sm">{detailBill.description}</p></div>
)}
{/* Embedded PDF / Attachment Preview */}
{detailBill.attachment_url && (
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-b">
<p className="text-sm font-medium truncate">Attachment</p>
<a href={detailBill.attachment_url} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-1 text-xs">
<Eye className="h-3 w-3" /> Open
</Button>
</a>
</div>
{detailBill.attachment_url.toLowerCase().endsWith(".pdf") ? (
<iframe
src={`${detailBill.attachment_url}#toolbar=1&navpanes=0`}
className="w-full h-[400px] border-0"
title="PDF Preview"
/>
) : (
<div className="p-4">
<img src={detailBill.attachment_url} alt="Bill attachment" className="w-full rounded-md border" />
</div>
)}
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDetailOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Bill Dialog */}
<Dialog open={editOpen} onOpenChange={(open) => { setEditOpen(open); if (!open) setEditingBill(null); }}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Bill</DialogTitle>
<DialogDescription>Update the bill details below.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Client / Association <span className="text-destructive">*</span></Label>
<Select value={form.association_id} onValueChange={(v) => setForm({ ...form, association_id: v, expense_account_id: "" })}>
<SelectTrigger><SelectValue placeholder="Select Client" /></SelectTrigger>
<SelectContent>
{associations.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>GL Account (Expense)</Label>
<ChartOfAccountsDropdown
accountType="expense"
associationId={form.association_id || null}
value={form.expense_account_id}
onChange={(v: string) => setForm({ ...form, expense_account_id: v })}
disabled={!form.association_id}
placeholder="Select GL Account"
/>
</div>
{vendorNotFound && (
<Alert variant="destructive" className="border-amber-300 bg-amber-50 text-amber-900">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Vendor "<strong>{vendorNotFound}</strong>" was not recognized. Please{" "}
<a href="/dashboard/vendors" target="_blank" rel="noopener noreferrer" className="underline font-semibold text-primary">
add this vendor
</a>{" "}
first, then retry.
</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Vendor <span className="text-destructive">*</span></Label>
<Select
value={form.vendor_id}
onValueChange={(v) => {
const selected = filteredVendors.find((vn: any) => vn.id === v);
setForm({ ...form, vendor_id: v, vendor_name: selected?.name || "" });
setVendorNotFound(null);
}}
disabled={!form.association_id}
>
<SelectTrigger><SelectValue placeholder={form.association_id ? "Select vendor" : "Select a client first"} /></SelectTrigger>
<SelectContent>
{filteredVendors.map((v: any) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>Invoice Number</Label>
<Input value={form.invoice_number} onChange={(e) => setForm({ ...form, invoice_number: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Bill Date</Label>
<Input type="date" value={form.bill_date} onChange={(e) => setForm({ ...form, bill_date: e.target.value })} />
</div>
<div>
<Label>Due Date</Label>
<Input type="date" value={form.due_date} onChange={(e) => setForm({ ...form, due_date: e.target.value })} />
</div>
</div>
<div>
<Label>Amount <span className="text-destructive">*</span></Label>
<Input type="number" step="0.01" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} />
</div>
<div>
<Label>Description</Label>
<Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
{/* Attachment */}
<div>
<Label>Attachment (PDF / Image)</Label>
{editingBill?.attachment_url && !uploadFile && (
<div className="mt-1 mb-2 flex items-center gap-2 text-sm">
<a href={editingBill.attachment_url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline truncate max-w-[300px]">
{decodeURIComponent(editingBill.attachment_url.split("/").pop() || "attachment")}
</a>
<Badge variant="outline" className="text-xs">Current</Badge>
</div>
)}
<div
className="mt-1 border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors hover:bg-muted/50 border-border"
onClick={() => fileRef.current?.click()}
>
<input
ref={fileRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
className="hidden"
onChange={(e) => setUploadFile(e.target.files?.[0] || null)}
/>
{uploadFile ? (
<div className="flex items-center justify-center gap-2">
<span className="text-sm font-medium text-foreground">{uploadFile.name}</span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); setUploadFile(null); }}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Upload className="h-4 w-4 mx-auto mb-1 text-muted-foreground" />
<p className="text-xs text-muted-foreground">{editingBill?.attachment_url ? "Upload a replacement file" : "Upload an attachment"}</p>
</>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={submitting}>Cancel</Button>
<Button onClick={handleUpdate} disabled={submitting}>
{submitting ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Bill</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this bill? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Notify Board Dialog */}
<Dialog open={notifyOpen} onOpenChange={setNotifyOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Bell className="h-4 w-4" /> Notify Board</DialogTitle>
<DialogDescription>
Send an email and in-app notification to all board members with approval authority for the selected association.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label>Association <span className="text-destructive">*</span></Label>
<Select value={notifyAssociationId} onValueChange={setNotifyAssociationId}>
<SelectTrigger><SelectValue placeholder="Select association" /></SelectTrigger>
<SelectContent>
{associations.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>Recipients</Label>
<div className="border rounded-md p-3 max-h-[180px] overflow-y-auto bg-muted/30 space-y-1.5 mt-1 text-sm">
{!notifyAssociationId ? (
<p className="text-muted-foreground italic">Select an association to see recipients.</p>
) : notifyLoadingMembers ? (
<p className="text-muted-foreground italic">Loading...</p>
) : notifyBoardMembers.length === 0 ? (
<p className="text-muted-foreground italic">No board members with approval authority found.</p>
) : (
notifyBoardMembers.map((bm: any) => (
<div key={bm.id} className="flex items-center justify-between bg-background p-2 rounded border">
<div>
<div className="font-medium">{bm.member_name}</div>
{bm.member_email && <div className="text-xs text-muted-foreground">{bm.member_email}</div>}
</div>
<div className="flex gap-1">
{bm.member_email && <Badge variant="outline" className="text-xs">Email</Badge>}
{bm.user_id && <Badge variant="outline" className="text-xs">In-app</Badge>}
</div>
</div>
))
)}
</div>
</div>
<div className="bg-muted/30 border rounded-md p-3 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-1">Message preview</p>
<p className="italic">
"One or more bills have been uploaded for your approval. Please sign in and navigate to the following link to review: https://avria.cloud/dashboard/bill-approvals"
</p>
<p className="mt-2 text-xs">
Board members will <strong>also receive a separate secure email for each pending bill</strong> with one-click <strong>Approve</strong> and <strong>Deny</strong> buttons no sign-in required.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNotifyOpen(false)} disabled={notifySending}>Cancel</Button>
<Button onClick={handleSendNotifyBoard} disabled={notifySending || !notifyAssociationId || notifyBoardMembers.length === 0}>
{notifySending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Send Notifications
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Print Checks Dialog: choose bank account per association + MICR option */}
<Dialog open={printDialogOpen} onOpenChange={(open) => { if (!printing) setPrintDialogOpen(open); }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Printer className="h-5 w-5 text-primary" /> Print Checks</DialogTitle>
<DialogDescription>
Choose which bank account each client's checks should be drawn from. Already-printed bills will be reprinted using their existing check number.
</DialogDescription>
</DialogHeader>
{printLoadingBanks ? (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : (
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
{(() => {
const selectedBills = bills.filter((b) => selectedBillIds.has(b.id));
const assocIds = Array.from(new Set(selectedBills.map((b) => b.association_id).filter(Boolean)));
if (assocIds.length === 0) {
return <p className="text-sm text-muted-foreground">No bills selected.</p>;
}
return assocIds.map((assocId) => {
const assocName = associations.find((a: any) => a.id === assocId)?.name || "Unknown";
const assocBills = selectedBills.filter((b) => b.association_id === assocId);
const banks = printBanks.filter((b: any) => b.association_id === assocId);
const reprintCount = assocBills.filter((b) => !!b.check_id).length;
const newCount = assocBills.length - reprintCount;
const selectedBank = banks.find((b: any) => b.id === printAssocBankMap[assocId]);
return (
<div key={assocId} className="rounded-lg border p-3 space-y-2 bg-card">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium text-sm">{assocName}</div>
<div className="text-xs text-muted-foreground mt-0.5">
{assocBills.length} check{assocBills.length === 1 ? "" : "s"}
{reprintCount > 0 && (
<span className="ml-1">({newCount} new, {reprintCount} reprint{reprintCount === 1 ? "" : "s"})</span>
)}
</div>
</div>
{selectedBank?.next_check_number != null && newCount > 0 && (
<div className="text-xs text-muted-foreground">
Next #: <span className="font-mono">{selectedBank.next_check_number}</span>
</div>
)}
</div>
{banks.length === 0 ? (
<p className="text-xs text-destructive">No active bank account for this client. Add one first.</p>
) : (
<Select
value={printAssocBankMap[assocId] || ""}
onValueChange={(v) => setPrintAssocBankMap((prev) => ({ ...prev, [assocId]: v }))}
>
<SelectTrigger><SelectValue placeholder="Select bank account" /></SelectTrigger>
<SelectContent>
{banks.map((b: any) => (
<SelectItem key={b.id} value={b.id}>
{b.account_name}{b.bank_name ? ` — ${b.bank_name}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
});
})()}
<div className="flex items-start gap-3 pt-2 border-t">
<Checkbox
id="combine-vendor"
checked={combinePerVendor}
onCheckedChange={(v) => setCombinePerVendor(!!v)}
className="mt-1"
/>
<div>
<Label htmlFor="combine-vendor" className="cursor-pointer">Combine bills per vendor into one check</Label>
<p className="text-xs text-muted-foreground mt-1">
Bills for the same vendor (within the same association) are merged into a single check, with each invoice itemized on the stub.
</p>
</div>
</div>
<div className="flex items-start gap-3 pt-2 border-t">
<Checkbox
id="print-micr"
checked={printIncludeMicr}
onCheckedChange={(v) => setPrintIncludeMicr(!!v)}
className="mt-1"
/>
<div>
<Label htmlFor="print-micr" className="cursor-pointer">Print on blank check stock (include MICR line)</Label>
<p className="text-xs text-muted-foreground mt-1">
Requires routing & account numbers on the bank account, and a MICR-capable printer with magnetic ink.
</p>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPrintDialogOpen(false)} disabled={printing}>Cancel</Button>
<Button onClick={handlePrintChecksForSelected} disabled={printing || printLoadingBanks} className="gap-2">
{printing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Printer className="w-4 h-4" />}
{printing ? "Generating…" : `Print ${selectedBillIds.size} Check${selectedBillIds.size === 1 ? "" : "s"}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Buildium Import Dialog */}
<Dialog open={buildiumOpen} onOpenChange={setBuildiumOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Import Bills from Buildium</DialogTitle>
<DialogDescription>
Pull bills (and missing vendors) from Buildium. Existing bills are matched by Buildium ID, and likely duplicates (same vendor + amount + invoice # or bill date) are linked instead of re-created.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Association</Label>
<Select value={buildiumAssoc} onValueChange={setBuildiumAssoc}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Associations</SelectItem>
{associations.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Due Date From</Label>
<Input type="date" value={buildiumFrom} onChange={(e) => setBuildiumFrom(e.target.value)} />
</div>
<div>
<Label>Due Date To</Label>
<Input type="date" value={buildiumTo} onChange={(e) => setBuildiumTo(e.target.value)} />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBuildiumOpen(false)} disabled={buildiumImporting}>Cancel</Button>
<Button onClick={importFromBuildium} disabled={buildiumImporting} className="gap-2">
{buildiumImporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}