Ajoute les exports Excel et PDF des membres
This commit is contained in:
1 parent
9df9c0d2ae
commit
b6f9a55a84
4 files changed
+249
-4
No files matched your search
@@ -0,0 +1,182 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const RED = '#C1272D';
|
||||
const INK = '#1B1B1B';
|
||||
const GRAY = '#6E675F';
|
||||
const LIGHT = '#F4F1EC';
|
||||
const WHITE = '#FFFFFF';
|
||||
|
||||
const logoCandidates = [
|
||||
fileURLToPath(new URL('../public/assets/logo.jpg', import.meta.url)),
|
||||
fileURLToPath(new URL('../../web/public/assets/logo.jpg', import.meta.url)),
|
||||
];
|
||||
const logoPath = logoCandidates.find(existsSync);
|
||||
|
||||
const value = (input) => String(input ?? '').trim();
|
||||
const statusLabel = (member, season) => member.valide ? `Validé ${season}` : 'À renouveler';
|
||||
|
||||
function collectPdf(draw, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
layout: 'landscape',
|
||||
margin: 36,
|
||||
...options,
|
||||
});
|
||||
const chunks = [];
|
||||
doc.on('data', (chunk) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
draw(doc);
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
|
||||
export function buildMembersPdf({ season, rows, association = {} }) {
|
||||
const associationName = value(association.association_name) || 'Association';
|
||||
const activeCount = rows.filter((row) => row.valide).length;
|
||||
const generatedAt = new Date().toLocaleDateString('fr-FR');
|
||||
const widths = [130, 100, 120, 135, 170, 65, 49];
|
||||
const headers = ['Entreprise', 'Dirigeant', 'Activité', 'Coordonnées', 'Adresse', 'Site', 'Statut'];
|
||||
const rowHeight = 43;
|
||||
|
||||
return collectPdf((doc) => {
|
||||
const left = 36;
|
||||
const tableWidth = widths.reduce((sum, width) => sum + width, 0);
|
||||
let pageNumber = 0;
|
||||
|
||||
const drawHeader = () => {
|
||||
pageNumber += 1;
|
||||
doc.rect(0, 0, doc.page.width, doc.page.height).fill(WHITE);
|
||||
const showLogo = Boolean(logoPath && pageNumber === 1);
|
||||
if (showLogo) doc.image(logoPath, left, 22, { fit: [48, 48] });
|
||||
doc.font('Helvetica-Bold').fontSize(8).fillColor(RED)
|
||||
.text(associationName.toUpperCase(), showLogo ? 96 : left, 27, { width: 330, lineBreak: false, ellipsis: true });
|
||||
doc.font('Helvetica-Bold').fontSize(20).fillColor(INK)
|
||||
.text('Liste des membres', showLogo ? 96 : left, 41, { width: 330, lineBreak: false });
|
||||
doc.font('Helvetica').fontSize(8).fillColor(GRAY)
|
||||
.text(`Saison ${season} - Édité le ${generatedAt}`, 495, 29, { width: 311, align: 'right', lineBreak: false });
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(INK)
|
||||
.text(`${rows.length} membres - ${activeCount} valides`, 495, 45, { width: 311, align: 'right', lineBreak: false });
|
||||
doc.moveTo(left, 76).lineTo(left + tableWidth, 76).lineWidth(2).strokeColor(RED).stroke();
|
||||
|
||||
let x = left;
|
||||
doc.rect(left, 91, tableWidth, 27).fill(INK);
|
||||
headers.forEach((header, index) => {
|
||||
doc.font('Helvetica-Bold').fontSize(7).fillColor(WHITE)
|
||||
.text(header.toUpperCase(), x + 5, 101, { width: widths[index] - 10, lineBreak: false, ellipsis: true });
|
||||
x += widths[index];
|
||||
});
|
||||
const bottomMargin = doc.page.margins.bottom;
|
||||
doc.page.margins.bottom = 0;
|
||||
doc.font('Helvetica').fontSize(7.5).fillColor('#8A8279')
|
||||
.text(`Page ${pageNumber}`, left, doc.page.height - 22, { width: tableWidth, align: 'right', lineBreak: false });
|
||||
doc.page.margins.bottom = bottomMargin;
|
||||
return 118;
|
||||
};
|
||||
|
||||
let y = drawHeader();
|
||||
rows.forEach((member, index) => {
|
||||
if (y + rowHeight > 555) {
|
||||
doc.addPage();
|
||||
y = drawHeader();
|
||||
}
|
||||
if (index % 2 === 0) doc.rect(left, y, tableWidth, rowHeight).fill('#FAF8F5');
|
||||
|
||||
const activity = [value(member.categorie), value(member.secteur)].filter(Boolean).join('\n');
|
||||
const contact = [value(member.email), value(member.tel)].filter(Boolean).join('\n');
|
||||
const cells = [
|
||||
value(member.nom),
|
||||
value(member.dirigeant),
|
||||
activity,
|
||||
contact,
|
||||
value(member.adresse),
|
||||
value(member.site),
|
||||
statusLabel(member, season),
|
||||
];
|
||||
|
||||
let x = left;
|
||||
cells.forEach((cell, cellIndex) => {
|
||||
const isName = cellIndex === 0;
|
||||
const isStatus = cellIndex === 6;
|
||||
doc.font(isName || isStatus ? 'Helvetica-Bold' : 'Helvetica')
|
||||
.fontSize(isStatus ? 6.7 : 7.2)
|
||||
.fillColor(isStatus ? (member.valide ? '#2C7A4B' : RED) : INK)
|
||||
.text(cell || '-', x + 5, y + 7, {
|
||||
width: widths[cellIndex] - 10,
|
||||
height: rowHeight - 12,
|
||||
lineGap: 1.5,
|
||||
ellipsis: true,
|
||||
});
|
||||
x += widths[cellIndex];
|
||||
});
|
||||
y += rowHeight;
|
||||
});
|
||||
}, { info: { Title: `Liste des membres ${season}`, Author: associationName } });
|
||||
}
|
||||
|
||||
export async function buildMembersWorkbook({ season, rows, association = {} }) {
|
||||
const associationName = value(association.association_name) || 'Association';
|
||||
const activeCount = rows.filter((row) => row.valide).length;
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = associationName;
|
||||
workbook.created = new Date();
|
||||
workbook.subject = `Liste des membres - Saison ${season}`;
|
||||
|
||||
const sheet = workbook.addWorksheet('Membres', {
|
||||
views: [{ state: 'frozen', ySplit: 5 }],
|
||||
pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0 },
|
||||
});
|
||||
sheet.mergeCells('A1:I1');
|
||||
sheet.getCell('A1').value = `${associationName} - Liste des membres`;
|
||||
sheet.getCell('A1').font = { bold: true, size: 18, color: { argb: 'FF1B1B1B' } };
|
||||
sheet.getCell('A2').value = `Saison ${season}`;
|
||||
sheet.getCell('C2').value = 'Nombre de membres';
|
||||
sheet.getCell('D2').value = rows.length;
|
||||
sheet.getCell('F2').value = 'Membres valides';
|
||||
sheet.getCell('G2').value = activeCount;
|
||||
|
||||
const headerRow = sheet.getRow(5);
|
||||
headerRow.values = ['Entreprise', 'Catégorie', 'Secteur', 'Dirigeant', 'Adresse', 'E-mail', 'Téléphone', 'Site internet', 'Statut'];
|
||||
headerRow.height = 25;
|
||||
headerRow.eachCell((cell) => {
|
||||
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1B1B1B' } };
|
||||
cell.alignment = { vertical: 'middle' };
|
||||
});
|
||||
|
||||
rows.forEach((member, index) => {
|
||||
const row = sheet.addRow([
|
||||
value(member.nom),
|
||||
value(member.categorie),
|
||||
value(member.secteur),
|
||||
value(member.dirigeant),
|
||||
value(member.adresse),
|
||||
value(member.email),
|
||||
value(member.tel),
|
||||
value(member.site),
|
||||
statusLabel(member, season),
|
||||
]);
|
||||
row.height = 31;
|
||||
row.alignment = { vertical: 'top', wrapText: true };
|
||||
if (index % 2 === 0) {
|
||||
row.eachCell((cell) => {
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFAF8F5' } };
|
||||
});
|
||||
}
|
||||
row.getCell(9).font = { bold: true, color: { argb: member.valide ? 'FF2C7A4B' : 'FFC1272D' } };
|
||||
});
|
||||
|
||||
const lastRow = 5 + rows.length;
|
||||
if (rows.length) sheet.autoFilter = { from: 'A5', to: `I${lastRow}` };
|
||||
[28, 22, 24, 24, 42, 32, 18, 28, 20].forEach((width, index) => {
|
||||
sheet.getColumn(index + 1).width = width;
|
||||
});
|
||||
sheet.properties.defaultRowHeight = 20;
|
||||
sheet.pageMargins = { left: 0.25, right: 0.25, top: 0.5, bottom: 0.5, header: 0.2, footer: 0.2 };
|
||||
|
||||
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { AccessError } from '../errors.js';
|
||||
import { buildInvitationEmail } from '../emailTemplate.js';
|
||||
import { buildCustomEmail } from '../customEmailTemplate.js';
|
||||
import { buildProcessingRegister, buildImageConsentForm } from '../complianceDocuments.js';
|
||||
import { buildMembersPdf, buildMembersWorkbook } from '../memberDocuments.js';
|
||||
import { billingRouter } from './billing.js';
|
||||
|
||||
export const adminRouter = Router();
|
||||
@@ -66,6 +67,11 @@ async function loadAssociationSettings() {
|
||||
return Object.fromEntries(result.rows.map((row) => [row.key, row.value]));
|
||||
}
|
||||
|
||||
function currentSeason(date = new Date()) {
|
||||
const start = date.getMonth() >= 8 ? date.getFullYear() : date.getFullYear() - 1;
|
||||
return `${start}-${start + 1}`;
|
||||
}
|
||||
|
||||
// ---------- Dashboard (admin only) ----------
|
||||
adminRouter.get('/dashboard', adminOnly, async (_req, res, next) => {
|
||||
try {
|
||||
@@ -98,6 +104,38 @@ adminRouter.get('/members', async (_req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.get('/members.xlsx', async (_req, res, next) => {
|
||||
try {
|
||||
const [members, association] = await Promise.all([
|
||||
query(`${MEMBER_SQL} ORDER BY LOWER(m.nom), m.id`),
|
||||
loadAssociationSettings(),
|
||||
]);
|
||||
const season = currentSeason();
|
||||
const workbook = await buildMembersWorkbook({ season, rows: members.rows, association });
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="membres-${season}.xlsx"`);
|
||||
res.send(workbook);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.get('/members.pdf', async (_req, res, next) => {
|
||||
try {
|
||||
const [members, association] = await Promise.all([
|
||||
query(`${MEMBER_SQL} ORDER BY LOWER(m.nom), m.id`),
|
||||
loadAssociationSettings(),
|
||||
]);
|
||||
const season = currentSeason();
|
||||
const pdf = await buildMembersPdf({ season, rows: members.rows, association });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="membres-${season}.pdf"`);
|
||||
res.send(pdf);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.post('/members', validate(adminMemberSchema), async (req, res, next) => {
|
||||
try {
|
||||
const d = req.data;
|
||||
|
||||
Reference in new issue
Block a user