import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { useEffect, useState, useMemo, Fragment } from "react"; import { ProtectedLayout } from "@/components/protected-layout"; import { PageContainer } from "@/components/app-shell"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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, 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, PushPaymentToTrustButton } from "@/components/trust/trust-account-panel"; import { toast } from "sonner"; export const Route = createFileRoute("/invoices/$invoiceId")({ component: () => ( ), }); function InvoiceDetail() { const { invoiceId } = Route.useParams(); const navigate = useNavigate(); const { user, isAdmin } = useAuth(); const [invoice, setInvoice] = useState(null); const [items, setItems] = useState([]); const [payments, setPayments] = useState([]); const [firm, setFirm] = useState(null); 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); const [invRes, itemRes, payRes, firmRes] = await Promise.all([ supabase .from("invoices") .select("*, client:clients(id, name, primary_contact_name, address_line1, address_line2, city, state, postal_code)") .eq("id", invoiceId) .maybeSingle(), supabase .from("invoice_line_items") .select("*, case:cases(id, case_number, title, practice_area), user:profiles(id, full_name, email)") .eq("invoice_id", invoiceId) .order("sort_order", { ascending: true }), supabase.from("invoice_payments").select("*").eq("invoice_id", invoiceId).order("paid_on", { ascending: false }), supabase.from("firm_settings").select("*").maybeSingle(), ]); setInvoice(invRes.data); setItems(itemRes.data ?? []); setPayments(payRes.data ?? []); setFirm(firmRes.data); setLoading(false); }; useEffect(() => { load(); }, [invoiceId]); // Subsection keys & labels — order matters for display. const SUBSECTIONS: { key: string; label: string; isExpense: boolean; billable: boolean }[] = [ { key: "bill_fee", label: "Billable Fees", isExpense: false, billable: true }, { key: "bill_exp", label: "Billable Expenses", isExpense: true, billable: true }, { key: "nb_fee", label: "Non-Billable Fees", isExpense: false, billable: false }, { key: "nb_exp", label: "Non-Billable Expenses", isExpense: true, billable: false }, ]; const classifyItem = (it: any): string => { const isExpense = it.kind === "expense"; const isNonBillable = Number(it.amount) === 0 && (it.time_entry_id || it.expense_id); if (isExpense) return isNonBillable ? "nb_exp" : "bill_exp"; return isNonBillable ? "nb_fee" : "bill_fee"; }; const groups = useMemo(() => { const map = new Map< string, { caseRow: any; items: any[]; subtotal: number; subsections: { key: string; label: string; billable: boolean; items: any[]; subtotal: number }[]; } >(); for (const it of items) { const key = it.case?.id ?? "_none"; if (!map.has(key)) { map.set(key, { caseRow: it.case, items: [], subtotal: 0, subsections: SUBSECTIONS.map((s) => ({ key: s.key, label: s.label, billable: s.billable, items: [], subtotal: 0 })), }); } const g = map.get(key)!; g.items.push(it); const subKey = classifyItem(it); const sub = g.subsections.find((s) => s.key === subKey)!; sub.items.push(it); const amt = Number(it.amount); sub.subtotal += amt; if (sub.billable) g.subtotal += amt; } // Drop empty subsections for (const g of map.values()) g.subsections = g.subsections.filter((s) => s.items.length > 0); return Array.from(map.values()); }, [items]); if (loading) return

Loading…

