Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
da390accae
commit
d0c860b8fe
@@ -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 ($)</th>
|
||||
<th className="text-right px-2 py-2 font-medium">Late ($)</th>
|
||||
<th className="text-right px-2 py-2 font-medium">Admin ($)</th>
|
||||
<th className="text-right px-2 py-2 font-medium">Legal ($)</th>
|
||||
<th className="text-right px-2 py-2 font-medium">Viol ($)</th>
|
||||
<th className="text-right px-2 py-2 font-medium">Int ($)</th>
|
||||
<th className="text-right px-2 py-2 font-medium text-destructive">Bank ($)</th>
|
||||
<th className="text-right px-2 py-2 font-medium">Pay (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,
|
||||
|
||||
Reference in New Issue
Block a user