822 lines
22 KiB
TypeScript
822 lines
22 KiB
TypeScript
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;
|