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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user