From fe0498cadea8093eb2e40ab62c85564f199a02bb Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:24:41 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/reports.index.tsx | 559 +++++++++++++++++++++++++++++++++++ 1 file changed, 559 insertions(+) create mode 100644 src/routes/reports.index.tsx 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)}
+
+ )} +
+
+ )} +
+ ); +}