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"
|
||||
>
|
||||
{isExportingPdf ? (
|
||||
<div className="h-4 w-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
Exporter PDF
|
||||
)}
|
||||
{isExportingPdf ? "Génération..." : "Exporter PDF"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExportSharePoint}
|
||||
|
||||
267
server/freeproPdfService.ts
Normal file
267
server/freeproPdfService.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Génération PDF Ventilation FreePro côté serveur avec pdf-lib.
|
||||
* Garantit 1 page A4 portrait quelle que soit la taille des données.
|
||||
*/
|
||||
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
|
||||
|
||||
interface PdfLine {
|
||||
structure: string;
|
||||
type: string;
|
||||
montantCentimes: number;
|
||||
}
|
||||
|
||||
interface PdfImportInfo {
|
||||
mois: string; // ex: "01/2025"
|
||||
refPiece: string;
|
||||
}
|
||||
|
||||
function formatMontant(centimes: number): string {
|
||||
const euros = centimes / 100;
|
||||
const neg = euros < 0;
|
||||
const abs = Math.abs(euros);
|
||||
const str = abs.toFixed(2).replace(".", ",");
|
||||
const parts = str.split(",");
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
return (neg ? "-" : "") + parts.join(",") + " EUR";
|
||||
}
|
||||
|
||||
export async function generateFreeproPdf(
|
||||
info: PdfImportInfo,
|
||||
lines: PdfLine[]
|
||||
): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const page = pdfDoc.addPage([595.28, 841.89]); // A4 portrait en points (72dpi)
|
||||
|
||||
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const { width, height } = page.getSize();
|
||||
const marginX = 40;
|
||||
const tableStartY = height - 90; // Y depuis le bas (pdf-lib: 0=bas)
|
||||
const tableEndY = 28;
|
||||
const usableW = width - marginX * 2;
|
||||
|
||||
// Colonnes
|
||||
const col0W = usableW * 0.45;
|
||||
const col1W = usableW * 0.32;
|
||||
const col2W = usableW * 0.23;
|
||||
const col1X = marginX + col0W;
|
||||
const col2X = col1X + col1W;
|
||||
|
||||
// Calcul hauteur de ligne
|
||||
const nbRows = lines.length + 2; // header + data + footer
|
||||
const availH = tableStartY - tableEndY;
|
||||
const rowH = availH / nbRows;
|
||||
const fontSize = Math.max(5, Math.min(9, rowH * 0.55));
|
||||
|
||||
// ── En-tête du document ──────────────────────────────────────────────────
|
||||
const now = new Date();
|
||||
// Date édition (haut droite)
|
||||
page.drawText(
|
||||
`Edite le ${now.toLocaleDateString("fr-FR")} - ${now.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}`,
|
||||
{
|
||||
x: width - marginX - 120,
|
||||
y: height - 20,
|
||||
size: 7,
|
||||
font: helvetica,
|
||||
color: rgb(0.4, 0.4, 0.4),
|
||||
}
|
||||
);
|
||||
|
||||
// Titre
|
||||
const title = "Ventilation facture FREE PRO";
|
||||
const titleW = helveticaBold.widthOfTextAtSize(title, 14);
|
||||
page.drawText(title, {
|
||||
x: (width - titleW) / 2,
|
||||
y: height - 38,
|
||||
size: 14,
|
||||
font: helveticaBold,
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
|
||||
// Mois
|
||||
const moisLabel = info.mois
|
||||
? (() => {
|
||||
const [mm, yyyy] = info.mois.split("/");
|
||||
const d = new Date(parseInt(yyyy), parseInt(mm) - 1, 1);
|
||||
return `${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`;
|
||||
})()
|
||||
: info.mois;
|
||||
const moisW = helveticaBold.widthOfTextAtSize(moisLabel, 11);
|
||||
page.drawText(moisLabel, {
|
||||
x: (width - moisW) / 2,
|
||||
y: height - 56,
|
||||
size: 11,
|
||||
font: helveticaBold,
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
|
||||
// Ref pièce
|
||||
page.drawText("ref_piece :", {
|
||||
x: marginX,
|
||||
y: height - 74,
|
||||
size: 8,
|
||||
font: helveticaBold,
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
page.drawText(info.refPiece || "", {
|
||||
x: marginX + 60,
|
||||
y: height - 74,
|
||||
size: 8,
|
||||
font: helvetica,
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
|
||||
// ── Helpers de dessin ────────────────────────────────────────────────────
|
||||
const drawHLine = (yFromTop: number, lw: number, gray: number) => {
|
||||
const y = height - yFromTop;
|
||||
page.drawLine({
|
||||
start: { x: marginX, y },
|
||||
end: { x: marginX + usableW, y },
|
||||
thickness: lw,
|
||||
color: rgb(gray, gray, gray),
|
||||
});
|
||||
};
|
||||
|
||||
const drawVLines = (yFromTop: number, h: number) => {
|
||||
const y = height - yFromTop;
|
||||
const xs = [marginX, col1X, col2X, marginX + usableW];
|
||||
for (const x of xs) {
|
||||
page.drawLine({
|
||||
start: { x, y },
|
||||
end: { x, y: y - h },
|
||||
thickness: 0.3,
|
||||
color: rgb(0.7, 0.7, 0.7),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const drawRowBg = (yFromTop: number, bgGray: number) => {
|
||||
page.drawRectangle({
|
||||
x: marginX,
|
||||
y: height - yFromTop - rowH,
|
||||
width: usableW,
|
||||
height: rowH,
|
||||
color: rgb(bgGray, bgGray, bgGray),
|
||||
});
|
||||
};
|
||||
|
||||
const drawCell = (
|
||||
text: string,
|
||||
xLeft: number,
|
||||
xRight: number,
|
||||
yFromTop: number,
|
||||
bold: boolean,
|
||||
align: "left" | "right" = "left"
|
||||
) => {
|
||||
const font = bold ? helveticaBold : helvetica;
|
||||
const maxW = xRight - xLeft - 4;
|
||||
// Tronquer si trop long
|
||||
let t = text;
|
||||
while (t.length > 1 && font.widthOfTextAtSize(t, fontSize) > maxW) {
|
||||
t = t.slice(0, -1);
|
||||
}
|
||||
const tw = font.widthOfTextAtSize(t, fontSize);
|
||||
const x = align === "right" ? xRight - tw - 2 : xLeft + 2;
|
||||
const y = height - yFromTop - rowH * 0.65;
|
||||
page.drawText(t, { x, y, size: fontSize, font, color: rgb(0, 0, 0) });
|
||||
};
|
||||
|
||||
// ── Header du tableau ────────────────────────────────────────────────────
|
||||
const headerYFromTop = height - tableStartY; // converti en "depuis le haut"
|
||||
// On travaille directement en coordonnées "depuis le haut" pour la logique
|
||||
// puis on convertit pour pdf-lib
|
||||
|
||||
// Recalcul en coordonnées "depuis le haut de la page" pour la logique
|
||||
const tStartFromTop = height - tableStartY; // pixels depuis le haut = height - y_pdflib
|
||||
|
||||
// En fait, simplifions : on calcule tout en "Y depuis le haut de la page"
|
||||
// et on convertit à la fin pour pdf-lib (y_pdflib = height - y_fromtop)
|
||||
|
||||
// Redéfinir les helpers avec "yT" = Y depuis le haut
|
||||
const hLine = (yT: number, lw: number, gray: number) => {
|
||||
page.drawLine({
|
||||
start: { x: marginX, y: height - yT },
|
||||
end: { x: marginX + usableW, y: height - yT },
|
||||
thickness: lw,
|
||||
color: rgb(gray, gray, gray),
|
||||
});
|
||||
};
|
||||
|
||||
const vLines = (yT: number, h: number) => {
|
||||
for (const x of [marginX, col1X, col2X, marginX + usableW]) {
|
||||
page.drawLine({
|
||||
start: { x, y: height - yT },
|
||||
end: { x, y: height - yT - h },
|
||||
thickness: 0.3,
|
||||
color: rgb(0.7, 0.7, 0.7),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const rowBg = (yT: number, gray: number) => {
|
||||
page.drawRectangle({
|
||||
x: marginX,
|
||||
y: height - yT - rowH,
|
||||
width: usableW,
|
||||
height: rowH,
|
||||
color: rgb(gray, gray, gray),
|
||||
});
|
||||
};
|
||||
|
||||
const cell = (
|
||||
text: string,
|
||||
xL: number,
|
||||
xR: number,
|
||||
yT: number,
|
||||
bold: boolean,
|
||||
align: "left" | "right" = "left"
|
||||
) => {
|
||||
const font = bold ? helveticaBold : helvetica;
|
||||
const maxW = xR - xL - 4;
|
||||
let t = text;
|
||||
while (t.length > 1 && font.widthOfTextAtSize(t, fontSize) > maxW) {
|
||||
t = t.slice(0, -1);
|
||||
}
|
||||
const tw = font.widthOfTextAtSize(t, fontSize);
|
||||
const x = align === "right" ? xR - tw - 2 : xL + 2;
|
||||
const y = height - yT - rowH * 0.65;
|
||||
page.drawText(t, { x, y, size: fontSize, font, color: rgb(0, 0, 0) });
|
||||
};
|
||||
|
||||
// Tableau commence à yT = 90 (depuis le haut)
|
||||
const tableTopYT = 90;
|
||||
|
||||
// Header
|
||||
rowBg(tableTopYT, 0.88);
|
||||
hLine(tableTopYT, 0.5, 0);
|
||||
cell("Structure", marginX, col1X, tableTopYT, true);
|
||||
cell("Type", col1X, col2X, tableTopYT, true);
|
||||
cell("Montant TTC", col2X, marginX + usableW, tableTopYT, true, "right");
|
||||
hLine(tableTopYT + rowH, 0.5, 0);
|
||||
vLines(tableTopYT, rowH);
|
||||
|
||||
// Lignes de données
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const l = lines[i];
|
||||
const yT = tableTopYT + rowH * (i + 1);
|
||||
if (i % 2 === 1) rowBg(yT, 0.97);
|
||||
cell(l.structure || "", marginX, col1X, yT, false);
|
||||
cell(l.type || "", col1X, col2X, yT, false);
|
||||
cell(formatMontant(l.montantCentimes), col2X, marginX + usableW, yT, false, "right");
|
||||
hLine(yT + rowH, 0.2, 0.78);
|
||||
vLines(yT, rowH);
|
||||
}
|
||||
|
||||
// Footer total
|
||||
const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0);
|
||||
const footerYT = tableTopYT + rowH * (lines.length + 1);
|
||||
rowBg(footerYT, 0.88);
|
||||
hLine(footerYT, 0.5, 0);
|
||||
cell("Total general", marginX, col1X, footerYT, true);
|
||||
cell(formatMontant(totalCentimes), col2X, marginX + usableW, footerYT, true, "right");
|
||||
hLine(footerYT + rowH, 0.5, 0);
|
||||
vLines(footerYT, rowH);
|
||||
|
||||
return pdfDoc.save();
|
||||
}
|
||||
@@ -2269,9 +2269,33 @@ export const appRouter = router({
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/** Génère le PDF côté serveur et le retourne en base64 */
|
||||
generatePdf: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const data = await getFreeproImportWithLines(input.id);
|
||||
if (!data) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (data.import.userId !== ctx.user.id)
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
|
||||
const { generateFreeproPdf } = await import('./freeproPdfService');
|
||||
const pdfBytes = await generateFreeproPdf(
|
||||
{ mois: data.import.moisLabel, refPiece: data.import.refPiece || '' },
|
||||
data.lines.map(l => ({
|
||||
structure: l.structure || '',
|
||||
type: l.type || '',
|
||||
montantCentimes: l.montantCentimes,
|
||||
}))
|
||||
);
|
||||
const base64 = Buffer.from(pdfBytes).toString('base64');
|
||||
const [mm, yyyy] = data.import.moisLabel.split('/');
|
||||
const fileName = `FreePro - ventilation facture ${mm.padStart(2,'0')}.${yyyy.slice(2)}.pdf`;
|
||||
return { base64, fileName };
|
||||
}),
|
||||
|
||||
/** Exporte la ventilation FreePro vers SharePoint */
|
||||
exportToSharePoint: protectedProcedure
|
||||
.input(z.object({ id: z.number(), pdfBase64: z.string() }))
|
||||
.input(z.object({ id: z.number(), pdfBase64: z.string().optional() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const data = await getFreeproImportWithLines(input.id);
|
||||
if (!data) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
@@ -2298,8 +2322,17 @@ export const appRouter = router({
|
||||
const anneeCourt = anneeStr.slice(2);
|
||||
const fileName = `FreePro - ventilation facture ${moisPad}.${anneeCourt}.pdf`;
|
||||
|
||||
// Convertir le PDF base64 en buffer
|
||||
const pdfBuffer = Buffer.from(input.pdfBase64, 'base64');
|
||||
// Générer le PDF côté serveur
|
||||
const { generateFreeproPdf } = await import('./freeproPdfService');
|
||||
const pdfBytes = await generateFreeproPdf(
|
||||
{ mois: data.import.moisLabel, refPiece: data.import.refPiece || '' },
|
||||
data.lines.map(l => ({
|
||||
structure: l.structure || '',
|
||||
type: l.type || '',
|
||||
montantCentimes: l.montantCentimes,
|
||||
}))
|
||||
);
|
||||
const pdfBuffer = Buffer.from(pdfBytes);
|
||||
|
||||
const { uploadToSharePoint } = await import('./sharepoint');
|
||||
const result = await uploadToSharePoint(
|
||||
|
||||
135
test_pdf_freepro.ts
Normal file
135
test_pdf_freepro.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import jsPDFModule from "jspdf";
|
||||
import * as fs from "fs";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const jsPDF = (jsPDFModule as any).default ?? jsPDFModule;
|
||||
|
||||
const lines = [
|
||||
{ structure: "1001BPT", type: "Lien 5G", montantCentimes: 0 },
|
||||
{ structure: "1001VAR - ITEP SESSAD VAREY", type: "Tél. mobile", montantCentimes: 48 },
|
||||
{ structure: "1031MER", type: "Lien 5G", montantCentimes: 2398 },
|
||||
{ structure: "1038MBN", type: "Lien fibre", montantCentimes: 5999 },
|
||||
{ structure: "1038RAC", type: "Lien fibre", montantCentimes: 5999 },
|
||||
{ structure: "1042CLA", type: "Lien fibre", montantCentimes: 5999 },
|
||||
{ structure: "1069BOUIME", type: "Lien fibre", montantCentimes: 5999 },
|
||||
{ structure: "1069IVP", type: "Lien fibre", montantCentimes: 5999 },
|
||||
{ structure: "1083ADV", type: "Lien 5G", montantCentimes: 1199 },
|
||||
{ structure: "1083ADV", type: "Lien fibre", montantCentimes: 23996 },
|
||||
{ structure: "1083ADV", type: "Tél. mobile", montantCentimes: 14261 },
|
||||
{ structure: "1083MIS", type: "Tél. mobile", montantCentimes: 9592 },
|
||||
{ structure: "1083QVT", type: "Tél. mobile", montantCentimes: 1199 },
|
||||
{ structure: "1083SYL", type: "Lien 5G", montantCentimes: 1199 },
|
||||
{ structure: "1083SYL", type: "Lien fibre", montantCentimes: 11998 },
|
||||
{ structure: "1083SYL", type: "Tél. mobile", montantCentimes: 7194 },
|
||||
{ structure: "1084CAS", type: "Tél. mobile", montantCentimes: 1199 },
|
||||
{ structure: "2001BRP", type: "Lien 5G", montantCentimes: 1199 },
|
||||
{ structure: "2001MUS", type: "Lien 5G", montantCentimes: 0 },
|
||||
{ structure: "2001ROS", type: "Tél. mobile", montantCentimes: 1223 },
|
||||
{ structure: "2011MON", type: "Lien fibre", montantCentimes: 5999 },
|
||||
{ structure: "2013ANG", type: "Lien 5G", montantCentimes: 1199 },
|
||||
{ structure: "2021MOU", type: "Lien fibre", montantCentimes: 5999 },
|
||||
{ structure: "2063VSJ", type: "Lien fibre", montantCentimes: 5999 },
|
||||
{ structure: "2069MAU", type: "Lien 5G", montantCentimes: 1199 },
|
||||
{ structure: "2069SAL", type: "Tél. mobile", montantCentimes: 1199 },
|
||||
{ structure: "2081ANC", type: "Lien 5G", montantCentimes: 1199 },
|
||||
{ structure: "2081BLA", type: "Lien 5G", montantCentimes: 0 },
|
||||
{ structure: "3069UDA", type: "Lien 5G", montantCentimes: 1199 },
|
||||
{ structure: "3069UDA", type: "Lien fibre", montantCentimes: 27599 },
|
||||
{ structure: "3069UDA", type: "Tél. mobile", montantCentimes: -1 },
|
||||
];
|
||||
|
||||
const formatMontant = (centimes: number): string => {
|
||||
const euros = centimes / 100;
|
||||
const abs = Math.abs(euros);
|
||||
const str = abs.toFixed(2).replace(".", ",");
|
||||
const parts = str.split(",");
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
return (euros < 0 ? "-" : "") + parts.join(",") + " EUR";
|
||||
};
|
||||
|
||||
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
|
||||
const pageW = 210;
|
||||
const pageH = 297;
|
||||
const margin = 14;
|
||||
const tableStartY = 31;
|
||||
const tableEndY = pageH - 10;
|
||||
const usableW = pageW - margin * 2;
|
||||
|
||||
// En-tête
|
||||
doc.setFontSize(7);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text("Edite le 05/06/2026 - 17:00", pageW - margin, 7, { align: "right" });
|
||||
doc.setFontSize(13);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Ventilation facture FREE PRO", pageW / 2, 13, { align: "center" });
|
||||
doc.setFontSize(10);
|
||||
doc.text("01/01/2025", pageW / 2, 20, { align: "center" });
|
||||
doc.setFontSize(8);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("ref_piece :", margin, 27);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text("F202501004510", margin + 22, 27);
|
||||
|
||||
// Calcul dimensions
|
||||
const nbRows = lines.length + 2;
|
||||
const availH = tableEndY - tableStartY;
|
||||
const rowH = availH / nbRows;
|
||||
const fontSize = Math.max(5, Math.min(9, Math.floor(rowH * 0.55 / 0.353)));
|
||||
|
||||
console.log(`nbRows=${nbRows}, availH=${availH.toFixed(1)}mm, rowH=${rowH.toFixed(2)}mm, fontSize=${fontSize}pt`);
|
||||
console.log(`Tableau: ${tableStartY}mm → ${(tableStartY + nbRows * rowH).toFixed(1)}mm (limite=${tableEndY}mm)`);
|
||||
|
||||
const col0W = usableW * 0.45;
|
||||
const col1W = usableW * 0.32;
|
||||
const col2W = usableW * 0.23;
|
||||
const col1X = margin + col0W;
|
||||
const col2X = col1X + col1W;
|
||||
|
||||
doc.setFontSize(fontSize);
|
||||
|
||||
const drawRow = (y: number, c0: string, c1: string, c2: string, bold: boolean, bg?: [number,number,number]) => {
|
||||
if (bg) { doc.setFillColor(bg[0], bg[1], bg[2]); doc.rect(margin, y, usableW, rowH, "F"); }
|
||||
doc.setFont("helvetica", bold ? "bold" : "normal");
|
||||
const textY = y + rowH * 0.65;
|
||||
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);
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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, l.type, formatMontant(l.montantCentimes), false, bg);
|
||||
drawHLine(y + rowH, 0.1, 200, 200, 200);
|
||||
drawVLines(y, rowH);
|
||||
}
|
||||
|
||||
const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0);
|
||||
const footerY = headerY + rowH * (lines.length + 1);
|
||||
drawHLine(footerY, 0.4, 0, 0, 0);
|
||||
drawRow(footerY, "Total general", "", formatMontant(totalCentimes), true, [240,240,240]);
|
||||
drawHLine(footerY + rowH, 0.4, 0, 0, 0);
|
||||
drawVLines(footerY, rowH);
|
||||
|
||||
const pdfBytes = doc.output("arraybuffer");
|
||||
fs.writeFileSync("/tmp/test_freepro_01_25.pdf", Buffer.from(pdfBytes));
|
||||
console.log("PDF généré : /tmp/test_freepro_01_25.pdf");
|
||||
console.log(`Nombre de pages : ${doc.getNumberOfPages()}`);
|
||||
Reference in New Issue
Block a user