diff --git a/server/src/memberDocuments.js b/server/src/memberDocuments.js new file mode 100644 index 0000000..48d7fbe --- /dev/null +++ b/server/src/memberDocuments.js @@ -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()); +} diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index 2d912ef..f44d7b9 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -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; diff --git a/web/src/components/admin/AdminTabs.jsx b/web/src/components/admin/AdminTabs.jsx index 2d8cc4d..cf909e4 100644 --- a/web/src/components/admin/AdminTabs.jsx +++ b/web/src/components/admin/AdminTabs.jsx @@ -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 (
-
+

Membres · {actifs} validés

Saison {season} · {members.length - actifs} à renouveler
- +
+ + + + + +
ⓘ diff --git a/web/src/styles.css b/web/src/styles.css index c44b4b3..5eb07ac 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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. */