Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
288 lines
12 KiB
TypeScript
288 lines
12 KiB
TypeScript
import { createFileRoute, Link } from "@tanstack/react-router";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { ProtectedLayout } from "@/components/protected-layout";
|
||
import { PageContainer, PageHeader } from "@/components/app-shell";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { supabase } from "@/integrations/supabase/client";
|
||
import { formatCurrency, formatDate } from "@/lib/format";
|
||
import { Search, Wallet, Download, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||
|
||
export const Route = createFileRoute("/trust/")({
|
||
component: () => (
|
||
<ProtectedLayout>
|
||
<TrustLedgerPage />
|
||
</ProtectedLayout>
|
||
),
|
||
});
|
||
|
||
interface Entry {
|
||
id: string;
|
||
client_id: string;
|
||
case_id: string | null;
|
||
entry_date: string;
|
||
entry_type: "deposit" | "withdrawal";
|
||
amount: number;
|
||
note: string | null;
|
||
}
|
||
|
||
interface ClientLite {
|
||
id: string;
|
||
name: string | null;
|
||
archived_at: string | null;
|
||
}
|
||
|
||
interface CaseLite {
|
||
id: string;
|
||
case_number: string | null;
|
||
title: string | null;
|
||
}
|
||
|
||
function TrustLedgerPage() {
|
||
const [entries, setEntries] = useState<Entry[]>([]);
|
||
const [clients, setClients] = useState<Map<string, ClientLite>>(new Map());
|
||
const [cases, setCases] = useState<Map<string, CaseLite>>(new Map());
|
||
const [loading, setLoading] = useState(true);
|
||
const [search, setSearch] = useState("");
|
||
const [view, setView] = useState<"all" | "active" | "archived">("active");
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
setLoading(true);
|
||
const [entriesRes, clientsRes, casesRes] = await Promise.all([
|
||
supabase
|
||
.from("trust_ledger_entries")
|
||
.select("id, client_id, case_id, entry_date, entry_type, amount, note")
|
||
.order("entry_date", { ascending: false })
|
||
.limit(5000),
|
||
supabase.from("clients").select("id, name, archived_at"),
|
||
supabase.from("cases").select("id, case_number, title"),
|
||
]);
|
||
setEntries((entriesRes.data ?? []) as Entry[]);
|
||
const cm = new Map<string, ClientLite>();
|
||
((clientsRes.data ?? []) as ClientLite[]).forEach((c) => cm.set(c.id, c));
|
||
setClients(cm);
|
||
const csm = new Map<string, CaseLite>();
|
||
((casesRes.data ?? []) as CaseLite[]).forEach((c) => csm.set(c.id, c));
|
||
setCases(csm);
|
||
setLoading(false);
|
||
})();
|
||
}, []);
|
||
|
||
// Per-client balances
|
||
const balancesByClient = useMemo(() => {
|
||
const m = new Map<string, { client: ClientLite | null; balance: number; count: number; lastDate: string | null }>();
|
||
for (const e of entries) {
|
||
const k = e.client_id;
|
||
if (!m.has(k)) m.set(k, { client: clients.get(k) ?? null, balance: 0, count: 0, lastDate: null });
|
||
const g = m.get(k)!;
|
||
g.balance += e.entry_type === "deposit" ? Number(e.amount) : -Number(e.amount);
|
||
g.count += 1;
|
||
if (!g.lastDate || e.entry_date > g.lastDate) g.lastDate = e.entry_date;
|
||
}
|
||
return m;
|
||
}, [entries, clients]);
|
||
|
||
const filteredClients = useMemo(() => {
|
||
const q = search.trim().toLowerCase();
|
||
let arr = Array.from(balancesByClient.entries()).map(([id, v]) => ({ id, ...v }));
|
||
if (view === "active") arr = arr.filter((r) => !r.client?.archived_at);
|
||
else if (view === "archived") arr = arr.filter((r) => !!r.client?.archived_at);
|
||
if (q) {
|
||
arr = arr.filter((r) => (r.client?.name ?? "").toLowerCase().includes(q));
|
||
}
|
||
return arr.sort((a, b) => (a.client?.name ?? "").localeCompare(b.client?.name ?? ""));
|
||
}, [balancesByClient, search, view]);
|
||
|
||
const totalBalance = useMemo(
|
||
() => filteredClients.reduce((s, r) => s + r.balance, 0),
|
||
[filteredClients],
|
||
);
|
||
|
||
const visibleClientIds = useMemo(() => new Set(filteredClients.map((c) => c.id)), [filteredClients]);
|
||
const recentEntries = useMemo(
|
||
() => entries.filter((e) => visibleClientIds.has(e.client_id)).slice(0, 100),
|
||
[entries, visibleClientIds],
|
||
);
|
||
|
||
const exportCsv = () => {
|
||
const rows = entries
|
||
.filter((e) => visibleClientIds.has(e.client_id))
|
||
.map((e) => {
|
||
const c = clients.get(e.client_id);
|
||
const cs = e.case_id ? cases.get(e.case_id) : null;
|
||
return {
|
||
date: e.entry_date,
|
||
client: c?.name ?? "",
|
||
archived: c?.archived_at ? "yes" : "",
|
||
case_number: cs?.case_number ?? "",
|
||
case_title: cs?.title ?? "",
|
||
type: e.entry_type,
|
||
amount: Number(e.amount).toFixed(2),
|
||
note: (e.note ?? "").replace(/"/g, '""'),
|
||
};
|
||
});
|
||
const headers = ["date", "client", "archived", "case_number", "case_title", "type", "amount", "note"];
|
||
const csv = [
|
||
headers.join(","),
|
||
...rows.map((r) => headers.map((h) => `"${(r as any)[h]}"`).join(",")),
|
||
].join("\n");
|
||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `trust-ledger-${new Date().toISOString().slice(0, 10)}.csv`;
|
||
a.click();
|
||
};
|
||
|
||
return (
|
||
<PageContainer>
|
||
<PageHeader
|
||
title="Trust accounting"
|
||
description="All client trust balances and ledger activity."
|
||
actions={
|
||
<Button variant="outline" size="sm" onClick={exportCsv}>
|
||
<Download className="h-3.5 w-3.5 mr-1.5" /> Export CSV
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="text-xs uppercase tracking-wider text-muted-foreground">Total balance ({view})</div>
|
||
<div className={`font-serif text-2xl mt-1 tabular-nums ${totalBalance < 0 ? "text-destructive" : ""}`}>
|
||
{formatCurrency(totalBalance)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="text-xs uppercase tracking-wider text-muted-foreground">Clients with activity</div>
|
||
<div className="font-serif text-2xl mt-1 tabular-nums">{filteredClients.length}</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="text-xs uppercase tracking-wider text-muted-foreground">Total entries</div>
|
||
<div className="font-serif text-2xl mt-1 tabular-nums">{entries.length}</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<div className="flex flex-col md:flex-row gap-3 mb-4">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder="Search clients…"
|
||
className="pl-8"
|
||
/>
|
||
</div>
|
||
<Tabs value={view} onValueChange={(v) => setView(v as any)}>
|
||
<TabsList>
|
||
<TabsTrigger value="active">Active</TabsTrigger>
|
||
<TabsTrigger value="archived">Archived</TabsTrigger>
|
||
<TabsTrigger value="all">All</TabsTrigger>
|
||
</TabsList>
|
||
</Tabs>
|
||
</div>
|
||
|
||
<Card className="mb-6">
|
||
<CardContent className="p-0">
|
||
{loading ? (
|
||
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
|
||
) : filteredClients.length === 0 ? (
|
||
<p className="p-4 text-sm text-muted-foreground italic">No trust activity matches.</p>
|
||
) : (
|
||
<div className="divide-y">
|
||
<div className="grid grid-cols-12 px-4 py-2 text-[11px] uppercase tracking-wider text-muted-foreground bg-muted/30">
|
||
<div className="col-span-6">Client</div>
|
||
<div className="col-span-2 text-right">Entries</div>
|
||
<div className="col-span-2">Last activity</div>
|
||
<div className="col-span-2 text-right">Balance</div>
|
||
</div>
|
||
{filteredClients.map((r) => (
|
||
<Link
|
||
key={r.id}
|
||
to="/clients/$clientId"
|
||
params={{ clientId: r.id }}
|
||
className="grid grid-cols-12 items-center px-4 py-2.5 text-sm hover:bg-muted/30"
|
||
>
|
||
<div className="col-span-6 flex items-center gap-2 min-w-0">
|
||
<Wallet className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||
<span className="truncate font-medium">{r.client?.name ?? "Unknown client"}</span>
|
||
{r.client?.archived_at && (
|
||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">archived</span>
|
||
)}
|
||
</div>
|
||
<div className="col-span-2 text-right tabular-nums text-muted-foreground">{r.count}</div>
|
||
<div className="col-span-2 text-muted-foreground">{r.lastDate ? formatDate(r.lastDate) : "—"}</div>
|
||
<div className={`col-span-2 text-right tabular-nums font-medium ${r.balance < 0 ? "text-destructive" : ""}`}>
|
||
{formatCurrency(r.balance)}
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<PageHeader title="Recent activity" description="Last 100 ledger entries (matching filters)." />
|
||
<Card>
|
||
<CardContent className="p-0">
|
||
{recentEntries.length === 0 ? (
|
||
<p className="p-4 text-sm text-muted-foreground italic">No entries.</p>
|
||
) : (
|
||
<div className="divide-y">
|
||
{recentEntries.map((e) => {
|
||
const c = clients.get(e.client_id);
|
||
const cs = e.case_id ? cases.get(e.case_id) : null;
|
||
const isDeposit = e.entry_type === "deposit";
|
||
return (
|
||
<div key={e.id} className="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<div
|
||
className={`h-6 w-6 rounded-full flex items-center justify-center shrink-0 ${
|
||
isDeposit ? "bg-emerald-500/15 text-emerald-700" : "bg-orange-500/15 text-orange-700"
|
||
}`}
|
||
>
|
||
{isDeposit ? <ArrowDownToLine className="h-3 w-3" /> : <ArrowUpFromLine className="h-3 w-3" />}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="truncate">
|
||
<Link
|
||
to="/clients/$clientId"
|
||
params={{ clientId: e.client_id }}
|
||
className="font-medium hover:text-primary"
|
||
>
|
||
{c?.name ?? "Unknown client"}
|
||
</Link>
|
||
{cs && (
|
||
<span className="text-muted-foreground"> · {cs.case_number ?? cs.title}</span>
|
||
)}
|
||
{e.note && <span className="text-muted-foreground"> · {e.note}</span>}
|
||
</div>
|
||
<div className="text-[11px] text-muted-foreground">{formatDate(e.entry_date)}</div>
|
||
</div>
|
||
</div>
|
||
<div
|
||
className={`tabular-nums font-medium shrink-0 ${
|
||
isDeposit ? "text-emerald-700" : "text-orange-700"
|
||
}`}
|
||
>
|
||
{isDeposit ? "+" : "−"}
|
||
{formatCurrency(e.amount)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</PageContainer>
|
||
);
|
||
} |