From a6213785eacdb9f0903b8f70286c70786dcf6847 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 01:06:47 +0000 Subject: [PATCH 1/6] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 87 +++++++++++++++++++ ...5_2ca5538f-221c-439d-8f12-54b0ce082083.sql | 64 ++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 supabase/migrations/20260418010645_2ca5538f-221c-439d-8f12-54b0ce082083.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 1d0485f..c990442 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -1561,6 +1561,7 @@ export type Database = { due_date: string | null id: string invoice_number: string + is_retainer: boolean issue_date: string notes: string | null paid_at: string | null @@ -1579,6 +1580,7 @@ export type Database = { due_date?: string | null id?: string invoice_number: string + is_retainer?: boolean issue_date?: string notes?: string | null paid_at?: string | null @@ -1597,6 +1599,7 @@ export type Database = { due_date?: string | null id?: string invoice_number?: string + is_retainer?: boolean issue_date?: string notes?: string | null paid_at?: string | null @@ -2287,6 +2290,90 @@ export type Database = { }, ] } + trust_ledger_entries: { + Row: { + amount: number + applied_invoice_id: string | null + applied_payment_id: string | null + client_id: string + created_at: string + created_by: string | null + entry_date: string + entry_type: string + id: string + note: string | null + source_invoice_id: string | null + source_payment_id: string | null + updated_at: string + } + Insert: { + amount: number + applied_invoice_id?: string | null + applied_payment_id?: string | null + client_id: string + created_at?: string + created_by?: string | null + entry_date?: string + entry_type: string + id?: string + note?: string | null + source_invoice_id?: string | null + source_payment_id?: string | null + updated_at?: string + } + Update: { + amount?: number + applied_invoice_id?: string | null + applied_payment_id?: string | null + client_id?: string + created_at?: string + created_by?: string | null + entry_date?: string + entry_type?: string + id?: string + note?: string | null + source_invoice_id?: string | null + source_payment_id?: string | null + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "trust_ledger_entries_applied_invoice_id_fkey" + columns: ["applied_invoice_id"] + isOneToOne: false + referencedRelation: "invoices" + referencedColumns: ["id"] + }, + { + foreignKeyName: "trust_ledger_entries_applied_payment_id_fkey" + columns: ["applied_payment_id"] + isOneToOne: false + referencedRelation: "invoice_payments" + referencedColumns: ["id"] + }, + { + foreignKeyName: "trust_ledger_entries_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "clients" + referencedColumns: ["id"] + }, + { + foreignKeyName: "trust_ledger_entries_source_invoice_id_fkey" + columns: ["source_invoice_id"] + isOneToOne: false + referencedRelation: "invoices" + referencedColumns: ["id"] + }, + { + foreignKeyName: "trust_ledger_entries_source_payment_id_fkey" + columns: ["source_payment_id"] + isOneToOne: false + referencedRelation: "invoice_payments" + referencedColumns: ["id"] + }, + ] + } user_roles: { Row: { created_at: string diff --git a/supabase/migrations/20260418010645_2ca5538f-221c-439d-8f12-54b0ce082083.sql b/supabase/migrations/20260418010645_2ca5538f-221c-439d-8f12-54b0ce082083.sql new file mode 100644 index 0000000..eee168b --- /dev/null +++ b/supabase/migrations/20260418010645_2ca5538f-221c-439d-8f12-54b0ce082083.sql @@ -0,0 +1,64 @@ + +ALTER TABLE public.invoices ADD COLUMN IF NOT EXISTS is_retainer boolean NOT NULL DEFAULT false; + +CREATE TABLE IF NOT EXISTS public.trust_ledger_entries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + client_id uuid NOT NULL REFERENCES public.clients(id) ON DELETE CASCADE, + entry_date date NOT NULL DEFAULT CURRENT_DATE, + entry_type text NOT NULL CHECK (entry_type IN ('deposit', 'withdrawal')), + amount numeric NOT NULL CHECK (amount >= 0), + note text, + source_invoice_id uuid REFERENCES public.invoices(id) ON DELETE SET NULL, + applied_invoice_id uuid REFERENCES public.invoices(id) ON DELETE SET NULL, + source_payment_id uuid REFERENCES public.invoice_payments(id) ON DELETE SET NULL, + applied_payment_id uuid REFERENCES public.invoice_payments(id) ON DELETE SET NULL, + created_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS trust_ledger_client_idx ON public.trust_ledger_entries(client_id, entry_date DESC); + +ALTER TABLE public.trust_ledger_entries ENABLE ROW LEVEL SECURITY; + +CREATE POLICY trust_ledger_select ON public.trust_ledger_entries + FOR SELECT TO authenticated USING (true); + +CREATE POLICY trust_ledger_insert ON public.trust_ledger_entries + FOR INSERT TO authenticated WITH CHECK (auth.uid() IS NOT NULL); + +CREATE POLICY trust_ledger_delete ON public.trust_ledger_entries + FOR DELETE TO authenticated USING (public.is_admin(auth.uid()) OR created_by = auth.uid()); + +CREATE TRIGGER trust_ledger_set_updated_at + BEFORE UPDATE ON public.trust_ledger_entries + FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); + +CREATE OR REPLACE FUNCTION public.tg_trust_deposit_from_payment() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_client_id uuid; + v_is_retainer boolean; +BEGIN + SELECT i.client_id, i.is_retainer INTO v_client_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, entry_date, entry_type, amount, note, source_invoice_id, source_payment_id, created_by) + VALUES + (v_client_id, NEW.paid_on, 'deposit', NEW.amount, + 'Retainer payment', NEW.invoice_id, NEW.id, NEW.created_by); + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trust_deposit_from_payment ON public.invoice_payments; +CREATE TRIGGER trust_deposit_from_payment + AFTER INSERT ON public.invoice_payments + FOR EACH ROW EXECUTE FUNCTION public.tg_trust_deposit_from_payment(); From a583e788baec80be71e814594574a3a0dda73ad7 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 01:08:11 +0000 Subject: [PATCH 2/6] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/trust/trust-account-panel.tsx | 421 +++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 src/components/trust/trust-account-panel.tsx diff --git a/src/components/trust/trust-account-panel.tsx b/src/components/trust/trust-account-panel.tsx new file mode 100644 index 0000000..a46df08 --- /dev/null +++ b/src/components/trust/trust-account-panel.tsx @@ -0,0 +1,421 @@ +import { useEffect, useState, useCallback } from "react"; +import { Link } from "@tanstack/react-router"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Wallet, Plus, ArrowDownToLine, ArrowUpFromLine, Trash2, Loader2 } from "lucide-react"; +import { formatCurrency, formatDate } from "@/lib/format"; +import { toast } from "sonner"; + +interface Entry { + id: string; + entry_date: string; + entry_type: "deposit" | "withdrawal"; + amount: number; + note: string | null; + source_invoice_id: string | null; + applied_invoice_id: string | null; + created_by: string | null; +} + +export function TrustAccountPanel({ clientId }: { clientId: string }) { + const { user, isAdmin } = useAuth(); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [adjustOpen, setAdjustOpen] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + const { data } = await supabase + .from("trust_ledger_entries") + .select("*") + .eq("client_id", clientId) + .order("entry_date", { ascending: false }) + .order("created_at", { ascending: false }); + setEntries((data ?? []) as Entry[]); + setLoading(false); + }, [clientId]); + + useEffect(() => { + load(); + const ch = supabase + .channel(`trust-${clientId}`) + .on( + "postgres_changes", + { event: "*", schema: "public", table: "trust_ledger_entries", filter: `client_id=eq.${clientId}` }, + () => load(), + ) + .subscribe(); + return () => { + supabase.removeChannel(ch); + }; + }, [clientId, load]); + + const balance = entries.reduce( + (sum, e) => sum + (e.entry_type === "deposit" ? Number(e.amount) : -Number(e.amount)), + 0, + ); + + 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); + if (error) return toast.error(error.message); + toast.success("Entry removed"); + load(); + }; + + return ( + + +
+
+ +

Trust account

+
+
+ + +
+
+ +
+ Available balance + + {formatCurrency(balance)} + +
+ +
+
Ledger
+ {loading ? ( +

Loading…

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

+ No activity yet. Mark an invoice as a retainer and record payment 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 && ( + + )} +
+
+ ); + })} +
+ )} +
+
+ + !v && setAdjustOpen(null)} + clientId={clientId} + userId={user?.id ?? null} + balance={balance} + onSaved={load} + /> +
+ ); +} + +function ManualAdjustDialog({ + open, + type, + onOpenChange, + clientId, + userId, + balance, + onSaved, +}: { + open: boolean; + type: "deposit" | "withdrawal"; + onOpenChange: (b: boolean) => void; + clientId: string; + userId: string | null; + balance: number; + onSaved: () => void; +}) { + const [amount, setAmount] = useState(""); + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + const [note, setNote] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + setAmount(""); + setDate(new Date().toISOString().slice(0, 10)); + setNote(""); + } + }, [open]); + + const save = async () => { + const amt = Number(amount); + if (!amt || amt <= 0) return toast.error("Enter a valid amount"); + if (type === "withdrawal" && amt > balance) { + if (!confirm(`This will overdraw the trust by ${formatCurrency(amt - balance)}. Continue?`)) return; + } + setSaving(true); + const { error } = await supabase.from("trust_ledger_entries").insert({ + client_id: clientId, + entry_date: date, + entry_type: type, + amount: amt, + note: note || (type === "deposit" ? "Manual deposit" : "Manual withdrawal"), + created_by: userId, + }); + setSaving(false); + if (error) return toast.error(error.message); + toast.success(type === "deposit" ? "Deposit recorded" : "Withdrawal recorded"); + onOpenChange(false); + onSaved(); + }; + + return ( + + + + {type === "deposit" ? "Record trust deposit" : "Record trust withdrawal"} + +
+
+
+ + setAmount(e.target.value)} /> +
+
+ + setDate(e.target.value)} /> +
+
+
+ +