mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
3f39bfbd70
Relax the amount validation so a Charge/Expense/WriteOff can be posted with a $0.00 amount (used as a note/memo on the ledger). Payments and prepayments still require a positive amount; negatives remain blocked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
770 lines
34 KiB
TypeScript
770 lines
34 KiB
TypeScript
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<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);
|
|
// 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 (
|
|
<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>
|
|
);
|
|
}
|