; if (!invoice) { return (

Invoice not found.

); } const canEdit = isAdmin || invoice.created_by === user?.id; const isDraft = invoice.status === "draft"; const balance = Number(invoice.total) - Number(invoice.amount_paid); const setStatus = async (status: string) => { const { error } = await supabase.from("invoices").update({ status: status as any }).eq("id", invoiceId); if (error) toast.error(error.message); else { toast.success(`Marked ${status}`); load(); } }; const removeItem = async (id: string) => { if (!confirm("Remove this line item?")) return; const { error } = await supabase.from("invoice_line_items").delete().eq("id", id); if (error) return toast.error(error.message); await recalcInvoiceTotals(invoiceId); load(); }; const updateItem = async (id: string, patch: any) => { const next: any = { ...patch }; if (patch.quantity != null || patch.rate != null) { const cur = items.find((i) => i.id === id); const q = Number(patch.quantity ?? cur.quantity); const r = Number(patch.rate ?? cur.rate); next.amount = +(q * r).toFixed(2); } const { error } = await supabase.from("invoice_line_items").update(next).eq("id", id); if (error) return toast.error(error.message); await recalcInvoiceTotals(invoiceId); load(); }; const addManualItem = async (caseId: string | null) => { setSavingItem(true); const sort_order = items.length ? Math.max(...items.map((i) => i.sort_order)) + 1 : 0; const { error } = await supabase.from("invoice_line_items").insert({ invoice_id: invoiceId, case_id: caseId, kind: "manual", description: "New line", quantity: 1, rate: 0, amount: 0, sort_order, }); setSavingItem(false); if (error) return toast.error(error.message); await recalcInvoiceTotals(invoiceId); load(); }; const updateNotes = async (notes: string) => { await supabase.from("invoices").update({ notes }).eq("id", invoiceId); }; const updateTaxRate = async (ratePct: string) => { const subtotal = Number(invoice.subtotal); const taxRate = Math.max(0, Number(ratePct) || 0) / 100; const tax = +(subtotal * taxRate).toFixed(2); const total = +(subtotal + tax).toFixed(2); await supabase.from("invoices").update({ tax, total }).eq("id", invoiceId); load(); }; const downloadPdf = async () => { let logoDataUrl: string | null = null; if (firm?.logo_storage_path) { const { data: signed } = await supabase.storage .from("firm-logos") .createSignedUrl(firm.logo_storage_path, 60); if (signed?.signedUrl) { try { const res = await fetch(signed.signedUrl); const blob = await res.blob(); logoDataUrl = await new Promise((resolve) => { const r = new FileReader(); r.onload = () => resolve(r.result as string); r.readAsDataURL(blob); }); } catch { /* ignore */ } } } const input: InvoicePdfInput = { firm: { name: firm?.company_name, address1: firm?.address_line1, address2: firm?.address_line2, city: firm?.city, state: firm?.state, postal: firm?.postal_code, email: firm?.contact_email, phone: firm?.contact_phone, website: firm?.website, footerNote: firm?.footer_note, logoDataUrl, }, client: { name: invoice.client?.name ?? "Client", contact: invoice.client?.primary_contact_name, address1: invoice.client?.address_line1, address2: invoice.client?.address_line2, city: invoice.client?.city, state: invoice.client?.state, postal: invoice.client?.postal_code, }, invoice: { number: invoice.invoice_number, issueDate: invoice.issue_date, dueDate: invoice.due_date, status: invoice.status, notes: invoice.notes, matter: groups.length === 1 ? (groups[0].caseRow?.title ?? null) : null, caseNumber: groups.length === 1 ? (groups[0].caseRow?.case_number ?? null) : null, }, groups: groups.map((g) => { const mapItem = (it: any) => { const name: string = it.user?.full_name || it.user?.email || ""; const initials = name .trim() .split(/\s+/) .map((p: string) => p[0]) .filter(Boolean) .slice(0, 3) .join("") .toUpperCase(); const isNonBillable = Number(it.amount) === 0 && (it.time_entry_id || it.expense_id); return { kind: it.kind, description: it.description, work_date: it.work_date, quantity: Number(it.quantity), rate: Number(it.rate), amount: Number(it.amount), user_name: name || null, user_initials: initials || null, billable: !isNonBillable, }; }; return { caseNumber: g.caseRow?.case_number ?? "—", caseTitle: g.caseRow?.title ?? "(unassigned)", practiceArea: g.caseRow?.practice_area, subtotal: g.subtotal, items: g.items.map(mapItem), subsections: g.subsections.map((s) => ({ key: s.key, label: s.label, billable: s.billable, subtotal: s.subtotal, items: s.items.map(mapItem), })), }; }), totals: { subtotal: Number(invoice.subtotal), tax: Number(invoice.tax), total: Number(invoice.total), paid: Number(invoice.amount_paid), balance, taxRatePct: Number(invoice.subtotal) > 0 ? Math.round((Number(invoice.tax) / Number(invoice.subtotal)) * 10000) / 100 : 0, feesAndExpenses: Number(invoice.subtotal), }, }; await downloadInvoicePdf(input, `${invoice.invoice_number}.pdf`); }; const deleteInvoice = async () => { if (!confirm("Delete this draft invoice and unbill its time/expenses?")) return; // Unlink time/expenses await supabase.from("time_entries").update({ invoice_id: null }).eq("invoice_id", invoiceId); await supabase.from("expenses").update({ invoice_id: null }).eq("invoice_id", invoiceId); const { error } = await supabase.from("invoices").delete().eq("id", invoiceId); if (error) return toast.error(error.message); toast.success("Invoice deleted"); navigate({ to: "/invoices" }); }; return (
Invoice {invoice.status} {invoice.is_retainer && ( Retainer )}

{invoice.invoice_number}

{invoice.client?.name} {" · "}Issued {formatDate(invoice.issue_date)} {invoice.due_date && <> · Due {formatDate(invoice.due_date)}}

{canEdit && isDraft && ( )} {canEdit && (invoice.status === "sent" || invoice.status === "overdue") && ( )} {canEdit && invoice.status !== "void" && invoice.status !== "paid" && ( )} {canEdit && balance > 0 && !invoice.is_retainer && ( )} {canEdit && ( )}
{groups.length === 0 && ( No line items. )} {groups.map((g, gi) => (
{g.caseRow?.title ?? "Unassigned"}
{g.caseRow?.case_number}{g.caseRow?.practice_area ? ` · ${g.caseRow.practice_area}` : ""}
{formatCurrency(g.subtotal)}
{canEdit && isDraft && {g.subsections.map((sub) => ( {canEdit && isDraft && {sub.items.map((it) => ( {canEdit && isDraft && ( )} ))} ))}
Date Description Qty Rate Amount}
{sub.label} {sub.billable ? formatCurrency(sub.subtotal) : "—"} }
{it.work_date ? formatDate(it.work_date) : "—"} {canEdit && isDraft ? ( e.target.value !== it.description && updateItem(it.id, { description: e.target.value })} /> ) : (
{it.description}
{it.user?.full_name &&
{it.user.full_name}
}
)}
{canEdit && isDraft ? ( Number(e.target.value) !== Number(it.quantity) && updateItem(it.id, { quantity: Number(e.target.value) })} /> ) : Number(it.quantity).toFixed(2)} {canEdit && isDraft ? ( Number(e.target.value) !== Number(it.rate) && updateItem(it.id, { rate: Number(e.target.value) })} /> ) : formatCurrency(it.rate)} {sub.billable ? formatCurrency(it.amount) : "—"}
{canEdit && isDraft && (
)}
))} {canEdit && isDraft && groups.length === 0 && ( )} {canEdit && isDraft ? (