From 51830b88be1041b678adf374e6dad676f943c100 Mon Sep 17 00:00:00 2001 From: Manus Date: Fri, 5 Jun 2026 11:27:58 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20PDF=20g=C3=A9n=C3=A9r=C3=A9=20c=C3=B4t?= =?UTF-8?q?=C3=A9=20serveur=20(pdf-lib)=20-=201=20page=20A4=20portrait=20g?= =?UTF-8?q?arantie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/VentilationFreePro.tsx | 179 ++++------------ server/freeproPdfService.ts | 267 ++++++++++++++++++++++++ server/routers.ts | 39 +++- test_pdf_freepro.ts | 135 ++++++++++++ 4 files changed, 474 insertions(+), 146 deletions(-) create mode 100644 server/freeproPdfService.ts create mode 100644 test_pdf_freepro.ts diff --git a/client/src/pages/VentilationFreePro.tsx b/client/src/pages/VentilationFreePro.tsx index 3cdc6a7..0739581 100644 --- a/client/src/pages/VentilationFreePro.tsx +++ b/client/src/pages/VentilationFreePro.tsx @@ -36,8 +36,7 @@ import { Euro, Share2, } from "lucide-react"; -import jsPDF from "jspdf"; -import autoTable from "jspdf-autotable"; +// PDF généré côté serveur via trpc.freepro.generatePdf // ── Types ────────────────────────────────────────────────────────────────── @@ -83,138 +82,7 @@ function typeBadgeColor(type: string): string { return "bg-orange-100 text-orange-800 border-orange-200"; } -/** Formatage montant pour PDF : sans espace insécable pour éviter les artefacts jsPDF */ -function formatMontantPdf(centimes: number): string { - const val = centimes / 100; - const parts = val.toFixed(2).split("."); - // Séparateur de milliers = espace simple (pas \u202f qui cause le slash) - const intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, " "); - return `${intPart},${parts[1]} EUR`; -} - -// ── Construction du PDF (partagée entre export local et SharePoint) ───────── - -function buildPdfDoc(importRecord: ImportRecord, lines: VentilationLine[]): jsPDF { - // A4 portrait : 210mm × 297mm - const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); - const pageW = 210; - const pageH = 297; - const margin = 14; - const tableStartY = 31; // Y où commence le tableau - const tableEndY = pageH - 10; // Y max du tableau - const usableW = pageW - margin * 2; // 182mm - - // ── En-tête ──────────────────────────────────────────────────────────────── - const now = new Date(); - const editDate = `Edite le ${now.toLocaleDateString("fr-FR")} - ${now.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}`; - doc.setFontSize(7); - doc.setFont("helvetica", "normal"); - doc.text(editDate, pageW - margin, 7, { align: "right" }); - - doc.setFontSize(13); - doc.setFont("helvetica", "bold"); - doc.text("Ventilation facture FREE PRO", pageW / 2, 13, { align: "center" }); - - const [moisStr, anneeStr] = importRecord.moisLabel.split("/"); - doc.setFontSize(10); - doc.text(`01/${moisStr}/${anneeStr}`, pageW / 2, 20, { align: "center" }); - - doc.setFontSize(8); - doc.setFont("helvetica", "bold"); - doc.text("ref_piece :", margin, 27); - doc.setFont("helvetica", "normal"); - doc.text(importRecord.refPiece ?? "", margin + 22, 27); - - // ── Calcul des dimensions du tableau ──────────────────────────────────────── - const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0); - // nbRows = 1 header + N lignes + 1 footer - const nbRows = lines.length + 2; - // Hauteur totale disponible pour le tableau - const availH = tableEndY - tableStartY; - // Hauteur exacte par ligne - const rowH = availH / nbRows; - // Police : 1pt = 0.353mm, on prend 55% de rowH pour le texte - const fontSize = Math.max(5, Math.min(9, Math.floor(rowH * 0.55 / 0.353))); - - // Largeurs colonnes - const col0W = usableW * 0.45; // Structure - const col1W = usableW * 0.32; // Type - const col2W = usableW * 0.23; // Montant - const col1X = margin + col0W; - const col2X = col1X + col1W; - - // ── Dessin manuel du tableau ───────────────────────────────────────────────── - doc.setFontSize(fontSize); - - const drawRow = (y: number, c0: string, c1: string, c2: string, bold: boolean, bg?: [number, number, number]) => { - // Fond de ligne - if (bg) { - doc.setFillColor(bg[0], bg[1], bg[2]); - doc.rect(margin, y, usableW, rowH, "F"); - } - // Texte - doc.setFont("helvetica", bold ? "bold" : "normal"); - const textY = y + rowH * 0.65; // centrage vertical approximatif - const pad = 1.5; - doc.text(c0, margin + pad, textY, { maxWidth: col0W - pad * 2 }); - doc.text(c1, col1X + pad, textY, { maxWidth: col1W - pad * 2 }); - doc.text(c2, col2X + col2W - pad, textY, { align: "right", maxWidth: col2W - pad * 2 }); - }; - - const drawHLine = (y: number, lw: number, r: number, g: number, b: number) => { - doc.setDrawColor(r, g, b); - doc.setLineWidth(lw); - doc.line(margin, y, margin + usableW, y); - }; - - const drawVLines = (y: number, h: number) => { - doc.setDrawColor(180, 180, 180); - doc.setLineWidth(0.1); - doc.line(margin, y, margin, y + h); - doc.line(col1X, y, col1X, y + h); - doc.line(col2X, y, col2X, y + h); - doc.line(margin + usableW, y, margin + usableW, y + h); - }; - - // Header - const headerY = tableStartY; - drawHLine(headerY, 0.4, 0, 0, 0); - drawRow(headerY, "Structure", "Type", "Montant TTC", true); - drawHLine(headerY + rowH, 0.4, 0, 0, 0); - drawVLines(headerY, rowH); - - // Body - for (let i = 0; i < lines.length; i++) { - const l = lines[i]; - const y = headerY + rowH * (i + 1); - const bg: [number, number, number] | undefined = i % 2 === 1 ? [248, 248, 248] : undefined; - drawRow(y, l.structure ?? "(vide)", l.type, formatMontantPdf(l.montantCentimes), false, bg); - drawHLine(y + rowH, 0.1, 200, 200, 200); - drawVLines(y, rowH); - } - - // Footer (Total) - const footerY = headerY + rowH * (lines.length + 1); - drawHLine(footerY, 0.4, 0, 0, 0); - drawRow(footerY, "Total general", "", formatMontantPdf(totalCentimes), true, [240, 240, 240]); - drawHLine(footerY + rowH, 0.4, 0, 0, 0); - drawVLines(footerY, rowH); - - return doc; -} - -function exportToPdf(importRecord: ImportRecord, lines: VentilationLine[]) { - const doc = buildPdfDoc(importRecord, lines); - const [moisStr, anneeStr] = importRecord.moisLabel.split("/"); - const moisPad = moisStr.padStart(2, "0"); - const anneeCourt = anneeStr.slice(2); - doc.save(`FreePro - ventilation facture ${moisPad}.${anneeCourt}.pdf`); -} - -function buildPdfBase64(importRecord: ImportRecord, lines: VentilationLine[]): string { - const doc = buildPdfDoc(importRecord, lines); - return doc.output("datauristring").split(",")[1]; -} +// PDF généré côté serveur — pas de jsPDF côté client // ── Composant principal ──────────────────────────────────────────────────── @@ -264,6 +132,29 @@ function VentilationFreeProContent() { }, }); + // Mutation génération PDF côté serveur + const [isExportingPdf, setIsExportingPdf] = useState(false); + const generatePdfMutation = trpc.freepro.generatePdf.useMutation({ + onSuccess: (data) => { + setIsExportingPdf(false); + // Télécharger le PDF depuis le base64 + const byteChars = atob(data.base64); + const byteArr = new Uint8Array(byteChars.length); + for (let i = 0; i < byteChars.length; i++) byteArr[i] = byteChars.charCodeAt(i); + const blob = new Blob([byteArr], { type: 'application/pdf' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = data.fileName; + a.click(); + URL.revokeObjectURL(url); + }, + onError: (err) => { + setIsExportingPdf(false); + toast.error(`Erreur génération PDF : ${err.message}`); + }, + }); + const [isExportingSP, setIsExportingSP] = useState(false); const exportToSharePointMutation = trpc.freepro.exportToSharePoint.useMutation({ onSuccess: (data) => { @@ -277,13 +168,10 @@ function VentilationFreeProContent() { }); const handleExportSharePoint = useCallback(() => { - if (!selectedImportId || !detail) return; - const imp = detail.import as ImportRecord; - const lines = (detail.lines ?? []) as VentilationLine[]; - const pdfBase64 = buildPdfBase64(imp, lines); + if (!selectedImportId) return; setIsExportingSP(true); - exportToSharePointMutation.mutate({ id: selectedImportId, pdfBase64 }); - }, [selectedImportId, detail, exportToSharePointMutation]); + exportToSharePointMutation.mutate({ id: selectedImportId }); + }, [selectedImportId, exportToSharePointMutation]); // Gestion du fichier const handleFile = useCallback( @@ -521,11 +409,16 @@ function VentilationFreeProContent() { {imp && (