Added trust ledger route & UI
X-Lovable-Edit-ID: edt-44313d40-545b-4bf7-96ca-ef0a8911c2c7 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
Archive as ArchiveIcon,
|
||||
BarChart3,
|
||||
CalendarClock,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -62,6 +63,7 @@ const NAV: NavItem[] = (() => {
|
||||
{ to: "/reports", label: "Reports", icon: BarChart3 },
|
||||
{ to: "/status", label: "Status Updates", icon: Activity },
|
||||
{ to: "/tasks", label: "Tasks", icon: CheckSquare },
|
||||
{ to: "/trust", label: "Trust", icon: Wallet },
|
||||
].sort((a, b) => a.label.localeCompare(b.label));
|
||||
const bottom: NavItem[] = [
|
||||
{ to: "/archive", label: "Archive", icon: ArchiveIcon },
|
||||
|
||||
@@ -21,9 +21,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Wallet, Plus, ArrowDownToLine, ArrowUpFromLine, Trash2, Loader2, Briefcase } from "lucide-react";
|
||||
import { Wallet, Plus, ArrowDownToLine, ArrowUpFromLine, Trash2, Loader2, Briefcase, Upload } from "lucide-react";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { toast } from "sonner";
|
||||
import Papa from "papaparse";
|
||||
|
||||
interface Entry {
|
||||
id: string;
|
||||
@@ -218,6 +219,13 @@ export function TrustAccountPanel({
|
||||
<Button size="sm" variant="outline" onClick={() => setAdjustOpen("withdrawal")}>
|
||||
<ArrowUpFromLine className="h-3.5 w-3.5 mr-1" /> Withdraw
|
||||
</Button>
|
||||
<ImportTrustCsvButton
|
||||
clientId={clientId}
|
||||
defaultCaseId={caseId ?? null}
|
||||
cases={cases}
|
||||
userId={user?.id ?? null}
|
||||
onImported={load}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -665,3 +673,153 @@ export function PushPaymentToTrustButton({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
|
||||
function ImportTrustCsvButton({
|
||||
clientId,
|
||||
defaultCaseId,
|
||||
cases,
|
||||
userId,
|
||||
onImported,
|
||||
}: {
|
||||
clientId: string;
|
||||
defaultCaseId: string | null;
|
||||
cases: CaseOption[];
|
||||
userId: string | null;
|
||||
onImported: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const onPick = (file: File) => {
|
||||
setBusy(true);
|
||||
Papa.parse<Record<string, string>>(file, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: async (parsed) => {
|
||||
try {
|
||||
const headers = parsed.meta.fields ?? [];
|
||||
const map: Record<string, string> = {};
|
||||
for (const h of headers) {
|
||||
const n = norm(h);
|
||||
if (["date", "entrydate", "txdate"].includes(n)) map[h] = "date";
|
||||
else if (["type", "entrytype", "txtype", "transactiontype"].includes(n)) map[h] = "type";
|
||||
else if (["amount", "total"].includes(n)) map[h] = "amount";
|
||||
else if (["deposit", "credit"].includes(n)) map[h] = "deposit";
|
||||
else if (["withdrawal", "debit", "payment"].includes(n)) map[h] = "withdrawal";
|
||||
else if (["note", "notes", "memo", "description"].includes(n)) map[h] = "note";
|
||||
else if (["case", "casenumber", "casename", "matter"].includes(n)) map[h] = "case";
|
||||
}
|
||||
// Build case lookup
|
||||
const caseByNum = new Map<string, string>();
|
||||
const caseByTitle = new Map<string, string>();
|
||||
for (const c of cases) {
|
||||
if (c.case_number) caseByNum.set(c.case_number.toLowerCase().trim(), c.id);
|
||||
if (c.title) caseByTitle.set(c.title.toLowerCase().trim(), c.id);
|
||||
}
|
||||
|
||||
const rows: Array<{
|
||||
client_id: string;
|
||||
case_id: string | null;
|
||||
entry_date: string;
|
||||
entry_type: string;
|
||||
amount: number;
|
||||
note: string | null;
|
||||
created_by: string | null;
|
||||
}> = [];
|
||||
let skipped = 0;
|
||||
|
||||
for (const raw of parsed.data) {
|
||||
const row: Record<string, string> = {};
|
||||
for (const [h, k] of Object.entries(map)) {
|
||||
const v = (raw as any)[h];
|
||||
if (v != null && v !== "") row[k] = String(v).trim();
|
||||
}
|
||||
const dep = Number(row.deposit) || 0;
|
||||
const wdr = Number(row.withdrawal) || 0;
|
||||
let amount = Number(row.amount) || 0;
|
||||
let type: "deposit" | "withdrawal" | null = null;
|
||||
const tRaw = (row.type ?? "").toLowerCase();
|
||||
if (["deposit", "credit", "in", "+"].includes(tRaw)) type = "deposit";
|
||||
else if (["withdrawal", "withdraw", "debit", "out", "-", "payment"].includes(tRaw)) type = "withdrawal";
|
||||
if (!type && dep > 0) { type = "deposit"; amount = dep; }
|
||||
else if (!type && wdr > 0) { type = "withdrawal"; amount = wdr; }
|
||||
if (!type && amount < 0) { type = "withdrawal"; amount = Math.abs(amount); }
|
||||
if (!type && amount > 0) type = "deposit";
|
||||
if (!type || !amount || amount <= 0) { skipped++; continue; }
|
||||
|
||||
// Date
|
||||
let date = row.date || new Date().toISOString().slice(0, 10);
|
||||
// Try Date parsing if not YYYY-MM-DD
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
const d = new Date(date);
|
||||
if (!isNaN(d.getTime())) date = d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// Optional case
|
||||
let case_id: string | null = defaultCaseId;
|
||||
if (row.case) {
|
||||
const k = row.case.toLowerCase().trim();
|
||||
case_id = caseByNum.get(k) ?? caseByTitle.get(k) ?? case_id;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
client_id: clientId,
|
||||
case_id,
|
||||
entry_date: date,
|
||||
entry_type: type,
|
||||
amount: Math.abs(amount),
|
||||
note: row.note || null,
|
||||
created_by: userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
toast.error("No valid rows found in CSV");
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert in chunks
|
||||
const CHUNK = 200;
|
||||
let inserted = 0;
|
||||
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||
const chunk = rows.slice(i, i + CHUNK);
|
||||
const { error } = await supabase.from("trust_ledger_entries").insert(chunk as any);
|
||||
if (error) {
|
||||
toast.error(`Import failed: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
inserted += chunk.length;
|
||||
}
|
||||
toast.success(`Imported ${inserted} entr${inserted === 1 ? "y" : "ies"}${skipped ? `, skipped ${skipped}` : ""}`);
|
||||
onImported();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
toast.error(`CSV parse error: ${err.message}`);
|
||||
setBusy(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button asChild size="sm" variant="outline" disabled={busy} title="Import trust ledger from CSV (columns: Date, Type, Amount, Note, optional Case; or Debit/Credit)">
|
||||
<label className="cursor-pointer">
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Upload className="h-3.5 w-3.5 mr-1" />}
|
||||
Import CSV
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (f) onPick(f);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3383,6 +3383,7 @@ export type Database = {
|
||||
created_by: string | null
|
||||
entry_date: string
|
||||
entry_type: string
|
||||
external_id: string | null
|
||||
id: string
|
||||
note: string | null
|
||||
source_invoice_id: string | null
|
||||
@@ -3399,6 +3400,7 @@ export type Database = {
|
||||
created_by?: string | null
|
||||
entry_date?: string
|
||||
entry_type: string
|
||||
external_id?: string | null
|
||||
id?: string
|
||||
note?: string | null
|
||||
source_invoice_id?: string | null
|
||||
@@ -3415,6 +3417,7 @@ export type Database = {
|
||||
created_by?: string | null
|
||||
entry_date?: string
|
||||
entry_type?: string
|
||||
external_id?: string | null
|
||||
id?: string
|
||||
note?: string | null
|
||||
source_invoice_id?: string | null
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Route as SetupRouteImport } from './routes/setup'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as TrustIndexRouteImport } from './routes/trust.index'
|
||||
import { Route as TasksIndexRouteImport } from './routes/tasks.index'
|
||||
import { Route as StatusIndexRouteImport } from './routes/status.index'
|
||||
import { Route as SettingsIndexRouteImport } from './routes/settings.index'
|
||||
@@ -89,6 +90,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const TrustIndexRoute = TrustIndexRouteImport.update({
|
||||
id: '/trust/',
|
||||
path: '/trust/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const TasksIndexRoute = TasksIndexRouteImport.update({
|
||||
id: '/tasks/',
|
||||
path: '/tasks/',
|
||||
@@ -419,6 +425,7 @@ export interface FileRoutesByFullPath {
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
'/status/': typeof StatusIndexRoute
|
||||
'/tasks/': typeof TasksIndexRoute
|
||||
'/trust/': typeof TrustIndexRoute
|
||||
'/api/public/process-task-reminders': typeof ApiPublicProcessTaskRemindersRoute
|
||||
'/clients/$clientId/fees': typeof ClientsClientIdFeesRoute
|
||||
'/documents/pleading/new': typeof DocumentsPleadingNewRoute
|
||||
@@ -479,6 +486,7 @@ export interface FileRoutesByTo {
|
||||
'/settings': typeof SettingsIndexRoute
|
||||
'/status': typeof StatusIndexRoute
|
||||
'/tasks': typeof TasksIndexRoute
|
||||
'/trust': typeof TrustIndexRoute
|
||||
'/api/public/process-task-reminders': typeof ApiPublicProcessTaskRemindersRoute
|
||||
'/clients/$clientId/fees': typeof ClientsClientIdFeesRoute
|
||||
'/documents/pleading/new': typeof DocumentsPleadingNewRoute
|
||||
@@ -541,6 +549,7 @@ export interface FileRoutesById {
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
'/status/': typeof StatusIndexRoute
|
||||
'/tasks/': typeof TasksIndexRoute
|
||||
'/trust/': typeof TrustIndexRoute
|
||||
'/api/public/process-task-reminders': typeof ApiPublicProcessTaskRemindersRoute
|
||||
'/clients/$clientId/fees': typeof ClientsClientIdFeesRoute
|
||||
'/documents/pleading/new': typeof DocumentsPleadingNewRoute
|
||||
@@ -604,6 +613,7 @@ export interface FileRouteTypes {
|
||||
| '/settings/'
|
||||
| '/status/'
|
||||
| '/tasks/'
|
||||
| '/trust/'
|
||||
| '/api/public/process-task-reminders'
|
||||
| '/clients/$clientId/fees'
|
||||
| '/documents/pleading/new'
|
||||
@@ -664,6 +674,7 @@ export interface FileRouteTypes {
|
||||
| '/settings'
|
||||
| '/status'
|
||||
| '/tasks'
|
||||
| '/trust'
|
||||
| '/api/public/process-task-reminders'
|
||||
| '/clients/$clientId/fees'
|
||||
| '/documents/pleading/new'
|
||||
@@ -725,6 +736,7 @@ export interface FileRouteTypes {
|
||||
| '/settings/'
|
||||
| '/status/'
|
||||
| '/tasks/'
|
||||
| '/trust/'
|
||||
| '/api/public/process-task-reminders'
|
||||
| '/clients/$clientId/fees'
|
||||
| '/documents/pleading/new'
|
||||
@@ -776,6 +788,7 @@ export interface RootRouteChildren {
|
||||
ReportsIndexRoute: typeof ReportsIndexRoute
|
||||
StatusIndexRoute: typeof StatusIndexRoute
|
||||
TasksIndexRoute: typeof TasksIndexRoute
|
||||
TrustIndexRoute: typeof TrustIndexRoute
|
||||
ApiPublicProcessTaskRemindersRoute: typeof ApiPublicProcessTaskRemindersRoute
|
||||
DocumentsPleadingNewRoute: typeof DocumentsPleadingNewRoute
|
||||
DocumentsTemplatesTemplateIdRoute: typeof DocumentsTemplatesTemplateIdRoute
|
||||
@@ -817,6 +830,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/trust/': {
|
||||
id: '/trust/'
|
||||
path: '/trust'
|
||||
fullPath: '/trust/'
|
||||
preLoaderRoute: typeof TrustIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/tasks/': {
|
||||
id: '/tasks/'
|
||||
path: '/tasks'
|
||||
@@ -1301,6 +1321,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ReportsIndexRoute: ReportsIndexRoute,
|
||||
StatusIndexRoute: StatusIndexRoute,
|
||||
TasksIndexRoute: TasksIndexRoute,
|
||||
TrustIndexRoute: TrustIndexRoute,
|
||||
ApiPublicProcessTaskRemindersRoute: ApiPublicProcessTaskRemindersRoute,
|
||||
DocumentsPleadingNewRoute: DocumentsPleadingNewRoute,
|
||||
DocumentsTemplatesTemplateIdRoute: DocumentsTemplatesTemplateIdRoute,
|
||||
|
||||
@@ -159,7 +159,7 @@ function Dashboard() {
|
||||
], []);
|
||||
|
||||
const financialCards = [
|
||||
{ label: "Trust account balance", value: formatCurrency(financial.trustBalance), tone: "bg-muted/40", icon: Wallet, to: "/clients" },
|
||||
{ label: "Trust account balance", value: formatCurrency(financial.trustBalance), tone: "bg-muted/40", icon: Wallet, to: "/trust" },
|
||||
{ label: "Invoices paid this mo.", value: formatCurrency(financial.paidThisMonth), tone: "bg-emerald-500/10", icon: Receipt, to: "/invoices" },
|
||||
{ label: "Overdue invoice total", value: formatCurrency(financial.overdueTotal), tone: "bg-destructive/10", icon: AlertCircle, to: "/invoices" },
|
||||
{ label: "Unsent invoice total", value: formatCurrency(financial.unsentTotal), tone: "bg-muted/40", icon: FileText, to: "/invoices" },
|
||||
|
||||
@@ -692,6 +692,73 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
return r;
|
||||
},
|
||||
},
|
||||
// Trust ledger
|
||||
{
|
||||
key: "trust_ledger",
|
||||
label: "Trust ledger → Trust accounting",
|
||||
description:
|
||||
"Trust account deposits and withdrawals. Expected columns: Date, Client, Type (deposit/withdrawal), Amount, Note, optional Case (case number or title), optional ExternalId, optional Debit/Credit (use instead of Type+Amount). Archived clients are accepted. Rows that don't match a client are SKIPPED.",
|
||||
table: "trust_ledger_entries",
|
||||
conflict: "external_id",
|
||||
required: ["client_id", "entry_type", "amount"],
|
||||
aliases: {
|
||||
id: "external_id", externalid: "external_id", trustid: "external_id",
|
||||
clientid: "_clientext", clientexternalid: "_clientext",
|
||||
client: "_clientname", clientname: "_clientname", hoa: "_clientname", association: "_clientname",
|
||||
caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum",
|
||||
casename: "_casename", casetitle: "_casename", matter: "_casename", mattername: "_casename",
|
||||
type: "_type", entrytype: "_type", txtype: "_type", transactiontype: "_type",
|
||||
amount: "_amount", total: "_amount",
|
||||
deposit: "_deposit", credit: "_deposit",
|
||||
withdrawal: "_withdrawal", debit: "_withdrawal", payment: "_withdrawal",
|
||||
date: "entry_date", entrydate: "entry_date", txdate: "entry_date",
|
||||
note: "note", notes: "note", memo: "note", description: "note",
|
||||
},
|
||||
numeric: ["_amount", "_deposit", "_withdrawal"],
|
||||
dateCols: ["entry_date"],
|
||||
transform: (r, ctx) => {
|
||||
// Resolve client (allow archived)
|
||||
const cidExt = r._clientext ? ctx.clientByExt.get(String(r._clientext)) : null;
|
||||
const cidName = r._clientname
|
||||
? ctx.clientByName.get(String(r._clientname).toLowerCase().trim())
|
||||
: null;
|
||||
r.client_id = cidExt ?? cidName ?? null;
|
||||
delete r._clientext; delete r._clientname;
|
||||
if (!r.client_id) return null;
|
||||
|
||||
// Resolve case (optional, archived ok — use full caseByTitle/Number maps)
|
||||
r.case_id =
|
||||
(r._caseext && ctx.caseByExt.get(String(r._caseext))) ||
|
||||
(r._casenum && ctx.caseByNumber.get(String(r._casenum))) ||
|
||||
(r._casename && ctx.caseByTitle.get(String(r._casename).toLowerCase().trim())) ||
|
||||
null;
|
||||
delete r._caseext; delete r._casenum; delete r._casename;
|
||||
|
||||
// Resolve type + amount. Support Type+Amount or separate Debit/Credit columns.
|
||||
const dep = Number(r._deposit) || 0;
|
||||
const wdr = Number(r._withdrawal) || 0;
|
||||
let amount = Number(r._amount) || 0;
|
||||
let type: string | null = null;
|
||||
if (r._type) {
|
||||
const t = String(r._type).toLowerCase().trim();
|
||||
if (["deposit", "credit", "in", "+"].includes(t)) type = "deposit";
|
||||
else if (["withdrawal", "withdraw", "debit", "out", "-", "payment"].includes(t)) type = "withdrawal";
|
||||
}
|
||||
if (!type && dep > 0) { type = "deposit"; amount = dep; }
|
||||
else if (!type && wdr > 0) { type = "withdrawal"; amount = wdr; }
|
||||
// Negative amount → withdrawal
|
||||
if (!type && amount < 0) { type = "withdrawal"; amount = Math.abs(amount); }
|
||||
if (!type && amount > 0) { type = "deposit"; }
|
||||
delete r._type; delete r._amount; delete r._deposit; delete r._withdrawal;
|
||||
|
||||
if (!type || !amount || amount <= 0) return null;
|
||||
r.entry_type = type;
|
||||
r.amount = Math.abs(amount);
|
||||
if (!r.entry_date) r.entry_date = new Date().toISOString().slice(0, 10);
|
||||
r.created_by = ctx.userId;
|
||||
return r;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
interface ImportResult {
|
||||
|
||||
@@ -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: () => (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE public.trust_ledger_entries ADD COLUMN IF NOT EXISTS external_id text;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS trust_ledger_entries_external_id_key
|
||||
ON public.trust_ledger_entries (external_id) WHERE external_id IS NOT NULL;
|
||||
Reference in New Issue
Block a user