Added Participation Page
This commit is contained in:
432
lib/participation-pdf.ts
Normal file
432
lib/participation-pdf.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const MONTANT_MOYEN = 6.8;
|
||||
|
||||
// ─── Palette ───────────────────────────────────────────────
|
||||
const TEAL = { r: 43 / 255, g: 147 / 255, b: 157 / 255 };
|
||||
const DARK = { r: 0.2, g: 0.2, b: 0.2 };
|
||||
const MID = { r: 0.5, g: 0.5, b: 0.5 };
|
||||
const LINE = { r: 0.82, g: 0.82, b: 0.82 };
|
||||
const LIGHT_BG = { r: 0.96, g: 0.96, b: 0.96 };
|
||||
const TEAL_BG = { r: 235 / 255, g: 248 / 255, b: 249 / 255 };
|
||||
|
||||
export interface ParticipationData {
|
||||
adherentNom: string;
|
||||
adherentPrenom: string;
|
||||
adherentAdresse: string;
|
||||
destinataireEmail: string;
|
||||
destinataireNom: string;
|
||||
dateTrajet: Date;
|
||||
adresseDepart: string;
|
||||
adresseArrivee: string;
|
||||
montant?: number;
|
||||
complement?: string;
|
||||
participationId?: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────
|
||||
|
||||
async function loadLogoAsPng(logoName: string, maxSize: number): Promise<Buffer | null> {
|
||||
try {
|
||||
const sharp = (await import('sharp')).default;
|
||||
const logoPath = path.join(process.cwd(), 'public', logoName);
|
||||
if (!fs.existsSync(logoPath)) return null;
|
||||
return sharp(logoPath)
|
||||
.resize(maxSize, maxSize, { fit: 'inside' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function wrapText(text: string, maxWidth: number, measureFn: (t: string) => number): string[] {
|
||||
const words = text.split(/\s+/);
|
||||
const lines: string[] = [];
|
||||
let currentLine = '';
|
||||
for (const word of words) {
|
||||
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
if (measureFn(testLine) <= maxWidth) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
if (currentLine) lines.push(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
}
|
||||
if (currentLine) lines.push(currentLine);
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ─── Génération du PDF ─────────────────────────────────────
|
||||
|
||||
export async function generateParticipationPDF(
|
||||
data: ParticipationData,
|
||||
outputPath: string
|
||||
): Promise<Buffer> {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
const page = pdfDoc.addPage([595, 842]); // A4
|
||||
const { width } = page.getSize();
|
||||
|
||||
const M = 50; // marge
|
||||
const R = width - M; // bord droit (545)
|
||||
const CW = width - 2 * M; // largeur utile (495)
|
||||
|
||||
const teal = rgb(TEAL.r, TEAL.g, TEAL.b);
|
||||
const dark = rgb(DARK.r, DARK.g, DARK.b);
|
||||
const mid = rgb(MID.r, MID.g, MID.b);
|
||||
|
||||
// Raccourcis
|
||||
const measure = (text: string, size: number, f = font) => f.widthOfTextAtSize(text, size);
|
||||
|
||||
const drawRight = (
|
||||
text: string, x: number, y: number, size: number,
|
||||
f = font, color = dark
|
||||
) => {
|
||||
page.drawText(text, { x: x - f.widthOfTextAtSize(text, size), y, size, font: f, color });
|
||||
};
|
||||
|
||||
const hLine = (y: number, x = M, w = CW) => {
|
||||
page.drawRectangle({
|
||||
x, y, width: w, height: 1,
|
||||
color: rgb(LINE.r, LINE.g, LINE.b),
|
||||
});
|
||||
};
|
||||
|
||||
// Données formatées
|
||||
const montant = data.montant ?? MONTANT_MOYEN;
|
||||
const montantStr = montant.toFixed(2).replace('.', ',') + ' \u20AC';
|
||||
const refNum = data.participationId
|
||||
? `PART-${data.participationId.slice(-8).toUpperCase()}`
|
||||
: 'PART-XXXXXXXX';
|
||||
const dateEmission = data.dateTrajet.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
});
|
||||
const dateTrajetLong = data.dateTrajet.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric',
|
||||
});
|
||||
|
||||
let y = 800;
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// EN-TÊTE
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
// Logo
|
||||
const logoPng = await loadLogoAsPng('logo.svg', 48);
|
||||
const logoS = 48;
|
||||
if (logoPng) {
|
||||
try {
|
||||
const img = await pdfDoc.embedPng(logoPng);
|
||||
const w = img.width * (logoS / img.height);
|
||||
page.drawImage(img, {
|
||||
x: M, y: y - logoS,
|
||||
width: Math.min(w, logoS), height: logoS,
|
||||
});
|
||||
} catch { /* fallback silencieux */ }
|
||||
}
|
||||
|
||||
// Infos association (à droite du logo)
|
||||
const hx = M + logoS + 14;
|
||||
page.drawText('Association MAD', {
|
||||
x: hx, y: y - 15, size: 13, font: fontBold, color: teal,
|
||||
});
|
||||
page.drawText('Adresse postale', {
|
||||
x: hx, y: y - 29, size: 9, font, color: mid,
|
||||
});
|
||||
page.drawText('Ville, Code postal', {
|
||||
x: hx, y: y - 41, size: 9, font, color: mid,
|
||||
});
|
||||
|
||||
// Référence document (aligné à droite)
|
||||
drawRight('Participation financière', R, y - 12, 9, font, mid);
|
||||
drawRight(refNum, R, y - 28, 11, fontBold, teal);
|
||||
drawRight(`\u00C9mise le ${dateEmission}`, R, y - 44, 9, font, mid);
|
||||
|
||||
// Séparateur
|
||||
y -= 62;
|
||||
hLine(y);
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// TITRE
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
y -= 30;
|
||||
page.drawText('Participation financière au transport', {
|
||||
x: M, y, size: 17, font: fontBold, color: teal,
|
||||
});
|
||||
y -= 16;
|
||||
page.drawText(
|
||||
"Document établi dans le cadre de l'accompagnement au transport par l'Association MAD.",
|
||||
{ x: M, y, size: 9, font, color: mid },
|
||||
);
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// BLOCS D'INFORMATION
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
y -= 56;
|
||||
const gap = 24;
|
||||
const bw = (CW - gap) / 2; // ~235px par bloc
|
||||
const bx2 = M + bw + gap; // x du bloc droit
|
||||
|
||||
// Helper : titre de section avec soulignement teal
|
||||
const sectionTitle = (label: string, x: number, yy: number) => {
|
||||
page.drawText(label, { x, y: yy, size: 8, font: fontBold, color: teal });
|
||||
page.drawRectangle({
|
||||
x, y: yy - 3,
|
||||
width: fontBold.widthOfTextAtSize(label, 8), height: 1.5,
|
||||
color: teal,
|
||||
});
|
||||
};
|
||||
|
||||
// ── Bloc gauche : FACTURÉ À ──
|
||||
const blockTopY = y;
|
||||
sectionTitle('FACTURÉ À', M, blockTopY);
|
||||
|
||||
let ly = blockTopY - 20;
|
||||
const destLines = wrapText(data.destinataireNom, bw, (t) => measure(t, 10, fontBold));
|
||||
for (const line of destLines.slice(0, 2)) {
|
||||
page.drawText(line, { x: M, y: ly, size: 10, font: fontBold, color: dark });
|
||||
ly -= 14;
|
||||
}
|
||||
page.drawText(data.destinataireEmail, {
|
||||
x: M, y: ly, size: 9, font, color: mid,
|
||||
});
|
||||
ly -= 14;
|
||||
|
||||
// ── Bloc droit : DÉTAILS DU TRAJET ──
|
||||
sectionTitle('DÉTAILS DU TRAJET', bx2, blockTopY);
|
||||
|
||||
let ry = blockTopY - 20;
|
||||
const infos: { label: string; value: string }[] = [
|
||||
{ label: 'Date', value: dateTrajetLong },
|
||||
{ label: 'Adhérent', value: `${data.adherentPrenom} ${data.adherentNom}` },
|
||||
];
|
||||
|
||||
for (const item of infos) {
|
||||
const prefix = `${item.label} : `;
|
||||
const prefixW = measure(prefix, 8, fontBold);
|
||||
|
||||
page.drawText(prefix, {
|
||||
x: bx2, y: ry, size: 8, font: fontBold, color: dark,
|
||||
});
|
||||
|
||||
const valLines = wrapText(item.value, bw - prefixW, (t) => measure(t, 9));
|
||||
for (let i = 0; i < Math.min(valLines.length, 2); i++) {
|
||||
page.drawText(valLines[i], {
|
||||
x: bx2 + prefixW, y: ry, size: 9, font, color: dark,
|
||||
});
|
||||
if (i < valLines.length - 1) ry -= 12;
|
||||
}
|
||||
ry -= 16;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// TABLEAU
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
y = Math.min(ly, ry) - 56;
|
||||
|
||||
// Positions des colonnes
|
||||
const cDesig = M + 10;
|
||||
const cQte = M + 290;
|
||||
const cPu = M + 370;
|
||||
// cMontant : aligné à droite sur R - 8
|
||||
|
||||
// ── En-tête du tableau ──
|
||||
const thH = 28;
|
||||
|
||||
// Ligne d'accent teal en haut
|
||||
page.drawRectangle({
|
||||
x: M, y: y + thH - 2, width: CW, height: 2, color: teal,
|
||||
});
|
||||
|
||||
// Fond gris clair
|
||||
page.drawRectangle({
|
||||
x: M, y: y, width: CW, height: thH - 2,
|
||||
color: rgb(LIGHT_BG.r, LIGHT_BG.g, LIGHT_BG.b),
|
||||
});
|
||||
|
||||
const thY = y + 8;
|
||||
page.drawText('DÉSIGNATION', {
|
||||
x: cDesig, y: thY, size: 8, font: fontBold, color: dark,
|
||||
});
|
||||
// QTÉ centré
|
||||
const qteLabel = 'QTÉ';
|
||||
const qteLabelW = measure(qteLabel, 8, fontBold);
|
||||
page.drawText(qteLabel, {
|
||||
x: cQte + 20 - qteLabelW / 2, y: thY, size: 8, font: fontBold, color: dark,
|
||||
});
|
||||
page.drawText('P.U.', {
|
||||
x: cPu, y: thY, size: 8, font: fontBold, color: dark,
|
||||
});
|
||||
drawRight('MONTANT', R - 8, thY, 8, fontBold, dark);
|
||||
|
||||
// Ligne sous en-tête
|
||||
hLine(y);
|
||||
|
||||
// ── Ligne article ──
|
||||
y -= 18;
|
||||
const articleTitleY = y;
|
||||
|
||||
// Titre de l'article
|
||||
page.drawText('Participation au transport', {
|
||||
x: cDesig, y: articleTitleY, size: 10, font: fontBold, color: dark,
|
||||
});
|
||||
|
||||
// Adresses complètes (wrappées, sans troncature)
|
||||
const maxDesigW = cQte - cDesig - 20;
|
||||
|
||||
const depLines = wrapText(
|
||||
`De : ${data.adresseDepart}`, maxDesigW, (t) => measure(t, 8),
|
||||
);
|
||||
const arrLines = wrapText(
|
||||
`Vers : ${data.adresseArrivee}`, maxDesigW, (t) => measure(t, 8),
|
||||
);
|
||||
|
||||
let addrY = articleTitleY - 16;
|
||||
for (const line of depLines.slice(0, 3)) {
|
||||
page.drawText(line, { x: cDesig, y: addrY, size: 8, font, color: mid });
|
||||
addrY -= 11;
|
||||
}
|
||||
addrY -= 2; // petit espace entre départ et arrivée
|
||||
for (const line of arrLines.slice(0, 3)) {
|
||||
page.drawText(line, { x: cDesig, y: addrY, size: 8, font, color: mid });
|
||||
addrY -= 11;
|
||||
}
|
||||
|
||||
// Valeurs numériques (centrées verticalement avec le titre)
|
||||
const numY = articleTitleY - 6;
|
||||
// QTÉ centré
|
||||
const qteVal = '1';
|
||||
const qteValW = measure(qteVal, 10);
|
||||
page.drawText(qteVal, {
|
||||
x: cQte + 20 - qteValW / 2, y: numY, size: 10, font, color: dark,
|
||||
});
|
||||
page.drawText(montantStr, {
|
||||
x: cPu, y: numY, size: 10, font, color: dark,
|
||||
});
|
||||
drawRight(montantStr, R - 8, numY, 10, fontBold, dark);
|
||||
|
||||
// Ligne de fin d'article
|
||||
const rowBottom = Math.min(addrY, numY - 16) - 6;
|
||||
hLine(rowBottom);
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// TOTAUX
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
y = rowBottom - 20;
|
||||
const tLabelX = R - 160;
|
||||
const tValX = R - 8;
|
||||
|
||||
// Sous-total
|
||||
page.drawText('Sous-total', {
|
||||
x: tLabelX, y, size: 10, font, color: dark,
|
||||
});
|
||||
drawRight(montantStr, tValX, y, 10, font, dark);
|
||||
|
||||
// Séparateur
|
||||
y -= 20;
|
||||
hLine(y + 8, tLabelX, R - tLabelX);
|
||||
|
||||
// Total dû (surligné)
|
||||
y -= 4;
|
||||
page.drawRectangle({
|
||||
x: tLabelX - 10, y: y - 6,
|
||||
width: R - tLabelX + 18, height: 26,
|
||||
color: rgb(TEAL_BG.r, TEAL_BG.g, TEAL_BG.b),
|
||||
});
|
||||
page.drawText('Total dû', {
|
||||
x: tLabelX, y: y + 2, size: 13, font: fontBold, color: teal,
|
||||
});
|
||||
drawRight(montantStr, tValX, y + 2, 13, fontBold, teal);
|
||||
|
||||
// Échéance
|
||||
y -= 32;
|
||||
page.drawText(`Échéance de paiement : ${dateEmission}`, {
|
||||
x: tLabelX, y, size: 8, font, color: mid,
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// COMPLÉMENT (optionnel)
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
if (data.complement) {
|
||||
y -= 34;
|
||||
page.drawText('Note :', {
|
||||
x: M, y, size: 9, font: fontBold, color: dark,
|
||||
});
|
||||
y -= 14;
|
||||
const compLines = wrapText(data.complement, CW - 20, (t) => measure(t, 9));
|
||||
for (const line of compLines.slice(0, 4)) {
|
||||
page.drawText(line, { x: M, y, size: 9, font, color: dark });
|
||||
y -= 13;
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// PIED DE PAGE
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
const footerY = 55;
|
||||
page.drawRectangle({
|
||||
x: 0, y: 0, width, height: footerY + 2,
|
||||
color: rgb(0.98, 0.98, 0.98),
|
||||
});
|
||||
hLine(footerY);
|
||||
|
||||
page.drawText(
|
||||
'Ce document fait office de participation financière au transport. Établi par Association MAD.',
|
||||
{ x: M, y: 35, size: 8, font, color: mid },
|
||||
);
|
||||
|
||||
// Propulsé par LGX + logo
|
||||
const propText = 'Propulsé par LGX';
|
||||
const lgxLogoPng = await loadLogoAsPng('lgx-logo.svg', 28);
|
||||
|
||||
if (lgxLogoPng) {
|
||||
try {
|
||||
const lgxImg = await pdfDoc.embedPng(lgxLogoPng);
|
||||
const lgxH = 16;
|
||||
const lgxW = lgxImg.width * (lgxH / lgxImg.height);
|
||||
const blockW = fontBold.widthOfTextAtSize(propText, 9) + lgxW + 8;
|
||||
const lgxX = R - blockW;
|
||||
page.drawText(propText, {
|
||||
x: lgxX, y: 35, size: 9, font: fontBold, color: mid,
|
||||
});
|
||||
page.drawImage(lgxImg, {
|
||||
x: lgxX + fontBold.widthOfTextAtSize(propText, 9) + 6,
|
||||
y: 29, width: lgxW, height: lgxH,
|
||||
});
|
||||
} catch {
|
||||
page.drawText(propText, {
|
||||
x: R - fontBold.widthOfTextAtSize(propText, 9),
|
||||
y: 35, size: 9, font: fontBold, color: teal,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
page.drawText(propText, {
|
||||
x: R - fontBold.widthOfTextAtSize(propText, 9),
|
||||
y: 35, size: 9, font: fontBold, color: teal,
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// SAUVEGARDE
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const dir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(outputPath, pdfBytes);
|
||||
return Buffer.from(pdfBytes);
|
||||
}
|
||||
|
||||
export function getParticipationStoragePath(participationId: string): string {
|
||||
return path.join(process.cwd(), 'data', 'participations', `${participationId}.pdf`);
|
||||
}
|
||||
Reference in New Issue
Block a user