From 616bc3b2ba84a770a4d68c55b2b95e97bbe17928 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:24:51 +0000 Subject: [PATCH 1/7] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 10 +++++ ...7_24de63a7-2b0f-4524-abd1-4da095af9cb7.sql | 42 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 supabase/migrations/20260419012447_24de63a7-2b0f-4524-abd1-4da095af9cb7.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index aa50265..7246c52 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -2896,6 +2896,7 @@ export type Database = { amount: number applied_invoice_id: string | null applied_payment_id: string | null + case_id: string | null client_id: string created_at: string created_by: string | null @@ -2911,6 +2912,7 @@ export type Database = { amount: number applied_invoice_id?: string | null applied_payment_id?: string | null + case_id?: string | null client_id: string created_at?: string created_by?: string | null @@ -2926,6 +2928,7 @@ export type Database = { amount?: number applied_invoice_id?: string | null applied_payment_id?: string | null + case_id?: string | null client_id?: string created_at?: string created_by?: string | null @@ -2952,6 +2955,13 @@ export type Database = { referencedRelation: "invoice_payments" referencedColumns: ["id"] }, + { + foreignKeyName: "trust_ledger_entries_case_id_fkey" + columns: ["case_id"] + isOneToOne: false + referencedRelation: "cases" + referencedColumns: ["id"] + }, { foreignKeyName: "trust_ledger_entries_client_id_fkey" columns: ["client_id"] diff --git a/supabase/migrations/20260419012447_24de63a7-2b0f-4524-abd1-4da095af9cb7.sql b/supabase/migrations/20260419012447_24de63a7-2b0f-4524-abd1-4da095af9cb7.sql new file mode 100644 index 0000000..6ab2fbd --- /dev/null +++ b/supabase/migrations/20260419012447_24de63a7-2b0f-4524-abd1-4da095af9cb7.sql @@ -0,0 +1,42 @@ +-- Add case_id to trust_ledger_entries to track per-case trust balances +ALTER TABLE public.trust_ledger_entries + ADD COLUMN IF NOT EXISTS case_id uuid REFERENCES public.cases(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS trust_ledger_case_idx + ON public.trust_ledger_entries (case_id, entry_date DESC); + +-- Backfill case_id from linked invoices where possible +UPDATE public.trust_ledger_entries t + SET case_id = i.case_id + FROM public.invoices i + WHERE t.case_id IS NULL + AND i.case_id IS NOT NULL + AND (t.source_invoice_id = i.id OR t.applied_invoice_id = i.id); + +-- Update retainer-payment trigger to copy case_id from the invoice +CREATE OR REPLACE FUNCTION public.tg_trust_deposit_from_payment() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public' +AS $function$ +DECLARE + v_client_id uuid; + v_case_id uuid; + v_is_retainer boolean; +BEGIN + SELECT i.client_id, i.case_id, i.is_retainer + INTO v_client_id, v_case_id, v_is_retainer + FROM public.invoices i WHERE i.id = NEW.invoice_id; + + IF v_is_retainer IS TRUE AND v_client_id IS NOT NULL AND NEW.amount > 0 THEN + INSERT INTO public.trust_ledger_entries + (client_id, case_id, entry_date, entry_type, amount, note, + source_invoice_id, source_payment_id, created_by) + VALUES + (v_client_id, v_case_id, NEW.paid_on, 'deposit', NEW.amount, + 'Retainer payment', NEW.invoice_id, NEW.id, NEW.created_by); + END IF; + RETURN NEW; +END; +$function$; \ No newline at end of file From 8f67f029447fc95fc309e25b826d8853a9ec8779 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:26:17 +0000 Subject: [PATCH 2/7] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/trust/trust-account-panel.tsx | 416 +++++++++++++++---- 1 file changed, 331 insertions(+), 85 deletions(-) diff --git a/src/components/trust/trust-account-panel.tsx b/src/components/trust/trust-account-panel.tsx index a46df08..e9980c3 100644 --- a/src/components/trust/trust-account-panel.tsx +++ b/src/components/trust/trust-account-panel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useMemo } from "react"; import { Link } from "@tanstack/react-router"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; @@ -21,7 +21,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Wallet, Plus, ArrowDownToLine, ArrowUpFromLine, Trash2, Loader2 } from "lucide-react"; +import { Wallet, Plus, ArrowDownToLine, ArrowUpFromLine, Trash2, Loader2, Briefcase } from "lucide-react"; import { formatCurrency, formatDate } from "@/lib/format"; import { toast } from "sonner"; @@ -31,33 +31,68 @@ interface Entry { entry_type: "deposit" | "withdrawal"; amount: number; note: string | null; + case_id: string | null; source_invoice_id: string | null; applied_invoice_id: string | null; created_by: string | null; } -export function TrustAccountPanel({ clientId }: { clientId: string }) { +interface CaseOption { + id: string; + case_number: string; + title: string; +} + +const POOL_KEY = "_pool"; + +/** + * Trust panel scoped to a client. If `caseId` is provided, the panel only + * shows that case's trust activity (used on the case detail page). + */ +export function TrustAccountPanel({ + clientId, + caseId, + hideCasePicker, +}: { + clientId: string; + caseId?: string; + hideCasePicker?: boolean; +}) { const { user, isAdmin } = useAuth(); const [entries, setEntries] = useState([]); + const [cases, setCases] = useState([]); const [loading, setLoading] = useState(true); const [adjustOpen, setAdjustOpen] = useState(null); const load = useCallback(async () => { setLoading(true); - const { data } = await supabase + let q = supabase .from("trust_ledger_entries") .select("*") .eq("client_id", clientId) .order("entry_date", { ascending: false }) .order("created_at", { ascending: false }); - setEntries((data ?? []) as Entry[]); + if (caseId) q = q.eq("case_id", caseId); + const [entriesRes, casesRes] = await Promise.all([ + q, + caseId + ? Promise.resolve({ data: [] as any[] }) + : supabase + .from("cases") + .select("id, case_number, title") + .eq("client_id", clientId) + .is("archived_at", null) + .order("opened_at", { ascending: false }), + ]); + setEntries((entriesRes.data ?? []) as Entry[]); + setCases(((casesRes.data ?? []) as CaseOption[])); setLoading(false); - }, [clientId]); + }, [clientId, caseId]); useEffect(() => { load(); const ch = supabase - .channel(`trust-${clientId}`) + .channel(`trust-${clientId}-${caseId ?? "all"}`) .on( "postgres_changes", { event: "*", schema: "public", table: "trust_ledger_entries", filter: `client_id=eq.${clientId}` }, @@ -67,13 +102,37 @@ export function TrustAccountPanel({ clientId }: { clientId: string }) { return () => { supabase.removeChannel(ch); }; - }, [clientId, load]); + }, [clientId, caseId, load]); - const balance = entries.reduce( + const totalBalance = entries.reduce( (sum, e) => sum + (e.entry_type === "deposit" ? Number(e.amount) : -Number(e.amount)), 0, ); + // Group entries by case bucket (POOL_KEY for null case_id) for the case-scoped breakdown + const byCase = useMemo(() => { + const map = new Map(); + for (const e of entries) { + const k = e.case_id ?? POOL_KEY; + if (!map.has(k)) map.set(k, { caseId: e.case_id, entries: [], balance: 0 }); + const g = map.get(k)!; + g.entries.push(e); + g.balance += e.entry_type === "deposit" ? Number(e.amount) : -Number(e.amount); + } + // Order: client pool first, then named cases + return Array.from(map.values()).sort((a, b) => { + if (a.caseId == null) return -1; + if (b.caseId == null) return 1; + return 0; + }); + }, [entries]); + + const caseLookup = useMemo(() => { + const m = new Map(); + cases.forEach((c) => m.set(c.id, c)); + return m; + }, [cases]); + const removeEntry = async (id: string) => { if (!confirm("Remove this trust ledger entry? This will not reverse the related invoice payment.")) return; const { error } = await supabase.from("trust_ledger_entries").delete().eq("id", id); @@ -82,13 +141,75 @@ export function TrustAccountPanel({ clientId }: { clientId: string }) { load(); }; + const renderEntryRow = (e: Entry) => { + const isDeposit = e.entry_type === "deposit"; + const link = isDeposit ? e.source_invoice_id : e.applied_invoice_id; + const canDelete = isAdmin || e.created_by === user?.id; + return ( +
+
+
+ {isDeposit ? : } +
+
+
+ {isDeposit ? "Deposit" : "Withdrawal"} + {e.note ? · {e.note} : null} +
+
+ {formatDate(e.entry_date)} + {link && ( + <> + {" · "} + + {isDeposit ? "from invoice" : "to invoice"} + + + )} +
+
+
+
+ + {isDeposit ? "+" : "−"} + {formatCurrency(e.amount)} + + {canDelete && ( + + )} +
+
+ ); + }; + return (
-

Trust account

+

{caseId ? "Case trust" : "Trust account"}

- Available balance + + {caseId ? "Case balance" : "Total balance"} + - {formatCurrency(balance)} + {formatCurrency(totalBalance)}
+ {/* Per-case breakdown (only on client view) */} + {!caseId && byCase.length > 1 && ( +
+
By case
+
+ {byCase.map((g) => { + const c = g.caseId ? caseLookup.get(g.caseId) : null; + return ( +
+
+ +
+ {g.caseId ? ( + c ? ( + + {c.case_number}{" "} + — {c.title} + + ) : ( + Case (archived) + ) + ) : ( + Client pool + )} +
+
+ + {formatCurrency(g.balance)} + +
+ ); + })} +
+
+ )} +
Ledger
{loading ? (

Loading…

) : entries.length === 0 ? (

- No activity yet. Mark an invoice as a retainer and record payment to fund this account. + No activity yet.{" "} + {caseId + ? "Push an invoice payment to trust to fund this case." + : "Mark an invoice as a retainer or push a payment to trust to fund this account."}

) : ( -
- {entries.map((e) => { - const isDeposit = e.entry_type === "deposit"; - const link = isDeposit ? e.source_invoice_id : e.applied_invoice_id; - const canDelete = isAdmin || e.created_by === user?.id; - return ( -
-
-
- {isDeposit ? ( - - ) : ( - - )} -
-
-
- {isDeposit ? "Deposit" : "Withdrawal"} - {e.note ? · {e.note} : null} -
-
- {formatDate(e.entry_date)} - {link && ( - <> - {" · "} - - {isDeposit ? "from invoice" : "to invoice"} - - - )} -
-
-
-
- - {isDeposit ? "+" : "−"} - {formatCurrency(e.amount)} - - {canDelete && ( - - )} -
-
- ); - })} -
+
{entries.map(renderEntryRow)}
)}
@@ -190,8 +296,11 @@ export function TrustAccountPanel({ clientId }: { clientId: string }) { type={adjustOpen ?? "deposit"} onOpenChange={(v) => !v && setAdjustOpen(null)} clientId={clientId} + defaultCaseId={caseId ?? null} + cases={cases} + hideCasePicker={!!caseId || !!hideCasePicker} userId={user?.id ?? null} - balance={balance} + balance={totalBalance} onSaved={load} /> @@ -203,6 +312,9 @@ function ManualAdjustDialog({ type, onOpenChange, clientId, + defaultCaseId, + cases, + hideCasePicker, userId, balance, onSaved, @@ -211,6 +323,9 @@ function ManualAdjustDialog({ type: "deposit" | "withdrawal"; onOpenChange: (b: boolean) => void; clientId: string; + defaultCaseId: string | null; + cases: CaseOption[]; + hideCasePicker: boolean; userId: string | null; balance: number; onSaved: () => void; @@ -218,6 +333,7 @@ function ManualAdjustDialog({ const [amount, setAmount] = useState(""); const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); const [note, setNote] = useState(""); + const [caseChoice, setCaseChoice] = useState(defaultCaseId ?? POOL_KEY); const [saving, setSaving] = useState(false); useEffect(() => { @@ -225,8 +341,9 @@ function ManualAdjustDialog({ setAmount(""); setDate(new Date().toISOString().slice(0, 10)); setNote(""); + setCaseChoice(defaultCaseId ?? POOL_KEY); } - }, [open]); + }, [open, defaultCaseId]); const save = async () => { const amt = Number(amount); @@ -237,6 +354,7 @@ function ManualAdjustDialog({ setSaving(true); const { error } = await supabase.from("trust_ledger_entries").insert({ client_id: clientId, + case_id: caseChoice === POOL_KEY ? null : caseChoice, entry_date: date, entry_type: type, amount: amt, @@ -257,6 +375,22 @@ function ManualAdjustDialog({ {type === "deposit" ? "Record trust deposit" : "Record trust withdrawal"}
+ {!hideCasePicker && ( +
+ + +
+ )}
@@ -288,11 +422,16 @@ function ManualAdjustDialog({ ); } -/** Dialog used on an invoice detail page to apply trust funds toward its balance. */ +/** + * Dialog used on an invoice detail page to apply trust funds toward its balance. + * Funds are scoped to the invoice's own case (per-case trust). If the invoice + * has no case, the client pool is used. + */ export function ApplyTrustDialog({ open, onOpenChange, clientId, + caseId, invoiceId, invoiceNumber, balanceDue, @@ -302,6 +441,7 @@ export function ApplyTrustDialog({ open: boolean; onOpenChange: (b: boolean) => void; clientId: string; + caseId: string | null; invoiceId: string; invoiceNumber: string; balanceDue: number; @@ -316,10 +456,13 @@ export function ApplyTrustDialog({ useEffect(() => { if (!open) return; (async () => { - const { data } = await supabase + let q = supabase .from("trust_ledger_entries") - .select("entry_type, amount") + .select("entry_type, amount, case_id") .eq("client_id", clientId); + if (caseId) q = q.eq("case_id", caseId); + else q = q.is("case_id", null); + const { data } = await q; const bal = (data ?? []).reduce( (s: number, e: any) => s + (e.entry_type === "deposit" ? Number(e.amount) : -Number(e.amount)), 0, @@ -329,7 +472,7 @@ export function ApplyTrustDialog({ setAmount(suggest > 0 ? suggest.toFixed(2) : ""); setDate(new Date().toISOString().slice(0, 10)); })(); - }, [open, clientId, balanceDue]); + }, [open, clientId, caseId, balanceDue]); const apply = async () => { const amt = Number(amount); @@ -356,9 +499,10 @@ export function ApplyTrustDialog({ setSaving(false); return toast.error(payErr?.message ?? "Could not record payment"); } - // 2) Add the matching trust ledger withdrawal pointing at this invoice. + // 2) Add the matching trust ledger withdrawal scoped to this invoice's case. const { error: ledErr } = await supabase.from("trust_ledger_entries").insert({ client_id: clientId, + case_id: caseId, entry_date: date, entry_type: "withdrawal", amount: amt, @@ -383,7 +527,9 @@ export function ApplyTrustDialog({
- Trust balance + + {caseId ? "Case trust balance" : "Client-pool trust balance"} + {trustBalance == null ? "…" : formatCurrency(trustBalance)} @@ -419,3 +565,103 @@ export function ApplyTrustDialog({ ); } + +/** + * Small button shown on each invoice payment row that lets the user move + * (some or all of) that payment into the case's trust account. + */ +export function PushPaymentToTrustButton({ + payment, + invoice, + userId, + onPushed, +}: { + payment: { id: string; amount: number; paid_on: string; method: string | null; reference: string | null }; + invoice: { id: string; invoice_number: string; client_id: string; case_id: string | null }; + userId: string | null; + onPushed: () => void; +}) { + const [open, setOpen] = useState(false); + const [amount, setAmount] = useState(Number(payment.amount).toFixed(2)); + const [date, setDate] = useState(payment.paid_on); + const [note, setNote] = useState( + `From payment on inv ${invoice.invoice_number}${payment.reference ? ` (${payment.reference})` : ""}`, + ); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + setAmount(Number(payment.amount).toFixed(2)); + setDate(payment.paid_on); + } + }, [open, payment.amount, payment.paid_on]); + + const push = async () => { + const amt = Number(amount); + if (!amt || amt <= 0) return toast.error("Enter a valid amount"); + setSaving(true); + const { error } = await supabase.from("trust_ledger_entries").insert({ + client_id: invoice.client_id, + case_id: invoice.case_id, + entry_date: date, + entry_type: "deposit", + amount: amt, + note, + source_invoice_id: invoice.id, + source_payment_id: payment.id, + created_by: userId, + }); + setSaving(false); + if (error) return toast.error(error.message); + toast.success("Pushed to trust"); + setOpen(false); + onPushed(); + }; + + return ( + <> + + + + + Push payment to trust + +
+

+ Records a trust deposit{invoice.case_id ? " on this case" : " in the client pool"}. The original + invoice payment is left untouched. +

+
+
+ + setAmount(e.target.value)} /> +
+
+ + setDate(e.target.value)} /> +
+
+
+ +