298 lines
9.2 KiB
TypeScript
298 lines
9.2 KiB
TypeScript
/**
|
|
* PDF report generator (queue analytics) using pdfkit.
|
|
*
|
|
* generateQueueReport returns a Buffer containing the binary PDF, ready to
|
|
* be base64-encoded and shipped to the client. All sections handle empty
|
|
* data sets gracefully so a freshly-onboarded clinic still gets a valid PDF.
|
|
*/
|
|
|
|
import PDFDocument from "pdfkit";
|
|
import {
|
|
getClinicById,
|
|
getAdvancedAnalytics,
|
|
getConsultationStats,
|
|
} from "../db.js";
|
|
import { childLogger } from "../_core/logger.js";
|
|
|
|
const log = childLogger("pdf-report");
|
|
|
|
// QueueMed medical theme
|
|
const COLOR_PRIMARY = "#10b981"; // emerald-500
|
|
const COLOR_ACCENT = "#06b6d4"; // cyan-500
|
|
const COLOR_TEAL = "#0d9488"; // teal-600
|
|
const COLOR_TEXT = "#0f172a"; // slate-900
|
|
const COLOR_MUTED = "#64748b"; // slate-500
|
|
const COLOR_BORDER = "#e2e8f0"; // slate-200
|
|
const COLOR_BG_SOFT = "#f0fdf4"; // green-50
|
|
|
|
const REASON_LABELS: Record<string, string> = {
|
|
consultation: "Consultation",
|
|
urgence: "Urgence",
|
|
certificat_scolaire: "Certificat scolaire",
|
|
certificat_sportif: "Certificat sportif",
|
|
arret_travail: "Arrêt de travail",
|
|
administratif: "Administratif",
|
|
autre: "Autre",
|
|
};
|
|
|
|
function formatDate(d: Date): string {
|
|
return d.toLocaleDateString("fr-FR", {
|
|
day: "2-digit",
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
}
|
|
|
|
export async function generateQueueReport(
|
|
clinicId: number,
|
|
dateRange: { days: number }
|
|
): Promise<Buffer> {
|
|
const days = Math.max(1, Math.min(dateRange.days ?? 30, 365));
|
|
const clinic = await getClinicById(clinicId);
|
|
if (!clinic) throw new Error(`Clinic ${clinicId} introuvable`);
|
|
|
|
// Fetch the same data the dashboard renders so the PDF matches the UI.
|
|
const [advanced, consult] = await Promise.all([
|
|
getAdvancedAnalytics(clinic.userId, { days, clinicId }),
|
|
getConsultationStats(clinicId, days),
|
|
]);
|
|
|
|
const now = new Date();
|
|
const since = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
|
|
|
const doc = new PDFDocument({
|
|
size: "A4",
|
|
margin: 48,
|
|
info: {
|
|
Title: `QueueMed — Rapport ${clinic.name}`,
|
|
Author: "QueueMed",
|
|
Subject: "Rapport file d'attente",
|
|
Creator: "QueueMed",
|
|
},
|
|
});
|
|
|
|
const chunks: Buffer[] = [];
|
|
doc.on("data", (c: Buffer) => chunks.push(c));
|
|
const finished = new Promise<Buffer>((resolve, reject) => {
|
|
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
|
doc.on("error", reject);
|
|
});
|
|
|
|
// ─── Header ────────────────────────────────────────────────────────────────
|
|
doc
|
|
.rect(0, 0, doc.page.width, 90)
|
|
.fill(COLOR_PRIMARY);
|
|
doc
|
|
.fillColor("#ffffff")
|
|
.fontSize(22)
|
|
.font("Helvetica-Bold")
|
|
.text("QueueMed — Rapport file d'attente", 48, 28);
|
|
doc
|
|
.fontSize(11)
|
|
.font("Helvetica")
|
|
.fillColor("#ecfeff")
|
|
.text(`Cabinet : ${clinic.name}`, 48, 58);
|
|
doc.text(
|
|
`Période : ${formatDate(since)} → ${formatDate(now)} (${days} j)`,
|
|
48,
|
|
72
|
|
);
|
|
|
|
doc.fillColor(COLOR_TEXT);
|
|
doc.y = 120;
|
|
doc.x = 48;
|
|
|
|
// ─── Summary table ─────────────────────────────────────────────────────────
|
|
doc
|
|
.fontSize(14)
|
|
.font("Helvetica-Bold")
|
|
.fillColor(COLOR_TEAL)
|
|
.text("Résumé", { underline: false });
|
|
doc.moveDown(0.4);
|
|
|
|
const totalJoined = advanced.totalJoined;
|
|
const totalServed = advanced.totalServed;
|
|
const totalAbsent = advanced.totalAbsent;
|
|
const noShowPct = Math.round((advanced.noShowRate ?? 0) * 100);
|
|
const totalConsults = consult.totalConsultations;
|
|
const avgConsultMin = consult.avgDurationMinutes;
|
|
const presenceRate = consult.presenceRate;
|
|
|
|
// Compute average wait from byHour aggregates is not direct — use avgWaitByDay
|
|
const allDays = advanced.avgWaitByDay;
|
|
const totalWait = allDays.reduce(
|
|
(acc, d) => acc + d.avgWaitMinutes * d.count,
|
|
0
|
|
);
|
|
const totalWaitCount = allDays.reduce((acc, d) => acc + d.count, 0);
|
|
const avgWaitMin = totalWaitCount > 0 ? Math.round(totalWait / totalWaitCount) : 0;
|
|
|
|
const summaryRows: Array<[string, string]> = [
|
|
["Patients inscrits", String(totalJoined)],
|
|
["Patients servis", String(totalServed)],
|
|
["Patients absents", String(totalAbsent)],
|
|
["Taux d'absence", `${noShowPct}%`],
|
|
["Taux de présence", `${presenceRate}%`],
|
|
["Attente moyenne", `${avgWaitMin} min`],
|
|
["Consultations terminées", String(totalConsults)],
|
|
["Durée moy. consultation", `${avgConsultMin} min`],
|
|
];
|
|
|
|
drawKeyValueTable(doc, summaryRows);
|
|
|
|
doc.moveDown(1);
|
|
|
|
// ─── By-hour breakdown ─────────────────────────────────────────────────────
|
|
doc
|
|
.fontSize(14)
|
|
.font("Helvetica-Bold")
|
|
.fillColor(COLOR_TEAL)
|
|
.text("Affluence par heure");
|
|
doc.moveDown(0.4);
|
|
|
|
const hourRows = advanced.byHour
|
|
.map((count, hour) => ({ hour, count }))
|
|
.filter((r) => r.count > 0);
|
|
|
|
if (hourRows.length === 0) {
|
|
doc
|
|
.fontSize(10)
|
|
.font("Helvetica-Oblique")
|
|
.fillColor(COLOR_MUTED)
|
|
.text("Aucune donnée d'affluence sur la période.");
|
|
} else {
|
|
const maxCount = Math.max(...hourRows.map((r) => r.count));
|
|
drawTableHeader(doc, ["Heure", "Patients", "Répartition"], [80, 80, 320]);
|
|
for (const r of hourRows) {
|
|
ensureSpace(doc, 22);
|
|
const y = doc.y;
|
|
doc
|
|
.font("Helvetica")
|
|
.fontSize(10)
|
|
.fillColor(COLOR_TEXT)
|
|
.text(`${r.hour.toString().padStart(2, "0")}h`, 48, y, { width: 80 });
|
|
doc.text(String(r.count), 128, y, { width: 80 });
|
|
// Bar
|
|
const barX = 208;
|
|
const barMaxW = 300;
|
|
const barW = Math.max(4, Math.round((r.count / maxCount) * barMaxW));
|
|
doc.rect(barX, y + 3, barW, 8).fill(COLOR_PRIMARY);
|
|
doc.fillColor(COLOR_TEXT);
|
|
doc.y = y + 18;
|
|
}
|
|
}
|
|
|
|
doc.moveDown(1);
|
|
|
|
// ─── Top visit reasons ─────────────────────────────────────────────────────
|
|
doc
|
|
.fontSize(14)
|
|
.font("Helvetica-Bold")
|
|
.fillColor(COLOR_TEAL)
|
|
.text("Motifs de consultation");
|
|
doc.moveDown(0.4);
|
|
|
|
if (consult.topReasons.length === 0) {
|
|
doc
|
|
.fontSize(10)
|
|
.font("Helvetica-Oblique")
|
|
.fillColor(COLOR_MUTED)
|
|
.text("Aucun motif de consultation enregistré.");
|
|
} else {
|
|
drawTableHeader(doc, ["Motif", "Nombre", "Part"], [240, 80, 120]);
|
|
const topTotal = consult.topReasons.reduce((s, r) => s + r.count, 0);
|
|
for (const r of consult.topReasons) {
|
|
ensureSpace(doc, 18);
|
|
const y = doc.y;
|
|
const label = REASON_LABELS[r.reason] ?? r.reason;
|
|
const pct = topTotal > 0 ? Math.round((r.count / topTotal) * 100) : 0;
|
|
doc
|
|
.font("Helvetica")
|
|
.fontSize(10)
|
|
.fillColor(COLOR_TEXT)
|
|
.text(label, 48, y, { width: 240 });
|
|
doc.text(String(r.count), 288, y, { width: 80 });
|
|
doc.text(`${pct}%`, 368, y, { width: 120 });
|
|
doc.y = y + 16;
|
|
}
|
|
}
|
|
|
|
// ─── Footer ────────────────────────────────────────────────────────────────
|
|
const footerY = doc.page.height - 36;
|
|
doc
|
|
.fontSize(9)
|
|
.font("Helvetica")
|
|
.fillColor(COLOR_MUTED)
|
|
.text(
|
|
`Généré par QueueMed le ${now.toLocaleString("fr-FR")}`,
|
|
48,
|
|
footerY,
|
|
{ align: "center", width: doc.page.width - 96 }
|
|
);
|
|
|
|
doc.end();
|
|
|
|
try {
|
|
return await finished;
|
|
} catch (err) {
|
|
log.error({ err, clinicId }, "PDF generation failed");
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
function ensureSpace(doc: PDFKit.PDFDocument, needed: number): void {
|
|
if (doc.y + needed > doc.page.height - 60) {
|
|
doc.addPage();
|
|
}
|
|
}
|
|
|
|
function drawKeyValueTable(
|
|
doc: PDFKit.PDFDocument,
|
|
rows: Array<[string, string]>
|
|
): void {
|
|
const colKeyW = 260;
|
|
const colValW = 240;
|
|
const x = 48;
|
|
for (const [k, v] of rows) {
|
|
ensureSpace(doc, 22);
|
|
const y = doc.y;
|
|
doc
|
|
.rect(x, y, colKeyW + colValW, 20)
|
|
.fill(COLOR_BG_SOFT)
|
|
.stroke(COLOR_BORDER);
|
|
doc
|
|
.font("Helvetica")
|
|
.fontSize(10)
|
|
.fillColor(COLOR_TEXT)
|
|
.text(k, x + 8, y + 6, { width: colKeyW - 16 });
|
|
doc
|
|
.font("Helvetica-Bold")
|
|
.fillColor(COLOR_TEAL)
|
|
.text(v, x + colKeyW, y + 6, { width: colValW - 8, align: "right" });
|
|
doc.y = y + 22;
|
|
}
|
|
doc.fillColor(COLOR_TEXT);
|
|
}
|
|
|
|
function drawTableHeader(
|
|
doc: PDFKit.PDFDocument,
|
|
headers: string[],
|
|
widths: number[]
|
|
): void {
|
|
ensureSpace(doc, 22);
|
|
const x = 48;
|
|
const y = doc.y;
|
|
doc.rect(x, y, widths.reduce((a, b) => a + b, 0), 20).fill(COLOR_PRIMARY);
|
|
let cx = x;
|
|
for (let i = 0; i < headers.length; i++) {
|
|
doc
|
|
.font("Helvetica-Bold")
|
|
.fontSize(10)
|
|
.fillColor("#ffffff")
|
|
.text(headers[i], cx + 8, y + 6, { width: widths[i] - 16 });
|
|
cx += widths[i];
|
|
}
|
|
doc.fillColor(COLOR_TEXT);
|
|
doc.y = y + 22;
|
|
}
|