fix: PDF généré côté serveur (pdf-lib) - 1 page A4 portrait garantie

This commit is contained in:
Manus
2026-06-05 11:27:58 -04:00
parent 5d919f891f
commit 51830b88be
4 changed files with 474 additions and 146 deletions

View File

@@ -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 && (
<div className="flex items-center gap-2">
<Button
onClick={() => exportToPdf(imp, lines)}
onClick={() => { setIsExportingPdf(true); generatePdfMutation.mutate({ id: selectedImportId! }); }}
disabled={isExportingPdf}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white"
>
<Download className="h-4 w-4" />
Exporter PDF
{isExportingPdf ? (
<div className="h-4 w-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isExportingPdf ? "Génération..." : "Exporter PDF"}
</Button>
<Button
onClick={handleExportSharePoint}