mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
f53a0aaf46
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>
327 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|