mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
39829b7e1b
- 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>
593 lines
26 KiB
TypeScript
593 lines
26 KiB
TypeScript
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="1–30" amount={aging.d30} cur={cur} tone="amber" />
|
||
<AgingPill label="31–60" amount={aging.d60} cur={cur} tone="orange" />
|
||
<AgingPill label="61–90" 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>
|
||
);
|
||
}
|