Added trust fund UI flows

X-Lovable-Edit-ID: edt-104a008a-6a8b-462a-bbd7-6df9755d8ea3
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 01:10:13 +00:00
co-authored by renee-png
6 changed files with 723 additions and 44 deletions
@@ -41,6 +41,7 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
const [taxPct, setTaxPct] = useState("0");
const [dueDays, setDueDays] = useState("30");
const [notes, setNotes] = useState("");
const [isRetainer, setIsRetainer] = useState(false);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -104,9 +105,53 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
const submit = async () => {
if (!user?.id) return;
if (selectedIds.length === 0) return toast.error("Select at least one case");
if (selectedIds.length === 0 && !isRetainer) return toast.error("Select at least one case");
setSaving(true);
try {
if (isRetainer) {
// Retainer invoices have no time/expenses — create a single line item for the deposit.
const yr = new Date().getFullYear();
const num = `INV-${yr}-${Math.floor(1000 + Math.random() * 9000)}`;
const due = new Date();
due.setDate(due.getDate() + (Number(dueDays) || 30));
const amt = Math.max(0, Number((document.getElementById("retainer-amount") as HTMLInputElement)?.value) || 0);
if (amt <= 0) {
setSaving(false);
return toast.error("Enter a retainer amount");
}
const tax = +(amt * (Number(taxPct) || 0) / 100).toFixed(2);
const { data: inv, error: ie } = await supabase
.from("invoices")
.insert({
invoice_number: num,
client_id: clientId,
status: "draft",
issue_date: new Date().toISOString().slice(0, 10),
due_date: due.toISOString().slice(0, 10),
subtotal: amt,
tax,
total: +(amt + tax).toFixed(2),
notes: notes || "Retainer deposit",
is_retainer: true,
created_by: user.id,
})
.select("id, invoice_number")
.single();
if (ie || !inv) throw ie ?? new Error("Failed to create");
await supabase.from("invoice_line_items").insert({
invoice_id: inv.id,
kind: "manual",
description: "Retainer deposit into trust account",
quantity: 1,
rate: amt,
amount: amt,
sort_order: 0,
});
toast.success(`Retainer invoice ${inv.invoice_number} created`);
onOpenChange(false);
navigate({ to: "/invoices/$invoiceId", params: { invoiceId: inv.id } });
return;
}
const { invoiceId, invoiceNumber } = await generateInvoiceForClient({
clientId,
caseIds: selectedIds,
@@ -132,39 +177,27 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
<DialogTitle>Generate invoice — {clientName}</DialogTitle>
</DialogHeader>
{loading ? (
<div className="py-8 text-center text-muted-foreground">Loading unbilled work…</div>
) : cases.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
No unbilled time or expenses across this client's cases.
</div>
) : (
<div className="space-y-4">
<div>
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Cases to bill</Label>
<div className="mt-2 border rounded-md divide-y max-h-[260px] overflow-auto">
{cases.map((c) => (
<label key={c.id} className="flex items-start gap-3 p-3 cursor-pointer hover:bg-muted/40">
<Checkbox
checked={!!selected[c.id]}
onCheckedChange={(v) => setSelected((p) => ({ ...p, [c.id]: !!v }))}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-sm truncate">{c.title}</span>
<span className="text-sm tabular-nums font-medium">{formatCurrency(c.total)}</span>
</div>
<div className="text-xs text-muted-foreground">
{c.case_number} · {c.timeCount} time entries ({formatCurrency(c.timeAmount)}) · {c.expenseCount} expenses ({formatCurrency(c.expenseAmount)})
</div>
</div>
</label>
))}
<div className="space-y-4">
<label className="flex items-start gap-3 p-3 rounded-md border cursor-pointer hover:bg-muted/40">
<Checkbox
checked={isRetainer}
onCheckedChange={(v) => setIsRetainer(!!v)}
className="mt-0.5"
/>
<div className="flex-1">
<div className="text-sm font-medium">Retainer / trust deposit invoice</div>
<div className="text-xs text-muted-foreground">
When paid, the amount is automatically deposited into this client's trust account.
</div>
</div>
</label>
{isRetainer ? (
<div className="grid grid-cols-3 gap-3">
<div>
<Label>Retainer amount</Label>
<Input id="retainer-amount" type="number" step="0.01" placeholder="0.00" />
</div>
<div>
<Label>Tax rate (%)</Label>
<Input type="number" step="0.01" value={taxPct} onChange={(e) => setTaxPct(e.target.value)} />
@@ -173,27 +206,73 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
<Label>Due in (days)</Label>
<Input type="number" value={dueDays} onChange={(e) => setDueDays(e.target.value)} />
</div>
<div className="col-span-1 flex items-end justify-end">
<div className="text-right">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">Estimated total</div>
<div className="font-serif text-xl">{formatCurrency(total)}</div>
{tax > 0 && <div className="text-[11px] text-muted-foreground">{formatCurrency(subtotal)} + {formatCurrency(tax)} tax</div>}
</div>
) : loading ? (
<div className="py-8 text-center text-muted-foreground">Loading unbilled work…</div>
) : cases.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
No unbilled time or expenses across this client's cases.
</div>
) : (
<>
<div>
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Cases to bill</Label>
<div className="mt-2 border rounded-md divide-y max-h-[260px] overflow-auto">
{cases.map((c) => (
<label key={c.id} className="flex items-start gap-3 p-3 cursor-pointer hover:bg-muted/40">
<Checkbox
checked={!!selected[c.id]}
onCheckedChange={(v) => setSelected((p) => ({ ...p, [c.id]: !!v }))}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-sm truncate">{c.title}</span>
<span className="text-sm tabular-nums font-medium">{formatCurrency(c.total)}</span>
</div>
<div className="text-xs text-muted-foreground">
{c.case_number} · {c.timeCount} time entries ({formatCurrency(c.timeAmount)}) · {c.expenseCount} expenses ({formatCurrency(c.expenseAmount)})
</div>
</div>
</label>
))}
</div>
</div>
</div>
<div>
<Label>Notes (optional)</Label>
<Textarea rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Payment terms, thank-you message…" />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label>Tax rate (%)</Label>
<Input type="number" step="0.01" value={taxPct} onChange={(e) => setTaxPct(e.target.value)} />
</div>
<div>
<Label>Due in (days)</Label>
<Input type="number" value={dueDays} onChange={(e) => setDueDays(e.target.value)} />
</div>
<div className="col-span-1 flex items-end justify-end">
<div className="text-right">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">Estimated total</div>
<div className="font-serif text-xl">{formatCurrency(total)}</div>
{tax > 0 && <div className="text-[11px] text-muted-foreground">{formatCurrency(subtotal)} + {formatCurrency(tax)} tax</div>}
</div>
</div>
</div>
</>
)}
<div>
<Label>Notes (optional)</Label>
<Textarea rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Payment terms, thank-you message…" />
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={submit} disabled={saving || cases.length === 0 || selectedIds.length === 0}>
<Button
onClick={submit}
disabled={saving || (!isRetainer && (cases.length === 0 || selectedIds.length === 0))}
>
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <FilePlus className="h-4 w-4 mr-2" />}
Generate draft invoice
{isRetainer ? "Create retainer invoice" : "Generate draft invoice"}
</Button>
</DialogFooter>
</DialogContent>
@@ -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<Entry[]>([]);
const [loading, setLoading] = useState(true);
const [adjustOpen, setAdjustOpen] = useState<null | "deposit" | "withdrawal">(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 (
<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>
</div>
<div className="flex gap-1.5">
<Button size="sm" variant="outline" onClick={() => setAdjustOpen("deposit")}>
<Plus className="h-3.5 w-3.5 mr-1" /> Deposit
</Button>
<Button size="sm" variant="outline" onClick={() => setAdjustOpen("withdrawal")}>
<ArrowUpFromLine className="h-3.5 w-3.5 mr-1" /> Withdraw
</Button>
</div>
</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={`font-serif text-2xl tabular-nums ${balance < 0 ? "text-destructive" : ""}`}
>
{formatCurrency(balance)}
</span>
</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.
</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>
</CardContent>
<ManualAdjustDialog
open={!!adjustOpen}
type={adjustOpen ?? "deposit"}
onOpenChange={(v) => !v && setAdjustOpen(null)}
clientId={clientId}
userId={user?.id ?? null}
balance={balance}
onSaved={load}
/>
</Card>
);
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{type === "deposit" ? "Record trust deposit" : "Record trust withdrawal"}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<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)} placeholder="Optional memo" />
</div>
{type === "withdrawal" && (
<p className="text-xs text-muted-foreground">
Available balance: <span className="tabular-nums">{formatCurrency(balance)}</span>
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={save} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Dialog used on an invoice detail page to apply trust funds toward its balance. */
export function ApplyTrustDialog({
open,
onOpenChange,
clientId,
invoiceId,
invoiceNumber,
balanceDue,
userId,
onApplied,
}: {
open: boolean;
onOpenChange: (b: boolean) => void;
clientId: string;
invoiceId: string;
invoiceNumber: string;
balanceDue: number;
userId: string | null;
onApplied: () => void;
}) {
const [trustBalance, setTrustBalance] = useState<number | null>(null);
const [amount, setAmount] = useState("");
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
(async () => {
const { data } = await supabase
.from("trust_ledger_entries")
.select("entry_type, amount")
.eq("client_id", clientId);
const bal = (data ?? []).reduce(
(s: number, e: any) => s + (e.entry_type === "deposit" ? Number(e.amount) : -Number(e.amount)),
0,
);
setTrustBalance(bal);
const suggest = Math.min(bal, balanceDue);
setAmount(suggest > 0 ? suggest.toFixed(2) : "");
setDate(new Date().toISOString().slice(0, 10));
})();
}, [open, clientId, balanceDue]);
const apply = async () => {
const amt = Number(amount);
if (!amt || amt <= 0) return toast.error("Enter a valid amount");
if (amt > balanceDue) return toast.error("Amount exceeds invoice balance due");
if (trustBalance != null && amt > trustBalance) {
if (!confirm(`This will overdraw the trust by ${formatCurrency(amt - trustBalance)}. Continue?`)) return;
}
setSaving(true);
// 1) Record the invoice payment (the existing trigger keeps invoice totals in sync).
const { data: pay, error: payErr } = await supabase
.from("invoice_payments")
.insert({
invoice_id: invoiceId,
amount: amt,
paid_on: date,
method: "trust",
reference: `Trust transfer · ${invoiceNumber}`,
created_by: userId,
})
.select("id")
.single();
if (payErr || !pay) {
setSaving(false);
return toast.error(payErr?.message ?? "Could not record payment");
}
// 2) Add the matching trust ledger withdrawal pointing at this invoice.
const { error: ledErr } = await supabase.from("trust_ledger_entries").insert({
client_id: clientId,
entry_date: date,
entry_type: "withdrawal",
amount: amt,
note: `Applied to ${invoiceNumber}`,
applied_invoice_id: invoiceId,
applied_payment_id: pay.id,
created_by: userId,
});
setSaving(false);
if (ledErr) return toast.error(ledErr.message);
toast.success("Trust funds applied");
onOpenChange(false);
onApplied();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Apply trust funds</DialogTitle>
</DialogHeader>
<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="tabular-nums font-medium">
{trustBalance == null ? "…" : formatCurrency(trustBalance)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Invoice balance due</span>
<span className="tabular-nums font-medium">{formatCurrency(balanceDue)}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Amount to apply</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>
{trustBalance != null && Number(amount) > trustBalance && (
<p className="text-xs text-destructive">
This will overdraw the trust by {formatCurrency(Number(amount) - trustBalance)}.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={apply} disabled={saving || !amount}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}Apply funds
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+87
View File
@@ -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
+3
View File
@@ -15,6 +15,7 @@ import { formatCurrency, formatDate, formatDateTime, statusBadgeClass } from "@/
import { toast } from "sonner";
import { downloadStatusReport } from "@/lib/status-pdf";
import { GenerateInvoiceDialog } from "@/components/invoices/generate-invoice-dialog";
import { TrustAccountPanel } from "@/components/trust/trust-account-panel";
export const Route = createFileRoute("/clients/$clientId")({
component: () => (
@@ -203,6 +204,8 @@ function ClientDetail() {
</CardContent>
</Card>
)}
<TrustAccountPanel clientId={clientId} />
</div>
<Card className="border-border/60 h-fit">
+26 -1
View File
@@ -12,10 +12,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { ArrowLeft, FileDown, Plus, Trash2, Loader2, Send, Ban, CheckCircle2, DollarSign } from "lucide-react";
import { ArrowLeft, FileDown, Plus, Trash2, Loader2, Send, Ban, CheckCircle2, DollarSign, Wallet } from "lucide-react";
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 { toast } from "sonner";
export const Route = createFileRoute("/invoices/$invoiceId")({
@@ -37,6 +38,7 @@ function InvoiceDetail() {
const [loading, setLoading] = useState(true);
const [savingItem, setSavingItem] = useState(false);
const [payOpen, setPayOpen] = useState(false);
const [trustOpen, setTrustOpen] = useState(false);
const load = async () => {
setLoading(true);
@@ -269,6 +271,11 @@ function InvoiceDetail() {
<div className="flex items-center gap-3 mb-1">
<span className="text-xs uppercase tracking-widest text-muted-foreground">Invoice</span>
<Badge variant="outline" className={statusBadgeClass(invoice.status)}>{invoice.status}</Badge>
{invoice.is_retainer && (
<Badge variant="outline" className="border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
<Wallet className="h-3 w-3 mr-1" /> Retainer
</Badge>
)}
</div>
<h1 className="font-serif text-3xl">{invoice.invoice_number}</h1>
<p className="text-sm text-muted-foreground mt-1">
@@ -290,6 +297,11 @@ function InvoiceDetail() {
{canEdit && invoice.status !== "void" && invoice.status !== "paid" && (
<Button variant="outline" onClick={() => setStatus("void")}><Ban className="h-4 w-4 mr-2" /> Void</Button>
)}
{canEdit && balance > 0 && !invoice.is_retainer && (
<Button variant="outline" onClick={() => setTrustOpen(true)}>
<Wallet className="h-4 w-4 mr-2" /> Apply trust funds
</Button>
)}
{canEdit && (
<Button onClick={() => setPayOpen(true)}><DollarSign className="h-4 w-4 mr-2" /> Record payment</Button>
)}
@@ -476,6 +488,19 @@ function InvoiceDetail() {
userId={user?.id ?? ""}
onSaved={load}
/>
{invoice.client?.id && (
<ApplyTrustDialog
open={trustOpen}
onOpenChange={setTrustOpen}
clientId={invoice.client.id}
invoiceId={invoiceId}
invoiceNumber={invoice.invoice_number}
balanceDue={balance}
userId={user?.id ?? null}
onApplied={load}
/>
)}
</PageContainer>
);
}
@@ -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();