Ajoute les exports Excel et PDF des membres

This commit is contained in:
Michael committed 2026-07-17 01:31:57 +02:00
1 parent 9df9c0d2ae
commit b6f9a55a84
4 files changed
+249 -4

No files matched your search

+182
View File
@@ -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());
}
+38
View File
@@ -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;
+16 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { FileSpreadsheet, FileText, Plus } from 'lucide-react';
import { api, dateParts, seasonLabel, statutLabel } from '../../lib/api.js';
import { copyRichEmail } from '../../lib/emailClipboard.js';
import { downloadOutlookDraft } from '../../lib/outlookDraft.js';
@@ -190,16 +191,27 @@ export function MembersTab() {
return (
<div className="card" style={{ borderRadius: 6, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 24px', borderBottom: '1px solid var(--border)' }}>
<div className="admin-panel-header">
<div>
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600 }}>Membres · {actifs} validés</h3>
<div style={{ fontSize: 12.5, color: 'var(--gray-light)', marginTop: 3 }}>
Saison {season} · {members.length - actifs} à renouveler
</div>
</div>
<button className="btn btn-red btn-sm" style={{ fontSize: 13, padding: '10px 18px' }} onClick={() => setModal('new')}>
+ Ajouter un membre
</button>
<div className="admin-panel-actions">
<a className="btn btn-outline-soft btn-sm admin-export-btn" href="/api/admin/members.xlsx" download>
<FileSpreadsheet size={17} aria-hidden="true" />
<span>Excel (.xlsx)</span>
</a>
<a className="btn btn-outline-soft btn-sm admin-export-btn" href="/api/admin/members.pdf" download>
<FileText size={17} aria-hidden="true" />
<span>PDF</span>
</a>
<button className="btn btn-red btn-sm admin-export-btn" onClick={() => setModal('new')}>
<Plus size={17} aria-hidden="true" />
<span>Ajouter un membre</span>
</button>
</div>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '14px 24px', background: '#FBF6EE', borderBottom: '1px solid var(--border-soft)', fontSize: 12.5, color: '#8A6D3B', lineHeight: 1.5 }}>
<span>ⓘ</span>
+13
View File
@@ -171,6 +171,16 @@ input:focus, textarea:focus, select:focus { outline: none; border-color: var(--r
}
.admin-sidebar-action:hover { background: rgba(255, 255, 255, .055); color: #fff; }
.admin-sidebar-action:focus-visible { outline: 2px solid var(--red-soft); outline-offset: 1px; }
.admin-panel-header {
display: flex; align-items: center; justify-content: space-between; gap: 20px;
padding: 20px 24px; border-bottom: 1px solid var(--border);
}
.admin-panel-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.admin-export-btn {
min-height: 38px; padding: 9px 13px; display: inline-flex; align-items: center; justify-content: center;
gap: 7px; font-size: 12.5px; line-height: 1; text-decoration: none; white-space: nowrap;
}
.admin-export-btn svg { flex: 0 0 auto; }
/* Membership request workflow */
.requests-layout { display: flex; flex-direction: column; gap: 20px; }
@@ -257,6 +267,9 @@ input:focus, textarea:focus, select:focus { outline: none; border-color: var(--r
.admin-nav { grid-template-columns: 1fr; gap: 20px; }
.admin-sidebar-footer { align-items: stretch; flex-direction: column; }
.admin-sidebar-action { width: 100%; }
.admin-panel-header { align-items: stretch; flex-direction: column; padding: 18px 20px; }
.admin-panel-actions { display: grid; grid-template-columns: 1fr 1fr; justify-content: stretch; }
.admin-panel-actions .admin-export-btn:last-child { grid-column: 1 / -1; }
/* Header nav: tighter spacing so the extra links (added alongside the
new "rencontres passées" page) wrap without feeling cramped. */