Files
mylegal-stage-law/src/routes/trust.index.tsx
T
2026-05-01 14:42:17 +00:00

288 lines
12 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 { 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>
);
}