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:08:11 +00:00
co-authored by renee-png
parent a6213785ea
commit a583e788ba
@@ -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>
);
}