Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
e1ef9495cb
commit
fe0498cade
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user