mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,773 @@
|
||||
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 { syncChargeToZoho, syncPaymentToZoho } from "@/lib/zohoFinancialSync";
|
||||
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<any[]>([]);
|
||||
const [units, setUnits] = useState<any[]>([]);
|
||||
const [selectedAssociationId, setSelectedAssociationId] = useState("");
|
||||
const [selectedUnitId, setSelectedUnitId] = useState("");
|
||||
|
||||
const needsPickers = !propUnitId;
|
||||
const activeUnitId = propUnitId || selectedUnitId;
|
||||
const activeAssociationId = propAssociationId || selectedAssociationId;
|
||||
|
||||
const [owners, setOwners] = useState<any[]>([]);
|
||||
const [accounts, setAccounts] = useState<any[]>([]);
|
||||
|
||||
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<any>(null);
|
||||
|
||||
const [splits, setSplits] = useState<{ id: string; accountId: string; amount: string }[]>([]);
|
||||
const [useSplits, setUseSplits] = useState(false);
|
||||
const [waterfallAllocations, setWaterfallAllocations] = useState<WaterfallAllocation[]>([]);
|
||||
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<HTMLInputElement>) => {
|
||||
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);
|
||||
if (!formData.amount || isNaN(numAmount) || numAmount <= 0) {
|
||||
toast({ variant: "destructive", title: "Invalid Amount", description: "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,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-sync to Zoho
|
||||
if (data?.id) {
|
||||
try {
|
||||
if (isCharge) await syncChargeToZoho(data.id);
|
||||
else await syncPaymentToZoho(data.id);
|
||||
} catch (syncErr) {
|
||||
console.warn("Zoho sync failed (non-blocking):", syncErr);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[750px] p-0 overflow-hidden">
|
||||
<div className="p-6 border-b">
|
||||
<h2 className="text-2xl font-semibold flex items-center gap-2">
|
||||
<FileText className="w-6 h-6 text-primary" />
|
||||
{isEditing ? "Edit Transaction" : "Record Transaction"}
|
||||
</h2>
|
||||
<DialogDescription className="text-muted-foreground mt-1.5 text-sm">
|
||||
{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."}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<form id="record-tx-form" onSubmit={handleSubmit} className="overflow-y-auto max-h-[75vh]">
|
||||
<div className="p-6 space-y-6">
|
||||
|
||||
{/* Association & Unit pickers when not provided */}
|
||||
{needsPickers && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="space-y-2">
|
||||
<Label>Association <span className="text-destructive">*</span></Label>
|
||||
<Select value={selectedAssociationId} onValueChange={(v) => { setSelectedAssociationId(v); setSelectedUnitId(""); setFormData(prev => ({ ...prev, ownerId: "", chart_of_account_id: "" })); }} disabled={fieldsDisabled}>
|
||||
<SelectTrigger className="h-10"><SelectValue placeholder="Select association" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{associations.map(a => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Unit <span className="text-destructive">*</span></Label>
|
||||
<Select value={selectedUnitId} onValueChange={(v) => { setSelectedUnitId(v); setFormData(prev => ({ ...prev, ownerId: "", chart_of_account_id: "" })); }} disabled={fieldsDisabled || !selectedAssociationId}>
|
||||
<SelectTrigger className="h-10"><SelectValue placeholder="Select unit" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{units.map(u => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
Unit {u.unit_number}{u.address ? ` — ${u.address}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`grid grid-cols-1 ${["Charge", "WriteOff"].includes(formData.type) ? "md:grid-cols-3" : "md:grid-cols-2"} gap-5`}>
|
||||
<div className="space-y-2">
|
||||
<Label>Transaction Type <span className="text-destructive">*</span></Label>
|
||||
<Select value={formData.type} onValueChange={(v) => handleSelectChange("type", v)} disabled={fieldsDisabled}>
|
||||
<SelectTrigger className="h-10"><SelectValue placeholder="Select type" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Payment">Payment</SelectItem>
|
||||
<SelectItem value="Charge">Charge</SelectItem>
|
||||
<SelectItem value="WriteOff">Write-Off Credit</SelectItem>
|
||||
<SelectItem value="Prepayment">Prepayment (Account Credit)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{["Charge", "WriteOff"].includes(formData.type) && (
|
||||
<div className="space-y-2">
|
||||
<Label>{formData.type === "WriteOff" ? "Write-Off Category" : "Charge Type"} <span className="text-destructive">*</span></Label>
|
||||
<Select value={formData.chargeType} onValueChange={(v) => handleSelectChange("chargeType", v)} disabled={fieldsDisabled}>
|
||||
<SelectTrigger className="h-10"><SelectValue placeholder="Select charge type" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{CHARGE_TYPES.map(ct => (
|
||||
<SelectItem key={ct.value} value={ct.value}>{ct.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Date <span className="text-destructive">*</span></Label>
|
||||
<div className="relative">
|
||||
<CalendarIcon className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input name="date" type="date" required value={formData.date} onChange={handleChange} className="pl-9 h-10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Owner <span className="text-destructive">*</span></Label>
|
||||
<Select value={formData.ownerId} onValueChange={(v) => handleSelectChange("ownerId", v)} disabled={fieldsDisabled || fetchingData || !activeUnitId}>
|
||||
<SelectTrigger className="h-10"><SelectValue placeholder={!activeUnitId ? "Select a unit first" : "Select owner"} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{owners.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>{o.first_name} {o.last_name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-[2fr_1fr] gap-5">
|
||||
<div className="space-y-2">
|
||||
<Label>Description <span className="text-destructive">*</span></Label>
|
||||
<Input name="description" required placeholder="E.g., Monthly Assessment" value={formData.description} onChange={handleChange} disabled={fieldsDisabled} className="h-10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Total Amount <span className="text-destructive">*</span></Label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input name="amount" type="number" step="0.01" min="0.01" required placeholder="0.00" value={formData.amount} onChange={handleChange} disabled={fieldsDisabled} className="pl-8 h-10 font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
{formData.type === "Charge" && formData.chargeType === "interest" && feeRules && (
|
||||
<p className="text-xs text-muted-foreground col-span-full">
|
||||
Auto-calculated at {feeRules.interest_rate}% on outstanding assessment balance as of {formData.date}. You can override.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Waterfall allocation preview for payments */}
|
||||
{formData.type === "Payment" && waterfallAllocations.length > 0 && (
|
||||
<div className="border rounded-lg overflow-hidden bg-muted/30">
|
||||
<div className="px-4 py-2.5 border-b bg-muted/50 flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
||||
<SplitSquareHorizontal className="w-4 h-4 text-primary" />
|
||||
Payment Application Preview
|
||||
</h4>
|
||||
<span className="text-[11px] text-muted-foreground">Applied by priority</span>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b">
|
||||
<tr className="text-xs text-muted-foreground">
|
||||
<th className="text-left p-2.5 font-medium">Charge Type</th>
|
||||
<th className="text-right p-2.5 font-medium">Balance Due</th>
|
||||
<th className="text-right p-2.5 font-medium">Applied</th>
|
||||
<th className="text-right p-2.5 font-medium">Remaining</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{waterfallAllocations.map((a) => (
|
||||
<tr key={a.type} className={a.applied > 0 ? "" : "opacity-50"}>
|
||||
<td className="p-2.5 font-medium">{a.label}</td>
|
||||
<td className="p-2.5 text-right font-mono text-muted-foreground">${a.openBefore.toFixed(2)}</td>
|
||||
<td className="p-2.5 text-right font-mono text-emerald-600 font-semibold">
|
||||
{a.applied > 0 ? `-$${a.applied.toFixed(2)}` : "—"}
|
||||
</td>
|
||||
<td className="p-2.5 text-right font-mono">${a.openAfter.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{formData.type === "Payment" && waterfallLoading && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Calculating allocation…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Add to Billable Expenses checkbox — only for charges */}
|
||||
{["Charge", "Expense"].includes(formData.type) && !isEditing && (
|
||||
<div className="flex items-center gap-2 bg-muted/50 p-3 rounded-lg border">
|
||||
<Checkbox
|
||||
id="addToBillable"
|
||||
checked={addToBillable}
|
||||
disabled={fieldsDisabled}
|
||||
onCheckedChange={(checked) => setAddToBillable(!!checked)}
|
||||
/>
|
||||
<label htmlFor="addToBillable" className="text-sm font-medium cursor-pointer select-none">
|
||||
Add to Client Billable Expenses
|
||||
</label>
|
||||
{addToBillable && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">Will appear in Review tab</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-border w-full my-2" />
|
||||
|
||||
{/* Ledger Allocation */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
||||
<SplitSquareHorizontal className="w-4 h-4 text-muted-foreground" />
|
||||
Ledger Allocation
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">Select the offset account, or split across multiple.</p>
|
||||
</div>
|
||||
|
||||
{!useSplits ? (
|
||||
<div className="space-y-2 bg-muted/50 p-4 rounded-lg border">
|
||||
<Label className="text-sm font-medium">Offsetting Account</Label>
|
||||
<Select value={formData.chart_of_account_id} onValueChange={(v) => handleSelectChange("chart_of_account_id", v)} disabled={fieldsDisabled}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="Select Account (optional)" /></SelectTrigger>
|
||||
<SelectContent className="max-h-[250px]">
|
||||
{accounts.map((acc) => (
|
||||
<SelectItem key={acc.id} value={acc.id}>{acc.account_name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={handleAddSplit} disabled={fieldsDisabled} className="text-primary mt-2 h-8 px-2">
|
||||
Split multiple accounts
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-muted/50 border-b">
|
||||
<tr>
|
||||
<th className="font-medium p-3 w-3/5">Ledger Account</th>
|
||||
<th className="font-medium p-3 w-1/3">Amount</th>
|
||||
<th className="font-medium p-3 text-center" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{splits.map((split) => (
|
||||
<tr key={split.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<Select value={split.accountId} onValueChange={(val) => handleSplitChange(split.id, "accountId", val)} disabled={fieldsDisabled}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue placeholder="Select Account" /></SelectTrigger>
|
||||
<SelectContent className="max-h-[250px]">
|
||||
{accounts.map((acc) => (
|
||||
<SelectItem key={acc.id} value={acc.id}>{acc.account_name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input type="number" step="0.01" min="0.01" placeholder="0.00" value={split.amount} onChange={(e) => handleSplitChange(split.id, "amount", e.target.value)} disabled={fieldsDisabled} className="pl-7 h-9 font-mono text-sm" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => handleRemoveSplit(split.id)} className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bg-muted/50 border-t">
|
||||
<td className="p-3 font-medium text-right">Allocated Total:</td>
|
||||
<td className="p-3 font-mono font-medium">
|
||||
<span className={Math.abs(totalSplitAmount - parseFloat(formData.amount || "0")) > 0.01 && formData.amount ? "text-destructive" : "text-emerald-600"}>
|
||||
${totalSplitAmount.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="p-3 bg-muted/50 border-t flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddSplit} disabled={fieldsDisabled} className="text-primary">
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> Add Line
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setUseSplits(false)} className="text-muted-foreground">
|
||||
Cancel Splits
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter className="p-5 border-t sm:justify-between items-center flex-col sm:flex-row gap-3">
|
||||
<div className="text-xs text-muted-foreground font-medium hidden sm:block">
|
||||
Write-off credits reduce the selected category balance.
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={loading} className="h-10 px-6 w-full sm:w-auto">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="record-tx-form" disabled={loading || fieldsDisabled} className="h-10 px-6 w-full sm:w-auto">
|
||||
{loading ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Saving...</> : "Save Transaction"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user