Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 01:50:23 +00:00
co-authored by renee-png
parent da390accae
commit d0c860b8fe
+344 -249
View File
@@ -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<any[]>([]);
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<string>(
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<string, number> = {
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<string, any>) => {
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 (
<div className="space-y-4">
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="-ml-2"
>
<Button variant="ghost" size="sm" onClick={onBack} className="-ml-2">
<ArrowLeft className="h-4 w-4 mr-1" /> Back to collections
</Button>
@@ -433,220 +521,227 @@ export function CollectionDetail({
{ho?.email && ` · ${ho.email}`}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select value={collection.status} onValueChange={updateStatus}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => setInterestOpen(true)}
disabled={!annualRate || annualRate <= 0}
title={
!annualRate || annualRate <= 0
? "Set an annual interest rate on the HOA client first"
: "Auto-calculate interest"
}
>
<Calculator className="h-4 w-4 mr-2" /> Post interest
</Button>
<Button onClick={() => setPostOpen(true)}>
<Plus className="h-4 w-4 mr-2" /> Post entry
</Button>
</div>
</div>
<div className="grid sm:grid-cols-3 gap-3">
<Card className="border-border/60">
<CardContent className="p-4">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
Current balance
</div>
<div
className={`text-2xl font-mono mt-1 ${
currentBalance > 0
? "text-destructive font-semibold"
: currentBalance < 0
? "text-emerald-600"
: ""
}`}
>
{formatCurrency(Math.abs(currentBalance))}
{currentBalance < 0 ? " CR" : currentBalance > 0 ? " DUE" : ""}
</div>
</CardContent>
</Card>
<Card className="border-border/60">
<CardContent className="p-4">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
Unpaid assessments
</div>
<div className="text-2xl font-mono mt-1">
{formatCurrency(unpaidAssessmentBalance)}
</div>
</CardContent>
</Card>
<Card className="border-border/60">
<CardContent className="p-4">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
Ledger entries
</div>
<div className="text-2xl font-mono mt-1">{entries.length}</div>
</CardContent>
</Card>
<Select value={collection.status} onValueChange={updateStatus}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Header controls */}
<Card className="border-border/60">
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground text-sm">
Loading…
<CardContent className="p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<Label className="text-xs">Ledger Name</Label>
<Input
value={ledgerName}
placeholder="e.g. 2026 Annual Assessment"
onChange={(e) => setLedgerName(e.target.value)}
onBlur={() => saveHeader({ name: ledgerName || null })}
/>
</div>
) : withRunning.length === 0 ? (
<div className="p-12 text-center text-muted-foreground text-sm">
No ledger entries yet. Post an entry to get started.
<div>
<Label className="text-xs">Interest Rate (annual %)</Label>
<Input
type="number" step="0.01"
value={rateOverride}
placeholder={annualRate != null ? `Default ${annualRate}%` : "Not set"}
onChange={(e) => setRateOverride(e.target.value)}
onBlur={() => saveHeader({ interest_rate_override: rateOverride === "" ? null : parseFloat(rateOverride) })}
/>
<p className="text-[11px] text-muted-foreground mt-1">Interest is calculated on the Assessment balance.</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Type</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Charge</TableHead>
<TableHead className="text-right">Payment</TableHead>
<TableHead className="text-right">Balance</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{opening !== 0 && (
<TableRow className="bg-muted/30">
<TableCell className="text-muted-foreground">
{formatDate(collection.opened_at)}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
Opening
</Badge>
</TableCell>
<TableCell className="text-muted-foreground italic">
Opening balance
</TableCell>
<TableCell className="text-right font-mono text-muted-foreground">
{opening > 0 ? formatCurrency(opening) : "—"}
</TableCell>
<TableCell className="text-right font-mono text-muted-foreground">
{opening < 0 ? formatCurrency(-opening) : "—"}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(Math.abs(opening))}
{opening < 0 ? " CR" : ""}
</TableCell>
<TableCell></TableCell>
</TableRow>
)}
{withRunning.map((e) => (
<TableRow key={e.id}>
<TableCell className="whitespace-nowrap">
{formatDate(e.entry_date)}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{txnLabel(e.transaction_type)}
</Badge>
</TableCell>
<TableCell className="max-w-md">
{e.description || "—"}
</TableCell>
<TableCell className="text-right font-mono">
{Number(e.debit) > 0 ? (
<span className="text-destructive">
{formatCurrency(Number(e.debit))}
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="text-right font-mono">
{Number(e.credit) > 0 ? (
<span className="text-emerald-600">
{formatCurrency(Number(e.credit))}
</span>
) : (
"—"
)}
</TableCell>
<TableCell
className={`text-right font-mono font-bold ${
e.runningBalance > 0
? "text-destructive"
: e.runningBalance < 0
? "text-emerald-600"
: ""
}`}
>
{formatCurrency(Math.abs(e.runningBalance))}
{e.runningBalance < 0 ? " CR" : ""}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
onClick={() => removeEntry(e.id)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<div className="flex items-end justify-end gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={exportStatement}><Download className="h-4 w-4 mr-1.5" /> Export Statement</Button>
<Button variant="outline" size="sm" onClick={loadFromUnitLedger}><RefreshCw className="h-4 w-4 mr-1.5" /> Load from Unit</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={syncAutomatedHistory}><RefreshCw className="h-4 w-4 mr-1.5" /> Sync Automated History</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}><Upload className="h-4 w-4 mr-1.5" /> Import CSV</Button>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={exportCSV}>
Export CSV
</Button>
{/* Total Due + Breakdown */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
<Card className="bg-foreground text-background border-foreground lg:col-span-1">
<CardContent className="p-5">
<div className="text-xs uppercase tracking-wider opacity-70 font-medium">Total Due</div>
<div className="text-4xl font-mono font-bold mt-2">{formatCurrency(Math.max(0, computed.total))}</div>
<div className="text-xs opacity-70 mt-1">
{computed.total > 0 ? "Outstanding Balance" : computed.total < 0 ? `Credit: ${formatCurrency(-computed.total)}` : "Paid in full"}
</div>
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardContent className="p-5">
<div className="text-xs uppercase tracking-wider text-muted-foreground font-medium mb-3">Amount Due Breakdown (in priority order)</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{BUCKETS.map((b, i) => {
const v = computed[b];
const active = v > 0;
return (
<div key={b} className={`rounded-md border p-3 ${active && i === 0 ? "border-destructive/60 bg-destructive/5" : "border-border/60"}`}>
<div className={`text-[10px] uppercase tracking-wider font-medium ${active && i === 0 ? "text-destructive" : "text-muted-foreground"}`}>
{i + 1}. {BUCKET_LABEL[b]}
</div>
<div className={`text-lg font-mono font-semibold mt-1 ${active ? "" : "text-muted-foreground"}`}>
{formatCurrency(Math.max(0, v))}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
<PostEntryDialog
open={postOpen}
onOpenChange={setPostOpen}
collectionId={collection.id}
userId={user?.id}
onSaved={() => {
load();
onChange();
}}
/>
{/* Ledger table */}
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0 overflow-x-auto">
{loading ? (
<div className="p-8 text-center text-muted-foreground text-sm">Loading…</div>
) : (
<table className="w-full text-sm">
<thead className="bg-muted/40 text-[11px] uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-3 py-2 font-medium">Date</th>
<th className="text-left px-3 py-2 font-medium">Description</th>
<th className="text-left px-3 py-2 font-medium">Account</th>
<th className="text-right px-2 py-2 font-medium">Assess&nbsp;($)</th>
<th className="text-right px-2 py-2 font-medium">Late&nbsp;($)</th>
<th className="text-right px-2 py-2 font-medium">Admin&nbsp;($)</th>
<th className="text-right px-2 py-2 font-medium">Legal&nbsp;($)</th>
<th className="text-right px-2 py-2 font-medium">Viol&nbsp;($)</th>
<th className="text-right px-2 py-2 font-medium">Int&nbsp;($)</th>
<th className="text-right px-2 py-2 font-medium text-destructive">Bank&nbsp;($)</th>
<th className="text-right px-2 py-2 font-medium">Pay&nbsp;(AR)</th>
<th className="text-right px-3 py-2 font-medium">Balance</th>
<th className="px-2 py-2"></th>
</tr>
</thead>
<tbody>
{opening !== 0 && (
<tr className="border-t bg-muted/20">
<td className="px-3 py-2 text-muted-foreground">{formatDate(collection.opened_at)}</td>
<td className="px-3 py-2 text-muted-foreground italic">Opening balance</td>
<td className="px-3 py-2 text-muted-foreground">opening</td>
<td className="px-2 py-2 text-right font-mono">{opening > 0 ? opening.toFixed(2) : "0.00"}</td>
<td colSpan={7} className="px-2 py-2 text-right font-mono text-muted-foreground">0.00</td>
<td className="px-3 py-2 text-right font-mono font-semibold">{formatCurrency(opening)}</td>
<td></td>
</tr>
)}
{withRunning.length === 0 && opening === 0 && (
<tr><td colSpan={13} className="text-center py-12 text-muted-foreground text-sm">No ledger entries yet. Use the buttons below to add one.</td></tr>
)}
{withRunning.map((e: any) => (
<tr key={e.id} className="border-t hover:bg-muted/20">
<td className="px-3 py-2 whitespace-nowrap">{formatDate(e.entry_date)}</td>
<td className="px-3 py-2">{e.description || "—"}</td>
<td className="px-3 py-2 text-muted-foreground capitalize">{e.account || e.transaction_type || "—"}</td>
<BucketCell value={num(e.assess)} />
<BucketCell value={num(e.late)} />
<BucketCell value={num(e.admin)} />
<BucketCell value={num(e.legal)} />
<BucketCell value={num(e.viol)} />
<BucketCell value={num(e.interest)} />
<BucketCell value={num(e.bank)} danger />
<BucketCell value={num(e.payment)} positive />
<td className={`px-3 py-2 text-right font-mono font-semibold ${e.runningBalance > 0 ? "" : e.runningBalance < 0 ? "text-emerald-600" : "text-muted-foreground"}`}>
{formatCurrency(e.runningBalance)}
</td>
<td className="px-2 py-2 text-right">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => removeEntry(e.id)}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</td>
</tr>
))}
<tr className="border-t bg-muted/30 font-medium">
<td colSpan={3} className="px-3 py-2 text-right text-muted-foreground uppercase text-[11px] tracking-wider">Totals:</td>
<td className="px-2 py-2 text-right font-mono">{formatCurrency(totals.assess)}</td>
<td className="px-2 py-2 text-right font-mono">{formatCurrency(totals.late)}</td>
<td className="px-2 py-2 text-right font-mono">{formatCurrency(totals.admin)}</td>
<td className="px-2 py-2 text-right font-mono">{formatCurrency(totals.legal)}</td>
<td className="px-2 py-2 text-right font-mono">{formatCurrency(totals.viol)}</td>
<td className="px-2 py-2 text-right font-mono">{formatCurrency(totals.interest)}</td>
<td className="px-2 py-2 text-right font-mono text-destructive">{formatCurrency(totals.bank)}</td>
<td className="px-2 py-2 text-right font-mono">{formatCurrency(totals.payment)}</td>
<td className="px-3 py-2 text-right font-mono">{formatCurrency(computed.total)}</td>
<td></td>
</tr>
</tbody>
</table>
)}
<PostInterestDialog
open={interestOpen}
onOpenChange={setInterestOpen}
{/* Quick-add row */}
<div className="flex flex-wrap gap-2 p-3 border-t bg-muted/10">
<Button variant="outline" size="sm" onClick={() => setChargeOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" /> Add Charge (Debit)
</Button>
<Button variant="outline" size="sm" className="text-emerald-700 border-emerald-200 hover:bg-emerald-50 dark:text-emerald-400 dark:border-emerald-900" onClick={() => setPaymentOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" /> Add Payment (AR Credit)
</Button>
<Button variant="outline" size="sm" className="text-destructive border-destructive/30 hover:bg-destructive/10" onClick={() => setBankOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" /> Add Bank Fee
</Button>
<Button variant="outline" size="sm" onClick={calcInterestRow} disabled={!effectiveRate || effectiveRate <= 0}>
<Calculator className="h-4 w-4 mr-1.5" /> Calculate Interest Row
</Button>
</div>
</CardContent>
</Card>
<AddChargeDialog
open={chargeOpen}
onOpenChange={setChargeOpen}
collectionId={collection.id}
userId={user?.id}
annualRate={annualRate ?? 0}
unpaidAssessmentBalance={unpaidAssessmentBalance}
onSaved={() => {
load();
onChange();
}}
onSaved={() => { load(); onChange(); }}
/>
<AddPaymentDialog
open={paymentOpen}
onOpenChange={setPaymentOpen}
collectionId={collection.id}
userId={user?.id}
onSaved={() => { load(); onChange(); }}
/>
<AddBankFeeDialog
open={bankOpen}
onOpenChange={setBankOpen}
collectionId={collection.id}
userId={user?.id}
onSaved={() => { load(); onChange(); }}
/>
<ImportCsvDialog
open={importOpen}
onOpenChange={setImportOpen}
collectionId={collection.id}
userId={user?.id}
onSaved={() => { load(); onChange(); }}
/>
</div>
);
}
function BucketCell({ value, danger, positive }: { value: number; danger?: boolean; positive?: boolean }) {
if (!value) return <td className="px-2 py-2 text-right font-mono text-muted-foreground/50">0.00</td>;
return (
<td className={`px-2 py-2 text-right font-mono ${danger ? "text-destructive" : positive ? "text-emerald-600" : ""}`}>
{value.toFixed(2)}
</td>
);
}
// ─── Add collection dialog ──────────────────────────────────────────
function AddCollectionDialog({
open,