From 970a94c7d5c9f05725aa14e63e0dd39d1df9d667 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:37:00 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- .../invoices/generate-invoice-dialog.tsx | 202 +++++++ src/routeTree.gen.ts | 51 ++ src/routes/invoices.$invoiceId.tsx | 552 ++++++++++++++++++ src/routes/invoices.index.tsx | 148 +++++ 4 files changed, 953 insertions(+) create mode 100644 src/components/invoices/generate-invoice-dialog.tsx create mode 100644 src/routes/invoices.$invoiceId.tsx create mode 100644 src/routes/invoices.index.tsx diff --git a/src/components/invoices/generate-invoice-dialog.tsx b/src/components/invoices/generate-invoice-dialog.tsx new file mode 100644 index 0000000..0fa0f5e --- /dev/null +++ b/src/components/invoices/generate-invoice-dialog.tsx @@ -0,0 +1,202 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +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 } from "lucide-react"; +import { formatCurrency } from "@/lib/format"; +import { toast } from "sonner"; +import { generateInvoiceForClient } from "@/lib/invoice-generation"; + +interface Props { + open: boolean; + onOpenChange: (b: boolean) => void; + clientId: string; + clientName: string; + /** preselect a single case (used when launched from a case page) */ + presetCaseId?: string; +} + +interface CaseUnbilled { + id: string; + case_number: string; + title: string; + timeCount: number; + timeAmount: number; + expenseCount: number; + expenseAmount: number; + total: number; +} + +export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName, presetCaseId }: Props) { + const { user } = useAuth(); + const navigate = useNavigate(); + const [cases, setCases] = useState([]); + const [selected, setSelected] = useState>({}); + const [taxPct, setTaxPct] = useState("0"); + const [dueDays, setDueDays] = useState("30"); + const [notes, setNotes] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) return; + (async () => { + setLoading(true); + const [{ data: cs }, { data: firm }] = await Promise.all([ + supabase.from("cases").select("id, case_number, title").eq("client_id", clientId), + supabase.from("firm_settings").select("default_tax_rate").maybeSingle(), + ]); + 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); + // Default selection + const sel: Record = {}; + if (presetCaseId) { + sel[presetCaseId] = true; + } else { + enriched.forEach((c) => { sel[c.id] = true; }); + } + setSelected(sel); + setLoading(false); + })(); + }, [open, 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) return toast.error("Select at least one case"); + setSaving(true); + try { + 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`); + onOpenChange(false); + navigate({ to: "/invoices/$invoiceId", params: { invoiceId } }); + } catch (e: any) { + toast.error(e?.message || "Failed to generate"); + } finally { + setSaving(false); + } + }; + + return ( + + + + Generate invoice — {clientName} + + + {loading ? ( +
Loading unbilled work…
+ ) : cases.length === 0 ? ( +
+ No unbilled time or expenses across this client's cases. +
+ ) : ( +
+
+ +
+ {cases.map((c) => ( + + ))} +
+
+ +
+
+ + setTaxPct(e.target.value)} /> +
+
+ + setDueDays(e.target.value)} /> +
+
+
+
Estimated total
+
{formatCurrency(total)}
+ {tax > 0 &&
{formatCurrency(subtotal)} + {formatCurrency(tax)} tax
} +
+
+
+ +
+ +