Built Reports page with tabs

X-Lovable-Edit-ID: edt-9e07025a-436e-4f69-9439-8ef91adbaf0d
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:25:29 +00:00
co-authored by renee-png
7 changed files with 822 additions and 38 deletions
+2
View File
@@ -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));
+36 -13
View File
@@ -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 (
<div className="space-y-4">
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={saveReport} disabled={items.length === 0}>
<Save className="h-4 w-4 mr-2" /> Save to Reports
</Button>
<Button variant="outline" onClick={exportPdf} disabled={items.length === 0}>
<FileDown className="h-4 w-4 mr-2" /> Export PDF
</Button>
+112
View File
@@ -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;
}
+35
View File
@@ -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<string, unknown> },
): 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 };
}
+21
View File
@@ -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,
+57 -25
View File
@@ -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() {
<TabsContent value="status">
<div className="flex items-center justify-between mb-3">
<p className="text-xs text-muted-foreground">{statusEntries.length} update{statusEntries.length === 1 ? "" : "s"} across all cases</p>
<Button
size="sm"
variant="outline"
disabled={statusEntries.length === 0}
onClick={() => {
downloadStatusReport({
title: "Client Status Report",
subtitle: client.name,
groupByCase: true,
entries: statusEntries.map((u) => ({
title: u.title,
body: u.body,
created_at: u.created_at,
author_name: u.user?.full_name,
author_email: u.user?.email,
case_number: u.case?.case_number,
case_title: u.case?.title,
})),
}, `status-report-${client.name.replace(/[^a-z0-9]+/gi, "-")}.pdf`);
}}
>
<FileDown className="h-3.5 w-3.5 mr-1.5" /> Export
</Button>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={statusEntries.length === 0}
onClick={async () => {
if (!user?.id) { toast.error("Not signed in"); return; }
const opts = {
title: "Client Status Report",
subtitle: client.name,
groupByCase: true,
entries: statusEntries.map((u) => ({
title: u.title, body: u.body, created_at: u.created_at,
author_name: u.user?.full_name, author_email: u.user?.email,
case_number: u.case?.case_number, case_title: u.case?.title,
})),
};
const filename = `status-report-${client.name.replace(/[^a-z0-9]+/gi, "-")}-${new Date().toISOString().slice(0, 10)}.pdf`;
try {
await saveStatusReportToDb(opts, filename, {
userId: user.id,
payload: { client_id: client.id, client_name: client.name, entry_count: statusEntries.length, scope: "client" },
});
toast.success("Saved to Reports");
} catch (err: any) {
toast.error(err?.message || "Could not save report");
}
}}
>
<Save className="h-3.5 w-3.5 mr-1.5" /> Save to Reports
</Button>
<Button
size="sm"
variant="outline"
disabled={statusEntries.length === 0}
onClick={() => {
downloadStatusReport({
title: "Client Status Report",
subtitle: client.name,
groupByCase: true,
entries: statusEntries.map((u) => ({
title: u.title,
body: u.body,
created_at: u.created_at,
author_name: u.user?.full_name,
author_email: u.user?.email,
case_number: u.case?.case_number,
case_title: u.case?.title,
})),
}, `status-report-${client.name.replace(/[^a-z0-9]+/gi, "-")}.pdf`);
}}
>
<FileDown className="h-3.5 w-3.5 mr-1.5" /> Export
</Button>
</div>
</div>
{statusEntries.length === 0 && <p className="text-sm text-muted-foreground">No status updates logged.</p>}
<div className="space-y-3 max-h-[480px] overflow-auto">
+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>
);
}