diff --git a/src/routes/invoices.new.$clientId.tsx b/src/routes/invoices.new.$clientId.tsx new file mode 100644 index 0000000..57b039d --- /dev/null +++ b/src/routes/invoices.new.$clientId.tsx @@ -0,0 +1,378 @@ +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
} +
+
+
+ + )} + + + + +