feat: RLA API v1.0.0 — API complète + exports + thème McKinsey

- 9 endpoints métier : synthese, alertes, en-service, en-cours,
  par-region, clotures, pilotage-proactif, matrice-risque
- Exports PDF (tous rôles) / XLSX PPTX DOCX (superadmin)
- services/calc.js : helpers normalisés partagés
- services/export-pdf.js : PDF async PDFKit par vue
- Thème McKinsey (#1C2B4B / bleu pétrole / gris sobre)
- Boutons XLSX/DOCX front (superadmin uniquement)
- BASEROW_API_URL → https://baserow.bolbol.tn/api/
- dotenv override: true

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nabil Derouiche
2026-03-12 23:47:10 +01:00
co-authored by Claude Sonnet 4.6
commit 88a0dbe6d2
39 changed files with 7622 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
const axios = require('axios');
const BASE_URL = process.env.BASEROW_API_URL;
const TOKEN = process.env.BASEROW_TOKEN;
const TABLE_MARCHES = process.env.BASEROW_TABLE_MARCHES;
const TABLE_PIPELINE = process.env.BASEROW_TABLE_PIPELINE;
const client = axios.create({
baseURL: BASE_URL,
headers: { Authorization: `Token ${TOKEN}` },
});
// Récupère toutes les lignes d'une table (gestion pagination)
async function fetchAllRows(tableId, filters = {}) {
const rows = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const { data } = await client.get(`/database/rows/table/${tableId}/`, {
params: { page, size: 200, user_field_names: true, ...filters },
});
rows.push(...data.results);
hasMore = data.next !== null;
page++;
}
return rows;
}
// Récupère une seule ligne par ID
async function fetchRow(tableId, rowId) {
const { data } = await client.get(
`/database/rows/table/${tableId}/${rowId}/`,
{ params: { user_field_names: true } }
);
return data;
}
module.exports = {
getMarches: (filters) => fetchAllRows(TABLE_MARCHES, filters),
getMarcheById: (id) => fetchRow(TABLE_MARCHES, id),
getPipeline: (filters) => fetchAllRows(TABLE_PIPELINE, filters),
};
+155
View File
@@ -0,0 +1,155 @@
/**
* services/calc.js
* Helpers partagés : calculs, formatage, seuils métier RLA
*/
const SEUIL_STANDARD = parseFloat(process.env.SEUIL_STANDARD || 70);
const SEUIL_MODERNISATION = parseFloat(process.env.SEUIL_MODERNISATION || 50);
const SEUIL_CRITIQUE_PCT = parseFloat(process.env.SEUIL_CRITIQUE_PCT || 90);
const DELAI_CRITIQUE = parseInt(process.env.DELAI_CRITIQUE || 45, 10);
const DELAI_ATTENTION = parseInt(process.env.DELAI_ATTENTION || 90, 10);
// ─── Parseurs ───────────────────────────────────────────────────────────────
function parseNum(v) {
const n = parseFloat(String(v ?? '').replace(/\s/g, '').replace(',', '.'));
return isNaN(n) ? 0 : n;
}
function parseDateFR(d) {
if (!d) return null;
// ISO or FR dd/mm/yyyy
const parts = String(d).split(/[\/\-]/);
if (parts.length === 3) {
const [a, b, c] = parts;
if (a.length === 4) return new Date(`${a}-${b}-${c}`); // YYYY-MM-DD
if (c.length === 4) return new Date(`${c}-${b}-${a}`); // DD/MM/YYYY
}
const dt = new Date(d);
return isNaN(dt.getTime()) ? null : dt;
}
// ─── Formatage ───────────────────────────────────────────────────────────────
function formatMontant(v) {
const n = parseNum(v);
if (n === 0) return '—';
return n.toLocaleString('fr-TN', { minimumFractionDigits: 0, maximumFractionDigits: 3 }) + ' DT';
}
function formatDateFR(d) {
const dt = parseDateFR(d);
if (!dt) return '—';
return dt.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function formatPct(v) {
const n = parseNum(v);
return n === 0 ? '0 %' : `${n.toFixed(1)} %`;
}
// ─── Détermination de statuts métier ─────────────────────────────────────────
function isCloture(r) {
const obs = String(r.observation || r.statut || '').toLowerCase();
return obs.includes('clôtur') || obs.includes('clotur') || !!r.date_cloture;
}
function getDelaiRestant(r) {
if (r.delai_restant != null && r.delai_restant !== '') {
const v = parseInt(r.delai_restant, 10);
return isNaN(v) ? null : v;
}
const fin = r.date_fin || r.datefin;
const dt = parseDateFR(fin);
if (!dt) return null;
return Math.ceil((dt - new Date()) / 86400000);
}
function niveauAlerte(delai) {
if (delai === null) return 'indéterminé';
if (delai <= DELAI_CRITIQUE) return 'critique';
if (delai <= DELAI_ATTENTION) return 'attention';
return 'normal';
}
function niveauAvancement(tauxPhy, nature) {
const t = parseNum(tauxPhy);
const seuil = String(nature || '').toLowerCase().includes('modern') ? SEUIL_MODERNISATION : SEUIL_STANDARD;
if (t >= SEUIL_CRITIQUE_PCT) return 'dépassé';
if (t >= seuil) return 'sous_avancement';
return 'normal';
}
// ─── Niveau de risque global ─────────────────────────────────────────────────
function niveauRisque(r) {
const delai = getDelaiRestant(r);
const avt = parseNum(r.taux_phy || r.avt_fin);
const nd = niveauAlerte(delai);
if (nd === 'critique' || avt >= SEUIL_CRITIQUE_PCT) return 'critique';
if (nd === 'attention') return 'élevé';
if (avt >= SEUIL_STANDARD) return 'moyen';
return 'faible';
}
// ─── Normalisation d'un marché ───────────────────────────────────────────────
function normalizeMarche(r) {
const delaiRestant = getDelaiRestant(r);
const tauxPhy = parseNum(r.taux_phy ?? r.avt_phy ?? r.avancement_physique);
const tauxFin = parseNum(r.taux_fin ?? r.avt_fin ?? r.avancement_financier);
const montant = parseNum(r.tot_marche ?? r.totmarche ?? r.montant);
const consomme = parseNum(r.consomme ?? r.montant_consomme ?? (montant * tauxFin / 100));
const restant = montant - consomme;
return {
id: r.id,
ref: r.ref || r.reference || r.id_marche || '',
projet: r.projet || '',
region: r.region || r.region_csc || '',
entrepreneur: r.entrepreneur || '',
nature: r.nature || '',
statut: r.statut || '',
observation: r.observation || '',
cloture: isCloture(r),
date_debut: formatDateFR(r.date_debut),
date_fin: formatDateFR(r.date_fin || r.datefin),
date_cloture: formatDateFR(r.date_cloture),
montant_raw: montant,
montant: formatMontant(montant),
consomme_raw: consomme,
consomme: formatMontant(consomme),
restant_raw: restant,
restant: formatMontant(restant),
taux_phy_raw: tauxPhy,
taux_phy: formatPct(tauxPhy),
taux_fin_raw: tauxFin,
taux_fin: formatPct(tauxFin),
delai_restant: delaiRestant,
niveau_alerte: niveauAlerte(delaiRestant),
niveau_avancement: niveauAvancement(r.taux_phy ?? r.avt_phy, r.nature),
niveau_risque: niveauRisque(r),
};
}
// ─── Seuils exportés ─────────────────────────────────────────────────────────
module.exports = {
SEUIL_STANDARD,
SEUIL_MODERNISATION,
SEUIL_CRITIQUE_PCT,
DELAI_CRITIQUE,
DELAI_ATTENTION,
parseNum,
parseDateFR,
formatMontant,
formatDateFR,
formatPct,
isCloture,
getDelaiRestant,
niveauAlerte,
niveauAvancement,
niveauRisque,
normalizeMarche,
};
+301
View File
@@ -0,0 +1,301 @@
/**
* services/export-pdf.js
* Génération de PDF par vue avec PDFKit (async/Promise)
*/
const PDFDocument = require('pdfkit');
// Palette RLA / McKinsey
const C = {
primary: '#002D62',
accent: '#E31837',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
muted: '#6b7280',
light: '#f8fafc',
border: '#e2e8f0',
text: '#1e293b',
white: '#ffffff',
};
function hex(h) {
const s = h.replace('#', '');
return [parseInt(s.slice(0,2),16), parseInt(s.slice(2,4),16), parseInt(s.slice(4,6),16)];
}
const fill = (d, h) => d.fillColor(hex(h));
const stroke = (d, h) => d.strokeColor(hex(h));
// ─── Collect PDF to Buffer ────────────────────────────────────────────────────
function pdfToBuffer(doc, writeFn) {
return new Promise((resolve, reject) => {
const chunks = [];
doc.on('data', c => chunks.push(c));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', err => reject(err));
try { writeFn(doc); doc.end(); } catch (e) { reject(e); }
});
}
// ─── Header / Footer ─────────────────────────────────────────────────────────
function header(doc, title, subtitle) {
fill(doc, C.primary);
doc.rect(0, 0, doc.page.width, 68).fill();
fill(doc, C.white);
doc.fontSize(17).font('Helvetica-Bold').text(title || '', 40, 18, { width: 500 });
if (subtitle) doc.fontSize(9).font('Helvetica').text(subtitle, 40, 42, { width: 500 });
const now = new Date().toLocaleDateString('fr-FR', { day:'2-digit', month:'2-digit', year:'numeric' });
doc.fontSize(8).text(`Édité le ${now}`, 0, 50, { align:'right', width: doc.page.width - 40 });
doc.y = 88;
}
function footer(doc, n) {
const y = doc.page.height - 38;
fill(doc, C.border);
doc.rect(0, y - 4, doc.page.width, 1).fill();
fill(doc, C.muted);
doc.fontSize(7.5).font('Helvetica')
.text('RLA — Marchés Tunisie Telecom Zone Sud', 40, y)
.text(`Page ${n}`, 0, y, { align:'right', width: doc.page.width - 40 });
}
// ─── KPI Box ─────────────────────────────────────────────────────────────────
function kpiBox(doc, x, y, w, h, label, value, color) {
fill(doc, C.light);
doc.rect(x, y, w, h).fill();
fill(doc, color || C.primary);
doc.rect(x, y, 4, h).fill();
fill(doc, C.muted);
doc.fontSize(7.5).font('Helvetica').text(label, x+10, y+7, { width: w-14 });
fill(doc, C.text);
doc.fontSize(16).font('Helvetica-Bold').text(String(value ?? '—'), x+10, y+20, { width: w-14 });
}
// ─── Table ────────────────────────────────────────────────────────────────────
function table(doc, { title, headers, rows, colWidths }) {
const pageW = doc.page.width - 80;
const totalW = colWidths.reduce((a, b) => a + b, 0);
const scale = pageW / totalW;
const widths = colWidths.map(w => Math.round(w * scale));
let y = doc.y;
if (title) {
fill(doc, C.text);
doc.fontSize(10).font('Helvetica-Bold').text(title, 40, y);
y += 16;
}
function drawHeader() {
fill(doc, C.primary);
doc.rect(40, y, pageW, 17).fill();
fill(doc, C.white);
doc.fontSize(7.5).font('Helvetica-Bold');
let x = 40;
for (let i = 0; i < headers.length; i++) {
doc.text(headers[i], x + 3, y + 4, { width: widths[i] - 6, ellipsis: true });
x += widths[i];
}
y += 17;
}
drawHeader();
let alt = false;
for (const row of rows) {
if (y > doc.page.height - 75) {
footer(doc, '—');
doc.addPage();
header(doc, '', '');
y = doc.y;
drawHeader();
alt = false;
}
const rowH = 15;
fill(doc, alt ? '#f1f5f9' : C.white);
doc.rect(40, y, pageW, rowH).fill();
fill(doc, C.text);
doc.fontSize(7).font('Helvetica');
let x = 40;
for (let i = 0; i < row.length; i++) {
doc.text(String(row[i] ?? '—'), x + 3, y + 4, { width: widths[i] - 6, ellipsis: true });
x += widths[i];
}
stroke(doc, C.border);
doc.moveTo(40, y + rowH).lineTo(40 + pageW, y + rowH).stroke();
y += rowH;
alt = !alt;
}
doc.y = y + 8;
}
const NL = n => ({ critique:'CRITIQUE', attention:'ATTENTION', élevé:'ÉLEVÉ', moyen:'MOYEN', faible:'FAIBLE', normal:'NORMAL', sous_avancement:'SOUS-AVT' }[n] || String(n||'').toUpperCase());
// ─── Vues ─────────────────────────────────────────────────────────────────────
function generateSynthese(data) {
const doc = new PDFDocument({ margin:40, size:'A4' });
return pdfToBuffer(doc, d => {
header(d, 'Synthèse Globale — Marchés RLA', 'Tunisie Telecom Zone Sud');
const kpis = [
{ l:'Total Marchés', v: data.total, c: C.primary },
{ l:'Actifs', v: data.actifs, c: C.success },
{ l:'Clôturés', v: data.clotures,c: C.muted },
{ l:'Alertes', v: data.alertes_delais?.count||0, c: C.warning },
{ l:'Avt. Moy.(%)', v:`${data.taux_avancement_moyen||0}%`, c: C.accent },
];
let kx = 40;
for (const k of kpis) { kpiBox(d, kx, d.y, 95, 48, k.l, k.v, k.c); kx += 101; }
d.y += 58;
if (data.budget) {
fill(d, C.text); d.fontSize(10).font('Helvetica-Bold').text('Budget', 40, d.y); d.y += 12;
for (const [l, v] of [['Total',data.budget.total],['Consommé',data.budget.consomme],['Restant',data.budget.restant]]) {
fill(d, C.muted); d.fontSize(8.5).font('Helvetica').text(l+' :', 40, d.y, {width:110});
fill(d, C.text); d.fontSize(8.5).font('Helvetica-Bold').text(v, 155, d.y, {width:250}); d.y += 13;
}
}
if (data.par_statut) {
d.y += 6;
table(d, { title:'Répartition par Statut', headers:['Statut','Nombre'], colWidths:[350,100],
rows: Object.entries(data.par_statut).map(([s,n])=>[s,n]) });
}
if (data.alertes_delais?.items?.length) {
const items = data.alertes_delais.items.slice(0,10);
table(d, { title:`Top ${items.length} Alertes Délais`, headers:['Réf.','Projet','Région','Entrepreneur','J. Rest.','Niveau'],
colWidths:[70,140,65,120,55,55], rows: items.map(a=>[a.ref,a.projet,a.region,a.entrepreneur,a.delai_restant,NL(a.niveau)]) });
}
footer(d, 1);
});
}
function generateAlertes(data) {
const doc = new PDFDocument({ margin:40, size:'A4', layout:'landscape' });
return pdfToBuffer(doc, d => {
header(d, 'Alertes Délais — Marchés RLA', `${data.count||0} alerte(s) — dont ${data.critique||0} critique(s)`);
if (data.items?.length) {
table(d, { title:'Liste des Alertes', headers:['Réf.','Projet','Région','Entrepreneur','Taux Phy.','Date Fin','J. Rest.','Niveau'],
colWidths:[70,155,65,125,55,65,55,55], rows: data.items.map(a=>[a.ref,a.projet,a.region,a.entrepreneur,a.taux_phy,a.date_fin,a.delai_restant,NL(a.niveau_alerte||a.niveau)]) });
} else { fill(d, C.success); d.fontSize(14).font('Helvetica-Bold').text('Aucune alerte active.', {align:'center'}); }
footer(d, 1);
});
}
function generateEnService(data) {
const doc = new PDFDocument({ margin:40, size:'A4', layout:'landscape' });
return pdfToBuffer(doc, d => {
header(d, 'Marchés en Service — RLA', `${data.count||0} marché(s) actif(s)`);
table(d, { title:'Liste des Marchés en Service',
headers:['Réf.','Projet','Région','Entrepreneur','Montant','Taux Phy.','Taux Fin.','Date Fin','Alerte'],
colWidths:[60,140,65,115,90,50,50,65,55],
rows: (data.items||[]).map(r=>[r.ref,r.projet,r.region,r.entrepreneur,r.montant,r.taux_phy,r.taux_fin,r.date_fin,NL(r.niveau_alerte)]) });
footer(d, 1);
});
}
function generateEnCours(data) {
const doc = new PDFDocument({ margin:40, size:'A4', layout:'landscape' });
return pdfToBuffer(doc, d => {
header(d, 'Marchés en Cours — RLA', `${data.count||0} marché(s) en cours`);
table(d, { title:'Liste des Marchés en Cours',
headers:['Réf.','Projet','Région','Entrepreneur','Montant','Taux Phy.','Taux Fin.','Date Fin','Niveau Avt.'],
colWidths:[60,135,65,115,90,50,50,65,60],
rows: (data.items||[]).map(r=>[r.ref,r.projet,r.region,r.entrepreneur,r.montant,r.taux_phy,r.taux_fin,r.date_fin,r.niveau_avancement]) });
footer(d, 1);
});
}
function generateParRegion(data) {
const doc = new PDFDocument({ margin:40, size:'A4' });
return pdfToBuffer(doc, d => {
header(d, 'Vue par Région — Marchés RLA', `${data.count||0} région(s)`);
for (const reg of data.regions||[]) {
if (d.y > d.page.height - 120) { footer(d, '—'); d.addPage(); header(d,'',''); }
fill(d, C.primary); d.rect(40, d.y, d.page.width-80, 22).fill();
fill(d, C.white); d.fontSize(11).font('Helvetica-Bold').text(reg.region, 52, d.y+5); d.y += 30;
const kpis = [
{l:'Actifs',v:reg.actifs},{l:'Clôturés',v:reg.clotures},
{l:'Alertes',v:reg.alertes_count,c:C.warning},{l:'Critiques',v:reg.alertes_critique,c:C.danger},
{l:'Taux moy.',v:`${reg.taux_moyen}%`},
];
let kx=40; for(const k of kpis){kpiBox(d,kx,d.y,92,40,k.l,k.v,k.c||C.primary);kx+=98;}
d.y+=50; d.moveDown(0.3);
}
footer(d, 1);
});
}
function generateClotures(data) {
const doc = new PDFDocument({ margin:40, size:'A4', layout:'landscape' });
return pdfToBuffer(doc, d => {
header(d, 'Marchés Clôturés — RLA', `${data.count||0} marché(s) — Budget : ${data.budget_total||'—'}`);
table(d, { title:'Liste des Marchés Clôturés',
headers:['Réf.','Projet','Région','Entrepreneur','Montant','Taux Phy.','Date Clôture'],
colWidths:[70,155,65,130,100,60,75],
rows: (data.items||[]).map(r=>[r.ref,r.projet,r.region,r.entrepreneur,r.montant,r.taux_phy,r.date_cloture]) });
footer(d, 1);
});
}
function generatePilotage(data) {
const doc = new PDFDocument({ margin:40, size:'A4' });
return pdfToBuffer(doc, d => {
const r = data.resume||{};
header(d, 'Pilotage Proactif — Marchés RLA', `Total actifs : ${r.total||0}`);
const kpis = [
{l:'Dans les normes',v:r.normal||0,c:C.success},
{l:'Sous avancement',v:r.sous_avancement||0,c:C.warning},
{l:'Dépassé',v:r.depasse||0,c:C.danger},
];
let kx=40; for(const k of kpis){kpiBox(d,kx,d.y,148,50,k.l,k.v,k.c);kx+=156;}
d.y+=62;
const problematic = [...(data.depasse||[]),...(data.sous_avancement||[])];
if (problematic.length) {
table(d, { title:'Marchés à surveiller', headers:['Réf.','Projet','Région','Entrepreneur','Taux Phy.','Niveau'],
colWidths:[70,155,65,130,60,75], rows: problematic.map(r=>[r.ref,r.projet,r.region,r.entrepreneur,r.taux_phy,r.niveau_avancement]) });
}
footer(d, 1);
});
}
function generateMatriceRisque(data) {
const doc = new PDFDocument({ margin:40, size:'A4' });
return pdfToBuffer(doc, d => {
const pn = data.par_niveau||{};
header(d, 'Matrice de Risque — Marchés RLA', `${data.total||0} marchés analysés`);
const kpis = [
{l:'Critique',v:pn.critique||0,c:C.danger},
{l:'Élevé',v:pn['élevé']||0,c:C.warning},
{l:'Moyen',v:pn.moyen||0,c:'#6366f1'},
{l:'Faible',v:pn.faible||0,c:C.success},
];
let kx=40; for(const k of kpis){kpiBox(d,kx,d.y,110,50,k.l,k.v,k.c);kx+=118;}
d.y+=62;
if (data.items?.length) {
const sorted = [...data.items].sort((a,b)=>(b.score_delai+b.score_avancement)-(a.score_delai+a.score_avancement));
table(d, { title:'Marchés classés par niveau de risque', headers:['Réf.','Projet','Région','Entrepreneur','Taux Phy.','J. Rest.','Risque'],
colWidths:[70,145,65,125,55,50,55], rows: sorted.map(r=>[r.ref,r.projet,r.region,r.entrepreneur,r.taux_phy,r.delai_restant??'—',NL(r.niveau_risque)]) });
}
footer(d, 1);
});
}
function generateGeneric(title, data) {
const doc = new PDFDocument({ margin:40, size:'A4', layout:'landscape' });
return pdfToBuffer(doc, d => {
header(d, title, '');
const items = data.items||[];
if (items.length) {
const keys = Object.keys(items[0]).filter(k=>!k.endsWith('_raw')&&k!=='id'&&typeof items[0][k]!=='object').slice(0,8);
table(d, { headers:keys, colWidths:keys.map(()=>Math.floor(700/keys.length)),
rows: items.slice(0,100).map(r=>keys.map(k=>r[k])) });
}
footer(d, 1);
});
}
module.exports = {
generateSynthese, generateAlertes, generateEnService, generateEnCours,
generateParRegion, generateClotures, generatePilotage, generateMatriceRisque, generateGeneric,
};
+84
View File
@@ -0,0 +1,84 @@
/**
* services/export-xlsx.js
* Génération XLSX avec ExcelJS (SuperAdmin uniquement)
*/
const ExcelJS = require('exceljs');
const HEADER_FILL = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF002D62' } };
const HEADER_FONT = { color: { argb: 'FFFFFFFF' }, bold: true, size: 10 };
const ALT_FILL = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } };
function styleHeader(row) {
row.eachCell(cell => {
cell.fill = HEADER_FILL;
cell.font = HEADER_FONT;
cell.alignment = { vertical: 'middle', horizontal: 'center' };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } };
});
row.height = 22;
}
function styleDataRow(row, alt) {
if (alt) {
row.eachCell(cell => { cell.fill = ALT_FILL; });
}
row.height = 16;
}
async function generateXlsx(view, data) {
const wb = new ExcelJS.Workbook();
wb.creator = 'RLA API';
wb.created = new Date();
const titles = {
synthese: 'Synthèse Globale',
alertes: 'Alertes Délais',
'en-service': 'Marchés en Service',
'en-cours': 'Marchés en Cours',
'par-region': 'Par Région',
clotures: 'Marchés Clôturés',
pilotage: 'Pilotage Proactif',
'matrice-risque': 'Matrice de Risque',
};
const title = titles[view] || view;
const ws = wb.addWorksheet(title.slice(0, 31));
const items = data.items || data.regions || [];
if (!items.length) {
ws.addRow(['Aucune donnée disponible.']);
return wb.xlsx.writeBuffer();
}
const sample = items[0];
const keys = Object.keys(sample).filter(k => !k.endsWith('_raw') && k !== 'id' && typeof sample[k] !== 'object');
// En-tête
ws.columns = keys.map(k => ({ header: k, key: k, width: 20 }));
styleHeader(ws.getRow(1));
// Données
items.forEach((item, i) => {
const row = ws.addRow(keys.map(k => item[k] ?? ''));
styleDataRow(row, i % 2 === 1);
});
// Freeze header
ws.views = [{ state: 'frozen', ySplit: 1 }];
// Onglet résumé si synthèse
if (view === 'synthese' && data.par_statut) {
const ws2 = wb.addWorksheet('Par Statut');
ws2.columns = [{ header: 'Statut', key: 'statut', width: 30 }, { header: 'Nombre', key: 'nb', width: 15 }];
styleHeader(ws2.getRow(1));
Object.entries(data.par_statut).forEach(([s, n], i) => {
const row = ws2.addRow({ statut: s, nb: n });
styleDataRow(row, i % 2 === 1);
});
}
return wb.xlsx.writeBuffer();
}
module.exports = { generateXlsx };
+31
View File
@@ -0,0 +1,31 @@
const fs = require('fs');
const path = require('path');
const LOGS_FILE = path.join(__dirname, '..', 'logs', 'connexions.json');
const MAX_LOGS = 500;
function readLogs() {
try {
return JSON.parse(fs.readFileSync(LOGS_FILE, 'utf8'));
} catch (_) {
return [];
}
}
function writeLogs(logs) {
fs.mkdirSync(path.dirname(LOGS_FILE), { recursive: true });
fs.writeFileSync(LOGS_FILE, JSON.stringify(logs, null, 2), 'utf8');
}
function logLogin({ username, role = null, ip = null, success }) {
const logs = readLogs();
logs.unshift({ timestamp: new Date().toISOString(), username, role, ip, success: !!success });
if (logs.length > MAX_LOGS) logs.length = MAX_LOGS;
writeLogs(logs);
}
function getLogs(limit = 100) {
return readLogs().slice(0, limit);
}
module.exports = { logLogin, getLogs };
+30
View File
@@ -0,0 +1,30 @@
const fs = require('fs');
const path = require('path');
const DATA_FILE = path.join(__dirname, '..', 'data', 'users.json');
function initUsersFile() {
if (fs.existsSync(DATA_FILE)) return;
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
try {
const envUsers = JSON.parse(process.env.USERS || '[]');
fs.writeFileSync(DATA_FILE, JSON.stringify(envUsers, null, 2), 'utf8');
console.log(`[users] Fichier initialisé depuis .env (${envUsers.length} utilisateurs)`);
} catch (_) {
fs.writeFileSync(DATA_FILE, '[]', 'utf8');
}
}
function getUsers() {
initUsersFile();
try {
return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
} catch (_) { return []; }
}
function saveUsers(users) {
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
fs.writeFileSync(DATA_FILE, JSON.stringify(users, null, 2), 'utf8');
}
module.exports = { getUsers, saveUsers };