Added trust tracking per case
X-Lovable-Edit-ID: edt-8c3ede29-7150-4201-a4b0-b5a673b5b2d4 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -10,7 +10,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact, Phone, Tag, Archive, ArchiveRestore, ArrowRightLeft, Search, GitFork } from "lucide-react";
|
||||
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact, Phone, Tag, Archive, ArchiveRestore, ArrowRightLeft, Search, GitFork, Wallet } from "lucide-react";
|
||||
import { TrustAccountPanel } from "@/components/trust/trust-account-panel";
|
||||
import { ConvertToCollectionsDialog } from "@/components/cases/convert-to-collections-dialog";
|
||||
import { ContactsLinkTab } from "@/components/contacts/contacts-link-tab";
|
||||
import { CaseCallLogsTab } from "@/components/cases/call-logs-tab";
|
||||
@@ -294,6 +295,9 @@ function CaseTabs({ data, caseId, canManage, load, tab, onTabChange }: { data: a
|
||||
<TabsTrigger value="time"><Clock className="h-3.5 w-3.5 mr-1.5" />Time</TabsTrigger>
|
||||
<TabsTrigger value="expenses"><DollarSign className="h-3.5 w-3.5 mr-1.5" />Expenses</TabsTrigger>
|
||||
<TabsTrigger value="invoices"><Receipt className="h-3.5 w-3.5 mr-1.5" />Invoices</TabsTrigger>
|
||||
{data.client_id && (
|
||||
<TabsTrigger value="trust"><Wallet className="h-3.5 w-3.5 mr-1.5" />Trust</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="calls"><Phone className="h-3.5 w-3.5 mr-1.5" />Calls</TabsTrigger>
|
||||
<TabsTrigger value="custom"><Tag className="h-3.5 w-3.5 mr-1.5" />Custom fields</TabsTrigger>
|
||||
{(data.client?.client_type === "hoa" || data.client?.client_type === "condo") && (
|
||||
@@ -307,6 +311,9 @@ function CaseTabs({ data, caseId, canManage, load, tab, onTabChange }: { data: a
|
||||
<TabsContent value="time"><CaseTimeTab caseRecord={data} onInvoice={() => onTabChange("invoices")} /></TabsContent>
|
||||
<TabsContent value="expenses"><CaseExpensesTab caseId={caseId} /></TabsContent>
|
||||
<TabsContent value="invoices"><CaseInvoicesTab caseRecord={data} /></TabsContent>
|
||||
{data.client_id && (
|
||||
<TabsContent value="trust"><TrustAccountPanel clientId={data.client_id} caseId={caseId} /></TabsContent>
|
||||
)}
|
||||
<TabsContent value="calls"><CaseCallLogsTab caseId={caseId} /></TabsContent>
|
||||
<TabsContent value="custom"><CaseCustomFieldsTab caseId={caseId} /></TabsContent>
|
||||
{(data.client?.client_type === "hoa" || data.client?.client_type === "condo") && (
|
||||
|
||||
@@ -16,7 +16,7 @@ import { ArrowLeft, FileDown, Plus, Trash2, Loader2, Send, Ban, CheckCircle2, Do
|
||||
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
|
||||
import { downloadInvoicePdf, type InvoicePdfInput } from "@/lib/invoice-pdf";
|
||||
import { recalcInvoiceTotals } from "@/lib/invoice-generation";
|
||||
import { ApplyTrustDialog } from "@/components/trust/trust-account-panel";
|
||||
import { ApplyTrustDialog, PushPaymentToTrustButton } from "@/components/trust/trust-account-panel";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/invoices/$invoiceId")({
|
||||
@@ -517,6 +517,19 @@ function InvoiceDetail() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="tabular-nums font-medium">{formatCurrency(p.amount)}</span>
|
||||
{canEdit && p.method !== "trust" && (
|
||||
<PushPaymentToTrustButton
|
||||
payment={p}
|
||||
invoice={{
|
||||
id: invoice.id,
|
||||
invoice_number: invoice.invoice_number,
|
||||
client_id: invoice.client?.id ?? invoice.client_id,
|
||||
case_id: invoice.case_id ?? null,
|
||||
}}
|
||||
userId={user?.id ?? null}
|
||||
onPushed={load}
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={async () => {
|
||||
@@ -556,6 +569,7 @@ function InvoiceDetail() {
|
||||
open={trustOpen}
|
||||
onOpenChange={setTrustOpen}
|
||||
clientId={invoice.client.id}
|
||||
caseId={invoice.case_id ?? null}
|
||||
invoiceId={invoiceId}
|
||||
invoiceNumber={invoice.invoice_number}
|
||||
balanceDue={balance}
|
||||
|
||||
@@ -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$;
|
||||
Reference in New Issue
Block a user