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:
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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