Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-19 18:24:41 +00:00
co-authored by renee-png
parent e1ef9495cb
commit fe0498cade
+559
View File
@@ -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: () => (
<ProtectedLayout>
<ReportsPage />
</ProtectedLayout>
),
});
function ReportsPage() {
return (
<PageContainer>
<PageHeader
title="Reports"
description="Saved status reports and on-demand client time and expense reports."
/>
<Tabs defaultValue="status">
<TabsList>
<TabsTrigger value="status">Status Reports</TabsTrigger>
<TabsTrigger value="time">Client Time</TabsTrigger>
<TabsTrigger value="expenses">Client Expenses</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-4">
<SavedStatusReports />
</TabsContent>
<TabsContent value="time" className="mt-4">
<TimeReport />
</TabsContent>
<TabsContent value="expenses" className="mt-4">
<ExpenseReport />
</TabsContent>
</Tabs>
</PageContainer>
);
}
/* ---------- Saved status reports ---------- */
function SavedStatusReports() {
const [rows, setRows] = useState<any[]>([]);
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 (
<Card className="border-border/60">
<CardContent className="p-0">
{loading ? (
<div className="text-center py-12 text-muted-foreground text-sm">Loading…</div>
) : rows.length === 0 ? (
<div className="text-center py-12 text-muted-foreground text-sm">
<FileText className="h-8 w-8 mx-auto mb-2 opacity-40" />
No saved status reports yet. Generate one from a case or client and click "Save to Reports".
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Name</th>
<th className="text-left px-4 py-3 font-medium">Scope</th>
<th className="text-left px-4 py-3 font-medium">Entries</th>
<th className="text-left px-4 py-3 font-medium">Created</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const p = (r.payload ?? {}) as Record<string, unknown>;
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 (
<tr key={r.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3">
<div className="font-medium">{r.name}</div>
{subject && (
<div className="text-xs text-muted-foreground">{subject}</div>
)}
</td>
<td className="px-4 py-3 text-muted-foreground capitalize">{scope}</td>
<td className="px-4 py-3 text-muted-foreground">{entryCount}</td>
<td className="px-4 py-3 text-muted-foreground">{formatDateTime(r.created_at)}</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="icon" onClick={() => download(r)} title="Download">
<Download className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => remove(r)} title="Delete">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</CardContent>
</Card>
);
}
/* ---------- Shared client picker + date range ---------- */
function useClients() {
const [clients, setClients] = useState<any[]>([]);
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 (
<Card className="border-border/60 mb-4">
<CardContent className="p-4 grid sm:grid-cols-[1fr_160px_160px] gap-3 items-end">
<div>
<Label className="text-xs">Client</Label>
<SearchableSelect
value={clientId}
onValueChange={setClientId}
placeholder="Select a client…"
searchPlaceholder="Search clients…"
options={clients.map((c) => ({ value: c.id, label: c.name, keywords: c.name }))}
/>
</div>
<div>
<Label className="text-xs">From</Label>
<Input type="date" value={fromDate} onChange={(e) => setFromDate(e.target.value)} />
</div>
<div>
<Label className="text-xs">To</Label>
<Input type="date" value={toDate} onChange={(e) => setToDate(e.target.value)} />
</div>
</CardContent>
</Card>
);
}
/* ---------- Time report ---------- */
function TimeReport() {
const clients = useClients();
const [clientId, setClientId] = useState("");
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
const [rows, setRows] = useState<TimeReportRow[]>([]);
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<string, { case_number: string; title: string }>();
(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<string, string> = {};
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 (
<div>
<ClientDateFilter
clientId={clientId}
setClientId={setClientId}
fromDate={fromDate}
setFromDate={setFromDate}
toDate={toDate}
setToDate={setToDate}
clients={clients}
/>
{!clientId ? (
<Card className="border-border/60">
<CardContent className="p-12 text-center text-sm text-muted-foreground">
Select a client to view time entries.
</CardContent>
</Card>
) : (
<Card className="border-border/60">
<CardContent className="p-0">
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="text-sm">
<span className="font-medium">{rows.length}</span>{" "}
<span className="text-muted-foreground">entries · </span>
<span className="font-medium">{totals.hours.toFixed(2)}</span>{" "}
<span className="text-muted-foreground">hours · </span>
<span className="font-medium">{formatCurrency(totals.amount)}</span>{" "}
<span className="text-muted-foreground">total ({formatCurrency(totals.billable)} billable)</span>
</div>
<Button size="sm" variant="outline" onClick={downloadPdf} disabled={rows.length === 0}>
<FileDown className="h-4 w-4 mr-2" /> Download PDF
</Button>
</div>
{loading ? (
<div className="p-12 text-center text-sm text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" /> Loading…
</div>
) : rows.length === 0 ? (
<div className="p-12 text-center text-sm text-muted-foreground">No time entries in range.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-2 font-medium">Date</th>
<th className="text-left px-4 py-2 font-medium">Case</th>
<th className="text-left px-4 py-2 font-medium">User</th>
<th className="text-left px-4 py-2 font-medium">Description</th>
<th className="text-right px-4 py-2 font-medium">Hours</th>
<th className="text-right px-4 py-2 font-medium">Rate</th>
<th className="text-right px-4 py-2 font-medium">Amount</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t hover:bg-muted/30">
<td className="px-4 py-2 whitespace-nowrap">{formatDate(r.work_date)}</td>
<td className="px-4 py-2">{r.case_label}</td>
<td className="px-4 py-2">{r.user_name}</td>
<td className="px-4 py-2 text-muted-foreground">{r.description}</td>
<td className="px-4 py-2 text-right">{r.hours.toFixed(2)}</td>
<td className="px-4 py-2 text-right">{formatCurrency(r.rate)}</td>
<td className="px-4 py-2 text-right">{formatCurrency(r.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
)}
</div>
);
}
/* ---------- Expense report ---------- */
function ExpenseReport() {
const clients = useClients();
const [clientId, setClientId] = useState("");
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
const [rows, setRows] = useState<ExpenseReportRow[]>([]);
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<string, { case_number: string; title: string }>();
(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<string, string> = {};
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 (
<div>
<ClientDateFilter
clientId={clientId}
setClientId={setClientId}
fromDate={fromDate}
setFromDate={setFromDate}
toDate={toDate}
setToDate={setToDate}
clients={clients}
/>
{!clientId ? (
<Card className="border-border/60">
<CardContent className="p-12 text-center text-sm text-muted-foreground">
Select a client to view expenses.
</CardContent>
</Card>
) : (
<Card className="border-border/60">
<CardContent className="p-0">
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="text-sm">
<span className="font-medium">{rows.length}</span>{" "}
<span className="text-muted-foreground">expenses · </span>
<span className="font-medium">{formatCurrency(totals.amount)}</span>{" "}
<span className="text-muted-foreground">total ({formatCurrency(totals.billable)} billable)</span>
</div>
<Button size="sm" variant="outline" onClick={downloadPdf} disabled={rows.length === 0}>
<FileDown className="h-4 w-4 mr-2" /> Download PDF
</Button>
</div>
{loading ? (
<div className="p-12 text-center text-sm text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" /> Loading…
</div>
) : rows.length === 0 ? (
<div className="p-12 text-center text-sm text-muted-foreground">No expenses in range.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-2 font-medium">Date</th>
<th className="text-left px-4 py-2 font-medium">Case</th>
<th className="text-left px-4 py-2 font-medium">User</th>
<th className="text-left px-4 py-2 font-medium">Description</th>
<th className="text-left px-4 py-2 font-medium">Billable</th>
<th className="text-right px-4 py-2 font-medium">Amount</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t hover:bg-muted/30">
<td className="px-4 py-2 whitespace-nowrap">{formatDate(r.expense_date)}</td>
<td className="px-4 py-2">{r.case_label}</td>
<td className="px-4 py-2">{r.user_name}</td>
<td className="px-4 py-2 text-muted-foreground">{r.description}</td>
<td className="px-4 py-2">{r.billable ? "Yes" : "No"}</td>
<td className="px-4 py-2 text-right">{formatCurrency(r.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
)}
</div>
);
}