diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index f50ca10..e2727ff 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -23,6 +23,7 @@ import { Inbox as InboxIcon, CreditCard, Archive as ArchiveIcon, + BarChart3, } from "lucide-react"; import { cn } from "@/lib/utils"; import type { ReactNode } from "react"; @@ -55,6 +56,7 @@ const NAV: NavItem[] = (() => { { to: "/messages", label: "Messages", icon: MessageSquare }, { to: "/payments", label: "Payments", icon: CreditCard }, { to: "/documents", label: "Pleadings", icon: FolderOpen }, + { to: "/reports", label: "Reports", icon: BarChart3 }, { to: "/status", label: "Status Updates", icon: Activity }, { to: "/tasks", label: "Tasks", icon: CheckSquare }, ].sort((a, b) => a.label.localeCompare(b.label)); diff --git a/src/components/cases/status-tab.tsx b/src/components/cases/status-tab.tsx index ea79d19..9d6f8e9 100644 --- a/src/components/cases/status-tab.tsx +++ b/src/components/cases/status-tab.tsx @@ -6,10 +6,10 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; -import { Plus, Trash2, Loader2, FileDown, Pencil } from "lucide-react"; +import { Plus, Trash2, Loader2, FileDown, Pencil, Save } from "lucide-react"; import { formatDateTime } from "@/lib/format"; import { toast } from "sonner"; -import { downloadStatusReport } from "@/lib/status-pdf"; +import { downloadStatusReport, saveStatusReportToDb } from "@/lib/status-pdf"; export function CaseStatusTab({ caseId, caseLabel }: { caseId: string; caseLabel?: string }) { const { user, isAdmin } = useAuth(); @@ -79,24 +79,47 @@ export function CaseStatusTab({ caseId, caseLabel }: { caseId: string; caseLabel if (error) toast.error(error.message); else { toast.success("Deleted"); load(); } }; + const buildReportOpts = () => ({ + title: "Case Status Report", + subtitle: caseLabel, + entries: items.map((u) => ({ + title: u.title, + body: u.body, + created_at: u.created_at, + author_name: u.user?.full_name, + author_email: u.user?.email, + })), + }); + const exportPdf = () => { if (items.length === 0) { toast.error("No status updates to export"); return; } - downloadStatusReport({ - title: "Case Status Report", - subtitle: caseLabel, - entries: items.map((u) => ({ - title: u.title, - body: u.body, - created_at: u.created_at, - author_name: u.user?.full_name, - author_email: u.user?.email, - })), - }, `status-report-${(caseLabel || caseId).replace(/[^a-z0-9]+/gi, "-")}.pdf`); + downloadStatusReport( + buildReportOpts(), + `status-report-${(caseLabel || caseId).replace(/[^a-z0-9]+/gi, "-")}.pdf`, + ); + }; + + const saveReport = async () => { + if (items.length === 0) { toast.error("No status updates to save"); return; } + if (!user?.id) { toast.error("Not signed in"); return; } + const filename = `status-report-${(caseLabel || caseId).replace(/[^a-z0-9]+/gi, "-")}-${new Date().toISOString().slice(0, 10)}.pdf`; + try { + await saveStatusReportToDb(buildReportOpts(), filename, { + userId: user.id, + payload: { case_id: caseId, case_label: caseLabel ?? null, entry_count: items.length, scope: "case" }, + }); + toast.success("Saved to Reports"); + } catch (err: any) { + toast.error(err?.message || "Could not save report"); + } }; return (
+ 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". +
+ ) : ( + + + + + + + + + + + + {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 ( + + + + + + + + ); + })} + +
NameScopeEntriesCreatedActions
+
{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.
+ ) : ( +
+ + + + + + + + + + + + + + {rows.map((r, i) => ( + + + + + + + + + + ))} + +
DateCaseUserDescriptionHoursRateAmount
{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.
+ ) : ( +
+ + + + + + + + + + + + + {rows.map((r, i) => ( + + + + + + + + + ))} + +
DateCaseUserDescriptionBillableAmount
{formatDate(r.expense_date)}{r.case_label}{r.user_name}{r.description}{r.billable ? "Yes" : "No"}{formatCurrency(r.amount)}
+
+ )} +
+
+ )} +
+ ); +}