Ajoute la gestion des adhesions et de la facturation
This commit is contained in:
1 parent
bdaf6cb156
commit
942b5a1ad2
13 files changed
+2029
-32
No files matched your search
Generated
+1013
File diff suppressed because it is too large.
Load diff
@@ -11,11 +11,13 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-rate-limit": "^7.4.0",
|
"express-rate-limit": "^7.4.0",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"pdfkit": "^0.19.1",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import PDFDocument from 'pdfkit';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
const RED = '#C1272D';
|
||||||
|
const INK = '#1B1B1B';
|
||||||
|
const GRAY = '#6E675F';
|
||||||
|
const LIGHT = '#F4F1EC';
|
||||||
|
|
||||||
|
const text = (value) => String(value ?? '');
|
||||||
|
const money = (value) => `${Number(value || 0).toFixed(2).replace('.', ',')} EUR`;
|
||||||
|
const dateFr = (value) => {
|
||||||
|
if (!value) return '';
|
||||||
|
const raw = value instanceof Date ? value.toISOString().slice(0, 10) : String(value).slice(0, 10);
|
||||||
|
const [year, month, day] = raw.split('-');
|
||||||
|
return `${day}/${month}/${year}`;
|
||||||
|
};
|
||||||
|
const typeLabel = (value) => value === 'sluc_partner' ? 'Partenaire SLUC' : 'Non partenaire SLUC';
|
||||||
|
const paymentLabel = (value) => ({ carte: 'Carte bleue', virement: 'Virement', cheque: 'Chèque' }[value] || '');
|
||||||
|
|
||||||
|
function collectPdf(draw, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const doc = new PDFDocument({ size: 'A4', margin: 48, bufferPages: true, ...options });
|
||||||
|
const chunks = [];
|
||||||
|
doc.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on('error', reject);
|
||||||
|
draw(doc);
|
||||||
|
const range = doc.bufferedPageRange();
|
||||||
|
for (let page = range.start; page < range.start + range.count; page += 1) {
|
||||||
|
doc.switchToPage(page);
|
||||||
|
const bottomMargin = doc.page.margins.bottom;
|
||||||
|
doc.page.margins.bottom = 0;
|
||||||
|
doc.fontSize(8).fillColor('#8A8279').text(
|
||||||
|
`Page ${page - range.start + 1} / ${range.count}`,
|
||||||
|
48,
|
||||||
|
doc.page.height - 24,
|
||||||
|
{ width: doc.page.width - 96, align: 'right', lineBreak: false }
|
||||||
|
);
|
||||||
|
doc.page.margins.bottom = bottomMargin;
|
||||||
|
}
|
||||||
|
doc.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMultiline(doc, value, x, y, width, options = {}) {
|
||||||
|
doc.text(text(value), x, y, { width, lineGap: 2, ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInvoicePdf(invoice) {
|
||||||
|
const issuer = invoice.issuer_snapshot || {};
|
||||||
|
return collectPdf((doc) => {
|
||||||
|
doc.fillColor(INK).font('Helvetica-Bold').fontSize(21).text(text(issuer.association_name || 'Association'), 48, 48, { width: 310 });
|
||||||
|
doc.fillColor(RED).fontSize(28).text('FACTURE', 365, 45, { width: 180, align: 'right' });
|
||||||
|
doc.moveTo(48, 84).lineTo(547, 84).lineWidth(2).strokeColor(RED).stroke();
|
||||||
|
|
||||||
|
doc.font('Helvetica-Bold').fontSize(10).fillColor(GRAY).text('ÉMETTEUR', 48, 108);
|
||||||
|
doc.font('Helvetica').fontSize(10).fillColor(INK);
|
||||||
|
drawMultiline(doc, issuer.association_name, 48, 126, 215);
|
||||||
|
drawMultiline(doc, issuer.association_address, 48, 143, 215);
|
||||||
|
if (issuer.association_email) drawMultiline(doc, issuer.association_email, 48, 178, 215);
|
||||||
|
if (issuer.association_phone) drawMultiline(doc, issuer.association_phone, 48, 194, 215);
|
||||||
|
if (issuer.association_siret) doc.text(`SIRET : ${issuer.association_siret}`, 48, 210, { width: 215 });
|
||||||
|
|
||||||
|
doc.font('Helvetica-Bold').fontSize(10).fillColor(GRAY).text('DESTINATAIRE', 310, 108);
|
||||||
|
doc.font('Helvetica-Bold').fontSize(11).fillColor(INK).text(text(invoice.member_name), 310, 126, { width: 237 });
|
||||||
|
doc.font('Helvetica').fontSize(10);
|
||||||
|
drawMultiline(doc, invoice.member_address, 310, 145, 237);
|
||||||
|
drawMultiline(doc, invoice.member_email, 310, 185, 237);
|
||||||
|
|
||||||
|
doc.roundedRect(48, 240, 499, 58, 4).fill(LIGHT);
|
||||||
|
const meta = [
|
||||||
|
['N° de facture', invoice.invoice_number],
|
||||||
|
['Date d’émission', dateFr(invoice.issued_at)],
|
||||||
|
['Date d’échéance', dateFr(invoice.due_date)],
|
||||||
|
];
|
||||||
|
meta.forEach(([label, value], index) => {
|
||||||
|
const x = 62 + index * 164;
|
||||||
|
doc.font('Helvetica').fontSize(8).fillColor(GRAY).text(label.toUpperCase(), x, 253, { width: 145 });
|
||||||
|
doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text(text(value), x, 270, { width: 145 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableY = 332;
|
||||||
|
doc.rect(48, tableY, 499, 30).fill(INK);
|
||||||
|
doc.font('Helvetica-Bold').fontSize(9).fillColor('#FFFFFF');
|
||||||
|
doc.text('DÉSIGNATION', 60, tableY + 10, { width: 275 });
|
||||||
|
doc.text('HT', 350, tableY + 10, { width: 80, align: 'right' });
|
||||||
|
doc.text('TVA', 445, tableY + 10, { width: 88, align: 'right' });
|
||||||
|
doc.rect(48, tableY + 30, 499, 58).fill('#FAF8F5');
|
||||||
|
doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text(`Adhésion ${invoice.season}`, 60, tableY + 45, { width: 270 });
|
||||||
|
doc.font('Helvetica').fontSize(9).fillColor(GRAY).text(typeLabel(invoice.billing_type), 60, tableY + 62, { width: 270 });
|
||||||
|
doc.font('Helvetica').fontSize(10).fillColor(INK).text(money(invoice.amount_ht), 350, tableY + 50, { width: 80, align: 'right' });
|
||||||
|
doc.text(`${Number(invoice.vat_rate).toFixed(2).replace('.', ',')} %`, 445, tableY + 50, { width: 88, align: 'right' });
|
||||||
|
|
||||||
|
const totalsY = 430;
|
||||||
|
const totals = [
|
||||||
|
['Total HT', invoice.amount_ht],
|
||||||
|
['TVA', invoice.vat_amount],
|
||||||
|
['Total TTC', invoice.amount_ttc],
|
||||||
|
];
|
||||||
|
totals.forEach(([label, value], index) => {
|
||||||
|
const y = totalsY + index * 27;
|
||||||
|
if (index === 2) doc.roundedRect(337, y - 7, 210, 28, 3).fill(RED);
|
||||||
|
doc.font('Helvetica-Bold').fontSize(index === 2 ? 11 : 10).fillColor(index === 2 ? '#FFFFFF' : INK)
|
||||||
|
.text(label, 350, y, { width: 90 })
|
||||||
|
.text(money(value), 440, y, { width: 94, align: 'right' });
|
||||||
|
});
|
||||||
|
|
||||||
|
let y = 540;
|
||||||
|
if (invoice.status === 'payee') {
|
||||||
|
doc.roundedRect(48, y, 499, 46, 4).fill('#E8F3EC');
|
||||||
|
doc.font('Helvetica-Bold').fontSize(11).fillColor('#22623E').text('FACTURE RÉGLÉE', 62, y + 10);
|
||||||
|
doc.font('Helvetica').fontSize(9).text(`${paymentLabel(invoice.payment_method)} - ${dateFr(invoice.paid_at)}`, 62, y + 27);
|
||||||
|
y += 65;
|
||||||
|
} else {
|
||||||
|
doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text('RÈGLEMENT', 48, y);
|
||||||
|
doc.font('Helvetica').fontSize(9.5).fillColor(GRAY).text(`À régler avant le ${dateFr(invoice.due_date)}.`, 48, y + 18);
|
||||||
|
if (issuer.iban) doc.text(`IBAN : ${issuer.iban}`, 48, y + 36, { width: 499 });
|
||||||
|
y += issuer.iban ? 72 : 54;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issuer.legal_mentions) {
|
||||||
|
if (y > 690) {
|
||||||
|
doc.addPage();
|
||||||
|
y = 60;
|
||||||
|
}
|
||||||
|
doc.moveTo(48, y).lineTo(547, y).lineWidth(1).strokeColor('#D9D3CB').stroke();
|
||||||
|
doc.font('Helvetica-Bold').fontSize(8).fillColor(GRAY).text('MENTIONS LÉGALES', 48, y + 14);
|
||||||
|
doc.font('Helvetica').fontSize(8).fillColor(GRAY).text(text(issuer.legal_mentions), 48, y + 29, { width: 499, lineGap: 2 });
|
||||||
|
}
|
||||||
|
}, { info: { Title: `Facture ${invoice.invoice_number}`, Author: text(invoice.issuer_snapshot?.association_name) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPaidMembersPdf({ season, rows }) {
|
||||||
|
return collectPdf((doc) => {
|
||||||
|
const pageWidth = doc.page.width;
|
||||||
|
const left = 36;
|
||||||
|
const widths = [90, 150, 90, 70, 75, 70, 70, 75];
|
||||||
|
const headers = ['Facture', 'Entreprise', 'Type', 'Date', 'Règlement', 'HT', 'TVA', 'TTC'];
|
||||||
|
const drawHeader = () => {
|
||||||
|
doc.font('Helvetica-Bold').fontSize(20).fillColor(INK).text(`Adhésions réglées - ${season}`, left, 36);
|
||||||
|
doc.font('Helvetica').fontSize(9).fillColor(GRAY).text(`${rows.length} membre${rows.length > 1 ? 's' : ''} à jour`, left, 64);
|
||||||
|
let x = left;
|
||||||
|
doc.rect(left, 88, pageWidth - left * 2, 26).fill(INK);
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
doc.font('Helvetica-Bold').fontSize(7.5).fillColor('#FFFFFF').text(header.toUpperCase(), x + 4, 97, { width: widths[index] - 8, align: index >= 5 ? 'right' : 'left' });
|
||||||
|
x += widths[index];
|
||||||
|
});
|
||||||
|
return 114;
|
||||||
|
};
|
||||||
|
let y = drawHeader();
|
||||||
|
rows.forEach((row, rowIndex) => {
|
||||||
|
if (y > 535) {
|
||||||
|
doc.addPage();
|
||||||
|
y = drawHeader();
|
||||||
|
}
|
||||||
|
if (rowIndex % 2 === 0) doc.rect(left, y, pageWidth - left * 2, 29).fill('#FAF8F5');
|
||||||
|
const values = [
|
||||||
|
row.invoice_number,
|
||||||
|
row.member_name,
|
||||||
|
typeLabel(row.billing_type),
|
||||||
|
dateFr(row.paid_at),
|
||||||
|
paymentLabel(row.payment_method),
|
||||||
|
money(row.amount_ht),
|
||||||
|
money(row.vat_amount),
|
||||||
|
money(row.amount_ttc),
|
||||||
|
];
|
||||||
|
let x = left;
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
doc.font('Helvetica').fontSize(7.5).fillColor(INK).text(text(value), x + 4, y + 10, { width: widths[index] - 8, align: index >= 5 ? 'right' : 'left', ellipsis: true });
|
||||||
|
x += widths[index];
|
||||||
|
});
|
||||||
|
y += 29;
|
||||||
|
});
|
||||||
|
const total = rows.reduce((sum, row) => sum + Number(row.amount_ttc), 0);
|
||||||
|
doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text(`Total encaissé : ${money(total)}`, left, y + 18, { width: pageWidth - left * 2, align: 'right' });
|
||||||
|
}, { size: 'A4', layout: 'landscape', margin: 36, info: { Title: `Adhésions réglées ${season}` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildPaidMembersWorkbook({ season, rows }) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
workbook.creator = 'SBC - Module facturation';
|
||||||
|
workbook.created = new Date();
|
||||||
|
const sheet = workbook.addWorksheet('Adhésions payées', { views: [{ state: 'frozen', ySplit: 5 }] });
|
||||||
|
sheet.mergeCells('A1:H1');
|
||||||
|
sheet.getCell('A1').value = `Adhésions réglées - Saison ${season}`;
|
||||||
|
sheet.getCell('A1').font = { bold: true, size: 18, color: { argb: 'FF1B1B1B' } };
|
||||||
|
sheet.getCell('A2').value = 'Membres à jour';
|
||||||
|
sheet.getCell('B2').value = rows.length;
|
||||||
|
sheet.getCell('D2').value = 'Total encaissé TTC';
|
||||||
|
sheet.getCell('E2').value = { formula: rows.length ? `SUM(H6:H${5 + rows.length})` : '0' };
|
||||||
|
sheet.getCell('E2').numFmt = '#,##0.00 [$EUR]';
|
||||||
|
sheet.getRow(5).values = ['N° facture', 'Entreprise', 'Type', 'Date de règlement', 'Mode de règlement', 'Montant HT', 'TVA', 'Montant TTC'];
|
||||||
|
sheet.getRow(5).height = 24;
|
||||||
|
sheet.getRow(5).eachCell((cell) => {
|
||||||
|
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1B1B1B' } };
|
||||||
|
cell.alignment = { vertical: 'middle' };
|
||||||
|
});
|
||||||
|
rows.forEach((row) => {
|
||||||
|
sheet.addRow([
|
||||||
|
row.invoice_number,
|
||||||
|
row.member_name,
|
||||||
|
typeLabel(row.billing_type),
|
||||||
|
row.paid_at ? new Date(`${String(row.paid_at).slice(0, 10)}T00:00:00Z`) : null,
|
||||||
|
paymentLabel(row.payment_method),
|
||||||
|
Number(row.amount_ht),
|
||||||
|
Number(row.vat_amount),
|
||||||
|
Number(row.amount_ttc),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
const lastDataRow = 5 + rows.length;
|
||||||
|
if (rows.length) {
|
||||||
|
sheet.autoFilter = { from: 'A5', to: `H${lastDataRow}` };
|
||||||
|
for (let row = 6; row <= lastDataRow; row += 1) {
|
||||||
|
sheet.getCell(`D${row}`).numFmt = 'dd/mm/yyyy';
|
||||||
|
['F', 'G', 'H'].forEach((column) => { sheet.getCell(`${column}${row}`).numFmt = '#,##0.00 [$EUR]'; });
|
||||||
|
if (row % 2 === 0) {
|
||||||
|
sheet.getRow(row).eachCell((cell) => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFAF8F5' } }; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const widths = [20, 34, 24, 20, 22, 16, 16, 18];
|
||||||
|
widths.forEach((width, index) => { sheet.getColumn(index + 1).width = width; });
|
||||||
|
sheet.getColumn(2).alignment = { wrapText: true, vertical: 'top' };
|
||||||
|
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||||
|
}
|
||||||
@@ -22,13 +22,15 @@ import { createStaffUser, resetStaffAccess } from '../userAccess.js';
|
|||||||
import { AccessError } from '../errors.js';
|
import { AccessError } from '../errors.js';
|
||||||
import { buildInvitationEmail } from '../emailTemplate.js';
|
import { buildInvitationEmail } from '../emailTemplate.js';
|
||||||
import { buildCustomEmail } from '../customEmailTemplate.js';
|
import { buildCustomEmail } from '../customEmailTemplate.js';
|
||||||
|
import { billingRouter } from './billing.js';
|
||||||
|
|
||||||
export const adminRouter = Router();
|
export const adminRouter = Router();
|
||||||
|
|
||||||
// Every route below requires at least an authenticated staff session
|
// Every route below requires at least an authenticated staff session
|
||||||
// (admin or moderator); individual routes further restrict to admin-only
|
// (admin or moderator); individual routes further restrict to admin-only
|
||||||
// where noted.
|
// where noted.
|
||||||
adminRouter.use(requireAuth(['admin', 'moderator']));
|
adminRouter.use(requireAuth(['admin', 'moderator', 'treasurer']));
|
||||||
|
adminRouter.use('/billing', billingRouter);
|
||||||
const adminOnly = requireAuth('admin');
|
const adminOnly = requireAuth('admin');
|
||||||
|
|
||||||
const MEMBER_SQL = `
|
const MEMBER_SQL = `
|
||||||
@@ -680,7 +682,7 @@ adminRouter.get('/users', adminOnly, async (req, res, next) => {
|
|||||||
SELECT id, email, full_name, role, must_change_password,
|
SELECT id, email, full_name, role, must_change_password,
|
||||||
CASE WHEN must_change_password THEN temp_password ELSE NULL END AS temp_password,
|
CASE WHEN must_change_password THEN temp_password ELSE NULL END AS temp_password,
|
||||||
created_at
|
created_at
|
||||||
FROM users WHERE role IN ('admin', 'moderator')
|
FROM users WHERE role IN ('admin', 'moderator', 'treasurer')
|
||||||
ORDER BY role, full_name, email`);
|
ORDER BY role, full_name, email`);
|
||||||
res.json({ users: result.rows.map((u) => ({ ...u, is_self: u.id === req.user.sub })) });
|
res.json({ users: result.rows.map((u) => ({ ...u, is_self: u.id === req.user.sub })) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -714,7 +716,7 @@ adminRouter.put('/users/:id/role', adminOnly, validate(idParam, 'params'), valid
|
|||||||
if (req.params.id === req.user.sub) {
|
if (req.params.id === req.user.sub) {
|
||||||
return res.status(400).json({ error: 'Vous ne pouvez pas modifier votre propre rôle.' });
|
return res.status(400).json({ error: 'Vous ne pouvez pas modifier votre propre rôle.' });
|
||||||
}
|
}
|
||||||
const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]);
|
const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator', 'treasurer')`, [req.params.id]);
|
||||||
if (target.rowCount === 0) return res.status(404).json({ error: 'Compte introuvable' });
|
if (target.rowCount === 0) return res.status(404).json({ error: 'Compte introuvable' });
|
||||||
if (target.rows[0].role === 'admin' && req.data.role !== 'admin') {
|
if (target.rows[0].role === 'admin' && req.data.role !== 'admin') {
|
||||||
const adminCount = await query(`SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin'`);
|
const adminCount = await query(`SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin'`);
|
||||||
@@ -734,7 +736,7 @@ adminRouter.delete('/users/:id', adminOnly, validate(idParam, 'params'), async (
|
|||||||
if (req.params.id === req.user.sub) {
|
if (req.params.id === req.user.sub) {
|
||||||
return res.status(400).json({ error: 'Vous ne pouvez pas supprimer votre propre compte.' });
|
return res.status(400).json({ error: 'Vous ne pouvez pas supprimer votre propre compte.' });
|
||||||
}
|
}
|
||||||
const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]);
|
const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator', 'treasurer')`, [req.params.id]);
|
||||||
if (target.rowCount === 0) return res.status(404).json({ error: 'Compte introuvable' });
|
if (target.rowCount === 0) return res.status(404).json({ error: 'Compte introuvable' });
|
||||||
if (target.rows[0].role === 'admin') {
|
if (target.rows[0].role === 'admin') {
|
||||||
const adminCount = await query(`SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin'`);
|
const adminCount = await query(`SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin'`);
|
||||||
@@ -742,7 +744,7 @@ adminRouter.delete('/users/:id', adminOnly, validate(idParam, 'params'), async (
|
|||||||
return res.status(400).json({ error: 'Impossible de supprimer le dernier compte administrateur.' });
|
return res.status(400).json({ error: 'Impossible de supprimer le dernier compte administrateur.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await query(`DELETE FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]);
|
await query(`DELETE FROM users WHERE id = $1 AND role IN ('admin', 'moderator', 'treasurer')`, [req.params.id]);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { pool, query } from '../db.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { validate } from '../middleware/validate.js';
|
||||||
|
import {
|
||||||
|
billingSeasonParam,
|
||||||
|
billingSettingsSchema,
|
||||||
|
billingTypeSchema,
|
||||||
|
invoiceGenerationSchema,
|
||||||
|
paymentSchema,
|
||||||
|
idParam,
|
||||||
|
} from '../schemas.js';
|
||||||
|
import { associationSettings } from '../siteSettings.js';
|
||||||
|
import { buildInvoicePdf, buildPaidMembersPdf, buildPaidMembersWorkbook } from '../billingDocuments.js';
|
||||||
|
|
||||||
|
export const billingRouter = Router();
|
||||||
|
billingRouter.use(requireAuth(['admin', 'treasurer']));
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
sluc_partner_amount_ht: 0,
|
||||||
|
non_partner_amount_ht: 0,
|
||||||
|
vat_rate: 20,
|
||||||
|
payment_due_days: 30,
|
||||||
|
iban: '',
|
||||||
|
legal_mentions: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function currentSeason(date = new Date()) {
|
||||||
|
const start = date.getMonth() >= 8 ? date.getFullYear() : date.getFullYear() - 1;
|
||||||
|
return `${start}-${start + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberRow(row) {
|
||||||
|
if (!row) return row;
|
||||||
|
for (const key of ['sluc_partner_amount_ht', 'non_partner_amount_ht', 'vat_rate', 'amount_ht', 'vat_amount', 'amount_ttc']) {
|
||||||
|
if (row[key] != null) row[key] = Number(row[key]);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings(season, client = { query }) {
|
||||||
|
const result = await client.query('SELECT * FROM billing_season_settings WHERE season = $1', [season]);
|
||||||
|
return result.rows[0] ? { ...numberRow(result.rows[0]), configured: true } : { season, ...DEFAULT_SETTINGS, configured: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPaidRows(season) {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT invoice_number, member_name, billing_type, paid_at, payment_method,
|
||||||
|
amount_ht, vat_amount, amount_ttc
|
||||||
|
FROM membership_invoices
|
||||||
|
WHERE season = $1 AND status = 'payee'
|
||||||
|
ORDER BY paid_at, member_name`,
|
||||||
|
[season]
|
||||||
|
);
|
||||||
|
return result.rows.map(numberRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
billingRouter.get('/seasons', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query(`
|
||||||
|
SELECT season FROM billing_season_settings
|
||||||
|
UNION SELECT season FROM membership_invoices
|
||||||
|
ORDER BY season DESC`);
|
||||||
|
const seasons = [...new Set([currentSeason(), ...result.rows.map((row) => row.season)])].sort().reverse();
|
||||||
|
res.json({ seasons, current: currentSeason() });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
billingRouter.get('/seasons/:season', validate(billingSeasonParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { season } = req.params;
|
||||||
|
const [settings, members, stats] = await Promise.all([
|
||||||
|
loadSettings(season),
|
||||||
|
query(
|
||||||
|
`SELECT m.id, m.nom, m.dirigeant, m.email, m.adresse, m.billing_type, m.valide,
|
||||||
|
i.id AS invoice_id, i.invoice_number, i.issued_at, i.due_date,
|
||||||
|
i.amount_ht, i.vat_rate, i.vat_amount, i.amount_ttc,
|
||||||
|
i.status, i.payment_method, i.paid_at
|
||||||
|
FROM members m
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT * FROM membership_invoices mi
|
||||||
|
WHERE mi.member_id = m.id AND mi.season = $1 AND mi.status <> 'annulee'
|
||||||
|
ORDER BY mi.id DESC LIMIT 1
|
||||||
|
) i ON true
|
||||||
|
ORDER BY m.nom`,
|
||||||
|
[season]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`SELECT
|
||||||
|
(SELECT COUNT(*)::int FROM members) AS total_members,
|
||||||
|
COUNT(*) FILTER (WHERE status <> 'annulee')::int AS invoiced_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'payee')::int AS paid_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'emise')::int AS unpaid_count,
|
||||||
|
COALESCE(SUM(amount_ttc) FILTER (WHERE status <> 'annulee'), 0)::float8 AS total_invoiced,
|
||||||
|
COALESCE(SUM(amount_ttc) FILTER (WHERE status = 'payee'), 0)::float8 AS total_paid,
|
||||||
|
COALESCE(SUM(amount_ttc) FILTER (WHERE status = 'emise'), 0)::float8 AS total_outstanding,
|
||||||
|
COALESCE(SUM(vat_amount) FILTER (WHERE status = 'payee'), 0)::float8 AS vat_collected
|
||||||
|
FROM membership_invoices WHERE season = $1`,
|
||||||
|
[season]
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
res.json({
|
||||||
|
season,
|
||||||
|
settings,
|
||||||
|
members: members.rows.map(numberRow),
|
||||||
|
stats: stats.rows[0],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
billingRouter.put('/seasons/:season/settings', validate(billingSeasonParam, 'params'), validate(billingSettingsSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const d = req.data;
|
||||||
|
const iban = d.iban.replace(/\s/g, '').toUpperCase();
|
||||||
|
await query(
|
||||||
|
`INSERT INTO billing_season_settings
|
||||||
|
(season, sluc_partner_amount_ht, non_partner_amount_ht, vat_rate, payment_due_days, iban, legal_mentions)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (season) DO UPDATE SET
|
||||||
|
sluc_partner_amount_ht=EXCLUDED.sluc_partner_amount_ht,
|
||||||
|
non_partner_amount_ht=EXCLUDED.non_partner_amount_ht,
|
||||||
|
vat_rate=EXCLUDED.vat_rate,
|
||||||
|
payment_due_days=EXCLUDED.payment_due_days,
|
||||||
|
iban=EXCLUDED.iban,
|
||||||
|
legal_mentions=EXCLUDED.legal_mentions,
|
||||||
|
updated_at=now()`,
|
||||||
|
[req.params.season, d.sluc_partner_amount_ht, d.non_partner_amount_ht, d.vat_rate,
|
||||||
|
d.payment_due_days, iban, d.legal_mentions]
|
||||||
|
);
|
||||||
|
res.json({ settings: await loadSettings(req.params.season) });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
billingRouter.put('/members/:id/type', validate(idParam, 'params'), validate(billingTypeSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query(
|
||||||
|
'UPDATE members SET billing_type=$1, updated_at=now() WHERE id=$2 RETURNING id, billing_type',
|
||||||
|
[req.data.billing_type, req.params.id]
|
||||||
|
);
|
||||||
|
if (!result.rowCount) return res.status(404).json({ error: 'Membre introuvable' });
|
||||||
|
res.json({ member: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
billingRouter.post('/seasons/:season/invoices', validate(billingSeasonParam, 'params'), validate(invoiceGenerationSchema), async (req, res, next) => {
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = await pool.connect();
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const settingsResult = await client.query('SELECT * FROM billing_season_settings WHERE season=$1 FOR UPDATE', [req.params.season]);
|
||||||
|
if (!settingsResult.rowCount) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(409).json({ error: 'Configurez les tarifs de cette saison avant de générer les factures.' });
|
||||||
|
}
|
||||||
|
const settings = numberRow(settingsResult.rows[0]);
|
||||||
|
const memberIds = req.data.member_ids;
|
||||||
|
const members = await client.query(
|
||||||
|
`SELECT id, nom, adresse, email, billing_type FROM members
|
||||||
|
${memberIds ? 'WHERE id = ANY($1::int[])' : ''}
|
||||||
|
ORDER BY nom`,
|
||||||
|
memberIds ? [memberIds] : []
|
||||||
|
);
|
||||||
|
const existing = await client.query(
|
||||||
|
`SELECT member_id FROM membership_invoices
|
||||||
|
WHERE season=$1 AND status <> 'annulee' AND member_id = ANY($2::int[])`,
|
||||||
|
[req.params.season, members.rows.map((member) => member.id)]
|
||||||
|
);
|
||||||
|
const existingIds = new Set(existing.rows.map((row) => row.member_id));
|
||||||
|
const content = await client.query(`SELECT key, value FROM site_content WHERE key LIKE 'association_%'`);
|
||||||
|
const issuer = associationSettings(Object.fromEntries(content.rows.map((row) => [row.key, row.value])));
|
||||||
|
const issuerSnapshot = {
|
||||||
|
...issuer,
|
||||||
|
iban: settings.iban,
|
||||||
|
legal_mentions: settings.legal_mentions,
|
||||||
|
};
|
||||||
|
let created = 0;
|
||||||
|
for (const member of members.rows) {
|
||||||
|
if (existingIds.has(member.id)) continue;
|
||||||
|
const amountHt = member.billing_type === 'sluc_partner'
|
||||||
|
? settings.sluc_partner_amount_ht
|
||||||
|
: settings.non_partner_amount_ht;
|
||||||
|
const vatAmount = Math.round(amountHt * settings.vat_rate) / 100;
|
||||||
|
const amountTtc = Math.round((amountHt + vatAmount) * 100) / 100;
|
||||||
|
const sequence = await client.query(`SELECT nextval('billing_invoice_number_seq') AS n`);
|
||||||
|
const invoiceNumber = `FAC-${req.params.season.slice(0, 4)}-${String(sequence.rows[0].n).padStart(5, '0')}`;
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO membership_invoices
|
||||||
|
(season, member_id, invoice_number, due_date, member_name, member_address, member_email,
|
||||||
|
billing_type, amount_ht, vat_rate, vat_amount, amount_ttc, issuer_snapshot)
|
||||||
|
VALUES ($1, $2, $3, CURRENT_DATE + $4::int, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)`,
|
||||||
|
[req.params.season, member.id, invoiceNumber, settings.payment_due_days, member.nom,
|
||||||
|
member.adresse || '', member.email || '', member.billing_type, amountHt, settings.vat_rate,
|
||||||
|
vatAmount, amountTtc, JSON.stringify(issuerSnapshot)]
|
||||||
|
);
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.status(201).json({ created });
|
||||||
|
} catch (err) {
|
||||||
|
if (client) await client.query('ROLLBACK').catch(() => {});
|
||||||
|
next(err);
|
||||||
|
} finally {
|
||||||
|
client?.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
billingRouter.put('/invoices/:id/payment', validate(idParam, 'params'), validate(paymentSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE membership_invoices SET status='payee', payment_method=$1, paid_at=$2, updated_at=now()
|
||||||
|
WHERE id=$3 AND status <> 'annulee' RETURNING id`,
|
||||||
|
[req.data.payment_method, req.data.paid_at, req.params.id]
|
||||||
|
);
|
||||||
|
if (!result.rowCount) return res.status(404).json({ error: 'Facture introuvable ou annulée' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
billingRouter.delete('/invoices/:id/payment', validate(idParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE membership_invoices SET status='emise', payment_method=NULL, paid_at=NULL, updated_at=now()
|
||||||
|
WHERE id=$1 AND status='payee' RETURNING id`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
if (!result.rowCount) return res.status(404).json({ error: 'Règlement introuvable' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
billingRouter.post('/invoices/:id/cancel', validate(idParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE membership_invoices SET status='annulee', payment_method=NULL, paid_at=NULL, updated_at=now()
|
||||||
|
WHERE id=$1 AND status='emise' RETURNING id`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
if (!result.rowCount) return res.status(409).json({ error: 'Seule une facture non réglée peut être annulée.' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
billingRouter.get('/invoices/:id/pdf', validate(idParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query('SELECT * FROM membership_invoices WHERE id=$1', [req.params.id]);
|
||||||
|
if (!result.rowCount) return res.status(404).json({ error: 'Facture introuvable' });
|
||||||
|
const invoice = numberRow(result.rows[0]);
|
||||||
|
const pdf = await buildInvoicePdf(invoice);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${invoice.invoice_number}.pdf"`);
|
||||||
|
res.send(pdf);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
billingRouter.get('/seasons/:season/paid.xlsx', validate(billingSeasonParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rows = await loadPaidRows(req.params.season);
|
||||||
|
const workbook = await buildPaidMembersWorkbook({ season: req.params.season, rows });
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="adhesions-payees-${req.params.season}.xlsx"`);
|
||||||
|
res.send(workbook);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
billingRouter.get('/seasons/:season/paid.pdf', validate(billingSeasonParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rows = await loadPaidRows(req.params.season);
|
||||||
|
const pdf = await buildPaidMembersPdf({ season: req.params.season, rows });
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="adhesions-payees-${req.params.season}.pdf"`);
|
||||||
|
res.send(pdf);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
+32
-1
@@ -87,7 +87,7 @@ export const pastPhotoParam = z.object({
|
|||||||
|
|
||||||
export const categorySchema = z.object({ name: trimmed(80, 1) });
|
export const categorySchema = z.object({ name: trimmed(80, 1) });
|
||||||
|
|
||||||
export const staffRoleEnum = z.enum(['admin', 'moderator']);
|
export const staffRoleEnum = z.enum(['admin', 'moderator', 'treasurer']);
|
||||||
|
|
||||||
export const staffUserSchema = z.object({
|
export const staffUserSchema = z.object({
|
||||||
fullName: trimmed(120, 1),
|
fullName: trimmed(120, 1),
|
||||||
@@ -97,6 +97,37 @@ export const staffUserSchema = z.object({
|
|||||||
|
|
||||||
export const roleChangeSchema = z.object({ role: staffRoleEnum });
|
export const roleChangeSchema = z.object({ role: staffRoleEnum });
|
||||||
|
|
||||||
|
export const billingSeasonParam = z.object({
|
||||||
|
season: z.string().regex(/^\d{4}-\d{4}$/, 'format AAAA-AAAA'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const iban = z.string().trim().max(42).refine(
|
||||||
|
(value) => !value || /^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(value.replace(/\s/g, '').toUpperCase()),
|
||||||
|
'IBAN invalide'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const billingSettingsSchema = z.object({
|
||||||
|
sluc_partner_amount_ht: z.coerce.number().min(0).max(1000000),
|
||||||
|
non_partner_amount_ht: z.coerce.number().min(0).max(1000000),
|
||||||
|
vat_rate: z.coerce.number().min(0).max(100),
|
||||||
|
payment_due_days: z.coerce.number().int().min(0).max(365),
|
||||||
|
iban,
|
||||||
|
legal_mentions: trimmed(3000).optional().default(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const billingTypeSchema = z.object({
|
||||||
|
billing_type: z.enum(['sluc_partner', 'non_partner']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const invoiceGenerationSchema = z.object({
|
||||||
|
member_ids: z.array(z.coerce.number().int().positive()).min(1).max(10000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const paymentSchema = z.object({
|
||||||
|
payment_method: z.enum(['carte', 'virement', 'cheque']),
|
||||||
|
paid_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'format AAAA-MM-JJ'),
|
||||||
|
});
|
||||||
|
|
||||||
export const contentSchema = z.object({
|
export const contentSchema = z.object({
|
||||||
hero_quote_text: trimmed(300).optional(),
|
hero_quote_text: trimmed(300).optional(),
|
||||||
hero_quote_author: trimmed(120).optional(),
|
hero_quote_author: trimmed(120).optional(),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS members (
|
|||||||
tel TEXT CHECK (char_length(tel) <= 30),
|
tel TEXT CHECK (char_length(tel) <= 30),
|
||||||
site TEXT CHECK (char_length(site) <= 200),
|
site TEXT CHECK (char_length(site) <= 200),
|
||||||
adresse TEXT CHECK (char_length(adresse) <= 300),
|
adresse TEXT CHECK (char_length(adresse) <= 300),
|
||||||
|
billing_type TEXT NOT NULL DEFAULT 'non_partner' CHECK (billing_type IN ('sluc_partner', 'non_partner')),
|
||||||
presentation TEXT NOT NULL DEFAULT '' CHECK (char_length(presentation) <= 2000),
|
presentation TEXT NOT NULL DEFAULT '' CHECK (char_length(presentation) <= 2000),
|
||||||
valide BOOLEAN NOT NULL DEFAULT false,
|
valide BOOLEAN NOT NULL DEFAULT false,
|
||||||
logo_path TEXT,
|
logo_path TEXT,
|
||||||
@@ -25,14 +26,17 @@ CREATE TABLE IF NOT EXISTS members (
|
|||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS adresse TEXT CHECK (char_length(adresse) <= 300);
|
ALTER TABLE members ADD COLUMN IF NOT EXISTS adresse TEXT CHECK (char_length(adresse) <= 300);
|
||||||
|
ALTER TABLE members ADD COLUMN IF NOT EXISTS billing_type TEXT NOT NULL DEFAULT 'non_partner';
|
||||||
|
ALTER TABLE members DROP CONSTRAINT IF EXISTS members_billing_type_check;
|
||||||
|
ALTER TABLE members ADD CONSTRAINT members_billing_type_check CHECK (billing_type IN ('sluc_partner', 'non_partner'));
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
email CITEXT NOT NULL UNIQUE CHECK (char_length(email) <= 254),
|
email CITEXT NOT NULL UNIQUE CHECK (char_length(email) <= 254),
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
role TEXT NOT NULL CHECK (role IN ('member', 'admin', 'moderator')),
|
role TEXT NOT NULL CHECK (role IN ('member', 'admin', 'moderator', 'treasurer')),
|
||||||
member_id INTEGER UNIQUE REFERENCES members(id) ON DELETE CASCADE,
|
member_id INTEGER UNIQUE REFERENCES members(id) ON DELETE CASCADE,
|
||||||
-- Display name for admin/moderator accounts (member accounts show their
|
-- Display name for staff accounts (member accounts show their
|
||||||
-- name via the linked members row instead, this stays NULL for them).
|
-- name via the linked members row instead, this stays NULL for them).
|
||||||
full_name TEXT,
|
full_name TEXT,
|
||||||
-- Temporary password shown to the admin (create / reset access), kept
|
-- Temporary password shown to the admin (create / reset access), kept
|
||||||
@@ -48,7 +52,7 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS temp_password TEXT;
|
|||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT false;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT false;
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS full_name TEXT;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS full_name TEXT;
|
||||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||||
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('member', 'admin', 'moderator'));
|
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('member', 'admin', 'moderator', 'treasurer'));
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS rencontres (
|
CREATE TABLE IF NOT EXISTS rencontres (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -158,3 +162,46 @@ CREATE TABLE IF NOT EXISTS site_content (
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL DEFAULT ''
|
value TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Seasonal membership billing settings and immutable invoice snapshots.
|
||||||
|
CREATE TABLE IF NOT EXISTS billing_season_settings (
|
||||||
|
season TEXT PRIMARY KEY CHECK (season ~ '^[0-9]{4}-[0-9]{4}$'),
|
||||||
|
sluc_partner_amount_ht NUMERIC(12,2) NOT NULL DEFAULT 0 CHECK (sluc_partner_amount_ht >= 0),
|
||||||
|
non_partner_amount_ht NUMERIC(12,2) NOT NULL DEFAULT 0 CHECK (non_partner_amount_ht >= 0),
|
||||||
|
vat_rate NUMERIC(5,2) NOT NULL DEFAULT 20 CHECK (vat_rate BETWEEN 0 AND 100),
|
||||||
|
payment_due_days INTEGER NOT NULL DEFAULT 30 CHECK (payment_due_days BETWEEN 0 AND 365),
|
||||||
|
iban TEXT NOT NULL DEFAULT '' CHECK (char_length(iban) <= 42),
|
||||||
|
legal_mentions TEXT NOT NULL DEFAULT '' CHECK (char_length(legal_mentions) <= 3000),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS billing_invoice_number_seq START WITH 1;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS membership_invoices (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
season TEXT NOT NULL REFERENCES billing_season_settings(season) ON DELETE RESTRICT,
|
||||||
|
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE RESTRICT,
|
||||||
|
invoice_number TEXT NOT NULL UNIQUE,
|
||||||
|
issued_at DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
due_date DATE NOT NULL,
|
||||||
|
member_name TEXT NOT NULL,
|
||||||
|
member_address TEXT NOT NULL DEFAULT '',
|
||||||
|
member_email TEXT NOT NULL DEFAULT '',
|
||||||
|
billing_type TEXT NOT NULL CHECK (billing_type IN ('sluc_partner', 'non_partner')),
|
||||||
|
amount_ht NUMERIC(12,2) NOT NULL CHECK (amount_ht >= 0),
|
||||||
|
vat_rate NUMERIC(5,2) NOT NULL CHECK (vat_rate BETWEEN 0 AND 100),
|
||||||
|
vat_amount NUMERIC(12,2) NOT NULL CHECK (vat_amount >= 0),
|
||||||
|
amount_ttc NUMERIC(12,2) NOT NULL CHECK (amount_ttc >= 0),
|
||||||
|
issuer_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
status TEXT NOT NULL DEFAULT 'emise' CHECK (status IN ('emise', 'payee', 'annulee')),
|
||||||
|
payment_method TEXT CHECK (payment_method IN ('carte', 'virement', 'cheque')),
|
||||||
|
paid_at DATE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CHECK ((status = 'payee' AND payment_method IS NOT NULL AND paid_at IS NOT NULL) OR status <> 'payee')
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_membership_invoices_season ON membership_invoices(season);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_membership_invoices_member ON membership_invoices(member_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_membership_invoices_active_member_season
|
||||||
|
ON membership_invoices(member_id, season) WHERE status <> 'annulee';
|
||||||
@@ -3,9 +3,9 @@ import { query } from './db.js';
|
|||||||
import { generateTempPassword } from './passwords.js';
|
import { generateTempPassword } from './passwords.js';
|
||||||
import { AccessError } from './errors.js';
|
import { AccessError } from './errors.js';
|
||||||
|
|
||||||
const STAFF_ROLES = ['admin', 'moderator'];
|
const STAFF_ROLES = ['admin', 'moderator', 'treasurer'];
|
||||||
|
|
||||||
// Creates an admin or moderator account with a temporary password — same
|
// Creates a staff account with a temporary password — same
|
||||||
// readable-until-changed mechanic as member accounts (see memberAccess.js).
|
// readable-until-changed mechanic as member accounts (see memberAccess.js).
|
||||||
export async function createStaffUser({ fullName, email, role }) {
|
export async function createStaffUser({ fullName, email, role }) {
|
||||||
if (!STAFF_ROLES.includes(role)) throw new AccessError(400, 'Rôle invalide.');
|
if (!STAFF_ROLES.includes(role)) throw new AccessError(400, 'Rôle invalide.');
|
||||||
@@ -24,14 +24,14 @@ export async function createStaffUser({ fullName, email, role }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regenerates the temporary password for an existing admin/moderator
|
// Regenerates the temporary password for an existing staff
|
||||||
// account (e.g. they lost it).
|
// account (e.g. they lost it).
|
||||||
export async function resetStaffAccess(userId) {
|
export async function resetStaffAccess(userId) {
|
||||||
const tempPassword = generateTempPassword();
|
const tempPassword = generateTempPassword();
|
||||||
const hash = await bcrypt.hash(tempPassword, 12);
|
const hash = await bcrypt.hash(tempPassword, 12);
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`UPDATE users SET password_hash=$1, temp_password=$2, must_change_password=true
|
`UPDATE users SET password_hash=$1, temp_password=$2, must_change_password=true
|
||||||
WHERE id=$3 AND role IN ('admin', 'moderator') RETURNING id`,
|
WHERE id=$3 AND role IN ('admin', 'moderator', 'treasurer') RETURNING id`,
|
||||||
[hash, tempPassword, userId]
|
[hash, tempPassword, userId]
|
||||||
);
|
);
|
||||||
if (result.rowCount === 0) throw new AccessError(404, 'Compte introuvable.');
|
if (result.rowCount === 0) throw new AccessError(404, 'Compte introuvable.');
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ export default function App() {
|
|||||||
const isAdminBackend =
|
const isAdminBackend =
|
||||||
location.pathname.startsWith('/admin') ||
|
location.pathname.startsWith('/admin') ||
|
||||||
(location.pathname === '/espace-membre' &&
|
(location.pathname === '/espace-membre' &&
|
||||||
(user?.role === 'admin' || user?.role === 'moderator') &&
|
(user?.role === 'admin' || user?.role === 'moderator' || user?.role === 'treasurer') &&
|
||||||
!user?.mustChangePassword);
|
!user?.mustChangePassword);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { api } from '../../lib/api.js';
|
||||||
|
import Modal from '../Modal.jsx';
|
||||||
|
|
||||||
|
const TYPE_LABEL = {
|
||||||
|
sluc_partner: 'Partenaire SLUC',
|
||||||
|
non_partner: 'Non partenaire SLUC',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAYMENT_LABEL = {
|
||||||
|
carte: 'Carte bleue',
|
||||||
|
virement: 'Virement',
|
||||||
|
cheque: 'Chèque',
|
||||||
|
};
|
||||||
|
|
||||||
|
const euro = (value) => new Intl.NumberFormat('fr-FR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(Number(value || 0));
|
||||||
|
|
||||||
|
const dateInput = (value) => value ? String(value).slice(0, 10) : '';
|
||||||
|
const dateLabel = (value) => value
|
||||||
|
? new Date(`${String(value).slice(0, 10)}T00:00:00`).toLocaleDateString('fr-FR')
|
||||||
|
: '—';
|
||||||
|
const seasonLabel = (season) => season?.replace('-', '–') || '';
|
||||||
|
|
||||||
|
function SettingsPanel({ season, settings, onSaved, onClose }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
sluc_partner_amount_ht: settings.sluc_partner_amount_ht ?? 0,
|
||||||
|
non_partner_amount_ht: settings.non_partner_amount_ht ?? 0,
|
||||||
|
vat_rate: settings.vat_rate ?? 20,
|
||||||
|
payment_due_days: settings.payment_due_days ?? 30,
|
||||||
|
iban: settings.iban || '',
|
||||||
|
legal_mentions: settings.legal_mentions || '',
|
||||||
|
});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const change = (event) => setForm({ ...form, [event.target.name]: event.target.value });
|
||||||
|
|
||||||
|
const submit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.put(`/api/admin/billing/seasons/${season}/settings`, form);
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ borderRadius: 6, padding: 26, marginBottom: 22 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 20, marginBottom: 22 }}>
|
||||||
|
<div>
|
||||||
|
<h3 className="serif" style={{ fontSize: 20, fontWeight: 600 }}>Paramètres de la saison {seasonLabel(season)}</h3>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 5 }}>Les montants sont saisis hors taxes. La TVA et le TTC sont calculés à la génération.</p>
|
||||||
|
</div>
|
||||||
|
{settings.configured && <button type="button" className="btn-link-gray" onClick={onClose}>Fermer</button>}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="grid-4" style={{ gap: 12 }}>
|
||||||
|
<label className="field">Partenaire SLUC - HT
|
||||||
|
<input name="sluc_partner_amount_ht" type="number" min="0" max="1000000" step="0.01" value={form.sluc_partner_amount_ht} onChange={change} required />
|
||||||
|
</label>
|
||||||
|
<label className="field">Non partenaire - HT
|
||||||
|
<input name="non_partner_amount_ht" type="number" min="0" max="1000000" step="0.01" value={form.non_partner_amount_ht} onChange={change} required />
|
||||||
|
</label>
|
||||||
|
<label className="field">TVA
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input name="vat_rate" type="number" min="0" max="100" step="0.01" value={form.vat_rate} onChange={change} required style={{ paddingRight: 32 }} />
|
||||||
|
<span style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--gray-light)' }}>%</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="field">Échéance
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input name="payment_due_days" type="number" min="0" max="365" step="1" value={form.payment_due_days} onChange={change} required style={{ paddingRight: 52 }} />
|
||||||
|
<span style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--gray-light)', fontSize: 12 }}>jours</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field" style={{ marginTop: 14 }}>IBAN à afficher sur les factures
|
||||||
|
<input name="iban" value={form.iban} onChange={change} maxLength={42} placeholder="FR76…" />
|
||||||
|
</label>
|
||||||
|
<label className="field" style={{ marginTop: 14 }}>Mentions légales des factures
|
||||||
|
<textarea name="legal_mentions" value={form.legal_mentions} onChange={change} rows={4} maxLength={3000} placeholder="Conditions de règlement, pénalités de retard, indemnité forfaitaire…" />
|
||||||
|
</label>
|
||||||
|
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||||
|
<button type="submit" className="btn btn-red btn-sm" disabled={busy} style={{ marginTop: 18, fontSize: 14, padding: '12px 22px' }}>
|
||||||
|
{busy ? 'Enregistrement…' : 'Enregistrer les paramètres'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentModal({ member, onClose, onSaved }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
payment_method: member.payment_method || 'virement',
|
||||||
|
paid_at: dateInput(member.paid_at) || new Date().toISOString().slice(0, 10),
|
||||||
|
});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const submit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.put(`/api/admin/billing/invoices/${member.invoice_id}/payment`, form);
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePayment = async () => {
|
||||||
|
if (!window.confirm('Retirer ce règlement et repasser la facture en attente ?')) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.del(`/api/admin/billing/invoices/${member.invoice_id}/payment`);
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose} maxWidth={480} header={{ kicker: 'Règlement', title: member.nom, meta: `${member.invoice_number} · ${euro(member.amount_ttc)}` }}>
|
||||||
|
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<label className="field">Mode de règlement
|
||||||
|
<select name="payment_method" value={form.payment_method} onChange={(event) => setForm({ ...form, payment_method: event.target.value })}>
|
||||||
|
<option value="carte">Carte bleue</option>
|
||||||
|
<option value="virement">Virement</option>
|
||||||
|
<option value="cheque">Chèque</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">Date de règlement
|
||||||
|
<input name="paid_at" type="date" value={form.paid_at} onChange={(event) => setForm({ ...form, paid_at: event.target.value })} required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginTop: 22 }}>
|
||||||
|
<button type="submit" className="btn btn-red btn-sm" disabled={busy} style={{ flex: 1, fontSize: 14, padding: 12 }}>
|
||||||
|
{busy ? 'Enregistrement…' : member.status === 'payee' ? 'Modifier le règlement' : 'Enregistrer le règlement'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-outline-soft btn-sm" onClick={onClose}>Annuler</button>
|
||||||
|
</div>
|
||||||
|
{member.status === 'payee' && (
|
||||||
|
<button type="button" className="btn-link-gray" disabled={busy} onClick={removePayment} style={{ marginTop: 18, color: 'var(--red)' }}>
|
||||||
|
Retirer ce règlement
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BillingTab() {
|
||||||
|
const [seasons, setSeasons] = useState([]);
|
||||||
|
const [season, setSeason] = useState('');
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [paymentMember, setPaymentMember] = useState(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
if (!season) return Promise.resolve();
|
||||||
|
return api.get(`/api/admin/billing/seasons/${season}`)
|
||||||
|
.then((result) => {
|
||||||
|
setData(result);
|
||||||
|
if (!result.settings.configured) setSettingsOpen(true);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/api/admin/billing/seasons')
|
||||||
|
.then((result) => {
|
||||||
|
setSeasons(result.seasons);
|
||||||
|
setSeason(result.current);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setData(null);
|
||||||
|
setError('');
|
||||||
|
reload();
|
||||||
|
}, [season]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
if (!query) return data?.members || [];
|
||||||
|
return (data?.members || []).filter((member) => `${member.nom} ${member.dirigeant || ''} ${member.email || ''}`.toLowerCase().includes(query));
|
||||||
|
}, [data, search]);
|
||||||
|
|
||||||
|
const updateType = async (member, billingType) => {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.put(`/api/admin/billing/members/${member.id}/type`, { billing_type: billingType });
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateInvoices = async (memberIds) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await api.post(`/api/admin/billing/seasons/${season}/invoices`, memberIds ? { member_ids: memberIds } : {});
|
||||||
|
await reload();
|
||||||
|
if (!result.created) setError('Toutes les factures concernées existent déjà.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelInvoice = async (member) => {
|
||||||
|
if (!window.confirm(`Annuler la facture ${member.invoice_number} ? Elle restera dans l'historique comptable.`)) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.post(`/api/admin/billing/invoices/${member.invoice_id}/cancel`);
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!season || !data) return <p style={{ color: 'var(--gray-light)' }}>{error || 'Chargement de la facturation…'}</p>;
|
||||||
|
|
||||||
|
const stats = data.stats;
|
||||||
|
const recovery = Number(stats.total_invoiced) > 0
|
||||||
|
? Math.round((Number(stats.total_paid) / Number(stats.total_invoiced)) * 100)
|
||||||
|
: 0;
|
||||||
|
const missingCount = data.members.filter((member) => !member.invoice_id).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 18, flexWrap: 'wrap', marginBottom: 22 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--gray-light)', textTransform: 'uppercase', letterSpacing: '.08em' }}>Saison</label>
|
||||||
|
<select value={season} onChange={(event) => setSeason(event.target.value)} style={{ minWidth: 150, padding: '10px 12px', border: '1px solid var(--input-border)', borderRadius: 3, background: '#fff', fontWeight: 600 }}>
|
||||||
|
{seasons.map((item) => <option key={item} value={item}>{seasonLabel(item)}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button className="btn btn-outline-soft btn-sm" onClick={() => setSettingsOpen((open) => !open)}>⚙ Paramètres</button>
|
||||||
|
{stats.paid_count > 0 && <>
|
||||||
|
<a className="btn btn-outline-soft btn-sm" href={`/api/admin/billing/seasons/${season}/paid.xlsx`} style={{ textDecoration: 'none' }}>↓ Excel</a>
|
||||||
|
<a className="btn btn-outline-soft btn-sm" href={`/api/admin/billing/seasons/${season}/paid.pdf`} style={{ textDecoration: 'none' }}>↓ PDF</a>
|
||||||
|
</>}
|
||||||
|
<button className="btn btn-red btn-sm" disabled={busy || !data.settings.configured || missingCount === 0} onClick={() => generateInvoices()}>
|
||||||
|
{busy ? 'Traitement…' : `Générer les factures manquantes (${missingCount})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settingsOpen && (
|
||||||
|
<SettingsPanel
|
||||||
|
season={season}
|
||||||
|
settings={data.settings}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
onSaved={async () => { await reload(); setSettingsOpen(false); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!data.settings.configured && (
|
||||||
|
<div style={{ padding: '14px 18px', background: '#FBF0E6', border: '1px solid #F0D6BC', borderRadius: 6, color: '#8A5A22', marginBottom: 22, fontSize: 13.5 }}>
|
||||||
|
Configurez les tarifs, la TVA et l'IBAN avant de générer les factures de cette saison.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid-4" style={{ marginBottom: 22 }}>
|
||||||
|
{[
|
||||||
|
['Facturé TTC', euro(stats.total_invoiced), `${stats.invoiced_count} facture${stats.invoiced_count > 1 ? 's' : ''}`],
|
||||||
|
['Encaissé', euro(stats.total_paid), `${stats.paid_count} adhésion${stats.paid_count > 1 ? 's' : ''} à jour`],
|
||||||
|
['À encaisser', euro(stats.total_outstanding), `${stats.unpaid_count} règlement${stats.unpaid_count > 1 ? 's' : ''} attendu${stats.unpaid_count > 1 ? 's' : ''}`],
|
||||||
|
['Taux de règlement', `${recovery} %`, `TVA encaissée ${euro(stats.vat_collected)}`],
|
||||||
|
].map(([label, value, detail]) => (
|
||||||
|
<div key={label} className="card" style={{ borderRadius: 6, padding: 22 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--gray-light)', marginBottom: 10 }}>{label}</div>
|
||||||
|
<div className="serif" style={{ fontSize: 28, fontWeight: 600 }}>{value}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: 'var(--red)', marginTop: 8 }}>{detail}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error-text" style={{ marginBottom: 14 }}>{error}</p>}
|
||||||
|
|
||||||
|
<div className="card" style={{ borderRadius: 6, overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 14, flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600 }}>Suivi des adhésions</h3>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--gray-light)', marginTop: 3 }}>{stats.paid_count} à jour sur {stats.total_members} membres</div>
|
||||||
|
</div>
|
||||||
|
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Rechercher un membre…" style={{ width: 260, padding: '10px 12px', border: '1px solid var(--input-border)', borderRadius: 3, background: '#fff' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table className="table" style={{ minWidth: 1050 }}>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Entreprise</th><th>Type d'adhésion</th><th>Montant</th><th>Facture</th><th>Situation</th><th>Règlement</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((member) => {
|
||||||
|
const expectedHt = member.billing_type === 'sluc_partner'
|
||||||
|
? data.settings.sluc_partner_amount_ht
|
||||||
|
: data.settings.non_partner_amount_ht;
|
||||||
|
return (
|
||||||
|
<tr key={member.id}>
|
||||||
|
<td><strong>{member.nom}</strong><div style={{ fontSize: 11.5, color: 'var(--gray-light)', marginTop: 3 }}>{member.dirigeant}</div></td>
|
||||||
|
<td>
|
||||||
|
<select value={member.billing_type} disabled={Boolean(member.invoice_id)} onChange={(event) => updateType(member, event.target.value)} title={member.invoice_id ? 'Le type est figé pour la facture émise' : ''} style={{ minWidth: 170, padding: '8px 9px', border: '1px solid var(--input-border)', borderRadius: 3, background: member.invoice_id ? 'var(--admin-bg)' : '#fff', fontSize: 12.5 }}>
|
||||||
|
<option value="sluc_partner">Partenaire SLUC</option>
|
||||||
|
<option value="non_partner">Non partenaire SLUC</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>{member.invoice_id ? euro(member.amount_ttc) : <><strong>{euro(expectedHt)}</strong><small style={{ display: 'block', color: 'var(--gray-light)', marginTop: 3 }}>HT avant TVA</small></>}</td>
|
||||||
|
<td>
|
||||||
|
{member.invoice_id ? <>
|
||||||
|
<div style={{ fontSize: 12.5, fontWeight: 600 }}>{member.invoice_number}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: 'var(--gray-light)', marginTop: 3 }}>{dateLabel(member.issued_at)}</div>
|
||||||
|
</> : <span style={{ color: 'var(--gray-light)' }}>Non générée</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{!member.invoice_id && <span className="badge badge-amber">À facturer</span>}
|
||||||
|
{member.status === 'emise' && <span className="badge badge-red">À régler</span>}
|
||||||
|
{member.status === 'payee' && <span className="badge badge-green">À jour</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{member.status === 'payee' ? <>
|
||||||
|
<div style={{ fontSize: 12.5, fontWeight: 600 }}>{PAYMENT_LABEL[member.payment_method]}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: 'var(--gray-light)', marginTop: 3 }}>{dateLabel(member.paid_at)}</div>
|
||||||
|
</> : member.status === 'emise' ? <span style={{ fontSize: 12.5, color: 'var(--gray-light)' }}>Échéance {dateLabel(member.due_date)}</span> : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
|
{!member.invoice_id && <button className="btn-link" disabled={busy || !data.settings.configured} onClick={() => generateInvoices([member.id])}>Générer</button>}
|
||||||
|
{member.invoice_id && <a className="btn-link" href={`/api/admin/billing/invoices/${member.invoice_id}/pdf`} style={{ marginRight: 12 }}>PDF</a>}
|
||||||
|
{member.invoice_id && <button className="btn-link" onClick={() => setPaymentMember(member)}>{member.status === 'payee' ? 'Modifier' : 'Régler'}</button>}
|
||||||
|
{member.status === 'emise' && <button className="btn-link-gray" disabled={busy} onClick={() => cancelInvoice(member)} style={{ marginLeft: 12, color: 'var(--red)' }}>Annuler</button>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{!filtered.length && <div style={{ padding: 42, textAlign: 'center', color: 'var(--gray-light)' }}>Aucun membre trouvé.</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{paymentMember && (
|
||||||
|
<PaymentModal
|
||||||
|
member={paymentMember}
|
||||||
|
onClose={() => setPaymentMember(null)}
|
||||||
|
onSaved={async () => { setPaymentMember(null); await reload(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { api } from '../../lib/api.js';
|
|||||||
import Modal from '../Modal.jsx';
|
import Modal from '../Modal.jsx';
|
||||||
import { AccessCell, CredentialsModal } from './AccessControls.jsx';
|
import { AccessCell, CredentialsModal } from './AccessControls.jsx';
|
||||||
|
|
||||||
const ROLE_LABEL = { admin: 'Administrateur', moderator: 'Modérateur' };
|
const ROLE_LABEL = { admin: 'Administrateur', moderator: 'Modérateur', treasurer: 'Trésorier' };
|
||||||
|
|
||||||
function UserFormModal({ onClose, onCreated }) {
|
function UserFormModal({ onClose, onCreated }) {
|
||||||
const [form, setForm] = useState({ fullName: '', email: '', role: 'moderator' });
|
const [form, setForm] = useState({ fullName: '', email: '', role: 'moderator' });
|
||||||
@@ -35,6 +35,7 @@ function UserFormModal({ onClose, onCreated }) {
|
|||||||
<label className="field">Rôle
|
<label className="field">Rôle
|
||||||
<select name="role" value={form.role} onChange={onChange}>
|
<select name="role" value={form.role} onChange={onChange}>
|
||||||
<option value="moderator">Modérateur — membres, rencontres, inscriptions</option>
|
<option value="moderator">Modérateur — membres, rencontres, inscriptions</option>
|
||||||
|
<option value="treasurer">Trésorier — mêmes accès + facturation</option>
|
||||||
<option value="admin">Administrateur — accès complet</option>
|
<option value="admin">Administrateur — accès complet</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -101,10 +102,10 @@ export function UsersTab() {
|
|||||||
<div className="card" style={{ borderRadius: 6, overflow: 'hidden' }}>
|
<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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 24px', borderBottom: '1px solid var(--border)' }}>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600 }}>Administrateurs & modérateurs</h3>
|
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600 }}>Comptes du back-office</h3>
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--gray-light)', marginTop: 3, maxWidth: 520, lineHeight: 1.5 }}>
|
<div style={{ fontSize: 12.5, color: 'var(--gray-light)', marginTop: 3, maxWidth: 520, lineHeight: 1.5 }}>
|
||||||
Les modérateurs gèrent les membres, les rencontres et les inscriptions. Les administrateurs
|
Les modérateurs gèrent les membres et les rencontres. Les trésoriers disposent en plus de la
|
||||||
ont accès à l'ensemble du back-office.
|
facturation. Les administrateurs ont accès à l'ensemble du back-office.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-red btn-sm" style={{ fontSize: 13, padding: '10px 18px' }} onClick={() => setModal(true)}>
|
<button className="btn btn-red btn-sm" style={{ fontSize: 13, padding: '10px 18px' }} onClick={() => setModal(true)}>
|
||||||
@@ -142,13 +143,16 @@ export function UsersTab() {
|
|||||||
<span style={{ fontSize: 12, color: 'var(--gray-light)' }}>Vous</span>
|
<span style={{ fontSize: 12, color: 'var(--gray-light)' }}>Vous</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<select
|
||||||
className="btn-link-gray"
|
value={u.role}
|
||||||
style={{ color: 'var(--gray)', marginRight: 14 }}
|
onChange={(event) => changeRole(u, event.target.value)}
|
||||||
onClick={() => changeRole(u, u.role === 'admin' ? 'moderator' : 'admin')}
|
aria-label={`Rôle de ${u.full_name || u.email}`}
|
||||||
|
style={{ width: 145, marginRight: 14, padding: '7px 9px', border: '1px solid var(--input-border)', borderRadius: 3, background: '#fff', fontSize: 12.5 }}
|
||||||
>
|
>
|
||||||
{u.role === 'admin' ? 'Passer modérateur' : 'Passer admin'}
|
<option value="moderator">Modérateur</option>
|
||||||
</button>
|
<option value="treasurer">Trésorier</option>
|
||||||
|
<option value="admin">Administrateur</option>
|
||||||
|
</select>
|
||||||
<button className="btn-link" style={{ fontSize: 13 }} onClick={() => remove(u)}>Supprimer</button>
|
<button className="btn-link" style={{ fontSize: 13 }} onClick={() => remove(u)}>Supprimer</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+11
-7
@@ -8,6 +8,7 @@ import { PastEventsTab } from '../components/admin/AdminPast.jsx';
|
|||||||
import { UsersTab } from '../components/admin/AdminUsers.jsx';
|
import { UsersTab } from '../components/admin/AdminUsers.jsx';
|
||||||
import { EmailCreatorTab } from '../components/admin/AdminEmail.jsx';
|
import { EmailCreatorTab } from '../components/admin/AdminEmail.jsx';
|
||||||
import { RequestsTab } from '../components/admin/AdminRequests.jsx';
|
import { RequestsTab } from '../components/admin/AdminRequests.jsx';
|
||||||
|
import { BillingTab } from '../components/admin/AdminBilling.jsx';
|
||||||
|
|
||||||
// Moderators get members/membership requests/rencontres/inscriptions/rencontres passées
|
// Moderators get members/membership requests/rencontres/inscriptions/rencontres passées
|
||||||
// (create + edit, never delete) — everything else (dashboard, taxonomy,
|
// (create + edit, never delete) — everything else (dashboard, taxonomy,
|
||||||
@@ -15,16 +16,17 @@ import { RequestsTab } from '../components/admin/AdminRequests.jsx';
|
|||||||
// here (sidebar) and server-side (routes).
|
// here (sidebar) and server-side (routes).
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'dashboard', icon: '◧', label: 'Tableau de bord', title: 'Tableau de bord', roles: ['admin'] },
|
{ key: 'dashboard', icon: '◧', label: 'Tableau de bord', title: 'Tableau de bord', roles: ['admin'] },
|
||||||
{ key: 'membres', icon: '▤', label: 'Membres', title: 'Gestion des membres', roles: ['admin', 'moderator'] },
|
{ key: 'membres', icon: '▤', label: 'Membres', title: 'Gestion des membres', roles: ['admin', 'moderator', 'treasurer'] },
|
||||||
{ key: 'demandes', icon: '◌', label: 'Demandes', title: "Demandes d’adhésion", roles: ['admin', 'moderator'] },
|
{ key: 'demandes', icon: '◌', label: 'Demandes', title: "Demandes d’adhésion", roles: ['admin', 'moderator', 'treasurer'] },
|
||||||
{ key: 'rencontres', icon: '◈', label: 'Rencontres', title: 'Rencontres', roles: ['admin', 'moderator'] },
|
{ key: 'rencontres', icon: '◈', label: 'Rencontres', title: 'Rencontres', roles: ['admin', 'moderator', 'treasurer'] },
|
||||||
{ key: 'passees', icon: '▦', label: 'Rencontres passées', title: 'Rencontres passées', roles: ['admin', 'moderator'] },
|
{ key: 'passees', icon: '▦', label: 'Rencontres passées', title: 'Rencontres passées', roles: ['admin', 'moderator', 'treasurer'] },
|
||||||
{ key: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions', roles: ['admin', 'moderator'] },
|
{ key: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions', roles: ['admin', 'moderator', 'treasurer'] },
|
||||||
|
{ key: 'facturation', icon: '€', label: 'Facturation', title: 'Facturation des adhésions', roles: ['admin', 'treasurer'] },
|
||||||
{ key: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories', roles: ['admin'] },
|
{ key: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories', roles: ['admin'] },
|
||||||
{ key: 'contenu', icon: '▧', label: 'Contenu du site', title: 'Contenu du site', roles: ['admin'] },
|
{ key: 'contenu', icon: '▧', label: 'Contenu du site', title: 'Contenu du site', roles: ['admin'] },
|
||||||
{ key: 'parametres', icon: '⚙', label: 'Paramètres', title: "Paramètres de l'association", roles: ['admin'] },
|
{ key: 'parametres', icon: '⚙', label: 'Paramètres', title: "Paramètres de l'association", roles: ['admin'] },
|
||||||
{ key: 'emails', icon: '✉', label: "Créateur d'e-mail", title: "Créateur d'e-mail", roles: ['admin'] },
|
{ key: 'emails', icon: '✉', label: "Créateur d'e-mail", title: "Créateur d'e-mail", roles: ['admin'] },
|
||||||
{ key: 'utilisateurs', icon: '⚿', label: 'Administrateurs', title: 'Administrateurs & modérateurs', roles: ['admin'] },
|
{ key: 'utilisateurs', icon: '⚿', label: 'Utilisateurs', title: 'Administrateurs, trésoriers & modérateurs', roles: ['admin'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
@@ -108,6 +110,7 @@ export function AdminShell() {
|
|||||||
|
|
||||||
const current = tabs.find((t) => t.key === tab) || tabs[0];
|
const current = tabs.find((t) => t.key === tab) || tabs[0];
|
||||||
const today = new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
const today = new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
const roleLabel = { admin: 'Admin', moderator: 'Modérateur', treasurer: 'Trésorier' }[user.role];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-shell" style={{ display: 'flex', minHeight: '100vh', background: 'var(--admin-bg)' }}>
|
<div className="admin-shell" style={{ display: 'flex', minHeight: '100vh', background: 'var(--admin-bg)' }}>
|
||||||
@@ -116,7 +119,7 @@ export function AdminShell() {
|
|||||||
<img src="/assets/logo.jpg" alt="SLUC" style={{ height: 40, width: 40, objectFit: 'cover', borderRadius: 4, background: '#fff' }} />
|
<img src="/assets/logo.jpg" alt="SLUC" style={{ height: 40, width: 40, objectFit: 'cover', borderRadius: 4, background: '#fff' }} />
|
||||||
<div style={{ lineHeight: 1.1 }}>
|
<div style={{ lineHeight: 1.1 }}>
|
||||||
<div className="serif" style={{ fontSize: 16, fontWeight: 600 }}>
|
<div className="serif" style={{ fontSize: 16, fontWeight: 600 }}>
|
||||||
{user.role === 'admin' ? 'Admin' : 'Modérateur'}
|
{roleLabel}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, letterSpacing: '.14em', textTransform: 'uppercase', color: '#8A8279' }}>Business Club</div>
|
<div style={{ fontSize: 10, letterSpacing: '.14em', textTransform: 'uppercase', color: '#8A8279' }}>Business Club</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,6 +157,7 @@ export function AdminShell() {
|
|||||||
{tab === 'rencontres' && <RencontresTab />}
|
{tab === 'rencontres' && <RencontresTab />}
|
||||||
{tab === 'passees' && <PastEventsTab />}
|
{tab === 'passees' && <PastEventsTab />}
|
||||||
{tab === 'inscriptions' && <InscriptionsTab />}
|
{tab === 'inscriptions' && <InscriptionsTab />}
|
||||||
|
{tab === 'facturation' && <BillingTab />}
|
||||||
{tab === 'categories' && <CategoriesTab />}
|
{tab === 'categories' && <CategoriesTab />}
|
||||||
{tab === 'contenu' && <ContenuTab />}
|
{tab === 'contenu' && <ContenuTab />}
|
||||||
{tab === 'parametres' && <ParametresTab />}
|
{tab === 'parametres' && <ParametresTab />}
|
||||||
|
|||||||
@@ -347,6 +347,6 @@ export default function Espace() {
|
|||||||
if (loading) return <main style={{ minHeight: '60vh' }} />;
|
if (loading) return <main style={{ minHeight: '60vh' }} />;
|
||||||
if (!user) return <main><LoginSection /></main>;
|
if (!user) return <main><LoginSection /></main>;
|
||||||
if (user.mustChangePassword) return <main><ForcedPasswordChange /></main>;
|
if (user.mustChangePassword) return <main><ForcedPasswordChange /></main>;
|
||||||
if (user.role === 'admin' || user.role === 'moderator') return <AdminShell />;
|
if (user.role === 'admin' || user.role === 'moderator' || user.role === 'treasurer') return <AdminShell />;
|
||||||
return <main><Portal /></main>;
|
return <main><Portal /></main>;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user