Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-19 01:26:17 +00:00
co-authored by renee-png
parent 616bc3b2ba
commit 8f67f02944
+331 -85
View File
@@ -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<Entry[]>([]);
const [cases, setCases] = useState<CaseOption[]>([]);
const [loading, setLoading] = useState(true);
const [adjustOpen, setAdjustOpen] = useState<null | "deposit" | "withdrawal">(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<string, { caseId: string | null; entries: Entry[]; balance: number }>();
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<string, CaseOption>();
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 (
<div key={e.id} className="flex items-start justify-between gap-3 py-2.5 text-sm">
<div className="flex items-start gap-2 min-w-0">
<div
className={`mt-0.5 h-6 w-6 rounded-full flex items-center justify-center ${
isDeposit
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400"
: "bg-orange-500/15 text-orange-700 dark:text-orange-400"
}`}
>
{isDeposit ? <ArrowDownToLine className="h-3 w-3" /> : <ArrowUpFromLine className="h-3 w-3" />}
</div>
<div className="min-w-0">
<div className="font-medium">
{isDeposit ? "Deposit" : "Withdrawal"}
{e.note ? <span className="text-muted-foreground font-normal"> · {e.note}</span> : null}
</div>
<div className="text-[11px] text-muted-foreground">
{formatDate(e.entry_date)}
{link && (
<>
{" · "}
<Link
to="/invoices/$invoiceId"
params={{ invoiceId: link }}
className="hover:text-primary underline-offset-2 hover:underline"
>
{isDeposit ? "from invoice" : "to invoice"}
</Link>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<span
className={`tabular-nums font-medium ${
isDeposit ? "text-emerald-700 dark:text-emerald-400" : "text-orange-700 dark:text-orange-400"
}`}
>
{isDeposit ? "+" : "−"}
{formatCurrency(e.amount)}
</span>
{canDelete && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => removeEntry(e.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
);
};
return (
<Card className="border-border/60">
<CardContent className="p-5 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<Wallet className="h-4 w-4 text-muted-foreground" />
<h3 className="font-serif text-lg">Trust account</h3>
<h3 className="font-serif text-lg">{caseId ? "Case trust" : "Trust account"}</h3>
</div>
<div className="flex gap-1.5">
<Button size="sm" variant="outline" onClick={() => setAdjustOpen("deposit")}>
@@ -101,86 +222,71 @@ export function TrustAccountPanel({ clientId }: { clientId: string }) {
</div>
<div className="rounded-md bg-muted/40 p-4 flex items-baseline justify-between">
<span className="text-xs uppercase tracking-wider text-muted-foreground">Available balance</span>
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{caseId ? "Case balance" : "Total balance"}
</span>
<span
className={`font-serif text-2xl tabular-nums ${balance < 0 ? "text-destructive" : ""}`}
className={`font-serif text-2xl tabular-nums ${totalBalance < 0 ? "text-destructive" : ""}`}
>
{formatCurrency(balance)}
{formatCurrency(totalBalance)}
</span>
</div>
{/* Per-case breakdown (only on client view) */}
{!caseId && byCase.length > 1 && (
<div className="space-y-1.5">
<div className="text-xs uppercase tracking-wider text-muted-foreground">By case</div>
<div className="rounded-md border divide-y">
{byCase.map((g) => {
const c = g.caseId ? caseLookup.get(g.caseId) : null;
return (
<div key={g.caseId ?? POOL_KEY} className="flex items-center justify-between px-3 py-2 text-sm">
<div className="flex items-center gap-2 min-w-0">
<Briefcase className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="min-w-0">
{g.caseId ? (
c ? (
<Link
to="/cases/$caseId"
params={{ caseId: g.caseId }}
className="hover:text-primary truncate block"
>
<span className="font-medium">{c.case_number}</span>{" "}
<span className="text-muted-foreground">— {c.title}</span>
</Link>
) : (
<span className="text-muted-foreground italic">Case (archived)</span>
)
) : (
<span className="font-medium">Client pool</span>
)}
</div>
</div>
<span
className={`tabular-nums font-medium ${g.balance < 0 ? "text-destructive" : ""}`}
>
{formatCurrency(g.balance)}
</span>
</div>
);
})}
</div>
</div>
)}
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-2">Ledger</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : entries.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
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."}
</p>
) : (
<div className="divide-y">
{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 (
<div key={e.id} className="flex items-start justify-between gap-3 py-2.5 text-sm">
<div className="flex items-start gap-2 min-w-0">
<div
className={`mt-0.5 h-6 w-6 rounded-full flex items-center justify-center ${
isDeposit ? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400" : "bg-orange-500/15 text-orange-700 dark:text-orange-400"
}`}
>
{isDeposit ? (
<ArrowDownToLine className="h-3 w-3" />
) : (
<ArrowUpFromLine className="h-3 w-3" />
)}
</div>
<div className="min-w-0">
<div className="font-medium">
{isDeposit ? "Deposit" : "Withdrawal"}
{e.note ? <span className="text-muted-foreground font-normal"> · {e.note}</span> : null}
</div>
<div className="text-[11px] text-muted-foreground">
{formatDate(e.entry_date)}
{link && (
<>
{" · "}
<Link
to="/invoices/$invoiceId"
params={{ invoiceId: link }}
className="hover:text-primary underline-offset-2 hover:underline"
>
{isDeposit ? "from invoice" : "to invoice"}
</Link>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<span
className={`tabular-nums font-medium ${isDeposit ? "text-emerald-700 dark:text-emerald-400" : "text-orange-700 dark:text-orange-400"}`}
>
{isDeposit ? "+" : "−"}
{formatCurrency(e.amount)}
</span>
{canDelete && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => removeEntry(e.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
);
})}
</div>
<div className="divide-y">{entries.map(renderEntryRow)}</div>
)}
</div>
</CardContent>
@@ -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}
/>
</Card>
@@ -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<string>(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({
<DialogTitle>{type === "deposit" ? "Record trust deposit" : "Record trust withdrawal"}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{!hideCasePicker && (
<div>
<Label>Allocate to</Label>
<Select value={caseChoice} onValueChange={setCaseChoice}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={POOL_KEY}>Client pool (no case)</SelectItem>
{cases.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.case_number} — {c.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Amount</Label>
@@ -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({
<div className="space-y-3">
<div className="rounded-md bg-muted/40 p-3 text-sm space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Trust balance</span>
<span className="text-muted-foreground">
{caseId ? "Case trust balance" : "Client-pool trust balance"}
</span>
<span className="tabular-nums font-medium">
{trustBalance == null ? "…" : formatCurrency(trustBalance)}
</span>
@@ -419,3 +565,103 @@ export function ApplyTrustDialog({
</Dialog>
);
}
/**
* 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 (
<>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-emerald-600"
title="Push to trust"
onClick={() => setOpen(true)}
>
<Wallet className="h-3 w-3" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Push payment to trust</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Records a trust deposit{invoice.case_id ? " on this case" : " in the client pool"}. The original
invoice payment is left untouched.
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Amount</Label>
<Input type="number" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} />
</div>
<div>
<Label>Date</Label>
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
</div>
<div>
<Label>Note</Label>
<Textarea rows={2} value={note} onChange={(e) => setNote(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={push} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}Push to trust
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}