/** * 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 = { 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 { 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((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; }