import { useState, useEffect, useMemo, useCallback } from "react"; import { todayLocal } from "@/lib/dateUtils"; import { useAuth } from "@/contexts/AuthContext"; import { supabase } from "@/integrations/supabase/client"; import { useToast } from "@/hooks/use-toast"; import { Dialog, DialogContent, DialogFooter, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Loader2, Plus, Trash2, SplitSquareHorizontal, DollarSign, Calendar as CalendarIcon, FileText } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { calculateRemainingUnpaidAssessmentBalance, computePaymentWaterfallAllocation, type WaterfallAllocation } from "@/lib/unitLedgerAccountBreakdown"; interface EditEntry { id: string; date: string; description: string | null; debit: number; credit: number; transaction_type: string; owner_id: string; chart_of_account_id?: string | null; } interface Props { open: boolean; onOpenChange: (open: boolean) => void; unitId?: string; associationId?: string; onSuccess?: () => void; editEntry?: EditEntry | null; } export default function UnitLedgerTransactionForm({ open, onOpenChange, unitId: propUnitId, associationId: propAssociationId, onSuccess, editEntry }: Props) { const { user } = useAuth(); const { toast } = useToast(); const [loading, setLoading] = useState(false); const [fetchingData, setFetchingData] = useState(false); // Association/Unit pickers (when not provided via props) const [associations, setAssociations] = useState([]); const [units, setUnits] = useState([]); const [selectedAssociationId, setSelectedAssociationId] = useState(""); const [selectedUnitId, setSelectedUnitId] = useState(""); const needsPickers = !propUnitId; const activeUnitId = propUnitId || selectedUnitId; const activeAssociationId = propAssociationId || selectedAssociationId; const [owners, setOwners] = useState([]); const [accounts, setAccounts] = useState([]); const isEditing = !!editEntry; const [formData, setFormData] = useState({ type: "Payment", chargeType: "assessment", date: "", ownerId: "", description: "", amount: "", chart_of_account_id: "", }); const fieldsDisabled = !formData.date; const CHARGE_TYPES = [ { value: "assessment", label: "Assessment" }, { value: "late_fee", label: "Late Fee" }, { value: "interest", label: "Interest" }, { value: "admin_fee", label: "Admin Fee" }, { value: "legal_fee", label: "Legal Fee" }, { value: "violation", label: "Violation Fine" }, { value: "bank_fee", label: "Bank Fee" }, { value: "special_assessment", label: "Special Assessment" }, { value: "other", label: "Other" }, ]; const CREDIT_WRITE_OFF_TYPES = new Set(CHARGE_TYPES.map((type) => type.value)); // Pre-fill form when editing useEffect(() => { if (open && editEntry) { const isDebit = Number(editEntry.debit || 0) > 0; const txType = editEntry.transaction_type || ""; const isPaymentType = ["Payment", "payment", "Prepayment", "prepayment", "refund", "Refund"].includes(txType); const isWriteOffCredit = !isDebit && CREDIT_WRITE_OFF_TYPES.has(txType); const chargeType = isDebit && !isPaymentType ? (CHARGE_TYPES.some(ct => ct.value === txType) ? txType : "assessment") : isWriteOffCredit ? txType : "assessment"; setFormData({ type: isDebit ? "Charge" : isWriteOffCredit ? "WriteOff" : (txType === "Prepayment" || txType === "prepayment" ? "Prepayment" : "Payment"), chargeType, date: editEntry.date || todayLocal(), ownerId: editEntry.owner_id || "", description: editEntry.description || "", amount: String(isDebit ? editEntry.debit : editEntry.credit), chart_of_account_id: editEntry.chart_of_account_id || "", }); } else if (open && !editEntry) { setFormData({ type: "Payment", chargeType: "assessment", date: "", ownerId: "", description: "", amount: "", chart_of_account_id: "", }); } }, [open, editEntry]); const [addToBillable, setAddToBillable] = useState(false); const [feeRules, setFeeRules] = useState(null); const [splits, setSplits] = useState<{ id: string; accountId: string; amount: string }[]>([]); const [useSplits, setUseSplits] = useState(false); const [waterfallAllocations, setWaterfallAllocations] = useState([]); const [waterfallLoading, setWaterfallLoading] = useState(false); // Auto-compute waterfall allocation when payment type, owner, or amount changes useEffect(() => { if (formData.type !== "Payment" || !formData.ownerId || !formData.amount) { setWaterfallAllocations([]); return; } const amount = parseFloat(formData.amount); if (isNaN(amount) || amount <= 0) { setWaterfallAllocations([]); return; } let cancelled = false; setWaterfallLoading(true); (async () => { try { const { data: entries } = await supabase .from("owner_ledger_entries") .select("id, date, created_at, debit, credit, transaction_type, description") .eq("owner_id", formData.ownerId) .order("date", { ascending: true }) .order("created_at", { ascending: true }); if (cancelled) return; const { allocations } = computePaymentWaterfallAllocation(entries || [], amount); setWaterfallAllocations(allocations); // Auto-populate description with waterfall breakdown if (allocations.length > 0) { const parts = allocations .filter(a => a.applied > 0) .map(a => `${a.label}: $${a.applied.toFixed(2)}`); const autoDesc = `Payment applied: ${parts.join(", ")}`; setFormData(prev => { // Only auto-fill if description is empty or was previously auto-generated if (!prev.description || prev.description.startsWith("Payment applied:")) { return { ...prev, description: autoDesc }; } return prev; }); } } catch (err) { console.error("Waterfall allocation error:", err); } finally { if (!cancelled) setWaterfallLoading(false); } })(); return () => { cancelled = true; }; }, [formData.type, formData.ownerId, formData.amount]); // Fetch fee rules for the association (interest rate) useEffect(() => { if (!activeAssociationId) { setFeeRules(null); return; } supabase.from("association_fee_rules").select("interest_rate").eq("association_id", activeAssociationId).maybeSingle() .then(({ data }) => setFeeRules(data)); }, [activeAssociationId]); // Auto-calculate interest when charge type is "interest" useEffect(() => { if (formData.type !== "Charge" || formData.chargeType !== "interest" || !feeRules || !activeUnitId || !formData.date) return; const rate = feeRules.interest_rate || 0; if (rate <= 0) return; const autoInterestDescriptionPattern = /^Interest @ .*remaining unpaid assessment balance$/i; // Fetch ledger entries up to the selected date to compute unpaid assessment balance for the selected unit (async () => { const { data: entries } = await supabase .from("owner_ledger_entries") .select("id, date, created_at, debit, credit, transaction_type, description") .eq("unit_id", activeUnitId) .lte("date", formData.date) .order("date", { ascending: true }) .order("created_at", { ascending: true }); if (!entries) return; const assessmentBalance = calculateRemainingUnpaidAssessmentBalance(entries); const interest = parseFloat((assessmentBalance * rate / 100).toFixed(2)); const defaultDescription = `Interest @ ${rate}% on $${assessmentBalance.toLocaleString("en-US", { minimumFractionDigits: 2 })} remaining unpaid unit assessment balance`; setFormData(prev => ({ ...prev, amount: interest > 0 ? interest.toString() : "", description: !prev.description || autoInterestDescriptionPattern.test(prev.description) ? defaultDescription : prev.description, })); })(); }, [formData.type, formData.chargeType, formData.date, activeUnitId, feeRules]); // Fetch associations when pickers are needed useEffect(() => { if (!open || !needsPickers) return; supabase.from("associations").select("id, name").eq("status", "active").order("name").then(({ data }) => { setAssociations(data || []); }); }, [open, needsPickers]); // Fetch units when association is selected useEffect(() => { if (!open || !needsPickers || !selectedAssociationId) { setUnits([]); return; } supabase .from("units") .select("id, unit_number, address") .eq("association_id", selectedAssociationId) .order("unit_number") .then(({ data }) => { setUnits(data || []); }); }, [open, needsPickers, selectedAssociationId]); // Fetch owners & accounts when unit is determined useEffect(() => { if (!open || !activeUnitId) return; const fetchLookups = async () => { setFetchingData(true); try { const { data: ownersData } = await supabase .from("owners") .select("id, first_name, last_name") .eq("unit_id", activeUnitId) .eq("status", "active") .order("last_name"); setOwners(ownersData || []); if (ownersData && ownersData.length > 0 && !formData.ownerId) { setFormData((prev) => ({ ...prev, ownerId: ownersData[0].id })); } if (activeAssociationId) { // Resolve the association's accounting system (zoho vs buildium) // so the COA list is scoped accordingly. const { data: assocRow } = await supabase .from("associations") .select("zoho_organization_id") .eq("id", activeAssociationId) .maybeSingle(); const system = assocRow?.zoho_organization_id ? "zoho" : "buildium"; const { data: accts } = await supabase .from("chart_of_accounts") .select("id, account_name, account_number, account_type, accounting_system") .eq("is_active", true) .eq("accounting_system", system) .order("account_number", { ascending: true }); setAccounts(accts || []); } } catch (err) { console.error("Error fetching lookups:", err); } finally { setFetchingData(false); } }; fetchLookups(); }, [open, activeUnitId, activeAssociationId]); // Reset form when dialog opens fresh (skip when editing — handled above) useEffect(() => { if (open && !editEntry) { setFormData({ type: "Payment", chargeType: "assessment", date: "", ownerId: "", description: "", amount: "", chart_of_account_id: "", }); setSplits([]); setUseSplits(false); setAddToBillable(false); if (needsPickers) { setSelectedAssociationId(""); setSelectedUnitId(""); } setOwners([]); setAccounts([]); } }, [open, editEntry]); const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); }; const handleSelectChange = (name: string, value: string) => { setFormData((prev) => ({ ...prev, [name]: value })); }; const handleAddSplit = () => { setUseSplits(true); setSplits([...splits, { id: crypto.randomUUID(), accountId: "", amount: "" }]); }; const handleRemoveSplit = (id: string) => { const newSplits = splits.filter((s) => s.id !== id); setSplits(newSplits); if (newSplits.length === 0) setUseSplits(false); }; const handleSplitChange = (id: string, field: string, value: string) => { setSplits(splits.map((s) => (s.id === id ? { ...s, [field]: value } : s))); }; const totalSplitAmount = useMemo(() => splits.reduce((sum, s) => sum + (parseFloat(s.amount) || 0), 0), [splits]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!activeUnitId) { toast({ variant: "destructive", title: "Error", description: "Please select a unit." }); return; } if (!formData.ownerId) { toast({ variant: "destructive", title: "Error", description: "Please select an owner." }); return; } if (!formData.date) { toast({ variant: "destructive", title: "Date Required", description: "Please choose a transaction date first." }); return; } const numAmount = parseFloat(formData.amount); // Allow a $0.00 charge (used as a note/memo on the ledger). Payments and // prepayments still require a positive amount. const allowZero = ["Charge", "Expense", "WriteOff"].includes(formData.type); if (isNaN(numAmount) || numAmount < 0 || (numAmount === 0 && !allowZero)) { toast({ variant: "destructive", title: "Invalid Amount", description: allowZero ? "Please enter a valid amount ($0.00 is allowed)." : "Please enter a valid positive amount.", }); return; } if (!formData.description.trim()) { toast({ variant: "destructive", title: "Invalid Description", description: "Please enter a description." }); return; } if (useSplits && splits.length > 0) { const hasEmpty = splits.some((s) => !s.accountId || !s.amount || parseFloat(s.amount) <= 0); if (hasEmpty) { toast({ variant: "destructive", title: "Invalid Splits", description: "All splits must have an account and valid amount." }); return; } if (Math.abs(totalSplitAmount - numAmount) > 0.01) { toast({ variant: "destructive", title: "Amounts Mismatch", description: `Split total ($${totalSplitAmount.toFixed(2)}) must equal amount ($${numAmount.toFixed(2)}).` }); return; } } setLoading(true); try { const isCharge = ["Charge", "Expense"].includes(formData.type); const isWriteOff = formData.type === "WriteOff"; const isPrepayment = formData.type === "Prepayment"; const payload: any = { unit_id: activeUnitId, association_id: activeAssociationId, owner_id: formData.ownerId, date: formData.date, description: formData.description.trim() || (isPrepayment ? "Prepayment / Account Credit" : isWriteOff ? "Write-Off Credit" : ""), transaction_type: isCharge || isWriteOff ? formData.chargeType : (isPrepayment ? "prepayment" : "payment"), debit: isCharge ? numAmount : 0, credit: isCharge ? 0 : numAmount, created_by: user?.id, }; let data: any; if (isEditing && editEntry) { const { data: updated, error } = await supabase.from("owner_ledger_entries").update(payload).eq("id", editEntry.id).select().single(); if (error) throw error; data = updated; } else { const { data: inserted, error } = await supabase.from("owner_ledger_entries").insert([payload]).select().single(); if (error) throw error; data = inserted; } toast({ title: isEditing ? "Transaction Updated" : "Transaction Saved", description: `Successfully ${isEditing ? "updated" : "posted"} ${isWriteOff ? "Write-Off Credit" : formData.type} for $${numAmount.toFixed(2)}.` }); // Create billable expense if checkbox was checked if (addToBillable && isCharge && activeAssociationId) { try { await supabase.from("billable_expenses").insert([{ association_id: activeAssociationId, date: formData.date, description: formData.description.trim(), amount: numAmount, category: formData.chargeType || "other", status: "review", created_by: user?.id, }]); } catch (billableErr) { console.warn("Failed to create billable expense (non-blocking):", billableErr); } } // Journal entries for GL if (activeAssociationId) { if (isPrepayment) { // Auto-post to Prepayment/Unearned Revenue account if it exists const { data: prepayAcct } = await supabase .from("chart_of_accounts") .select("id") .eq("association_id", activeAssociationId) .eq("is_active", true) .or("account_name.ilike.%prepay%,account_name.ilike.%unearned%,account_name.ilike.%advance%,account_name.ilike.%credit balance%") .limit(1) .maybeSingle(); if (prepayAcct) { await supabase.from("journal_entries").insert([{ association_id: activeAssociationId, date: formData.date, description: `Prepayment: ${formData.description.trim() || "Account Credit"}`, amount: numAmount, type: "credit", chart_of_account_id: prepayAcct.id, created_by: user?.id, }]); } } else if (useSplits && splits.length > 0) { const entries = splits.map((split) => ({ association_id: activeAssociationId, date: formData.date, description: `Unit Ledger (Split): ${formData.description.trim()}`, amount: parseFloat(split.amount), type: isCharge ? "debit" : "credit", chart_of_account_id: split.accountId, created_by: user?.id, })); await supabase.from("journal_entries").insert(entries); } else if (formData.chart_of_account_id) { await supabase.from("journal_entries").insert([{ association_id: activeAssociationId, date: formData.date, description: `Unit Ledger: ${formData.description.trim()}`, amount: numAmount, type: isCharge ? "debit" : "credit", chart_of_account_id: formData.chart_of_account_id, created_by: user?.id, }]); } } if (onSuccess) onSuccess(); onOpenChange(false); } catch (err: any) { console.error("[UnitLedgerTransactionForm] Error:", err); toast({ variant: "destructive", title: "Transaction Failed", description: err.message }); } finally { setLoading(false); } }; return (

{isEditing ? "Edit Transaction" : "Record Transaction"}

{isEditing ? "Update the details of this ledger entry." : "Record a charge or payment to the unit ledger. This updates owner ledger entries and posts to the general ledger."}
{/* Association & Unit pickers when not provided */} {needsPickers && (
)}
{["Charge", "WriteOff"].includes(formData.type) && (
)}
{formData.type === "Charge" && formData.chargeType === "interest" && feeRules && (

Auto-calculated at {feeRules.interest_rate}% on outstanding assessment balance as of {formData.date}. You can override.

)}
{/* Waterfall allocation preview for payments */} {formData.type === "Payment" && waterfallAllocations.length > 0 && (

Payment Application Preview

Applied by priority
{waterfallAllocations.map((a) => ( 0 ? "" : "opacity-50"}> ))}
Charge Type Balance Due Applied Remaining
{a.label} ${a.openBefore.toFixed(2)} {a.applied > 0 ? `-$${a.applied.toFixed(2)}` : "—"} ${a.openAfter.toFixed(2)}
)} {formData.type === "Payment" && waterfallLoading && (

Calculating allocation…

)} {/* Add to Billable Expenses checkbox — only for charges */} {["Charge", "Expense"].includes(formData.type) && !isEditing && (
setAddToBillable(!!checked)} /> {addToBillable && ( Will appear in Review tab )}
)}
{/* Ledger Allocation */}

Ledger Allocation

Select the offset account, or split across multiple.

{!useSplits ? (
) : (
{splits.map((split) => ( ))}
Ledger Account Amount
handleSplitChange(split.id, "amount", e.target.value)} disabled={fieldsDisabled} className="pl-7 h-9 font-mono text-sm" />
Allocated Total: 0.01 && formData.amount ? "text-destructive" : "text-emerald-600"}> ${totalSplitAmount.toFixed(2)}
)}
Write-off credits reduce the selected category balance.
); }