From 287160cedfdb6c537a0f8f1d429bac663dc3153a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 14:42:17 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/trust.index.tsx | 288 +++++++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 src/routes/trust.index.tsx diff --git a/src/routes/trust.index.tsx b/src/routes/trust.index.tsx new file mode 100644 index 0000000..8b09ad8 --- /dev/null +++ b/src/routes/trust.index.tsx @@ -0,0 +1,288 @@ +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: () => ( + + + + ), +}); + +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([]); + const [clients, setClients] = useState>(new Map()); + const [cases, setCases] = useState>(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(); + ((clientsRes.data ?? []) as ClientLite[]).forEach((c) => cm.set(c.id, c)); + setClients(cm); + const csm = new Map(); + ((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(); + 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 ( + + + Export CSV + + } + /> + +
+ + +
Total balance ({view})
+
+ {formatCurrency(totalBalance)} +
+
+
+ + +
Clients with activity
+
{filteredClients.length}
+
+
+ + +
Total entries
+
{entries.length}
+
+
+
+ +
+
+ + setSearch(e.target.value)} + placeholder="Search clients…" + className="pl-8" + /> +
+ setView(v as any)}> + + Active + Archived + All + + +
+ + + + {loading ? ( +

Loading…

+ ) : filteredClients.length === 0 ? ( +

No trust activity matches.

+ ) : ( +
+
+
Client
+
Entries
+
Last activity
+
Balance
+
+ {filteredClients.map((r) => ( + +
+ + {r.client?.name ?? "Unknown client"} + {r.client?.archived_at && ( + archived + )} +
+
{r.count}
+
{r.lastDate ? formatDate(r.lastDate) : "—"}
+
+ {formatCurrency(r.balance)} +
+ + ))} +
+ )} +
+
+ + + + + {recentEntries.length === 0 ? ( +

No entries.

+ ) : ( +
+ {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 ( +
+
+
+ {isDeposit ? : } +
+
+
+ + {c?.name ?? "Unknown client"} + + {cs && ( + · {cs.case_number ?? cs.title} + )} + {e.note && · {e.note}} +
+
{formatDate(e.entry_date)}
+
+
+
+ {isDeposit ? "+" : "−"} + {formatCurrency(e.amount)} +
+
+ ); + })} +
+ )} +
+
+
+ ); +} \ No newline at end of file