import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { ProtectedLayout } from "@/components/protected-layout"; import { PageContainer, PageHeader } from "@/components/app-shell"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; import { Loader2, FilePlus, ArrowLeft } from "lucide-react"; import { formatCurrency } from "@/lib/format"; import { toast } from "sonner"; import { generateInvoiceForClient } from "@/lib/invoice-generation"; interface SearchParams { caseId?: string; } export const Route = createFileRoute("/invoices/new/$clientId")({ validateSearch: (search: Record): SearchParams => ({ caseId: typeof search.caseId === "string" ? search.caseId : undefined, }), component: () => ( ), }); interface CaseUnbilled { id: string; case_number: string; title: string; timeCount: number; timeAmount: number; expenseCount: number; expenseAmount: number; total: number; } function NewInvoicePage() { const { clientId } = Route.useParams(); const { caseId: presetCaseId } = useSearch({ from: "/invoices/new/$clientId" }); const { user } = useAuth(); const navigate = useNavigate(); const [clientName, setClientName] = useState(""); const [cases, setCases] = useState([]); const [selected, setSelected] = useState>({}); const [taxPct, setTaxPct] = useState("0"); const [dueDays, setDueDays] = useState("30"); const [notes, setNotes] = useState(""); const [retainerAmount, setRetainerAmount] = useState(""); const [isRetainer, setIsRetainer] = useState(false); const [splitPerCase, setSplitPerCase] = useState(false); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); useEffect(() => { (async () => { setLoading(true); const [{ data: client }, { data: cs }, { data: firm }] = await Promise.all([ supabase.from("clients").select("name").eq("id", clientId).maybeSingle(), supabase.from("cases").select("id, case_number, title").eq("client_id", clientId).is("archived_at", null), supabase.from("firm_settings").select("default_tax_rate").maybeSingle(), ]); setClientName(client?.name ?? ""); const caseIds = (cs ?? []).map((c) => c.id); if (firm?.default_tax_rate != null) setTaxPct(String(firm.default_tax_rate)); if (caseIds.length === 0) { setCases([]); setLoading(false); return; } const [{ data: time }, { data: exp }] = await Promise.all([ supabase.from("time_entries").select("case_id, hours, hourly_rate") .in("case_id", caseIds).eq("billable", true).is("invoice_id", null), supabase.from("expenses").select("case_id, amount") .in("case_id", caseIds).eq("billable", true).is("invoice_id", null), ]); const tally: Record = {}; for (const t of time ?? []) { const k = t.case_id; if (!tally[k]) tally[k] = { tc: 0, ta: 0, ec: 0, ea: 0 }; tally[k].tc += 1; tally[k].ta += Number(t.hours) * Number(t.hourly_rate); } for (const e of exp ?? []) { const k = e.case_id; if (!tally[k]) tally[k] = { tc: 0, ta: 0, ec: 0, ea: 0 }; tally[k].ec += 1; tally[k].ea += Number(e.amount); } const enriched: CaseUnbilled[] = (cs ?? []).map((c) => { const t = tally[c.id] ?? { tc: 0, ta: 0, ec: 0, ea: 0 }; return { id: c.id, case_number: c.case_number, title: c.title, timeCount: t.tc, timeAmount: t.ta, expenseCount: t.ec, expenseAmount: t.ea, total: t.ta + t.ea, }; }).filter((c) => c.total > 0); setCases(enriched); const sel: Record = {}; if (presetCaseId) { sel[presetCaseId] = true; } else { enriched.forEach((c) => { sel[c.id] = true; }); } setSelected(sel); setLoading(false); })(); }, [clientId, presetCaseId]); const selectedIds = Object.entries(selected).filter(([, v]) => v).map(([k]) => k); const subtotal = cases.filter((c) => selected[c.id]).reduce((s, c) => s + c.total, 0); const tax = +(subtotal * (Number(taxPct) || 0) / 100).toFixed(2); const total = subtotal + tax; const submit = async () => { if (!user?.id) return; if (selectedIds.length === 0 && !isRetainer) return toast.error("Select at least one case"); setSaving(true); try { if (isRetainer) { 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(retainerAmount) || 0); if (amt <= 0) { setSaving(false); return toast.error("Enter a retainer amount"); } const taxAmt = +(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: taxAmt, total: +(amt + taxAmt).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`); navigate({ to: "/invoices/$invoiceId", params: { invoiceId: inv.id } }); return; } if (splitPerCase && selectedIds.length > 1) { const results: { id: string; number: string }[] = []; for (const cid of selectedIds) { const r = await generateInvoiceForClient({ clientId, caseIds: [cid], createdBy: user.id, taxRate: (Number(taxPct) || 0) / 100, dueDays: Number(dueDays) || 30, notes: notes || undefined, }); results.push({ id: r.invoiceId, number: r.invoiceNumber }); } toast.success(`${results.length} invoices created`); navigate({ to: "/invoices" }); return; } const { invoiceId, invoiceNumber } = await generateInvoiceForClient({ clientId, caseIds: selectedIds, createdBy: user.id, taxRate: (Number(taxPct) || 0) / 100, dueDays: Number(dueDays) || 30, notes: notes || undefined, }); toast.success(`Invoice ${invoiceNumber} created`); navigate({ to: "/invoices/$invoiceId", params: { invoiceId } }); } catch (e: any) { toast.error(e?.message || "Failed to generate"); } finally { setSaving(false); } }; return ( navigate({ to: "/invoices/new" })}> Change client } />
{isRetainer ? (
setRetainerAmount(e.target.value)} />
setTaxPct(e.target.value)} />
setDueDays(e.target.value)} />
) : loading ? ( Loading unbilled work… ) : cases.length === 0 ? ( No unbilled time or expenses across this client's cases. ) : ( <>
·
{cases.map((c) => ( ))}
{selectedIds.length > 1 && ( )}
setTaxPct(e.target.value)} />
setDueDays(e.target.value)} />
{splitPerCase && selectedIds.length > 1 ? "Combined total" : "Estimated total"}
{formatCurrency(total)}
{tax > 0 &&
{formatCurrency(subtotal)} + {formatCurrency(tax)} tax
}
)}