Added PDF export to status

X-Lovable-Edit-ID: edt-5ee146f3-38b4-401e-b3ca-b5b59ed8efee
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 02:08:58 +00:00
co-authored by renee-png
6 changed files with 305 additions and 36 deletions
BIN
View File
Binary file not shown.
+2
View File
@@ -62,6 +62,8 @@
"embla-carousel-react": "^8.6.0",
"file-saver": "^2.0.5",
"input-otp": "^1.4.2",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-day-picker": "^9.14.0",
+22 -3
View File
@@ -6,11 +6,12 @@ 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 } from "lucide-react";
import { Plus, Trash2, Loader2, FileDown } from "lucide-react";
import { formatDateTime } from "@/lib/format";
import { toast } from "sonner";
import { downloadStatusReport } from "@/lib/status-pdf";
export function CaseStatusTab({ caseId }: { caseId: string }) {
export function CaseStatusTab({ caseId, caseLabel }: { caseId: string; caseLabel?: string }) {
const { user, isAdmin } = useAuth();
const [items, setItems] = useState<any[]>([]);
const [showForm, setShowForm] = useState(false);
@@ -61,9 +62,27 @@ export function CaseStatusTab({ caseId }: { caseId: string }) {
if (error) toast.error(error.message); else { toast.success("Deleted"); load(); }
};
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`);
};
return (
<div className="space-y-4">
<div className="flex justify-end">
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={exportPdf} disabled={items.length === 0}>
<FileDown className="h-4 w-4 mr-2" /> Export PDF
</Button>
<Button onClick={() => setShowForm((s) => !s)}>
<Plus className="h-4 w-4 mr-2" /> {showForm ? "Cancel" : "Add status update"}
</Button>
+175
View File
@@ -0,0 +1,175 @@
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
export interface StatusEntry {
title: string; // event datetime (ISO)
body: string;
created_at: string;
author_name?: string | null;
author_email?: string | null;
case_number?: string | null;
case_title?: string | null;
}
export interface StatusReportOptions {
title: string;
subtitle?: string;
firmName?: string;
groupByCase?: boolean;
entries: StatusEntry[];
}
function initials(name?: string | null, email?: string | null): string {
const source = (name && name.trim()) || (email ? email.split("@")[0] : "");
if (!source) return "—";
const parts = source.split(/\s+|\./).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return source.slice(0, 2).toUpperCase();
}
function fmtDateTime(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "numeric",
minute: "2-digit",
});
}
export function generateStatusReportPdf(opts: StatusReportOptions): jsPDF {
const doc = new jsPDF({ unit: "pt", format: "letter" });
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 54;
// Header
doc.setFont("times", "bold");
doc.setFontSize(18);
doc.text(opts.title, margin, margin);
let y = margin + 22;
if (opts.subtitle) {
doc.setFont("times", "normal");
doc.setFontSize(11);
doc.setTextColor(90);
doc.text(opts.subtitle, margin, y);
doc.setTextColor(0);
y += 14;
}
doc.setFont("helvetica", "normal");
doc.setFontSize(9);
doc.setTextColor(120);
doc.text(
`Generated ${new Date().toLocaleString()}${opts.firmName ? " · " + opts.firmName : ""}`,
margin,
y,
);
doc.setTextColor(0);
y += 10;
doc.setDrawColor(180);
doc.line(margin, y, pageWidth - margin, y);
y += 18;
// Group entries
const groups: { key: string; label: string; items: StatusEntry[] }[] = [];
if (opts.groupByCase) {
const map = new Map<string, StatusEntry[]>();
for (const e of opts.entries) {
const key = `${e.case_number ?? ""}::${e.case_title ?? ""}`;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(e);
}
for (const [key, items] of map) {
const [num, title] = key.split("::");
const label = [num, title].filter(Boolean).join(" — ") || "Case";
groups.push({ key, label, items });
}
} else {
groups.push({ key: "all", label: "", items: opts.entries });
}
const rowsForGroup = (items: StatusEntry[]) =>
items.map((e) => [
fmtDateTime(e.title),
e.body,
`${initials(e.author_name, e.author_email)}\n${fmtDateTime(e.created_at)}`,
]);
for (const g of groups) {
if (opts.groupByCase) {
if (y > pageHeight - 120) {
doc.addPage();
y = margin;
}
doc.setFont("times", "bold");
doc.setFontSize(13);
doc.text(g.label, margin, y);
y += 6;
}
if (g.items.length === 0) {
doc.setFont("helvetica", "italic");
doc.setFontSize(10);
doc.setTextColor(140);
doc.text("No status updates.", margin, y + 16);
doc.setTextColor(0);
y += 30;
continue;
}
autoTable(doc, {
startY: y + 6,
head: [["Event date", "Details", "Logged by"]],
body: rowsForGroup(g.items),
margin: { left: margin, right: margin },
styles: {
font: "helvetica",
fontSize: 9.5,
cellPadding: 6,
valign: "top",
textColor: 30,
lineColor: 220,
lineWidth: 0.5,
},
headStyles: {
fillColor: [40, 40, 40],
textColor: 255,
fontStyle: "bold",
fontSize: 9.5,
},
alternateRowStyles: { fillColor: [248, 248, 246] },
columnStyles: {
0: { cellWidth: 96 },
1: { cellWidth: "auto" },
2: { cellWidth: 96, fontSize: 8.5, textColor: 80 },
},
didDrawPage: () => {
// Footer with page number
const ph = doc.internal.pageSize.getHeight();
const pw = doc.internal.pageSize.getWidth();
doc.setFont("helvetica", "normal");
doc.setFontSize(8);
doc.setTextColor(140);
const pageNum = doc.getNumberOfPages();
doc.text(`Page ${pageNum}`, pw - margin, ph - 24, { align: "right" });
doc.text(opts.title, margin, ph - 24);
doc.setTextColor(0);
},
});
// @ts-expect-error lastAutoTable injected by plugin
y = (doc.lastAutoTable?.finalY ?? y) + 24;
}
return doc;
}
export function downloadStatusReport(opts: StatusReportOptions, filename: string) {
const doc = generateStatusReportPdf(opts);
doc.save(filename);
}
+1 -1
View File
@@ -150,7 +150,7 @@ function CaseDetail() {
)}
</TabsList>
<TabsContent value="litigation"><CaseLitigationTab caseRecord={data} canManage={canManage} onSaved={load} /></TabsContent>
<TabsContent value="overview"><CaseStatusTab caseId={caseId} /></TabsContent>
<TabsContent value="overview"><CaseStatusTab caseId={caseId} caseLabel={`${data.case_number} — ${data.title}`} /></TabsContent>
<TabsContent value="documents"><CaseDocumentsTab caseId={caseId} /></TabsContent>
<TabsContent value="time"><CaseTimeTab caseRecord={data} /></TabsContent>
<TabsContent value="expenses"><CaseExpensesTab caseId={caseId} /></TabsContent>
+105 -32
View File
@@ -5,12 +5,14 @@ import { PageContainer, PageHeader } from "@/components/app-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
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, Briefcase as Building, MapPin, Mail, Phone, Users } from "lucide-react";
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
import { ArrowLeft, Edit, Plus, Building2, User, MapPin, Mail, Phone, Users, Activity, Briefcase, FileDown } from "lucide-react";
import { formatCurrency, formatDate, formatDateTime, statusBadgeClass } from "@/lib/format";
import { toast } from "sonner";
import { downloadStatusReport } from "@/lib/status-pdf";
export const Route = createFileRoute("/clients/$clientId")({
component: () => (
@@ -26,6 +28,7 @@ function ClientDetail() {
const { user, isAdmin } = useAuth();
const [client, setClient] = useState<any>(null);
const [cases, setCases] = useState<any[]>([]);
const [statusEntries, setStatusEntries] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editOpen, setEditOpen] = useState(false);
@@ -38,12 +41,20 @@ function ClientDetail() {
if (ce) toast.error("Failed to load client", { description: ce.message });
const caseIds = (cs ?? []).map((x: any) => x.id);
let totals: Record<string, { hours: number; amount: number }> = {};
let updates: any[] = [];
if (caseIds.length) {
const { data: te } = await supabase
.from("time_entries")
.select("case_id, hours, hourly_rate, billable")
.in("case_id", caseIds)
.eq("billable", true);
const [{ data: te }, { data: su }] = await Promise.all([
supabase
.from("time_entries")
.select("case_id, hours, hourly_rate, billable")
.in("case_id", caseIds)
.eq("billable", true),
supabase
.from("status_updates")
.select("*")
.in("case_id", caseIds)
.order("title", { ascending: false }),
]);
(te ?? []).forEach((t: any) => {
const h = Number(t.hours) || 0;
const r = Number(t.hourly_rate) || 0;
@@ -51,9 +62,22 @@ function ClientDetail() {
totals[t.case_id].hours += h;
totals[t.case_id].amount += h * r;
});
const ids = Array.from(new Set((su ?? []).map((d: any) => d.created_by).filter(Boolean)));
let profileMap: Record<string, any> = {};
if (ids.length) {
const { data: profs } = await supabase.from("profiles").select("id, full_name, email").in("id", ids);
profileMap = Object.fromEntries((profs ?? []).map((p: any) => [p.id, p]));
}
const caseMap = Object.fromEntries((cs ?? []).map((x: any) => [x.id, x]));
updates = (su ?? []).map((d: any) => ({
...d,
user: d.created_by ? profileMap[d.created_by] : null,
case: caseMap[d.case_id],
}));
}
setClient(c);
setCases((cs ?? []).map((x: any) => ({ ...x, billed: totals[x.id] ?? { hours: 0, amount: 0 } })));
setStatusEntries(updates);
setLoading(false);
};
@@ -177,31 +201,80 @@ function ClientDetail() {
<Card className="border-border/60 h-fit">
<CardContent className="p-5">
<h3 className="font-serif text-lg mb-3">Cases</h3>
{cases.length === 0 && <p className="text-sm text-muted-foreground">No cases yet.</p>}
<div className="space-y-1">
{cases.map((c) => (
<Link
key={c.id}
to="/cases/$caseId"
params={{ caseId: c.id }}
className="block p-2.5 rounded-md hover:bg-muted/60 -mx-2"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{c.title}</span>
<Badge variant="outline" className={`text-[10px] ${statusBadgeClass(c.status)}`}>
{c.status.replace("_", " ")}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
{c.case_number} · opened {formatDate(c.opened_at)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
Billed: {c.billed.hours.toFixed(1)} hr · {formatCurrency(c.billed.amount)}
</div>
</Link>
))}
</div>
<Tabs defaultValue="cases">
<TabsList className="mb-3 w-full">
<TabsTrigger value="cases" className="flex-1"><Briefcase className="h-3.5 w-3.5 mr-1.5" />Cases</TabsTrigger>
<TabsTrigger value="status" className="flex-1"><Activity className="h-3.5 w-3.5 mr-1.5" />Status</TabsTrigger>
</TabsList>
<TabsContent value="cases">
{cases.length === 0 && <p className="text-sm text-muted-foreground">No cases yet.</p>}
<div className="space-y-1">
{cases.map((c) => (
<Link
key={c.id}
to="/cases/$caseId"
params={{ caseId: c.id }}
className="block p-2.5 rounded-md hover:bg-muted/60 -mx-2"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{c.title}</span>
<Badge variant="outline" className={`text-[10px] ${statusBadgeClass(c.status)}`}>
{c.status.replace("_", " ")}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
{c.case_number} · opened {formatDate(c.opened_at)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
Billed: {c.billed.hours.toFixed(1)} hr · {formatCurrency(c.billed.amount)}
</div>
</Link>
))}
</div>
</TabsContent>
<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>
{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">
{statusEntries.map((u) => (
<div key={u.id} className="border-l-2 border-primary/40 pl-3 py-1">
<div className="text-sm font-medium">{formatDateTime(u.title)}</div>
<div className="text-[11px] text-muted-foreground mb-1">
{u.case?.case_number} · {u.user?.full_name || u.user?.email || "Unknown"}
</div>
<p className="text-xs text-muted-foreground line-clamp-3 whitespace-pre-wrap">{u.body}</p>
</div>
))}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>