mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40: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>
1748 lines
81 KiB
TypeScript
1748 lines
81 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";
|
|
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").eq("status", "active").order("name"),
|
|
supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type").eq("account_type", "expense").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").eq("status", "active").order("name"),
|
|
supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type").eq("account_type", "expense").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 filteredAccounts = form.association_id
|
|
? accounts.filter((a: any) => !a.association_id || a.association_id === form.association_id)
|
|
: [];
|
|
|
|
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>
|
|
<Select
|
|
value={form.expense_account_id}
|
|
onValueChange={(v) => setForm({ ...form, expense_account_id: v })}
|
|
disabled={!form.association_id}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={form.association_id ? "Select GL Account" : "Select a client first"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{filteredAccounts.map((a: any) => (
|
|
<SelectItem key={a.id} value={a.id}>{a.account_number} - {a.account_name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</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>
|
|
<Select value={form.expense_account_id} onValueChange={(v) => setForm({ ...form, expense_account_id: v })} disabled={!form.association_id}>
|
|
<SelectTrigger><SelectValue placeholder="Select GL Account" /></SelectTrigger>
|
|
<SelectContent>
|
|
{filteredAccounts.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.account_number} - {a.account_name}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</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>
|
|
);
|
|
}
|