Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user