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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user