Add ACMCC app source, Supabase backend, and project config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:19:26 -04:00
parent 313b51b412
commit 183fe0a93c
1422 changed files with 259271 additions and 0 deletions
@@ -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>
);
}