user report gtound work
This commit is contained in:
1 parent
1be2de05fa
commit
74f0d0dbed
42 files changed
+8127
-5
No files matched your search
@@ -0,0 +1,821 @@
|
||||
import jsPDF from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import type { UserReport } from "@fitai/shared";
|
||||
|
||||
export class PDFGenerator {
|
||||
private doc: jsPDF;
|
||||
private pageWidth: number;
|
||||
private pageHeight: number;
|
||||
private margin: { top: number; right: number; bottom: number; left: number };
|
||||
private currentY: number = 0;
|
||||
|
||||
// Theme colors (from globals.css)
|
||||
private colors = {
|
||||
primary: [60, 130, 225] as [number, number, number],
|
||||
primaryDark: [40, 80, 180] as [number, number, number],
|
||||
success: [46, 160, 67] as [number, number, number],
|
||||
warning: [240, 185, 11] as [number, number, number],
|
||||
destructive: [215, 50, 50] as [number, number, number],
|
||||
text: [30, 30, 30] as [number, number, number],
|
||||
textLight: [100, 100, 100] as [number, number, number],
|
||||
background: [245, 245, 245] as [number, number, number],
|
||||
white: [255, 255, 255] as [number, number, number],
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.doc = new jsPDF({
|
||||
orientation: "portrait",
|
||||
unit: "mm",
|
||||
format: "a4",
|
||||
});
|
||||
this.pageWidth = this.doc.internal.pageSize.getWidth();
|
||||
this.pageHeight = this.doc.internal.pageSize.getHeight();
|
||||
this.margin = { top: 20, right: 20, bottom: 20, left: 20 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete user report PDF
|
||||
*/
|
||||
generateUserReport(report: UserReport): jsPDF {
|
||||
this.addHeader(report);
|
||||
this.addUserInfo(report);
|
||||
this.addReportPeriod(report);
|
||||
this.addWeeklyCheckIns(report.weeklyCheckIns);
|
||||
this.addNutritionSummary(report.nutrition);
|
||||
this.addHydrationSummary(report.hydration);
|
||||
this.addGoalsSummary(report.goals);
|
||||
this.addProfileHistory(report.profileHistory);
|
||||
this.addRecommendations(report.recommendations);
|
||||
this.addFooter();
|
||||
|
||||
return this.doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save PDF to file
|
||||
*/
|
||||
save(filename: string): void {
|
||||
this.doc.save(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PDF as blob for API response
|
||||
*/
|
||||
toBlob(): Blob {
|
||||
return this.doc.output("blob");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PDF as base64 for API response
|
||||
*/
|
||||
toBase64(): string {
|
||||
return this.doc.output("datauristring").split(",")[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add header with title and FitAI branding
|
||||
*/
|
||||
private addHeader(report: UserReport): void {
|
||||
this.currentY = this.margin.top;
|
||||
|
||||
// Header background
|
||||
this.doc.setFillColor(...this.colors.primary);
|
||||
this.doc.rect(0, 0, this.pageWidth, 35, "F");
|
||||
|
||||
// Title
|
||||
this.doc.setTextColor(...this.colors.white);
|
||||
this.doc.setFontSize(24);
|
||||
this.doc.setFont("helvetica", "bold");
|
||||
this.doc.text("FitAI User Report", this.margin.left, 20);
|
||||
|
||||
// Subtitle
|
||||
this.doc.setFontSize(10);
|
||||
this.doc.setFont("helvetica", "normal");
|
||||
this.doc.text(
|
||||
`Generated: ${new Date().toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}`,
|
||||
this.margin.left,
|
||||
28,
|
||||
);
|
||||
|
||||
// FitAI logo text
|
||||
this.doc.setFontSize(14);
|
||||
this.doc.setFont("helvetica", "bold");
|
||||
this.doc.text("FitAI", this.pageWidth - this.margin.right - 20, 20);
|
||||
|
||||
this.currentY = 45;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add user information section
|
||||
*/
|
||||
private addUserInfo(report: UserReport): void {
|
||||
this.checkPageBreak(30);
|
||||
|
||||
this.doc.setTextColor(...this.colors.primary);
|
||||
this.doc.setFontSize(14);
|
||||
this.doc.setFont("helvetica", "bold");
|
||||
this.doc.text("User Information", this.margin.left, this.currentY);
|
||||
|
||||
this.currentY += 8;
|
||||
|
||||
const user = report.user;
|
||||
const client = report.client;
|
||||
const profile = report.fitnessProfile;
|
||||
|
||||
const userInfo = [
|
||||
["Name", `${user.firstName} ${user.lastName}`],
|
||||
["Email", user.email],
|
||||
["Phone", user.phone || "N/A"],
|
||||
["Role", user.role],
|
||||
[
|
||||
"Member Since",
|
||||
user.createdAt ? new Date(user.createdAt).toLocaleDateString() : "N/A",
|
||||
],
|
||||
];
|
||||
|
||||
if (client) {
|
||||
userInfo.push([
|
||||
"Membership",
|
||||
`${client.membershipType} - ${client.membershipStatus}`,
|
||||
]);
|
||||
userInfo.push([
|
||||
"Join Date",
|
||||
new Date(client.joinDate).toLocaleDateString(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (profile) {
|
||||
userInfo.push([
|
||||
"Fitness Profile",
|
||||
`Height: ${profile.height || "N/A"}cm, Weight: ${profile.weight || "N/A"}kg`,
|
||||
]);
|
||||
}
|
||||
|
||||
autoTable(this.doc, {
|
||||
startY: this.currentY,
|
||||
body: userInfo,
|
||||
theme: "plain",
|
||||
margin: { left: this.margin.left, right: this.margin.right },
|
||||
columnStyles: {
|
||||
0: { fontStyle: "bold", cellWidth: 40, fontSize: 10 },
|
||||
1: { cellWidth: "auto", fontSize: 10 },
|
||||
},
|
||||
styles: {
|
||||
cellPadding: 3,
|
||||
textColor: this.colors.text,
|
||||
},
|
||||
tableWidth: "auto" as any,
|
||||
});
|
||||
|
||||
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add report period section
|
||||
*/
|
||||
private addReportPeriod(report: UserReport): void {
|
||||
this.checkPageBreak(25);
|
||||
|
||||
this.doc.setTextColor(...this.colors.primary);
|
||||
this.doc.setFontSize(14);
|
||||
this.doc.setFont("helvetica", "bold");
|
||||
this.doc.text("Report Period", this.margin.left, this.currentY);
|
||||
|
||||
this.currentY += 8;
|
||||
|
||||
const periodInfo = [
|
||||
["Start Date", report.reportPeriod.startDate],
|
||||
["End Date", report.reportPeriod.endDate],
|
||||
[
|
||||
"Duration",
|
||||
`${Math.ceil(
|
||||
(new Date(report.reportPeriod.endDate).getTime() -
|
||||
new Date(report.reportPeriod.startDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)} days`,
|
||||
],
|
||||
];
|
||||
|
||||
autoTable(this.doc, {
|
||||
startY: this.currentY,
|
||||
body: periodInfo,
|
||||
theme: "plain",
|
||||
margin: { left: this.margin.left, right: this.margin.right },
|
||||
columnStyles: {
|
||||
0: { fontStyle: "bold", cellWidth: 40, fontSize: 10 },
|
||||
1: { cellWidth: "auto", fontSize: 10 },
|
||||
},
|
||||
styles: {
|
||||
cellPadding: 3,
|
||||
textColor: this.colors.text,
|
||||
},
|
||||
tableWidth: "auto" as any,
|
||||
});
|
||||
|
||||
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add weekly check-ins section
|
||||
*/
|
||||
private addWeeklyCheckIns(
|
||||
weeklyCheckIns: UserReport["weeklyCheckIns"],
|
||||
): void {
|
||||
this.checkPageBreak(50);
|
||||
|
||||
this.doc.setTextColor(...this.colors.primary);
|
||||
this.doc.setFontSize(14);
|
||||
this.doc.setFont("helvetica", "bold");
|
||||
this.doc.text("Weekly Check-ins", this.margin.left, this.currentY);
|
||||
|
||||
this.currentY += 8;
|
||||
|
||||
if (weeklyCheckIns.length === 0) {
|
||||
this.doc.setTextColor(...this.colors.textLight);
|
||||
this.doc.setFontSize(10);
|
||||
this.doc.setFont("helvetica", "italic");
|
||||
this.doc.text(
|
||||
"No check-in data available for this period.",
|
||||
this.margin.left,
|
||||
this.currentY,
|
||||
);
|
||||
this.currentY += 15;
|
||||
return;
|
||||
}
|
||||
|
||||
const tableData = weeklyCheckIns.map((week) => [
|
||||
week.weekStart,
|
||||
week.weekEnd,
|
||||
week.totalCheckIns.toString(),
|
||||
`${week.totalTimeMinutes} min`,
|
||||
`${week.averageDurationMinutes} min`,
|
||||
]);
|
||||
|
||||
autoTable(this.doc, {
|
||||
startY: this.currentY,
|
||||
head: [
|
||||
["Week Start", "Week End", "Check-ins", "Total Time", "Avg Duration"],
|
||||
],
|
||||
body: tableData,
|
||||
theme: "striped",
|
||||
headStyles: {
|
||||
fillColor: this.colors.primary,
|
||||
textColor: this.colors.white,
|
||||
fontStyle: "bold",
|
||||
fontSize: 9,
|
||||
},
|
||||
bodyStyles: {
|
||||
fontSize: 9,
|
||||
textColor: this.colors.text,
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [250, 250, 250],
|
||||
},
|
||||
margin: { left: this.margin.left, right: this.margin.right },
|
||||
});
|
||||
|
||||
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add nutrition summary section
|
||||
*/
|
||||
private addNutritionSummary(nutrition: UserReport["nutrition"]): void {
|
||||
this.checkPageBreak(60);
|
||||
|
||||
this.doc.setTextColor(...this.colors.primary);
|
||||
this.doc.setFontSize(14);
|
||||
this.doc.setFont("helvetica", "bold");
|
||||
this.doc.text("Nutrition Summary", this.margin.left, this.currentY);
|
||||
|
||||
this.currentY += 8;
|
||||
|
||||
const summaryStats = [
|
||||
["Total Days Tracked", nutrition.totalDays.toString()],
|
||||
[
|
||||
"Average Daily Calories",
|
||||
nutrition.averageDailyCalories.toLocaleString(),
|
||||
],
|
||||
[
|
||||
"Days Met Goal (±10%)",
|
||||
`${nutrition.daysMetGoal} / ${nutrition.totalDays}`,
|
||||
],
|
||||
];
|
||||
|
||||
autoTable(this.doc, {
|
||||
startY: this.currentY,
|
||||
body: summaryStats,
|
||||
theme: "plain",
|
||||
margin: { left: this.margin.left, right: this.margin.right },
|
||||
columnStyles: {
|
||||
0: { fontStyle: "bold", cellWidth: 60, fontSize: 10 },
|
||||
1: { cellWidth: "auto", fontSize: 10 },
|
||||
},
|
||||
styles: {
|
||||
cellPadding: 3,
|
||||
textColor: this.colors.text,
|
||||
},
|
||||
tableWidth: "auto" as any,
|
||||
});
|
||||
|
||||
this.currentY = (this.doc as any).lastAutoTable.finalY + 10;
|
||||
|
||||
// Add daily breakdown if available
|
||||
if (
|
||||
nutrition.dailySummaries.length > 0 &&
|
||||
nutrition.dailySummaries.length <= 14
|
||||
) {
|
||||
this.doc.setTextColor(...this.colors.textLight);
|
||||
this.doc.setFontSize(10);
|
||||
this.doc.setFont("helvetica", "italic");
|
||||
this.doc.text("Daily Breakdown", this.margin.left, this.currentY);
|
||||
|
||||
this.currentY += 5;
|
||||
|
||||
const dailyData = nutrition.dailySummaries.map((day) => [
|
||||
day.date,
|
||||
day.totalCalories.toLocaleString(),
|
||||
day.calorieGoal.toLocaleString(),
|
||||
day.caloriesDelta > 0
|
||||
? `+${day.caloriesDelta}`
|
||||
: day.caloriesDelta.toString(),
|
||||
day.mealsCount.toString(),
|
||||
]);
|
||||
|
||||
autoTable(this.doc, {
|
||||
startY: this.currentY,
|
||||
head: [["Date", "Calories", "Goal", "Delta", "Meals"]],
|
||||
body: dailyData,
|
||||
theme: "striped",
|
||||
headStyles: {
|
||||
fillColor: this.colors.primary,
|
||||
textColor: this.colors.white,
|
||||
fontStyle: "bold",
|
||||
fontSize: 8,
|
||||
},
|
||||
bodyStyles: {
|
||||
fontSize: 8,
|
||||
textColor: this.colors.text,
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [250, 250, 250],
|
||||
},
|
||||
margin: { left: this.margin.left, right: this.margin.right },
|
||||
});
|
||||
|
||||
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
|
||||
} else if (nutrition.dailySummaries.length > 14) {
|
||||
this.doc.setTextColor(...this.colors.textLight);
|
||||
this.doc.setFontSize(9);
|
||||
this.doc.text(
|
||||
`Daily breakdown (${nutrition.dailySummaries.length} days) - See dashboard for detailed view.`,
|
||||
this.margin.left,
|
||||
this.currentY,
|
||||
);
|
||||
this.currentY += 15;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add hydration summary section
|
||||
*/
|
||||
private addHydrationSummary(hydration: UserReport["hydration"]): void {
|
||||
this.checkPageBreak(50);
|
||||
|
||||
this.doc.setTextColor(...this.colors.primary);
|
||||
this.doc.setFontSize(14);
|
||||
this.doc.setFont("helvetica", "bold");
|
||||
this.doc.text("Hydration Summary", this.margin.left, this.currentY);
|
||||
|
||||
this.currentY += 8;
|
||||
|
||||
const summaryStats = [
|
||||
["Total Days Tracked", hydration.totalDays.toString()],
|
||||
["Average Daily Water", `${hydration.averageDailyWater} ml`],
|
||||
["Days Met Goal", `${hydration.daysMetGoal} / ${hydration.totalDays}`],
|
||||
];
|
||||
|
||||
autoTable(this.doc, {
|
||||
startY: this.currentY,
|
||||
body: summaryStats,
|
||||
theme: "plain",
|
||||
margin: { left: this.margin.left, right: this.margin.right },
|
||||
columnStyles: {
|
||||
0: { fontStyle: "bold", cellWidth: 60, fontSize: 10 },
|
||||
1: { cellWidth: "auto", fontSize: 10 },
|
||||
},
|
||||
styles: {
|
||||
cellPadding: 3,
|
||||
textColor: this.colors.text,
|
||||
},
|
||||
tableWidth: "auto" as any,
|
||||
});
|
||||
|
||||
this.currentY = (this.doc as any).lastAutoTable.finalY + 10;
|
||||
|
||||
// Add daily breakdown if available
|
||||
if (
|
||||
hydration.dailySummaries.length > 0 &&
|
||||
hydration.dailySummaries.length <= 14
|
||||
) {
|
||||
this.doc.setTextColor(...this.colors.textLight);
|
||||
this.doc.setFontSize(10);
|
||||
this.doc.setFont("helvetica", "italic");
|
||||
this.doc.text("Daily Breakdown", this.margin.left, this.currentY);
|
||||
|
||||
this.currentY += 5;
|
||||
|
||||
const dailyData = hydration.dailySummaries.map((day) => [
|
||||
day.date,
|
||||
`${day.totalWater} ml`,
|
||||
`${day.waterGoal} ml`,
|
||||
`${day.hydrationPercentage}%`,
|
||||
]);
|
||||
|
||||
autoTable(this.doc, {
|
||||
startY: this.currentY,
|
||||
head: [["Date", "Total Water", "Goal", "Achievement"]],
|
||||
body: dailyData,
|
||||
theme: "striped",
|
||||
headStyles: {
|
||||
fillColor: this.colors.primary,
|
||||
textColor: this.colors.white,
|
||||
fontStyle: "bold",
|
||||
fontSize: 8,
|
||||
},
|
||||
bodyStyles: {
|
||||
fontSize: 8,
|
||||
textColor: this.colors.text,
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [250, 250, 250],
|
||||
},
|
||||
margin: { left: this.margin.left, right: this.margin.right },
|
||||
});
|
||||
|
||||
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
|
||||
} else if (hydration.dailySummaries.length > 14) {
|
||||
this.doc.setTextColor(...this.colors.textLight);
|
||||
this.doc.setFontSize(9);
|
||||
this.doc.text(
|
||||
`Daily breakdown (${hydration.dailySummaries.length} days) - See dashboard for detailed view.`,
|
||||
this.margin.left,
|
||||
this.currentY,
|
||||
);
|
||||
this.currentY += 15;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add goals summary section
|
||||
*/
|
||||
private addGoalsSummary(goals: UserReport["goals"]): void {
|
||||
this.checkPageBreak(60);
|
||||
|
||||
this.doc.setTextColor(...this.colors.primary);
|
||||
this.doc.setFontSize(14);
|
||||
this.doc.setFont("helvetica", "bold");
|
||||
this.doc.text("Fitness Goals", this.margin.left, this.currentY);
|
||||
|
||||
this.currentY += 8;
|
||||
|
||||
const summaryStats = [
|
||||
["Active Goals", goals.totalActive.toString()],
|
||||
["Completed Goals", goals.totalCompleted.toString()],
|
||||
["Average Progress", `${goals.averageProgress}%`],
|
||||
];
|
||||
|
||||
autoTable(this.doc, {
|
||||
startY: this.currentY,
|
||||
body: summaryStats,
|
||||
theme: "plain",
|
||||
margin: { left: this.margin.left, right: this.margin.right },
|
||||
columnStyles: {
|
||||
0: { fontStyle: "bold", cellWidth: 60, fontSize: 10 },
|
||||
1: { cellWidth: "auto", fontSize: 10 },
|
||||
},
|
||||
styles: {
|
||||
cellPadding: 3,
|
||||
textColor: this.colors.text,
|
||||
},
|
||||
tableWidth: "auto" as any,
|
||||
});
|
||||
|
||||
this.currentY = (this.doc as any).lastAutoTable.finalY + 10;
|
||||
|
||||
// Add active goals
|
||||
if (goals.active.length > 0) {
|
||||
this.doc.setTextColor(...this.colors.success);
|
||||
this.doc.setFontSize(10);
|
||||
this.doc.setFont("helvetica", "bold");
|
||||
this.doc.text("Active Goals", this.margin.left, this.currentY);
|
||||
|
||||
this.currentY += 5;
|
||||
|
||||
const activeGoals = goals.active.map((goal) => [
|
||||
goal.title,
|
||||
goal.goalType,
|
||||
`${goal.progress}%`,
|
||||
goal.priority,
|
||||
]);
|
||||
|
||||
autoTable(this.doc, {
|
||||
startY: this.currentY,
|
||||
head: [["Title", "Type", "Progress", "Priority"]],
|
||||
body: activeGoals,
|
||||
theme: "striped",
|
||||
headStyles: {
|
||||
fillColor: this.colors.success,
|
||||
textColor: this.colors.white,
|
||||
fontStyle: "bold",
|
||||
fontSize: 8,
|
||||
},
|
||||
bodyStyles: {
|
||||
fontSize: 8,
|
||||
textColor: this.colors.text,
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [250, 250, 250],
|
||||
},
|
||||
margin: { left: this.margin.left, right: this.margin.right },
|
||||
});
|
||||
|
||||
this.currentY = (this.doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
|
||||
// Add completed goals
|
||||
if (goals.completed.length > 0) {
|
||||
this.doc.setTextColor(...this.colors.primary);
|
||||
this.doc.setFontSize(10);
|
||||
this.doc.setFont("helvetica", "bold");
|
||||
this.doc.text("Completed Goals", this.margin.left, this.currentY);
|
||||
|
||||
this.currentY += 5;
|
||||
|
||||
const completedGoals = goals.completed.map((goal) => [
|
||||
goal.title,
|
||||
goal.goalType,
|
||||
goal.completedDate
|
||||
? new Date(goal.completedDate).toLocaleDateString()
|
||||
: "N/A",
|
||||
]);
|
||||
|
||||
autoTable(this.doc, {
|
||||
startY: this.currentY,
|
||||
head: [["Title", "Type", "Completed Date"]],
|
||||
body: completedGoals,
|
||||
theme: "striped",
|
||||
headStyles: {
|
||||
fillColor: this.colors.primary,
|
||||
textColor: this.colors.white,
|
||||
fontStyle: "bold",
|
||||
fontSize: 8,
|
||||
},
|
||||
bodyStyles: {
|
||||
fontSize: 8,
|
||||
textColor: this.colors.text,
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [250, 250, 250],
|
||||
},
|
||||
margin: { left: this.margin.left, right: this.margin.right },
|
||||
});
|
||||
|
||||
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add profile history section
|
||||
*/
|
||||
private addProfileHistory(history: UserReport["profileHistory"]): void {
|
||||
this.checkPageBreak(50);
|
||||
|
||||
this.doc.setTextColor(...this.colors.primary);
|
||||
this.doc.setFontSize(14);
|
||||
this.doc.setFont("helvetica", "bold");
|
||||
this.doc.text("Fitness Profile Changes", this.margin.left, this.currentY);
|
||||
|
||||
this.currentY += 8;
|
||||
|
||||
if (history.length === 0) {
|
||||
this.doc.setTextColor(...this.colors.textLight);
|
||||
this.doc.setFontSize(10);
|
||||
this.doc.setFont("helvetica", "italic");
|
||||
this.doc.text(
|
||||
"No profile changes recorded during this period.",
|
||||
this.margin.left,
|
||||
this.currentY,
|
||||
);
|
||||
this.currentY += 15;
|
||||
return;
|
||||
}
|
||||
|
||||
const historyData = history.map((item) => [
|
||||
item.fieldName,
|
||||
item.changeType,
|
||||
item.previousValue || "N/A",
|
||||
item.newValue || "N/A",
|
||||
new Date(item.changedAt).toLocaleDateString(),
|
||||
]);
|
||||
|
||||
autoTable(this.doc, {
|
||||
startY: this.currentY,
|
||||
head: [["Field", "Change Type", "Previous", "New", "Date"]],
|
||||
body: historyData,
|
||||
theme: "striped",
|
||||
headStyles: {
|
||||
fillColor: this.colors.primary,
|
||||
textColor: this.colors.white,
|
||||
fontStyle: "bold",
|
||||
fontSize: 8,
|
||||
},
|
||||
bodyStyles: {
|
||||
fontSize: 8,
|
||||
textColor: this.colors.text,
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [250, 250, 250],
|
||||
},
|
||||
margin: { left: this.margin.left, right: this.margin.right },
|
||||
});
|
||||
|
||||
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add recommendations section
|
||||
*/
|
||||
private addRecommendations(
|
||||
recommendations: UserReport["recommendations"],
|
||||
): void {
|
||||
this.checkPageBreak(60);
|
||||
|
||||
this.doc.setTextColor(...this.colors.primary);
|
||||
this.doc.setFontSize(14);
|
||||
this.doc.setFont("helvetica", "bold");
|
||||
this.doc.text("AI Recommendations", this.margin.left, this.currentY);
|
||||
|
||||
this.currentY += 8;
|
||||
|
||||
const summaryStats = [
|
||||
["Accepted", recommendations.totalAccepted.toString()],
|
||||
["Rejected", recommendations.totalRejected.toString()],
|
||||
["Pending", recommendations.totalPending.toString()],
|
||||
];
|
||||
|
||||
autoTable(this.doc, {
|
||||
startY: this.currentY,
|
||||
body: summaryStats,
|
||||
theme: "plain",
|
||||
margin: { left: this.margin.left, right: this.margin.right },
|
||||
columnStyles: {
|
||||
0: { fontStyle: "bold", cellWidth: 60, fontSize: 10 },
|
||||
1: { cellWidth: "auto", fontSize: 10 },
|
||||
},
|
||||
styles: {
|
||||
cellPadding: 3,
|
||||
textColor: this.colors.text,
|
||||
},
|
||||
tableWidth: "auto" as any,
|
||||
});
|
||||
|
||||
this.currentY = (this.doc as any).lastAutoTable.finalY + 10;
|
||||
|
||||
// Add accepted recommendations
|
||||
if (recommendations.accepted.length > 0) {
|
||||
this.doc.setTextColor(...this.colors.success);
|
||||
this.doc.setFontSize(10);
|
||||
this.doc.setFont("helvetica", "bold");
|
||||
this.doc.text(
|
||||
"Accepted Recommendations",
|
||||
this.margin.left,
|
||||
this.currentY,
|
||||
);
|
||||
|
||||
this.currentY += 5;
|
||||
|
||||
const acceptedRecs = recommendations.accepted.map((rec) => [
|
||||
rec.recommendationText.substring(0, 80) +
|
||||
(rec.recommendationText.length > 80 ? "..." : ""),
|
||||
new Date(rec.generatedAt).toLocaleDateString(),
|
||||
]);
|
||||
|
||||
autoTable(this.doc, {
|
||||
startY: this.currentY,
|
||||
head: [["Recommendation", "Generated"]],
|
||||
body: acceptedRecs,
|
||||
theme: "striped",
|
||||
headStyles: {
|
||||
fillColor: this.colors.success,
|
||||
textColor: this.colors.white,
|
||||
fontStyle: "bold",
|
||||
fontSize: 8,
|
||||
},
|
||||
bodyStyles: {
|
||||
fontSize: 8,
|
||||
textColor: this.colors.text,
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [250, 250, 250],
|
||||
},
|
||||
margin: { left: this.margin.left, right: this.margin.right },
|
||||
});
|
||||
|
||||
this.currentY = (this.doc as any).lastAutoTable.finalY + 10;
|
||||
}
|
||||
|
||||
// Add pending recommendations
|
||||
if (recommendations.pending.length > 0) {
|
||||
this.doc.setTextColor(...this.colors.warning);
|
||||
this.doc.setFontSize(10);
|
||||
this.doc.setFont("helvetica", "bold");
|
||||
this.doc.text("Pending Recommendations", this.margin.left, this.currentY);
|
||||
|
||||
this.currentY += 5;
|
||||
|
||||
const pendingRecs = recommendations.pending.map((rec) => [
|
||||
rec.recommendationText.substring(0, 80) +
|
||||
(rec.recommendationText.length > 80 ? "..." : ""),
|
||||
new Date(rec.generatedAt).toLocaleDateString(),
|
||||
]);
|
||||
|
||||
autoTable(this.doc, {
|
||||
startY: this.currentY,
|
||||
head: [["Recommendation", "Generated"]],
|
||||
body: pendingRecs,
|
||||
theme: "striped",
|
||||
headStyles: {
|
||||
fillColor: this.colors.warning,
|
||||
textColor: this.colors.text,
|
||||
fontStyle: "bold",
|
||||
fontSize: 8,
|
||||
},
|
||||
bodyStyles: {
|
||||
fontSize: 8,
|
||||
textColor: this.colors.text,
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [250, 250, 250],
|
||||
},
|
||||
margin: { left: this.margin.left, right: this.margin.right },
|
||||
});
|
||||
|
||||
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add footer to each page
|
||||
*/
|
||||
private addFooter(): void {
|
||||
const pageCount = this.doc.getNumberOfPages();
|
||||
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
this.doc.setPage(i);
|
||||
|
||||
// Footer line
|
||||
this.doc.setDrawColor(...this.colors.primary);
|
||||
this.doc.setLineWidth(0.5);
|
||||
this.doc.line(
|
||||
this.margin.left,
|
||||
this.pageHeight - 15,
|
||||
this.pageWidth - this.margin.right,
|
||||
this.pageHeight - 15,
|
||||
);
|
||||
|
||||
// Footer text
|
||||
this.doc.setTextColor(...this.colors.textLight);
|
||||
this.doc.setFontSize(8);
|
||||
this.doc.setFont("helvetica", "normal");
|
||||
this.doc.text(
|
||||
`FitAI User Report - Page ${i} of ${pageCount}`,
|
||||
this.margin.left,
|
||||
this.pageHeight - 10,
|
||||
);
|
||||
this.doc.text(
|
||||
"Generated by FitAI",
|
||||
this.pageWidth - this.margin.right - 30,
|
||||
this.pageHeight - 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we need a page break
|
||||
*/
|
||||
private checkPageBreak(requiredSpace: number): void {
|
||||
if (this.currentY + requiredSpace > this.pageHeight - this.margin.bottom) {
|
||||
this.doc.addPage();
|
||||
this.currentY = this.margin.top;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PDFGenerator;
|
||||
Reference in new issue
Block a user