Files
acmcc/src/pages/accounting/AccountingCustomerDetailPage.tsx
T
admin 39829b7e1b Accounting statement matches main account-statement; fix payments cut off
- Homeowner "Export/Email Statement" now uses the same generateLedgerStatement
  layout as the main-app account statement (account holder, amounts-due
  breakdown, categorized columns incl. Pay (AR), no cover page) instead of the
  branded-cover table.
- Ledger view: default the "To" date to the latest entry when it's beyond
  today, so a payment dated when an invoice was marked paid (updated_at) is no
  longer filtered out of the rows / Total Paid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:17:04 -04:00

593 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Link, useParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { accounting } from "@/lib/accountingClient";
import { useCompanyId } from "./lib/useCompanyId";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Download, Mail, FileText, Pencil, Check, X } from "lucide-react";
import { toast } from "sonner";
import { money, fmtDate } from "./lib/format";
import { generateLedgerStatement } from "@/components/unit-profile/UnitLedgerStatementPDF";
function today() {
return new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" });
}
type LedgerRow = {
date: string;
type: string;
ref: string;
description: string;
debit: number;
credit: number;
sourceId: string;
sourceKind: "invoice" | "payment";
dueDate?: string | null;
};
export default function AccountingCustomerDetailPage() {
const { id = "" } = useParams();
const [tab, setTab] = useState("overview");
const { companyId, associationName } = useCompanyId();
const cid = companyId ?? "";
const cur = "USD";
const qc = useQueryClient();
// Default the ledger/statement to the full history so it matches the main-app
// owner ledger (all-time). Set to the earliest transaction once data loads;
// the user can still narrow the range. "" means "no lower bound" (show all).
const [from, setFrom] = useState("");
const [fromTouched, setFromTouched] = useState(false);
const [to, setTo] = useState(today());
const [toTouched, setToTouched] = useState(false);
const [drawer, setDrawer] = useState<LedgerRow | null>(null);
const [editing, setEditing] = useState(false);
const [editForm, setEditForm] = useState<any>(null);
const [saving, setSaving] = useState(false);
const { data: homeowner } = useQuery({
queryKey: ["customer", id],
enabled: !!id,
queryFn: async () => {
const { data } = await accounting.from("customers").select("*").eq("id", id).maybeSingle();
return data;
},
});
const { data: invoices = [] } = useQuery({
queryKey: ["customer-invoices", id],
enabled: !!id && !!cid,
queryFn: async () => {
const { data } = await accounting
.from("invoices")
.select("id,number,issue_date,due_date,total,paid_amount,status,updated_at,notes,external_source")
.eq("company_id", cid)
.eq("customer_id", id)
.order("issue_date", { ascending: true });
return data ?? [];
},
});
// Payments recorded against this homeowner (incl. those synced from the main
// app's owner ledger). Shown as real ledger credits so dates/amounts are exact.
const { data: payments = [] } = useQuery({
queryKey: ["customer-payments", id],
enabled: !!id && !!cid,
queryFn: async () => {
const { data } = await accounting
.from("payments_received")
.select("id,payment_date,amount,method,reference,memo,deposited")
.eq("company_id", cid)
.eq("customer_id", id)
.order("payment_date", { ascending: true });
return data ?? [];
},
});
const allRows = useMemo<LedgerRow[]>(() => {
const rows: LedgerRow[] = [];
for (const inv of invoices as any[]) {
const total = Number(inv.total ?? 0);
const paid = Number(inv.paid_amount ?? 0);
if (total > 0) {
rows.push({
date: inv.issue_date,
type: "Invoice",
ref: inv.number,
description: inv.notes ? `Invoice ${inv.number}${inv.notes}` : `Invoice ${inv.number}`,
debit: total,
credit: 0,
sourceId: inv.id,
sourceKind: "invoice",
dueDate: inv.due_date,
});
}
// Manual invoices track payment via paid_amount (no payments_received row).
// Synced ledger payments are rendered from payments_received below, so skip
// their paid_amount here to avoid double-counting.
if (paid > 0 && inv.external_source !== "acmacc_ledger") {
rows.push({
date: (inv.updated_at ?? inv.issue_date).slice(0, 10),
type: "Payment",
ref: `PMT-${inv.number}`,
description: `Payment received on Invoice ${inv.number}`,
debit: 0,
credit: paid,
sourceId: inv.id,
sourceKind: "payment",
});
}
}
for (const p of payments as any[]) {
rows.push({
date: p.payment_date,
type: "Payment",
ref: p.reference || "Payment",
description: [p.method, p.memo].filter(Boolean).join(" · ") || "Payment received",
debit: 0,
credit: Number(p.amount ?? 0),
sourceId: p.id,
sourceKind: "payment",
});
}
rows.sort((a, b) => a.date.localeCompare(b.date));
return rows;
}, [invoices, payments]);
// Default the "From" date to the earliest entry so the full ledger shows
// (matches the main-app owner ledger). Honors a user-chosen range thereafter.
useEffect(() => {
if (!fromTouched && !from && allRows.length) setFrom(allRows[0].date);
}, [allRows, fromTouched, from]);
// Extend "To" to the latest entry if it's beyond today, so recently-dated
// payments (e.g. an invoice marked paid today) aren't filtered out.
useEffect(() => {
if (toTouched || !allRows.length) return;
const latest = allRows[allRows.length - 1].date;
if (latest > to) setTo(latest);
}, [allRows, toTouched, to]);
const openingBalance = useMemo(
() => allRows.filter((r) => r.date < from).reduce((s, r) => s + r.debit - r.credit, 0),
[allRows, from]
);
const periodRows = useMemo(
() => allRows.filter((r) => r.date >= from && r.date <= to),
[allRows, from, to]
);
const withRunning = useMemo(() => {
let bal = openingBalance;
return periodRows.map((r) => {
bal += r.debit - r.credit;
return { ...r, running: bal };
});
}, [periodRows, openingBalance]);
const currentBalance = useMemo(
() => allRows.reduce((s, r) => s + r.debit - r.credit, 0),
[allRows]
);
const totalInvoiced = periodRows.reduce((s, r) => s + r.debit, 0);
const totalPaid = periodRows.reduce((s, r) => s + r.credit, 0);
const creditBalance = currentBalance < 0 ? Math.abs(currentBalance) : 0;
const outstanding = currentBalance > 0 ? currentBalance : 0;
const lastPayment = [...allRows].reverse().find((r) => r.credit > 0);
const aging = useMemo(() => {
const buckets = { current: 0, d30: 0, d60: 0, d90: 0, d90p: 0 };
const now = new Date();
for (const inv of invoices as any[]) {
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
if (open <= 0) continue;
const due = inv.due_date ? new Date(inv.due_date) : new Date(inv.issue_date);
const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
if (days <= 0) buckets.current += open;
else if (days <= 30) buckets.d30 += open;
else if (days <= 60) buckets.d60 += open;
else if (days <= 90) buckets.d90 += open;
else buckets.d90p += open;
}
return buckets;
}, [invoices]);
const overdue = aging.d30 + aging.d60 + aging.d90 + aging.d90p > 0;
const startEditing = () => {
if (!homeowner) return;
setEditForm({
name: homeowner.name ?? "",
email: homeowner.email ?? "",
phone: homeowner.phone ?? "",
account_number: homeowner.account_number ?? "",
unit_id: homeowner.unit_id ?? "",
property_address: homeowner.property_address ?? "",
lot_number: homeowner.lot_number ?? "",
unit_number: homeowner.unit_number ?? "",
mailing_address: homeowner.mailing_address ?? "",
move_in_date: homeowner.move_in_date ?? "",
});
setEditing(true);
};
const cancelEditing = () => {
setEditing(false);
setEditForm(null);
};
const saveEdit = async () => {
if (!editForm.name.trim()) return toast.error("Name required");
setSaving(true);
const { error } = await accounting.from("customers").update({
name: editForm.name,
email: editForm.email || null,
phone: editForm.phone || null,
account_number: editForm.account_number.trim() || null,
unit_id: editForm.unit_id.trim() || null,
property_address: editForm.property_address || null,
lot_number: editForm.lot_number || null,
unit_number: editForm.unit_number || null,
mailing_address: editForm.mailing_address || null,
move_in_date: editForm.move_in_date || null,
billing_address: editForm.mailing_address || editForm.property_address || null,
}).eq("id", id);
setSaving(false);
if (error) return toast.error(error.message);
toast.success("Homeowner updated");
setEditing(false);
setEditForm(null);
qc.invalidateQueries({ queryKey: ["customer", id] });
qc.invalidateQueries({ queryKey: ["customers", cid] });
};
const exportStatement = () => {
if (!homeowner) return;
if (!allRows.length) { toast.error("No ledger activity to export"); return; }
// Use the same account-statement layout as the main-app owner ledger
// (account holder, amounts-due breakdown, categorized columns, no cover).
const entries = allRows.map((r) => ({
date: r.date,
debit: r.debit,
credit: r.credit,
transaction_type: r.credit > 0 ? "payment" : (r.description || ""),
description: r.description,
}));
generateLedgerStatement({
unitData: {
unit_number: homeowner.unit_number,
address: homeowner.property_address,
account_number: homeowner.account_number,
},
owners: [{ is_primary: true, first_name: homeowner.name, last_name: "" }],
entries,
associationName: associationName ?? "Association",
});
};
const emailStatement = () => {
if (!homeowner) return;
exportStatement();
const subject = encodeURIComponent(`Statement from ${associationName ?? "us"}`);
const body = encodeURIComponent(
`Hello ${homeowner.name},\n\nPlease find attached your homeowner statement for the period ${fmtDate(from)} ${fmtDate(to)}.\nCurrent balance due: ${money(currentBalance, cur)}.\n\nThank you.`
);
window.location.href = `mailto:${homeowner.email ?? ""}?subject=${subject}&body=${body}`;
toast.info("Statement downloaded — attach it to the email window.");
};
if (!homeowner) return <div className="p-6 text-muted-foreground">Loading homeowner</div>;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link to="/dashboard/accounting/customers"><ArrowLeft className="h-4 w-4 mr-1" /> Homeowners</Link>
</Button>
<div>
<h1 className="text-2xl font-semibold">{homeowner.name}</h1>
{homeowner.property_address && (
<p className="text-sm text-muted-foreground">
{homeowner.property_address}
{homeowner.unit_number ? `, ${homeowner.unit_number}` : ""}
{homeowner.lot_number ? ` · Lot ${homeowner.lot_number}` : ""}
</p>
)}
</div>
</div>
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="invoices">Invoices</TabsTrigger>
<TabsTrigger value="ledger">Ledger</TabsTrigger>
</TabsList>
{/* ── Overview tab ── */}
<TabsContent value="overview" className="mt-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Homeowner info</CardTitle>
{!editing ? (
<Button size="sm" variant="outline" onClick={startEditing}>
<Pencil className="h-3.5 w-3.5 mr-1" /> Edit
</Button>
) : (
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={cancelEditing} disabled={saving}>
<X className="h-3.5 w-3.5 mr-1" /> Cancel
</Button>
<Button size="sm" onClick={saveEdit} disabled={saving}>
<Check className="h-3.5 w-3.5 mr-1" /> Save
</Button>
</div>
)}
</CardHeader>
<CardContent>
{editing && editForm ? (
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<Label>Full name *</Label>
<Input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} />
</div>
<div>
<Label>Email</Label>
<Input type="email" value={editForm.email} onChange={(e) => setEditForm({ ...editForm, email: e.target.value })} />
</div>
<div>
<Label>Phone</Label>
<Input value={editForm.phone} onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })} />
</div>
<div>
<Label>Account number</Label>
<Input value={editForm.account_number} onChange={(e) => setEditForm({ ...editForm, account_number: e.target.value })} />
</div>
<div>
<Label>Unit ID</Label>
<Input value={editForm.unit_id} onChange={(e) => setEditForm({ ...editForm, unit_id: e.target.value })} />
</div>
<div className="col-span-2">
<Label>Property address</Label>
<Input value={editForm.property_address} onChange={(e) => setEditForm({ ...editForm, property_address: e.target.value })} />
</div>
<div>
<Label>Lot number</Label>
<Input value={editForm.lot_number} onChange={(e) => setEditForm({ ...editForm, lot_number: e.target.value })} />
</div>
<div>
<Label>Unit / Apt</Label>
<Input value={editForm.unit_number} onChange={(e) => setEditForm({ ...editForm, unit_number: e.target.value })} />
</div>
<div className="col-span-2">
<Label>Mailing address <span className="text-muted-foreground text-xs">(if different from property)</span></Label>
<Input value={editForm.mailing_address} onChange={(e) => setEditForm({ ...editForm, mailing_address: e.target.value })} />
</div>
<div>
<Label>Move-in date</Label>
<Input type="date" value={editForm.move_in_date} onChange={(e) => setEditForm({ ...editForm, move_in_date: e.target.value })} />
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-muted-foreground">Email</div>
<div>{homeowner.email ? <a href={`mailto:${homeowner.email}`} className="text-primary hover:underline">{homeowner.email}</a> : "—"}</div>
</div>
<div>
<div className="text-muted-foreground">Phone</div>
<div>{homeowner.phone ? <a href={`tel:${homeowner.phone}`} className="hover:underline">{homeowner.phone}</a> : "—"}</div>
</div>
<div>
<div className="text-muted-foreground">Account number</div>
<div>{homeowner.account_number ?? "—"}</div>
</div>
<div>
<div className="text-muted-foreground">Unit ID</div>
<div>{homeowner.unit_id ?? "—"}</div>
</div>
<div className="col-span-2">
<div className="text-muted-foreground">Property address</div>
<div>{homeowner.property_address ?? "—"}{homeowner.unit_number ? `, ${homeowner.unit_number}` : ""}</div>
</div>
<div>
<div className="text-muted-foreground">Lot number</div>
<div>{homeowner.lot_number ?? "—"}</div>
</div>
<div>
<div className="text-muted-foreground">Move-in date</div>
<div>{homeowner.move_in_date ? fmtDate(homeowner.move_in_date) : "—"}</div>
</div>
{homeowner.mailing_address && (
<div className="col-span-2">
<div className="text-muted-foreground">Mailing address</div>
<div>{homeowner.mailing_address}</div>
</div>
)}
<div>
<div className="text-muted-foreground">Current balance</div>
<div className="font-semibold">{money(homeowner.balance, cur)}</div>
</div>
<div>
<div className="text-muted-foreground">Open invoices</div>
<div>{(invoices as any[]).filter(i => Number(i.total) - Number(i.paid_amount ?? 0) > 0).length}</div>
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* ── Invoices tab ── */}
<TabsContent value="invoices" className="mt-4">
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Issue</TableHead>
<TableHead>Due</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Open</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(invoices as any[]).map((i) => {
const open = Number(i.total) - Number(i.paid_amount ?? 0);
return (
<TableRow key={i.id}>
<TableCell className="font-medium">{i.number}</TableCell>
<TableCell>{fmtDate(i.issue_date)}</TableCell>
<TableCell>{fmtDate(i.due_date)}</TableCell>
<TableCell><Badge variant="outline">{i.status}</Badge></TableCell>
<TableCell className="text-right">{money(i.total, cur)}</TableCell>
<TableCell className="text-right">{money(i.paid_amount, cur)}</TableCell>
<TableCell className="text-right">{money(open, cur)}</TableCell>
</TableRow>
);
})}
{(invoices as any[]).length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">No invoices.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
{/* ── Ledger tab ── */}
<TabsContent value="ledger" className="mt-4 space-y-4">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<SummaryStat label="Total Charged" value={money(totalInvoiced, cur)} />
<SummaryStat label="Total Paid" value={money(totalPaid, cur)} />
<SummaryStat label="Outstanding" value={money(outstanding, cur)} className={overdue ? "text-destructive" : ""} />
<SummaryStat label="Credit Balance" value={money(creditBalance, cur)} />
<SummaryStat label="Last Payment" value={lastPayment ? `${money(lastPayment.credit, cur)} · ${fmtDate(lastPayment.date)}` : "—"} />
</div>
<Card>
<CardContent className="p-4 flex flex-wrap items-end gap-3">
<div><Label className="text-xs">From</Label><Input type="date" value={from} onChange={(e) => { setFromTouched(true); setFrom(e.target.value); }} className="w-40" /></div>
<div><Label className="text-xs">To</Label><Input type="date" value={to} onChange={(e) => { setToTouched(true); setTo(e.target.value); }} className="w-40" /></div>
<div className="ml-auto flex gap-2">
<Button variant="outline" onClick={exportStatement}><Download className="h-4 w-4 mr-1" /> Export Statement</Button>
<Button variant="outline" onClick={emailStatement}><Mail className="h-4 w-4 mr-1" /> Email Statement</Button>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Type</TableHead>
<TableHead>Ref #</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Charges</TableHead>
<TableHead className="text-right">Payments</TableHead>
<TableHead className="text-right">Running Balance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="bg-muted/40">
<TableCell>{fmtDate(from)}</TableCell>
<TableCell colSpan={3} className="italic text-muted-foreground">Opening Balance</TableCell>
<TableCell /><TableCell />
<TableCell className="text-right font-medium">{money(openingBalance, cur)}</TableCell>
</TableRow>
{withRunning.map((r, idx) => {
const isOverdue = r.dueDate && new Date(r.dueDate) < new Date() && r.running > 0;
return (
<TableRow key={idx}>
<TableCell>{fmtDate(r.date)}</TableCell>
<TableCell>{r.type}</TableCell>
<TableCell>
<button className="text-primary hover:underline" onClick={() => setDrawer(r)}>{r.ref}</button>
</TableCell>
<TableCell className="max-w-[300px] truncate">{r.description}</TableCell>
<TableCell className="text-right">{r.debit ? money(r.debit, cur) : ""}</TableCell>
<TableCell className="text-right">{r.credit ? money(r.credit, cur) : ""}</TableCell>
<TableCell className={`text-right ${isOverdue ? "text-destructive font-medium" : ""}`}>{money(r.running, cur)}</TableCell>
</TableRow>
);
})}
{withRunning.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">No transactions in this period.</TableCell>
</TableRow>
)}
<TableRow className="bg-muted">
<TableCell colSpan={6} className="font-bold">Current Balance</TableCell>
<TableCell className={`text-right font-bold ${overdue ? "text-destructive" : ""}`}>{money(currentBalance, cur)}</TableCell>
</TableRow>
</TableBody>
</Table>
<div className="p-4 border-t flex flex-wrap gap-2 items-center">
<span className="text-xs text-muted-foreground mr-2">Aging:</span>
<AgingPill label="Current" amount={aging.current} cur={cur} tone="emerald" />
<AgingPill label="130" amount={aging.d30} cur={cur} tone="amber" />
<AgingPill label="3160" amount={aging.d60} cur={cur} tone="orange" />
<AgingPill label="6190" amount={aging.d90} cur={cur} tone="rose" />
<AgingPill label="90+" amount={aging.d90p} cur={cur} tone="red" />
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<Sheet open={!!drawer} onOpenChange={(o) => !o && setDrawer(null)}>
<SheetContent>
<SheetHeader><SheetTitle>{drawer?.type} · {drawer?.ref}</SheetTitle></SheetHeader>
{drawer && (
<div className="mt-4 space-y-3 text-sm">
<div><div className="text-muted-foreground">Date</div><div>{fmtDate(drawer.date)}</div></div>
<div><div className="text-muted-foreground">Description</div><div>{drawer.description}</div></div>
{drawer.debit > 0 && <div><div className="text-muted-foreground">Charge</div><div className="font-medium">{money(drawer.debit, cur)}</div></div>}
{drawer.credit > 0 && <div><div className="text-muted-foreground">Payment</div><div className="font-medium">{money(drawer.credit, cur)}</div></div>}
<Button asChild className="mt-2"><Link to="/dashboard/accounting/invoices"><FileText className="h-4 w-4 mr-1" /> Open in Invoices</Link></Button>
</div>
)}
</SheetContent>
</Sheet>
</div>
);
}
function SummaryStat({ label, value, className = "" }: { label: string; value: string; className?: string }) {
return (
<Card><CardContent className="p-4">
<div className="text-xs text-muted-foreground">{label}</div>
<div className={`text-lg font-semibold ${className}`}>{value}</div>
</CardContent></Card>
);
}
function AgingPill({ label, amount, cur, tone }: { label: string; amount: number; cur: string; tone: string }) {
const toneMap: Record<string, string> = {
emerald: "bg-emerald-100 text-emerald-900",
amber: "bg-amber-100 text-amber-900",
orange: "bg-orange-100 text-orange-900",
rose: "bg-rose-100 text-rose-900",
red: "bg-red-200 text-red-900",
};
return (
<span className={`px-2 py-1 rounded-full text-xs ${toneMap[tone]}`}>
{label}: {money(amount, cur)}
</span>
);
}