fix: PDF généré côté serveur (pdf-lib) - 1 page A4 portrait garantie
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user