diff --git a/src/components/cases/collections-tab.tsx b/src/components/cases/collections-tab.tsx index fc9a269..099d2f8 100644 --- a/src/components/cases/collections-tab.tsx +++ b/src/components/cases/collections-tab.tsx @@ -302,7 +302,7 @@ export function CaseCollectionsTab({ export function CollectionDetail({ collection, annualRate, - currentBalance, + currentBalance: _currentBalance, onBack, onChange, }: { @@ -315,8 +315,23 @@ export function CollectionDetail({ const { user } = useAuth(); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); - const [postOpen, setPostOpen] = useState(false); - const [interestOpen, setInterestOpen] = useState(false); + const [chargeOpen, setChargeOpen] = useState(false); + const [paymentOpen, setPaymentOpen] = useState(false); + const [bankOpen, setBankOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + + // Editable header fields + const [ledgerName, setLedgerName] = useState(collection.name ?? ""); + const [rateOverride, setRateOverride] = useState( + collection.interest_rate_override != null + ? String(collection.interest_rate_override) + : "", + ); + + const effectiveRate = + rateOverride && !isNaN(parseFloat(rateOverride)) + ? parseFloat(rateOverride) + : (annualRate ?? 0); const load = async () => { setLoading(true); @@ -336,42 +351,38 @@ export function CollectionDetail({ }, [collection.id]); const opening = Number(collection.homeowner?.opening_balance ?? 0); + const ho = collection.homeowner; - const withRunning = useMemo(() => { - let bal = opening; - return entries.map((e) => { - bal += Number(e.debit) - Number(e.credit); - return { ...e, runningBalance: bal }; - }); - }, [entries, opening]); + const computed = useMemo( + () => computeBucketBalances(entries as any, opening), + [entries, opening], + ); + const withRunning = useMemo( + () => withRunningBalance(entries as any, opening), + [entries, opening], + ); - // Unpaid assessment balance for interest calc — assessments minus payments+adjustments applied - const unpaidAssessmentBalance = useMemo(() => { - let assessments = 0; - let payments = 0; - entries.forEach((e) => { - const t = String(e.transaction_type || "").toLowerCase(); - const debit = Number(e.debit) || 0; - const credit = Number(e.credit) || 0; - if (PAYMENT_TYPES.has(t)) { - payments += credit - debit; - } else if (t === "assessment") { - assessments += debit - credit; - } + const totals = useMemo(() => { + const t: Record = { + assess: 0, late: 0, admin: 0, legal: 0, viol: 0, interest: 0, bank: 0, payment: 0, + }; + entries.forEach((e: any) => { + BUCKETS.forEach((b) => (t[b] += num(e[b]))); + t.payment += num(e.payment); }); - return Math.max(0, assessments - payments); + return t; }, [entries]); + // Save header fields (debounced manual save) + const saveHeader = async (patch: Record) => { + const { error } = await supabase.from("collections").update(patch).eq("id", collection.id); + if (error) toast.error("Could not save", { description: error.message }); + else onChange(); + }; + const updateStatus = async (status: string) => { - const { error } = await supabase - .from("collections") - .update({ status }) - .eq("id", collection.id); - if (error) toast.error("Could not update status", { description: error.message }); - else { - toast.success("Status updated"); - onChange(); - } + await saveHeader({ status }); + toast.success("Status updated"); }; const removeEntry = async (id: string) => { @@ -388,37 +399,114 @@ export function CollectionDetail({ } }; - const exportCSV = () => { + const calcInterestRow = async () => { + if (!effectiveRate || effectiveRate <= 0) { + toast.error("Set an interest rate first"); + return; + } + const monthlyRate = effectiveRate / 100 / 12; + const assessOutstanding = computed.assess; // live unpaid assessment balance + const amount = Math.round(assessOutstanding * monthlyRate * 100) / 100; + if (amount <= 0) { + toast.info("Nothing to post — no unpaid assessments."); + return; + } + const today = new Date().toISOString().slice(0, 10); + const { error } = await supabase.from("collection_ledger_entries").insert({ + collection_id: collection.id, + entry_date: today, + transaction_type: "interest", + account: "interest", + description: `Interest @ ${effectiveRate}% per annum on $${assessOutstanding.toFixed(2)}`, + interest: amount, + created_by: user?.id, + }); + if (error) toast.error(error.message); + else { toast.success(`Posted ${formatCurrency(amount)} interest`); load(); onChange(); } + }; + + const exportStatement = () => { const rows = [ - ["Date", "Type", "Description", "Charge", "Payment", "Balance"].join(","), - ...withRunning.map((e) => - [ - e.entry_date, - txnLabel(e.transaction_type), - `"${(e.description || "").replace(/"/g, '""')}"`, - Number(e.debit).toFixed(2), - Number(e.credit).toFixed(2), - e.runningBalance.toFixed(2), - ].join(","), - ), + ["Date", "Description", "Account", "Assess", "Late", "Admin", "Legal", "Viol", "Int", "Bank", "Payment", "Balance"].join(","), + ...withRunning.map((e: any) => [ + e.entry_date, + `"${(e.description || "").replace(/"/g, '""')}"`, + e.account || e.transaction_type || "", + num(e.assess).toFixed(2), + num(e.late).toFixed(2), + num(e.admin).toFixed(2), + num(e.legal).toFixed(2), + num(e.viol).toFixed(2), + num(e.interest).toFixed(2), + num(e.bank).toFixed(2), + num(e.payment).toFixed(2), + e.runningBalance.toFixed(2), + ].join(",")), ].join("\n"); const blob = new Blob([rows], { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); - a.download = `Ledger_${collection.homeowner?.last_name || "homeowner"}.csv`; + a.download = `Statement_${ho?.last_name || "homeowner"}.csv`; a.click(); }; - const ho = collection.homeowner; + const loadFromUnitLedger = async () => { + // Re-pull homeowner opening balance and any other future "saved ledger" data + const { data, error } = await supabase + .from("homeowners").select("opening_balance").eq("id", collection.homeowner_id).single(); + if (error) { toast.error(error.message); return; } + toast.success(`Unit opening balance: ${formatCurrency(Number(data.opening_balance) || 0)}`); + onChange(); + }; + + const syncAutomatedHistory = async () => { + if (!confirm("Re-calculate all auto-posted interest from scratch? This will delete prior interest entries and re-apply monthly interest.")) return; + if (!effectiveRate || effectiveRate <= 0) { toast.error("Set an interest rate first"); return; } + // Delete existing interest rows + const { error: delErr } = await supabase + .from("collection_ledger_entries").delete() + .eq("collection_id", collection.id).eq("transaction_type", "interest"); + if (delErr) { toast.error(delErr.message); return; } + // Recompute month-by-month from opened_at to today + const start = new Date(collection.opened_at); + const end = new Date(); + const monthlyRate = effectiveRate / 100 / 12; + // Reload entries (without interest) to compute outstanding per month + const { data: fresh } = await supabase + .from("collection_ledger_entries").select("*") + .eq("collection_id", collection.id) + .order("entry_date", { ascending: true }); + const list: any[] = fresh ?? []; + const cursor = new Date(start); + cursor.setDate(1); + const newRows: any[] = []; + while (cursor <= end) { + const cutoff = new Date(cursor); cutoff.setMonth(cutoff.getMonth() + 1); + const upto = list.filter((e) => new Date(e.entry_date) < cutoff); + const bal = computeBucketBalances(upto as any, opening).assess; + const amt = Math.round(bal * monthlyRate * 100) / 100; + if (amt > 0) { + newRows.push({ + collection_id: collection.id, + entry_date: cutoff.toISOString().slice(0, 10), + transaction_type: "interest", account: "interest", + description: `Auto interest @ ${effectiveRate}% / yr on $${bal.toFixed(2)}`, + interest: amt, created_by: user?.id, + }); + } + cursor.setMonth(cursor.getMonth() + 1); + } + if (newRows.length) { + const { error } = await supabase.from("collection_ledger_entries").insert(newRows); + if (error) { toast.error(error.message); return; } + } + toast.success(`Synced — posted ${newRows.length} interest entries`); + load(); onChange(); + }; return (
- @@ -433,220 +521,227 @@ export function CollectionDetail({ {ho?.email && ` · ${ho.email}`}

-
- - - -
- - -
- - -
- Current balance -
-
0 - ? "text-destructive font-semibold" - : currentBalance < 0 - ? "text-emerald-600" - : "" - }`} - > - {formatCurrency(Math.abs(currentBalance))} - {currentBalance < 0 ? " CR" : currentBalance > 0 ? " DUE" : ""} -
-
-
- - -
- Unpaid assessments -
-
- {formatCurrency(unpaidAssessmentBalance)} -
-
-
- - -
- Ledger entries -
-
{entries.length}
-
-
+
+ {/* Header controls */} - - {loading ? ( -
- Loading… + +
+
+ + setLedgerName(e.target.value)} + onBlur={() => saveHeader({ name: ledgerName || null })} + />
- ) : withRunning.length === 0 ? ( -
- No ledger entries yet. Post an entry to get started. +
+ + setRateOverride(e.target.value)} + onBlur={() => saveHeader({ interest_rate_override: rateOverride === "" ? null : parseFloat(rateOverride) })} + /> +

Interest is calculated on the Assessment balance.

- ) : ( - - - - Date - Type - Description - Charge - Payment - Balance - - - - - {opening !== 0 && ( - - - {formatDate(collection.opened_at)} - - - - Opening - - - - Opening balance - - - {opening > 0 ? formatCurrency(opening) : "—"} - - - {opening < 0 ? formatCurrency(-opening) : "—"} - - - {formatCurrency(Math.abs(opening))} - {opening < 0 ? " CR" : ""} - - - - )} - {withRunning.map((e) => ( - - - {formatDate(e.entry_date)} - - - - {txnLabel(e.transaction_type)} - - - - {e.description || "—"} - - - {Number(e.debit) > 0 ? ( - - {formatCurrency(Number(e.debit))} - - ) : ( - "—" - )} - - - {Number(e.credit) > 0 ? ( - - {formatCurrency(Number(e.credit))} - - ) : ( - "—" - )} - - 0 - ? "text-destructive" - : e.runningBalance < 0 - ? "text-emerald-600" - : "" - }`} - > - {formatCurrency(Math.abs(e.runningBalance))} - {e.runningBalance < 0 ? " CR" : ""} - - - - - - ))} - -
- )} +
+ + +
+
+
+ + +
-
- + {/* Total Due + Breakdown */} +
+ + +
Total Due
+
{formatCurrency(Math.max(0, computed.total))}
+
+ {computed.total > 0 ? "Outstanding Balance" : computed.total < 0 ? `Credit: ${formatCurrency(-computed.total)}` : "Paid in full"} +
+
+
+ + +
Amount Due Breakdown (in priority order)
+
+ {BUCKETS.map((b, i) => { + const v = computed[b]; + const active = v > 0; + return ( +
+
+ {i + 1}. {BUCKET_LABEL[b]} +
+
+ {formatCurrency(Math.max(0, v))} +
+
+ ); + })} +
+
+
- { - load(); - onChange(); - }} - /> + {/* Ledger table */} + + + {loading ? ( +
Loading…
+ ) : ( + + + + + + + + + + + + + + + + + + + + {opening !== 0 && ( + + + + + + + + + + )} + {withRunning.length === 0 && opening === 0 && ( + + )} + {withRunning.map((e: any) => ( + + + + + + + + + + + + + + + + ))} + + + + + + + + + + + + + + +
DateDescriptionAccountAssess ($)Late ($)Admin ($)Legal ($)Viol ($)Int ($)Bank ($)Pay (AR)Balance
{formatDate(collection.opened_at)}Opening balanceopening{opening > 0 ? opening.toFixed(2) : "0.00"}0.00{formatCurrency(opening)}
No ledger entries yet. Use the buttons below to add one.
{formatDate(e.entry_date)}{e.description || "—"}{e.account || e.transaction_type || "—"} 0 ? "" : e.runningBalance < 0 ? "text-emerald-600" : "text-muted-foreground"}`}> + {formatCurrency(e.runningBalance)} + + +
Totals:{formatCurrency(totals.assess)}{formatCurrency(totals.late)}{formatCurrency(totals.admin)}{formatCurrency(totals.legal)}{formatCurrency(totals.viol)}{formatCurrency(totals.interest)}{formatCurrency(totals.bank)}{formatCurrency(totals.payment)}{formatCurrency(computed.total)}
+ )} - + + + + +
+ + + + { - load(); - onChange(); - }} + onSaved={() => { load(); onChange(); }} + /> + { load(); onChange(); }} + /> + { load(); onChange(); }} + /> + { load(); onChange(); }} />
); } +function BucketCell({ value, danger, positive }: { value: number; danger?: boolean; positive?: boolean }) { + if (!value) return 0.00; + return ( + + {value.toFixed(2)} + + ); +} + + // ─── Add collection dialog ────────────────────────────────────────── function AddCollectionDialog({ open,