diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 49495e4..0ac92bf 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -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 }, diff --git a/src/components/trust/trust-account-panel.tsx b/src/components/trust/trust-account-panel.tsx index e9980c3..80f073c 100644 --- a/src/components/trust/trust-account-panel.tsx +++ b/src/components/trust/trust-account-panel.tsx @@ -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({ + @@ -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>(file, { + header: true, + skipEmptyLines: true, + complete: async (parsed) => { + try { + const headers = parsed.meta.fields ?? []; + const map: Record = {}; + 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(); + const caseByTitle = new Map(); + 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 = {}; + 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 ( + + ); +} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index b04d819..98bc041 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -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 diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index bcbba1d..92d70af 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 41fd962..cff7544 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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" }, diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx index 627bf6d..821f131 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -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 { 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 diff --git a/supabase/migrations/20260501144357_f8aab03b-40b5-4f2d-b19e-cc8a58ad71f6.sql b/supabase/migrations/20260501144357_f8aab03b-40b5-4f2d-b19e-cc8a58ad71f6.sql new file mode 100644 index 0000000..73a0d05 --- /dev/null +++ b/supabase/migrations/20260501144357_f8aab03b-40b5-4f2d-b19e-cc8a58ad71f6.sql @@ -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; \ No newline at end of file