+
diff --git a/src/lib/billing-report-pdf.ts b/src/lib/billing-report-pdf.ts
new file mode 100644
index 0000000..14cd027
--- /dev/null
+++ b/src/lib/billing-report-pdf.ts
@@ -0,0 +1,112 @@
+import jsPDF from "jspdf";
+import autoTable from "jspdf-autotable";
+
+export interface TimeReportRow {
+ work_date: string;
+ case_label: string;
+ user_name: string;
+ description: string;
+ hours: number;
+ rate: number;
+ amount: number;
+ billable: boolean;
+}
+
+export interface ExpenseReportRow {
+ expense_date: string;
+ case_label: string;
+ user_name: string;
+ description: string;
+ amount: number;
+ billable: boolean;
+}
+
+interface BaseOpts {
+ clientName: string;
+ fromDate?: string;
+ toDate?: string;
+}
+
+function fmtDate(iso?: string | null) {
+ if (!iso) return "—";
+ const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
+ if (m) {
+ const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
+ return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" });
+ }
+ return new Date(iso).toLocaleDateString("en-US");
+}
+
+function fmtCurrency(n: number) {
+ return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n || 0);
+}
+
+function header(doc: jsPDF, title: string, opts: BaseOpts) {
+ const margin = 54;
+ doc.setFont("times", "bold");
+ doc.setFontSize(18);
+ doc.text(title, margin, margin);
+ doc.setFont("times", "normal");
+ doc.setFontSize(11);
+ doc.setTextColor(90);
+ doc.text(opts.clientName, margin, margin + 22);
+ if (opts.fromDate || opts.toDate) {
+ const range = `${opts.fromDate ? fmtDate(opts.fromDate) : "—"} to ${opts.toDate ? fmtDate(opts.toDate) : "—"}`;
+ doc.text(range, margin, margin + 38);
+ }
+ doc.setTextColor(0);
+}
+
+export function generateTimeReportPdf(opts: BaseOpts & { rows: TimeReportRow[] }): jsPDF {
+ const doc = new jsPDF({ unit: "pt", format: "letter" });
+ header(doc, "Time Report", opts);
+ const totalHours = opts.rows.reduce((a, r) => a + (r.hours || 0), 0);
+ const totalAmt = opts.rows.reduce((a, r) => a + (r.amount || 0), 0);
+ autoTable(doc, {
+ startY: 110,
+ head: [["Date", "Case", "User", "Description", "Hours", "Rate", "Amount"]],
+ body: opts.rows.map((r) => [
+ fmtDate(r.work_date),
+ r.case_label,
+ r.user_name,
+ r.description,
+ r.hours.toFixed(2),
+ fmtCurrency(r.rate),
+ fmtCurrency(r.amount),
+ ]),
+ foot: [["", "", "", "Totals", totalHours.toFixed(2), "", fmtCurrency(totalAmt)]],
+ styles: { font: "times", fontSize: 9, cellPadding: 4 },
+ headStyles: { fillColor: [240, 240, 240], textColor: 20 },
+ footStyles: { fillColor: [240, 240, 240], textColor: 20, fontStyle: "bold" },
+ columnStyles: {
+ 4: { halign: "right" },
+ 5: { halign: "right" },
+ 6: { halign: "right" },
+ },
+ });
+ return doc;
+}
+
+export function generateExpenseReportPdf(opts: BaseOpts & { rows: ExpenseReportRow[] }): jsPDF {
+ const doc = new jsPDF({ unit: "pt", format: "letter" });
+ header(doc, "Expense Report", opts);
+ const totalAmt = opts.rows.reduce((a, r) => a + (r.amount || 0), 0);
+ autoTable(doc, {
+ startY: 110,
+ head: [["Date", "Case", "User", "Description", "Billable", "Amount"]],
+ body: opts.rows.map((r) => [
+ fmtDate(r.expense_date),
+ r.case_label,
+ r.user_name,
+ r.description,
+ r.billable ? "Yes" : "No",
+ fmtCurrency(r.amount),
+ ]),
+ foot: [["", "", "", "", "Total", fmtCurrency(totalAmt)]],
+ styles: { font: "times", fontSize: 9, cellPadding: 4 },
+ headStyles: { fillColor: [240, 240, 240], textColor: 20 },
+ footStyles: { fillColor: [240, 240, 240], textColor: 20, fontStyle: "bold" },
+ columnStyles: { 5: { halign: "right" } },
+ });
+ return doc;
+}
diff --git a/src/lib/status-pdf.ts b/src/lib/status-pdf.ts
index 5713065..c33dd0f 100644
--- a/src/lib/status-pdf.ts
+++ b/src/lib/status-pdf.ts
@@ -171,3 +171,38 @@ export function downloadStatusReport(opts: StatusReportOptions, filename: string
const doc = generateStatusReportPdf(opts);
doc.save(filename);
}
+
+import { supabase } from "@/integrations/supabase/client";
+
+/**
+ * Upload a status-report PDF to the `generated-documents` bucket and create
+ * a `generated_documents` row with kind='status_report'. Returns the new row id.
+ */
+export async function saveStatusReportToDb(
+ opts: StatusReportOptions,
+ filename: string,
+ meta: { userId: string; payload?: Record
},
+): Promise<{ id: string; storage_path: string }> {
+ const doc = generateStatusReportPdf(opts);
+ const blob = doc.output("blob");
+ const safe = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
+ const storagePath = `${meta.userId}/${Date.now()}-${safe}`;
+ const { error: upErr } = await supabase.storage
+ .from("generated-documents")
+ .upload(storagePath, blob, { contentType: "application/pdf" });
+ if (upErr) throw upErr;
+
+ const { data, error } = await supabase
+ .from("generated_documents")
+ .insert({
+ kind: "status_report",
+ name: filename.replace(/\.pdf$/i, ""),
+ storage_path: storagePath,
+ created_by: meta.userId,
+ payload: (meta.payload ?? {}) as never,
+ })
+ .select("id, storage_path")
+ .single();
+ if (error) throw error;
+ return data as { id: string; storage_path: string };
+}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index e89f5d1..94db69b 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -16,6 +16,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as TasksIndexRouteImport } from './routes/tasks.index'
import { Route as StatusIndexRouteImport } from './routes/status.index'
import { Route as SettingsIndexRouteImport } from './routes/settings.index'
+import { Route as ReportsIndexRouteImport } from './routes/reports.index'
import { Route as PaymentsIndexRouteImport } from './routes/payments.index'
import { Route as MessagesIndexRouteImport } from './routes/messages.index'
import { Route as InvoicesIndexRouteImport } from './routes/invoices.index'
@@ -91,6 +92,11 @@ const SettingsIndexRoute = SettingsIndexRouteImport.update({
path: '/',
getParentRoute: () => SettingsRoute,
} as any)
+const ReportsIndexRoute = ReportsIndexRouteImport.update({
+ id: '/reports/',
+ path: '/reports/',
+ getParentRoute: () => rootRouteImport,
+} as any)
const PaymentsIndexRoute = PaymentsIndexRouteImport.update({
id: '/payments/',
path: '/payments/',
@@ -327,6 +333,7 @@ export interface FileRoutesByFullPath {
'/invoices/': typeof InvoicesIndexRoute
'/messages/': typeof MessagesIndexRoute
'/payments/': typeof PaymentsIndexRoute
+ '/reports/': typeof ReportsIndexRoute
'/settings/': typeof SettingsIndexRoute
'/status/': typeof StatusIndexRoute
'/tasks/': typeof TasksIndexRoute
@@ -374,6 +381,7 @@ export interface FileRoutesByTo {
'/invoices': typeof InvoicesIndexRoute
'/messages': typeof MessagesIndexRoute
'/payments': typeof PaymentsIndexRoute
+ '/reports': typeof ReportsIndexRoute
'/settings': typeof SettingsIndexRoute
'/status': typeof StatusIndexRoute
'/tasks': typeof TasksIndexRoute
@@ -423,6 +431,7 @@ export interface FileRoutesById {
'/invoices/': typeof InvoicesIndexRoute
'/messages/': typeof MessagesIndexRoute
'/payments/': typeof PaymentsIndexRoute
+ '/reports/': typeof ReportsIndexRoute
'/settings/': typeof SettingsIndexRoute
'/status/': typeof StatusIndexRoute
'/tasks/': typeof TasksIndexRoute
@@ -473,6 +482,7 @@ export interface FileRouteTypes {
| '/invoices/'
| '/messages/'
| '/payments/'
+ | '/reports/'
| '/settings/'
| '/status/'
| '/tasks/'
@@ -520,6 +530,7 @@ export interface FileRouteTypes {
| '/invoices'
| '/messages'
| '/payments'
+ | '/reports'
| '/settings'
| '/status'
| '/tasks'
@@ -568,6 +579,7 @@ export interface FileRouteTypes {
| '/invoices/'
| '/messages/'
| '/payments/'
+ | '/reports/'
| '/settings/'
| '/status/'
| '/tasks/'
@@ -607,6 +619,7 @@ export interface RootRouteChildren {
InvoicesIndexRoute: typeof InvoicesIndexRoute
MessagesIndexRoute: typeof MessagesIndexRoute
PaymentsIndexRoute: typeof PaymentsIndexRoute
+ ReportsIndexRoute: typeof ReportsIndexRoute
StatusIndexRoute: typeof StatusIndexRoute
TasksIndexRoute: typeof TasksIndexRoute
DocumentsPleadingNewRoute: typeof DocumentsPleadingNewRoute
@@ -666,6 +679,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsIndexRouteImport
parentRoute: typeof SettingsRoute
}
+ '/reports/': {
+ id: '/reports/'
+ path: '/reports'
+ fullPath: '/reports/'
+ preLoaderRoute: typeof ReportsIndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/payments/': {
id: '/payments/'
path: '/payments'
@@ -1015,6 +1035,7 @@ const rootRouteChildren: RootRouteChildren = {
InvoicesIndexRoute: InvoicesIndexRoute,
MessagesIndexRoute: MessagesIndexRoute,
PaymentsIndexRoute: PaymentsIndexRoute,
+ ReportsIndexRoute: ReportsIndexRoute,
StatusIndexRoute: StatusIndexRoute,
TasksIndexRoute: TasksIndexRoute,
DocumentsPleadingNewRoute: DocumentsPleadingNewRoute,
diff --git a/src/routes/clients.$clientId.tsx b/src/routes/clients.$clientId.tsx
index 8d7c0dd..2f981cd 100644
--- a/src/routes/clients.$clientId.tsx
+++ b/src/routes/clients.$clientId.tsx
@@ -9,12 +9,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { supabase } from "@/integrations/supabase/client";
import { ClientFormDialog } from "@/components/clients/client-form-dialog";
import { useAuth } from "@/lib/auth";
-import { ArrowLeft, Edit, Plus, Building2, User, MapPin, Mail, Phone, Users, Activity, Briefcase, FileDown, Contact, Receipt, Archive, ArchiveRestore, ListPlus } from "lucide-react";
+import { ArrowLeft, Edit, Plus, Building2, User, MapPin, Mail, Phone, Users, Activity, Briefcase, FileDown, Contact, Receipt, Archive, ArchiveRestore, ListPlus, Save } from "lucide-react";
import { ContactsLinkTab } from "@/components/contacts/contacts-link-tab";
import { ClientCustomFieldsTab } from "@/components/clients/client-custom-fields-tab";
import { formatCurrency, formatDate, formatDateTime, statusBadgeClass } from "@/lib/format";
import { toast } from "sonner";
-import { downloadStatusReport } from "@/lib/status-pdf";
+import { downloadStatusReport, saveStatusReportToDb } from "@/lib/status-pdf";
import { TrustAccountPanel } from "@/components/trust/trust-account-panel";
import { setArchived } from "@/lib/archive";
@@ -268,29 +268,61 @@ function ClientDetail() {
{statusEntries.length} update{statusEntries.length === 1 ? "" : "s"} across all cases
-
+
+
+
+
{statusEntries.length === 0 && No status updates logged.
}
diff --git a/src/routes/reports.index.tsx b/src/routes/reports.index.tsx
new file mode 100644
index 0000000..6b3f997
--- /dev/null
+++ b/src/routes/reports.index.tsx
@@ -0,0 +1,559 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { useEffect, useMemo, 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 { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { SearchableSelect } from "@/components/ui/searchable-select";
+import { supabase } from "@/integrations/supabase/client";
+import { toast } from "sonner";
+import { Download, Trash2, FileText, FileDown, Loader2 } from "lucide-react";
+import { formatCurrency, formatDate, formatDateTime } from "@/lib/format";
+import {
+ generateTimeReportPdf,
+ generateExpenseReportPdf,
+ type TimeReportRow,
+ type ExpenseReportRow,
+} from "@/lib/billing-report-pdf";
+
+export const Route = createFileRoute("/reports/")({
+ component: () => (
+
+
+
+ ),
+});
+
+function ReportsPage() {
+ return (
+
+
+
+
+ Status Reports
+ Client Time
+ Client Expenses
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/* ---------- Saved status reports ---------- */
+function SavedStatusReports() {
+ const [rows, setRows] = useState
([]);
+ const [loading, setLoading] = useState(true);
+
+ const load = async () => {
+ setLoading(true);
+ const { data, error } = await supabase
+ .from("generated_documents")
+ .select("*")
+ .eq("kind", "status_report")
+ .order("created_at", { ascending: false });
+ if (error) toast.error(error.message);
+ setRows(data ?? []);
+ setLoading(false);
+ };
+
+ useEffect(() => {
+ load();
+ }, []);
+
+ const download = async (row: any) => {
+ if (!row.storage_path) {
+ toast.error("No file stored for this report");
+ return;
+ }
+ const { data, error } = await supabase.storage
+ .from("generated-documents")
+ .createSignedUrl(row.storage_path, 60);
+ if (error) {
+ toast.error(error.message);
+ return;
+ }
+ window.open(data.signedUrl, "_blank");
+ };
+
+ const remove = async (row: any) => {
+ if (!confirm(`Delete "${row.name}"?`)) return;
+ if (row.storage_path) {
+ await supabase.storage.from("generated-documents").remove([row.storage_path]);
+ }
+ const { error } = await supabase.from("generated_documents").delete().eq("id", row.id);
+ if (error) {
+ toast.error(error.message);
+ return;
+ }
+ toast.success("Deleted");
+ load();
+ };
+
+ return (
+
+
+ {loading ? (
+ Loading…
+ ) : rows.length === 0 ? (
+
+
+ No saved status reports yet. Generate one from a case or client and click "Save to Reports".
+
+ ) : (
+
+
+
+ | Name |
+ Scope |
+ Entries |
+ Created |
+ Actions |
+
+
+
+ {rows.map((r) => {
+ const p = (r.payload ?? {}) as Record;
+ const scope = (p.scope as string) || "—";
+ const entryCount = (p.entry_count as number) ?? "—";
+ const subject =
+ (p.client_name as string) || (p.case_label as string) || "";
+ return (
+
+ |
+ {r.name}
+ {subject && (
+ {subject}
+ )}
+ |
+ {scope} |
+ {entryCount} |
+ {formatDateTime(r.created_at)} |
+
+
+
+ |
+
+ );
+ })}
+
+
+ )}
+
+
+ );
+}
+
+/* ---------- Shared client picker + date range ---------- */
+function useClients() {
+ const [clients, setClients] = useState([]);
+ useEffect(() => {
+ supabase
+ .from("clients")
+ .select("id, name")
+ .is("archived_at", null)
+ .order("name")
+ .then(({ data, error }) => {
+ if (error) toast.error(error.message);
+ else setClients(data ?? []);
+ });
+ }, []);
+ return clients;
+}
+
+function ClientDateFilter({
+ clientId,
+ setClientId,
+ fromDate,
+ setFromDate,
+ toDate,
+ setToDate,
+ clients,
+}: {
+ clientId: string;
+ setClientId: (v: string) => void;
+ fromDate: string;
+ setFromDate: (v: string) => void;
+ toDate: string;
+ setToDate: (v: string) => void;
+ clients: any[];
+}) {
+ return (
+
+
+
+
+ ({ value: c.id, label: c.name, keywords: c.name }))}
+ />
+
+
+
+ setFromDate(e.target.value)} />
+
+
+
+ setToDate(e.target.value)} />
+
+
+
+ );
+}
+
+/* ---------- Time report ---------- */
+function TimeReport() {
+ const clients = useClients();
+ const [clientId, setClientId] = useState("");
+ const [fromDate, setFromDate] = useState("");
+ const [toDate, setToDate] = useState("");
+ const [rows, setRows] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const clientName = clients.find((c) => c.id === clientId)?.name ?? "";
+
+ const load = async () => {
+ if (!clientId) return;
+ setLoading(true);
+ // fetch case ids for the client
+ const { data: caseRows, error: caseErr } = await supabase
+ .from("cases")
+ .select("id, case_number, title")
+ .eq("client_id", clientId);
+ if (caseErr) {
+ toast.error(caseErr.message);
+ setLoading(false);
+ return;
+ }
+ const caseMap = new Map();
+ (caseRows ?? []).forEach((c: any) => caseMap.set(c.id, { case_number: c.case_number, title: c.title }));
+ const caseIds = Array.from(caseMap.keys());
+ if (caseIds.length === 0) {
+ setRows([]);
+ setLoading(false);
+ return;
+ }
+ let q = supabase
+ .from("time_entries")
+ .select("*")
+ .in("case_id", caseIds)
+ .order("work_date", { ascending: false });
+ if (fromDate) q = q.gte("work_date", fromDate);
+ if (toDate) q = q.lte("work_date", toDate);
+ const { data: timeRows, error } = await q;
+ if (error) {
+ toast.error(error.message);
+ setLoading(false);
+ return;
+ }
+ const userIds = Array.from(new Set((timeRows ?? []).map((t: any) => t.user_id).filter(Boolean)));
+ const profMap: Record = {};
+ if (userIds.length) {
+ const { data: profs } = await supabase
+ .from("profiles")
+ .select("id, full_name, email")
+ .in("id", userIds);
+ (profs ?? []).forEach((p: any) => {
+ profMap[p.id] = p.full_name || p.email || p.id;
+ });
+ }
+ const transformed: TimeReportRow[] = (timeRows ?? []).map((t: any) => {
+ const c = caseMap.get(t.case_id);
+ const hours = Number(t.hours || 0);
+ const rate = Number(t.hourly_rate || 0);
+ return {
+ work_date: t.work_date,
+ case_label: c ? `${c.case_number} — ${c.title}` : "—",
+ user_name: profMap[t.user_id] || "—",
+ description: t.description || "",
+ hours,
+ rate,
+ amount: hours * rate,
+ billable: !!t.billable,
+ };
+ });
+ setRows(transformed);
+ setLoading(false);
+ };
+
+ useEffect(() => {
+ load();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [clientId, fromDate, toDate]);
+
+ const totals = useMemo(
+ () => ({
+ hours: rows.reduce((a, r) => a + r.hours, 0),
+ amount: rows.reduce((a, r) => a + r.amount, 0),
+ billable: rows.filter((r) => r.billable).reduce((a, r) => a + r.amount, 0),
+ }),
+ [rows],
+ );
+
+ const downloadPdf = () => {
+ if (rows.length === 0) {
+ toast.error("No time entries to export");
+ return;
+ }
+ const doc = generateTimeReportPdf({ clientName, fromDate, toDate, rows });
+ doc.save(`time-report-${clientName.replace(/[^a-z0-9]+/gi, "-")}.pdf`);
+ };
+
+ return (
+
+
+ {!clientId ? (
+
+
+ Select a client to view time entries.
+
+
+ ) : (
+
+
+
+
+ {rows.length}{" "}
+ entries ·
+ {totals.hours.toFixed(2)}{" "}
+ hours ·
+ {formatCurrency(totals.amount)}{" "}
+ total ({formatCurrency(totals.billable)} billable)
+
+
+
+ {loading ? (
+
+ Loading…
+
+ ) : rows.length === 0 ? (
+ No time entries in range.
+ ) : (
+
+
+
+
+ | Date |
+ Case |
+ User |
+ Description |
+ Hours |
+ Rate |
+ Amount |
+
+
+
+ {rows.map((r, i) => (
+
+ | {formatDate(r.work_date)} |
+ {r.case_label} |
+ {r.user_name} |
+ {r.description} |
+ {r.hours.toFixed(2)} |
+ {formatCurrency(r.rate)} |
+ {formatCurrency(r.amount)} |
+
+ ))}
+
+
+
+ )}
+
+
+ )}
+
+ );
+}
+
+/* ---------- Expense report ---------- */
+function ExpenseReport() {
+ const clients = useClients();
+ const [clientId, setClientId] = useState("");
+ const [fromDate, setFromDate] = useState("");
+ const [toDate, setToDate] = useState("");
+ const [rows, setRows] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const clientName = clients.find((c) => c.id === clientId)?.name ?? "";
+
+ const load = async () => {
+ if (!clientId) return;
+ setLoading(true);
+ const { data: caseRows, error: caseErr } = await supabase
+ .from("cases")
+ .select("id, case_number, title")
+ .eq("client_id", clientId);
+ if (caseErr) {
+ toast.error(caseErr.message);
+ setLoading(false);
+ return;
+ }
+ const caseMap = new Map();
+ (caseRows ?? []).forEach((c: any) => caseMap.set(c.id, { case_number: c.case_number, title: c.title }));
+ const caseIds = Array.from(caseMap.keys());
+ if (caseIds.length === 0) {
+ setRows([]);
+ setLoading(false);
+ return;
+ }
+ let q = supabase
+ .from("expenses")
+ .select("*")
+ .in("case_id", caseIds)
+ .order("expense_date", { ascending: false });
+ if (fromDate) q = q.gte("expense_date", fromDate);
+ if (toDate) q = q.lte("expense_date", toDate);
+ const { data: expRows, error } = await q;
+ if (error) {
+ toast.error(error.message);
+ setLoading(false);
+ return;
+ }
+ const userIds = Array.from(new Set((expRows ?? []).map((t: any) => t.user_id).filter(Boolean)));
+ const profMap: Record = {};
+ if (userIds.length) {
+ const { data: profs } = await supabase
+ .from("profiles")
+ .select("id, full_name, email")
+ .in("id", userIds);
+ (profs ?? []).forEach((p: any) => {
+ profMap[p.id] = p.full_name || p.email || p.id;
+ });
+ }
+ const transformed: ExpenseReportRow[] = (expRows ?? []).map((e: any) => {
+ const c = caseMap.get(e.case_id);
+ return {
+ expense_date: e.expense_date,
+ case_label: c ? `${c.case_number} — ${c.title}` : "—",
+ user_name: profMap[e.user_id] || "—",
+ description: e.description || "",
+ amount: Number(e.amount || 0),
+ billable: !!e.billable,
+ };
+ });
+ setRows(transformed);
+ setLoading(false);
+ };
+
+ useEffect(() => {
+ load();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [clientId, fromDate, toDate]);
+
+ const totals = useMemo(
+ () => ({
+ amount: rows.reduce((a, r) => a + r.amount, 0),
+ billable: rows.filter((r) => r.billable).reduce((a, r) => a + r.amount, 0),
+ }),
+ [rows],
+ );
+
+ const downloadPdf = () => {
+ if (rows.length === 0) {
+ toast.error("No expenses to export");
+ return;
+ }
+ const doc = generateExpenseReportPdf({ clientName, fromDate, toDate, rows });
+ doc.save(`expense-report-${clientName.replace(/[^a-z0-9]+/gi, "-")}.pdf`);
+ };
+
+ return (
+
+
+ {!clientId ? (
+
+
+ Select a client to view expenses.
+
+
+ ) : (
+
+
+
+
+ {rows.length}{" "}
+ expenses ·
+ {formatCurrency(totals.amount)}{" "}
+ total ({formatCurrency(totals.billable)} billable)
+
+
+
+ {loading ? (
+
+ Loading…
+
+ ) : rows.length === 0 ? (
+ No expenses in range.
+ ) : (
+
+
+
+
+ | Date |
+ Case |
+ User |
+ Description |
+ Billable |
+ Amount |
+
+
+
+ {rows.map((r, i) => (
+
+ | {formatDate(r.expense_date)} |
+ {r.case_label} |
+ {r.user_name} |
+ {r.description} |
+ {r.billable ? "Yes" : "No"} |
+ {formatCurrency(r.amount)} |
+
+ ))}
+
+
+
+ )}
+
+
+ )}
+
+ );
+}