Files
acmcc/src/pages/OwnerLedgerPage.tsx
T
admin f53a0aaf46 Owner Ledger: add $0.00 note entries
Add an "Add Note" action that records a memo on an owner's ledger as a
$0.00 entry (transaction_type 'note', debit/credit 0). Notes work on any
ledger including paid-up ($0.00 balance) accounts, render as styled memo
rows, and don't affect the running balance. The accounting sync treats a
zero entry as a no-op, so no phantom AR is created.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:12:50 -04:00

327 lines
16 KiB
TypeScript

import { useState, useEffect, useMemo } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { BookOpen, Plus, Search, Download, DollarSign, StickyNote } from "lucide-react";
import UnitOwnerSelect from "@/components/UnitOwnerSelect";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { format } from "date-fns";
const txnTypes = [
"assessment", "special_assessment", "late_fee", "interest", "fine",
"admin_charge", "payment", "reversal", "credit", "adjustment"
];
const txnTypeLabels: Record<string, string> = {
assessment: "Assessment", special_assessment: "Special Assessment",
late_fee: "Late Fee", interest: "Interest", fine: "Fine",
admin_charge: "Admin Charge", payment: "Payment", reversal: "Reversal",
credit: "Credit", adjustment: "Adjustment", note: "Note"
};
export default function OwnerLedgerPage() {
const { toast } = useToast();
const [entries, setEntries] = useState<any[]>([]);
const [owners, setOwners] = useState<any[]>([]);
const [units, setUnits] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedOwnerId, setSelectedOwnerId] = useState("");
const [search, setSearch] = useState("");
const [fromLastZero, setFromLastZero] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState({
date: new Date().toISOString().split("T")[0],
transaction_type: "assessment",
description: "",
debit: "",
credit: "",
unit_id: "",
});
const [noteDialogOpen, setNoteDialogOpen] = useState(false);
const [noteSaving, setNoteSaving] = useState(false);
const [noteForm, setNoteForm] = useState({ date: new Date().toISOString().split("T")[0], text: "" });
useEffect(() => {
Promise.all([
supabase.from("owners").select("id, first_name, last_name, unit_id, association_id, units(unit_number)").eq("status", "active").order("last_name"),
supabase.from("units").select("id, unit_number, association_id").order("unit_number"),
]).then(([oRes, uRes]) => {
setOwners(oRes.data || []);
setUnits(uRes.data || []);
if (oRes.data?.length) setSelectedOwnerId(oRes.data[0].id);
setLoading(false);
});
}, []);
useEffect(() => {
if (selectedOwnerId) fetchLedger();
}, [selectedOwnerId]);
const fetchLedger = async () => {
setLoading(true);
const { data } = await supabase
.from("owner_ledger_entries")
.select("*, units(unit_number)")
.eq("owner_id", selectedOwnerId)
.order("date", { ascending: true })
.order("created_at", { ascending: true });
setEntries(data || []);
setLoading(false);
};
const filtered = useMemo(() => {
if (!search) return entries;
const q = search.toLowerCase();
return entries.filter(e => (e.description || "").toLowerCase().includes(q) || (e.transaction_type || "").toLowerCase().includes(q));
}, [entries, search]);
const withBalance = useMemo(() => {
let bal = 0;
const all = filtered.map(e => {
bal += Number(e.debit) - Number(e.credit);
return { ...e, runningBalance: bal };
});
if (!fromLastZero) return all;
// Find the index of the LAST entry where running balance reached zero (within 1 cent).
// Show entries AFTER that point. If balance never returned to zero, show all.
let lastZeroIdx = -1;
for (let i = 0; i < all.length; i++) {
if (Math.abs(all[i].runningBalance) < 0.01) lastZeroIdx = i;
}
if (lastZeroIdx < 0) return all;
// Recompute running balance starting fresh after the zero point
const sliced = all.slice(lastZeroIdx + 1);
let b = 0;
return sliced.map(e => {
b += Number(e.debit) - Number(e.credit);
return { ...e, runningBalance: b };
});
}, [filtered, fromLastZero]);
const currentBalance = withBalance.length > 0 ? withBalance[withBalance.length - 1].runningBalance : 0;
const selectedOwner = owners.find(o => o.id === selectedOwnerId);
const handleCreate = async () => {
if (!selectedOwnerId) return;
const owner = owners.find(o => o.id === selectedOwnerId);
await supabase.from("owner_ledger_entries").insert({
owner_id: selectedOwnerId,
association_id: owner?.association_id,
unit_id: form.unit_id || owner?.unit_id || null,
date: form.date,
transaction_type: form.transaction_type,
description: form.description,
debit: parseFloat(form.debit) || 0,
credit: parseFloat(form.credit) || 0,
});
toast({ title: "Ledger entry added" });
setDialogOpen(false);
fetchLedger();
// Update owner balance
const newBal = currentBalance + (parseFloat(form.debit) || 0) - (parseFloat(form.credit) || 0);
await supabase.from("owners").update({ balance: newBal }).eq("id", selectedOwnerId);
};
// A note is a $0.00 ledger entry — a memo that records context without
// affecting the running balance. Works on any ledger, including $0 ones.
const handleAddNote = async () => {
if (!selectedOwnerId) return;
const text = noteForm.text.trim();
if (!text) { toast({ variant: "destructive", title: "Note is empty" }); return; }
setNoteSaving(true);
const owner = owners.find(o => o.id === selectedOwnerId);
const { error } = await supabase.from("owner_ledger_entries").insert({
owner_id: selectedOwnerId,
association_id: owner?.association_id,
unit_id: owner?.unit_id || null,
date: noteForm.date,
transaction_type: "note",
description: text,
debit: 0,
credit: 0,
});
setNoteSaving(false);
if (error) { toast({ variant: "destructive", title: "Error", description: error.message }); return; }
toast({ title: "Note added" });
setNoteDialogOpen(false);
fetchLedger();
};
const exportCSV = async () => {
const rows = [
["Date", "Type", "Description", "Debit", "Credit", "Balance"].join(","),
...withBalance.map(e => [e.date, txnTypeLabels[e.transaction_type] || e.transaction_type, `"${(e.description || "").replace(/"/g, '""')}"`, Number(e.debit).toFixed(2), Number(e.credit).toFixed(2), e.runningBalance.toFixed(2)].join(","))
].join("\n");
const { saveCsv } = await import("@/lib/saveFile");
await saveCsv(rows, `Owner_Ledger_${selectedOwner ? `${selectedOwner.last_name}_${selectedOwner.first_name}` : "export"}_${format(new Date(), "yyyy-MM-dd")}.csv`);
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2"><BookOpen className="h-6 w-6 text-primary" /> Owner Ledger</h1>
<p className="text-sm text-muted-foreground mt-1">View and manage owner account balances and transactions.</p>
</div>
<div className="flex gap-2">
<Button variant="outline" className="gap-2" onClick={exportCSV}><Download className="h-4 w-4" /> Export</Button>
<Button variant="outline" className="gap-2" disabled={!selectedOwnerId} onClick={() => {
setNoteForm({ date: new Date().toISOString().split("T")[0], text: "" });
setNoteDialogOpen(true);
}}><StickyNote className="h-4 w-4" /> Add Note</Button>
<Button className="gap-2" onClick={() => {
setForm({ date: new Date().toISOString().split("T")[0], transaction_type: "assessment", description: "", debit: "", credit: "", unit_id: selectedOwner?.unit_id || "" });
setDialogOpen(true);
}}><Plus className="h-4 w-4" /> Post Entry</Button>
</div>
</div>
{/* Owner selector and balance */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="pt-6 space-y-3">
<Label className="text-xs font-semibold text-muted-foreground uppercase">Select Owner</Label>
<UnitOwnerSelect
owners={owners}
value={selectedOwnerId}
onValueChange={setSelectedOwnerId}
placeholder="Select owner"
/>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-xs text-muted-foreground uppercase font-semibold">Current Balance</p>
<p className={`text-3xl font-bold font-mono ${currentBalance > 0 ? "text-destructive" : currentBalance < 0 ? "text-emerald-600" : ""}`}>
${Math.abs(currentBalance).toLocaleString("en-US", { minimumFractionDigits: 2 })}
{currentBalance < 0 ? " CR" : currentBalance > 0 ? " DUE" : ""}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-xs text-muted-foreground uppercase font-semibold">Transactions</p>
<p className="text-3xl font-bold">{entries.length}</p>
</CardContent>
</Card>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="relative max-w-md flex-1 min-w-[220px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search ledger..." className="pl-10" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Button
variant={fromLastZero ? "default" : "outline"}
size="sm"
onClick={() => setFromLastZero(v => !v)}
title="Show only entries since the most recent $0.00 balance"
>
{fromLastZero ? "Showing from last $0 balance" : "From last $0 balance"}
</Button>
</div>
{loading ? (
<div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>
) : !selectedOwnerId ? (
<Card><CardContent className="py-12 text-center text-muted-foreground">Select an owner to view their ledger.</CardContent></Card>
) : withBalance.length === 0 ? (
<Card><CardContent className="py-12 text-center text-muted-foreground">No ledger entries for this owner.</CardContent></Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Type</TableHead>
<TableHead>Description</TableHead>
<TableHead>Unit</TableHead>
<TableHead className="text-right">Charge</TableHead>
<TableHead className="text-right">Payment</TableHead>
<TableHead className="text-right">Balance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{withBalance.map((e) => {
const isNote = e.transaction_type === "note";
return (
<TableRow key={e.id} className={isNote ? "bg-amber-50/60" : ""}>
<TableCell className="font-medium whitespace-nowrap">{format(new Date(e.date + "T00:00:00"), "MM/dd/yyyy")}</TableCell>
<TableCell>
<Badge variant="outline" className={`text-xs capitalize ${isNote ? "border-amber-300 text-amber-700 gap-1" : ""}`}>
{isNote && <StickyNote className="h-3 w-3" />}{txnTypeLabels[e.transaction_type] || e.transaction_type}
</Badge>
</TableCell>
<TableCell className={isNote ? "italic text-muted-foreground" : ""}>{e.description || "—"}</TableCell>
<TableCell>{e.units?.unit_number || "—"}</TableCell>
<TableCell className="text-right font-mono">
{Number(e.debit) > 0 ? <span className="text-destructive">${Number(e.debit).toLocaleString("en-US", { minimumFractionDigits: 2 })}</span> : "—"}
</TableCell>
<TableCell className="text-right font-mono">
{Number(e.credit) > 0 ? <span className="text-emerald-600">${Number(e.credit).toLocaleString("en-US", { minimumFractionDigits: 2 })}</span> : "—"}
</TableCell>
<TableCell className={`text-right font-mono font-bold ${e.runningBalance > 0 ? "text-destructive" : e.runningBalance < 0 ? "text-emerald-600" : ""}`}>
${Math.abs(e.runningBalance).toLocaleString("en-US", { minimumFractionDigits: 2 })}
{e.runningBalance < 0 ? " CR" : ""}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Post Ledger Entry</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><Label>Date</Label><Input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} /></div>
<div>
<Label>Transaction Type</Label>
<Select value={form.transaction_type} onValueChange={(v) => setForm({ ...form, transaction_type: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{txnTypes.map(t => <SelectItem key={t} value={t}>{txnTypeLabels[t]}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div><Label>Description</Label><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></div>
<div className="grid grid-cols-2 gap-4">
<div><Label>Charge (Debit)</Label><Input type="number" value={form.debit} onChange={(e) => setForm({ ...form, debit: e.target.value })} placeholder="0.00" /></div>
<div><Label>Payment (Credit)</Label><Input type="number" value={form.credit} onChange={(e) => setForm({ ...form, credit: e.target.value })} placeholder="0.00" /></div>
</div>
</div>
<DialogFooter><Button onClick={handleCreate}>Post Entry</Button></DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={noteDialogOpen} onOpenChange={setNoteDialogOpen}>
<DialogContent>
<DialogHeader><DialogTitle className="flex items-center gap-2"><StickyNote className="h-4 w-4 text-amber-600" /> Add Note</DialogTitle></DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
A note is recorded on the ledger as a $0.00 entry it documents context without changing the balance.
</p>
<div><Label>Date</Label><Input type="date" value={noteForm.date} onChange={(e) => setNoteForm({ ...noteForm, date: e.target.value })} /></div>
<div><Label>Note</Label><Textarea rows={4} value={noteForm.text} onChange={(e) => setNoteForm({ ...noteForm, text: e.target.value })} placeholder="e.g. Spoke with owner about upcoming assessment; account paid in full." /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNoteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleAddNote} disabled={noteSaving || !noteForm.text.trim()}>Add Note</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}