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:
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* GET /api/alertes
|
||||
* Alertes délais : marchés dont la date de fin approche
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getMarches } = require('../services/baserow');
|
||||
const {
|
||||
isCloture, getDelaiRestant, niveauAlerte, normalizeMarche,
|
||||
DELAI_ATTENTION,
|
||||
} = require('../services/calc');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { region, entrepreneur, projet, niveau } = req.query;
|
||||
const regionFilter = req.regionFilter;
|
||||
|
||||
let rows = await getMarches();
|
||||
|
||||
// Filtres
|
||||
if (regionFilter) rows = rows.filter(r => r.region === regionFilter);
|
||||
else if (region) rows = rows.filter(r => r.region === region);
|
||||
if (entrepreneur) rows = rows.filter(r => String(r.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase()));
|
||||
if (projet) rows = rows.filter(r => String(r.projet || '').toLowerCase().includes(projet.toLowerCase()));
|
||||
|
||||
// Uniquement marchés actifs avec alerte (délai ≤ DELAI_ATTENTION)
|
||||
const actifs = rows.filter(r => !isCloture(r));
|
||||
const alertes = actifs
|
||||
.map(r => ({ ...r, _delai: getDelaiRestant(r) }))
|
||||
.filter(r => r._delai !== null && r._delai <= DELAI_ATTENTION)
|
||||
.map(r => ({ ...normalizeMarche(r), delai_restant: r._delai, niveau: niveauAlerte(r._delai) }))
|
||||
.filter(r => !niveau || r.niveau === niveau)
|
||||
.sort((a, b) => a.delai_restant - b.delai_restant);
|
||||
|
||||
res.json({
|
||||
count: alertes.length,
|
||||
critique: alertes.filter(a => a.niveau === 'critique').length,
|
||||
attention: alertes.filter(a => a.niveau === 'attention').length,
|
||||
items: alertes,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,37 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { getUsers } = require('../services/users');
|
||||
const { logLogin } = require('../services/logs');
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', async (req, res) => {
|
||||
const { username, password } = req.body || {};
|
||||
const ip = req.ip || req.socket?.remoteAddress || null;
|
||||
|
||||
const user = getUsers().find(u => u.username === username);
|
||||
|
||||
if (!user) {
|
||||
logLogin({ username: username || '?', role: null, ip, success: false });
|
||||
return res.status(401).json({ error: 'Identifiants invalides' });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password || '', user.password);
|
||||
if (!valid) {
|
||||
logLogin({ username, role: user.role, ip, success: false });
|
||||
return res.status(401).json({ error: 'Identifiants invalides' });
|
||||
}
|
||||
|
||||
logLogin({ username, role: user.role, ip, success: true });
|
||||
|
||||
const token = jwt.sign(
|
||||
{ sub: user.username, id: user.id, username: user.username, role: user.role, region: user.region },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '8h' }
|
||||
);
|
||||
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* GET /api/clotures
|
||||
* Marchés clôturés
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getMarches } = require('../services/baserow');
|
||||
const { isCloture, normalizeMarche, parseNum, formatMontant } = require('../services/calc');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { region, entrepreneur, projet, nature } = req.query;
|
||||
const regionFilter = req.regionFilter;
|
||||
|
||||
let rows = await getMarches();
|
||||
|
||||
// Uniquement clôturés
|
||||
rows = rows.filter(r => isCloture(r));
|
||||
|
||||
// Filtres
|
||||
if (regionFilter) rows = rows.filter(r => r.region === regionFilter);
|
||||
else if (region) rows = rows.filter(r => r.region === region);
|
||||
if (entrepreneur) rows = rows.filter(r => String(r.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase()));
|
||||
if (projet) rows = rows.filter(r => String(r.projet || '').toLowerCase().includes(projet.toLowerCase()));
|
||||
if (nature) rows = rows.filter(r => String(r.nature || '').toLowerCase().includes(nature.toLowerCase()));
|
||||
|
||||
const items = rows.map(normalizeMarche);
|
||||
|
||||
const totalBudget = rows.reduce((s, r) => s + parseNum(r.tot_marche ?? r.totmarche ?? r.montant), 0);
|
||||
|
||||
// Par région
|
||||
const parRegion = {};
|
||||
for (const r of rows) {
|
||||
const reg = r.region || 'Inconnu';
|
||||
parRegion[reg] = (parRegion[reg] || 0) + 1;
|
||||
}
|
||||
|
||||
res.json({
|
||||
count: items.length,
|
||||
budget_total: formatMontant(totalBudget),
|
||||
budget_total_raw: totalBudget,
|
||||
par_region: parRegion,
|
||||
items,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* GET /api/en-cours
|
||||
* Marchés en cours (actifs, progress < 100%)
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getMarches } = require('../services/baserow');
|
||||
const { isCloture, normalizeMarche, parseNum } = require('../services/calc');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { region, entrepreneur, projet, nature, statut } = req.query;
|
||||
const regionFilter = req.regionFilter;
|
||||
|
||||
let rows = await getMarches();
|
||||
|
||||
// Actifs dont taux_phy < 100
|
||||
rows = rows.filter(r => {
|
||||
if (isCloture(r)) return false;
|
||||
const t = parseNum(r.taux_phy ?? r.avt_phy);
|
||||
return t < 100;
|
||||
});
|
||||
|
||||
// Filtres
|
||||
if (regionFilter) rows = rows.filter(r => r.region === regionFilter);
|
||||
else if (region) rows = rows.filter(r => r.region === region);
|
||||
if (entrepreneur) rows = rows.filter(r => String(r.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase()));
|
||||
if (projet) rows = rows.filter(r => String(r.projet || '').toLowerCase().includes(projet.toLowerCase()));
|
||||
if (nature) rows = rows.filter(r => String(r.nature || '').toLowerCase().includes(nature.toLowerCase()));
|
||||
if (statut) rows = rows.filter(r => String(r.statut || '').toLowerCase().includes(statut.toLowerCase()));
|
||||
|
||||
const items = rows.map(normalizeMarche);
|
||||
|
||||
const tauxList = items.map(r => r.taux_phy_raw).filter(v => v > 0);
|
||||
const tauxMoyen = tauxList.length
|
||||
? Math.round(tauxList.reduce((a, b) => a + b, 0) / tauxList.length * 10) / 10
|
||||
: 0;
|
||||
|
||||
res.json({
|
||||
count: items.length,
|
||||
taux_avancement_moyen: tauxMoyen,
|
||||
items,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* GET /api/en-service
|
||||
* Marchés actifs en service (non clôturés)
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getMarches } = require('../services/baserow');
|
||||
const { isCloture, normalizeMarche, parseNum } = require('../services/calc');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { region, entrepreneur, projet, nature, statut } = req.query;
|
||||
const regionFilter = req.regionFilter;
|
||||
|
||||
let rows = await getMarches();
|
||||
|
||||
// Uniquement non clôturés
|
||||
rows = rows.filter(r => !isCloture(r));
|
||||
|
||||
// Filtres
|
||||
if (regionFilter) rows = rows.filter(r => r.region === regionFilter);
|
||||
else if (region) rows = rows.filter(r => r.region === region);
|
||||
if (entrepreneur) rows = rows.filter(r => String(r.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase()));
|
||||
if (projet) rows = rows.filter(r => String(r.projet || '').toLowerCase().includes(projet.toLowerCase()));
|
||||
if (nature) rows = rows.filter(r => String(r.nature || '').toLowerCase().includes(nature.toLowerCase()));
|
||||
if (statut) rows = rows.filter(r => String(r.statut || '').toLowerCase().includes(statut.toLowerCase()));
|
||||
|
||||
const items = rows.map(normalizeMarche);
|
||||
|
||||
// Agrégats
|
||||
const totalBudget = rows.reduce((s, r) => s + parseNum(r.tot_marche ?? r.totmarche ?? r.montant), 0);
|
||||
const tauxList = items.map(r => r.taux_phy_raw).filter(v => v > 0);
|
||||
const tauxMoyen = tauxList.length
|
||||
? Math.round(tauxList.reduce((a, b) => a + b, 0) / tauxList.length * 10) / 10
|
||||
: 0;
|
||||
|
||||
res.json({
|
||||
count: items.length,
|
||||
budget_total_raw: totalBudget,
|
||||
taux_avancement_moyen: tauxMoyen,
|
||||
items,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* routes/export.js
|
||||
* Exports PDF, PPTX, XLSX, DOCX par vue
|
||||
*
|
||||
* PDF → tous les rôles authentifiés
|
||||
* PPTX / XLSX / DOCX → SuperAdmin uniquement (vérifié ici)
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const { getMarches } = require('../services/baserow');
|
||||
const {
|
||||
isCloture, normalizeMarche, parseNum, formatMontant,
|
||||
getDelaiRestant, niveauAlerte, niveauAvancement, niveauRisque,
|
||||
DELAI_CRITIQUE, DELAI_ATTENTION, SEUIL_STANDARD, SEUIL_CRITIQUE_PCT,
|
||||
} = require('../services/calc');
|
||||
|
||||
const pdfGen = require('../services/export-pdf');
|
||||
const { generateXlsx } = require('../services/export-xlsx');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function applyFilters(rows, req) {
|
||||
const { region, entrepreneur, projet, nature, statut } = req.query;
|
||||
const regionFilter = req.regionFilter;
|
||||
let r = rows;
|
||||
if (regionFilter) r = r.filter(x => x.region === regionFilter);
|
||||
else if (region) r = r.filter(x => x.region === region);
|
||||
if (entrepreneur) r = r.filter(x => String(x.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase()));
|
||||
if (projet) r = r.filter(x => String(x.projet || '').toLowerCase().includes(projet.toLowerCase()));
|
||||
if (nature) r = r.filter(x => String(x.nature || '').toLowerCase().includes(nature.toLowerCase()));
|
||||
if (statut) r = r.filter(x => String(x.statut || '').toLowerCase().includes(statut.toLowerCase()));
|
||||
return r;
|
||||
}
|
||||
|
||||
async function buildViewData(view, req) {
|
||||
const allRows = await getMarches();
|
||||
let rows = applyFilters(allRows, req);
|
||||
|
||||
const actifs = rows.filter(r => !isCloture(r));
|
||||
const clotures = rows.filter(r => isCloture(r));
|
||||
|
||||
switch (view) {
|
||||
case 'synthese': {
|
||||
const tauxList = actifs.map(r => parseNum(r.taux_phy ?? r.avt_phy)).filter(v => v > 0);
|
||||
const tauxMoyen = tauxList.length ? Math.round(tauxList.reduce((a,b)=>a+b,0)/tauxList.length*10)/10 : 0;
|
||||
const totalBudget = actifs.reduce((s,r) => s+parseNum(r.tot_marche??r.totmarche??r.montant),0);
|
||||
const parStatut = {};
|
||||
for (const r of rows) { const s=String(r.statut||'Inconnu'); parStatut[s]=(parStatut[s]||0)+1; }
|
||||
const alertes = actifs
|
||||
.map(r=>({...r,_d:getDelaiRestant(r)}))
|
||||
.filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION)
|
||||
.map(r=>({ref:r.ref||'',projet:r.projet||'',region:r.region||'',entrepreneur:r.entrepreneur||'',delai_restant:r._d,niveau:niveauAlerte(r._d)}))
|
||||
.sort((a,b)=>a.delai_restant-b.delai_restant);
|
||||
return {
|
||||
total: rows.length, actifs: actifs.length, clotures: clotures.length,
|
||||
taux_avancement_moyen: tauxMoyen, par_statut: parStatut,
|
||||
budget: { total: formatMontant(totalBudget), total_raw: totalBudget },
|
||||
alertes_delais: { count: alertes.length, critique: alertes.filter(a=>a.niveau==='critique').length, items: alertes },
|
||||
};
|
||||
}
|
||||
case 'alertes': {
|
||||
const items = actifs
|
||||
.map(r=>({...r,_d:getDelaiRestant(r)}))
|
||||
.filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION)
|
||||
.map(r=>({...normalizeMarche(r),delai_restant:r._d,niveau:niveauAlerte(r._d),niveau_alerte:niveauAlerte(r._d)}))
|
||||
.sort((a,b)=>a.delai_restant-b.delai_restant);
|
||||
return { count: items.length, critique: items.filter(a=>a.niveau==='critique').length, items };
|
||||
}
|
||||
case 'en-service':
|
||||
return { count: actifs.length, items: actifs.map(normalizeMarche) };
|
||||
case 'en-cours': {
|
||||
const enCours = actifs.filter(r=>parseNum(r.taux_phy??r.avt_phy)<100);
|
||||
return { count: enCours.length, items: enCours.map(normalizeMarche) };
|
||||
}
|
||||
case 'par-region': {
|
||||
const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'];
|
||||
const regions = ALL_REGIONS.map(reg => {
|
||||
const regActifs = actifs.filter(r=>(r.region||'')=== reg);
|
||||
const regTotal = rows.filter(r=>(r.region||'')=== reg);
|
||||
const tauxList = regActifs.map(r=>parseNum(r.taux_phy??r.avt_phy)).filter(v=>v>0);
|
||||
const tauxMoyen = tauxList.length ? Math.round(tauxList.reduce((a,b)=>a+b,0)/tauxList.length*10)/10 : 0;
|
||||
const budget = regActifs.reduce((s,r)=>s+parseNum(r.tot_marche??r.totmarche??r.montant),0);
|
||||
const alertes = regActifs
|
||||
.map(r=>({...r,_d:getDelaiRestant(r)}))
|
||||
.filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION);
|
||||
return { region: reg, actifs: regActifs.length, clotures: regTotal.length-regActifs.length, total: regTotal.length,
|
||||
taux_moyen: tauxMoyen, budget: formatMontant(budget), alertes_count: alertes.length,
|
||||
alertes_critique: alertes.filter(r=>r._d<=DELAI_CRITIQUE).length };
|
||||
});
|
||||
return { count: regions.length, regions };
|
||||
}
|
||||
case 'clotures': {
|
||||
const totalBudget = clotures.reduce((s,r)=>s+parseNum(r.tot_marche??r.totmarche??r.montant),0);
|
||||
return { count: clotures.length, budget_total: formatMontant(totalBudget), items: clotures.map(normalizeMarche) };
|
||||
}
|
||||
case 'pilotage': {
|
||||
const items = actifs.map(r=>{
|
||||
const d = getDelaiRestant(r);
|
||||
return {...normalizeMarche(r), delai_restant:d, niveau_alerte:niveauAlerte(d), niveau_avancement:niveauAvancement(r.taux_phy??r.avt_phy,r.nature)};
|
||||
});
|
||||
const normal = items.filter(r=>r.niveau_avancement==='normal');
|
||||
const sous = items.filter(r=>r.niveau_avancement==='sous_avancement');
|
||||
const dep = items.filter(r=>r.niveau_avancement==='dépassé');
|
||||
return { resume:{total:items.length,normal:normal.length,sous_avancement:sous.length,depasse:dep.length},
|
||||
normal, sous_avancement:sous, depasse:dep, items };
|
||||
}
|
||||
case 'matrice-risque': {
|
||||
const items = actifs.map(r=>{
|
||||
const d=getDelaiRestant(r);
|
||||
return {...normalizeMarche(r),delai_restant:d,niveau_alerte:niveauAlerte(d),niveau_risque:niveauRisque(r),
|
||||
score_delai: d===null?1:d<=DELAI_CRITIQUE?3:d<=DELAI_ATTENTION?2:1,
|
||||
score_avancement: parseNum(r.taux_phy??r.avt_phy)>=SEUIL_CRITIQUE_PCT?3:parseNum(r.taux_phy??r.avt_phy)>=SEUIL_STANDARD?2:1,
|
||||
};
|
||||
});
|
||||
const pn={critique:0,élevé:0,moyen:0,faible:0};
|
||||
for(const i of items){ if(pn[i.niveau_risque]!==undefined) pn[i.niveau_risque]++; else pn[i.niveau_risque]=1; }
|
||||
return { total:items.length, par_niveau:pn, items };
|
||||
}
|
||||
default:
|
||||
return { items: actifs.map(normalizeMarche) };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Route PDF ────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/pdf', async (req, res) => {
|
||||
try {
|
||||
const view = req.query.view || 'synthese';
|
||||
const data = await buildViewData(view, req);
|
||||
|
||||
let buf;
|
||||
switch (view) {
|
||||
case 'synthese': buf = await pdfGen.generateSynthese(data); break;
|
||||
case 'alertes': buf = await pdfGen.generateAlertes(data); break;
|
||||
case 'en-service': buf = await pdfGen.generateEnService(data); break;
|
||||
case 'en-cours': buf = await pdfGen.generateEnCours(data); break;
|
||||
case 'par-region': buf = await pdfGen.generateParRegion(data); break;
|
||||
case 'clotures': buf = await pdfGen.generateClotures(data); break;
|
||||
case 'pilotage': buf = await pdfGen.generatePilotage(data); break;
|
||||
case 'matrice-risque': buf = await pdfGen.generateMatriceRisque(data); break;
|
||||
default: buf = await pdfGen.generateGeneric(view, data); break;
|
||||
}
|
||||
|
||||
const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.pdf`;
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': buf.length,
|
||||
});
|
||||
res.end(buf);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur génération PDF', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Route XLSX (SuperAdmin) ──────────────────────────────────────────────────
|
||||
|
||||
router.get('/xlsx', async (req, res) => {
|
||||
if (req.user?.role !== 'superadmin') {
|
||||
return res.status(403).json({ error: 'Accès réservé au SuperAdmin' });
|
||||
}
|
||||
try {
|
||||
const view = req.query.view || 'synthese';
|
||||
const data = await buildViewData(view, req);
|
||||
const buf = await generateXlsx(view, data);
|
||||
const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.xlsx`;
|
||||
res.set({
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
});
|
||||
res.end(buf);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur génération XLSX', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Route PPTX (SuperAdmin) ──────────────────────────────────────────────────
|
||||
|
||||
router.get('/pptx', async (req, res) => {
|
||||
if (req.user?.role !== 'superadmin') {
|
||||
return res.status(403).json({ error: 'Accès réservé au SuperAdmin' });
|
||||
}
|
||||
try {
|
||||
const PptxGenJS = require('pptxgenjs');
|
||||
const view = req.query.view || 'synthese';
|
||||
const data = await buildViewData(view, req);
|
||||
|
||||
const pptx = new PptxGenJS();
|
||||
pptx.layout = 'LAYOUT_WIDE';
|
||||
pptx.author = 'RLA API';
|
||||
pptx.company = 'Tunisie Telecom Zone Sud';
|
||||
pptx.subject = `RLA — ${view}`;
|
||||
|
||||
// Slide de titre
|
||||
const slide1 = pptx.addSlide();
|
||||
slide1.background = { color: '002D62' };
|
||||
slide1.addText(`RLA — ${view.toUpperCase()}`, {
|
||||
x: 0.5, y: 2, w: '90%', h: 1.2,
|
||||
fontSize: 36, bold: true, color: 'FFFFFF', align: 'center',
|
||||
});
|
||||
slide1.addText('Marchés Tunisie Telecom Zone Sud', {
|
||||
x: 0.5, y: 3.4, w: '90%', h: 0.5,
|
||||
fontSize: 16, color: 'B3C5E0', align: 'center',
|
||||
});
|
||||
slide1.addText(new Date().toLocaleDateString('fr-FR'), {
|
||||
x: 0.5, y: 4, w: '90%', h: 0.4,
|
||||
fontSize: 12, color: 'E31837', align: 'center',
|
||||
});
|
||||
|
||||
// Slide données
|
||||
const slide2 = pptx.addSlide();
|
||||
slide2.addText(`Données — ${view}`, {
|
||||
x: 0.3, y: 0.2, w: '95%', h: 0.5,
|
||||
fontSize: 18, bold: true, color: '002D62',
|
||||
});
|
||||
|
||||
// Table si items
|
||||
const items = data.items || data.regions || [];
|
||||
if (items.length) {
|
||||
const sample = items[0];
|
||||
const keys = Object.keys(sample).filter(k => !k.endsWith('_raw') && k!=='id' && typeof sample[k]!=='object').slice(0,7);
|
||||
const tableData = [
|
||||
keys.map(k => ({ text: k, options: { bold: true, color: 'FFFFFF', fill: '002D62' } })),
|
||||
...items.slice(0, 20).map(item =>
|
||||
keys.map(k => ({ text: String(item[k] ?? '—') }))
|
||||
),
|
||||
];
|
||||
slide2.addTable(tableData, {
|
||||
x: 0.3, y: 0.9, w: 9.4,
|
||||
fontSize: 9,
|
||||
border: { type: 'solid', color: 'E2E8F0' },
|
||||
colW: keys.map(() => +(9.4 / keys.length).toFixed(2)),
|
||||
});
|
||||
}
|
||||
|
||||
const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.pptx`;
|
||||
const buf = await pptx.write({ outputType: 'nodebuffer' });
|
||||
res.set({
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
});
|
||||
res.end(buf);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur génération PPTX', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Route DOCX (SuperAdmin) ──────────────────────────────────────────────────
|
||||
|
||||
router.get('/docx', async (req, res) => {
|
||||
if (req.user?.role !== 'superadmin') {
|
||||
return res.status(403).json({ error: 'Accès réservé au SuperAdmin' });
|
||||
}
|
||||
try {
|
||||
const { Document, Packer, Paragraph, Table, TableRow, TableCell, TextRun, HeadingLevel, AlignmentType, WidthType, BorderStyle } = require('docx');
|
||||
const view = req.query.view || 'synthese';
|
||||
const data = await buildViewData(view, req);
|
||||
const items = data.items || data.regions || [];
|
||||
|
||||
const children = [
|
||||
new Paragraph({
|
||||
text: `RLA — ${view.toUpperCase()}`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: `Marchés Tunisie Telecom Zone Sud — Édité le ${new Date().toLocaleDateString('fr-FR')}`,
|
||||
children: [new TextRun({ text: '', break: 1 })],
|
||||
}),
|
||||
];
|
||||
|
||||
if (items.length) {
|
||||
const sample = items[0];
|
||||
const keys = Object.keys(sample).filter(k => !k.endsWith('_raw') && k!=='id' && typeof sample[k]!=='object').slice(0,7);
|
||||
|
||||
const tableRows = [
|
||||
new TableRow({
|
||||
children: keys.map(k => new TableCell({
|
||||
children: [new Paragraph({ children: [new TextRun({ text: k, bold: true, color: 'FFFFFF' })], alignment: AlignmentType.CENTER })],
|
||||
shading: { fill: '002D62' },
|
||||
})),
|
||||
}),
|
||||
...items.slice(0, 50).map((item, i) => new TableRow({
|
||||
children: keys.map(k => new TableCell({
|
||||
children: [new Paragraph(String(item[k] ?? '—'))],
|
||||
shading: i % 2 === 1 ? { fill: 'F1F5F9' } : undefined,
|
||||
})),
|
||||
})),
|
||||
];
|
||||
|
||||
children.push(new Table({
|
||||
rows: tableRows,
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
}));
|
||||
}
|
||||
|
||||
const doc = new Document({ sections: [{ children }] });
|
||||
const buf = await Packer.toBuffer(doc);
|
||||
const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.docx`;
|
||||
res.set({
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
});
|
||||
res.end(buf);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur génération DOCX', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getLogs } = require('../services/logs');
|
||||
|
||||
// GET /api/logs
|
||||
router.get('/', (req, res) => {
|
||||
res.json(getLogs(100));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,26 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getMarches, getMarcheById } = require('../services/baserow');
|
||||
|
||||
// GET /api/marches
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rows = await getMarches();
|
||||
res.json({ count: rows.length, results: rows });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/marches/:id
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await getMarcheById(req.params.id);
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
const status = err.response?.status === 404 ? 404 : 502;
|
||||
res.status(status).json({ error: 'Marché introuvable', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* GET /api/matrice-risque
|
||||
* Matrice de risque : probabilité × impact (délai × avancement)
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getMarches } = require('../services/baserow');
|
||||
const {
|
||||
isCloture, normalizeMarche,
|
||||
getDelaiRestant, niveauAlerte, niveauRisque,
|
||||
parseNum, SEUIL_STANDARD, SEUIL_CRITIQUE_PCT,
|
||||
DELAI_CRITIQUE, DELAI_ATTENTION,
|
||||
} = require('../services/calc');
|
||||
|
||||
/**
|
||||
* Score délai : 1 (ok) → 3 (critique)
|
||||
*/
|
||||
function scoreDelai(delai) {
|
||||
if (delai === null) return 1; // inconnu → faible
|
||||
if (delai <= DELAI_CRITIQUE) return 3;
|
||||
if (delai <= DELAI_ATTENTION) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score avancement : 1 (ok) → 3 (dépassé)
|
||||
*/
|
||||
function scoreAvancement(tauxPhy) {
|
||||
const t = parseNum(tauxPhy);
|
||||
if (t >= SEUIL_CRITIQUE_PCT) return 3;
|
||||
if (t >= SEUIL_STANDARD) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { region, entrepreneur, nature } = req.query;
|
||||
const regionFilter = req.regionFilter;
|
||||
|
||||
let rows = await getMarches();
|
||||
rows = rows.filter(r => !isCloture(r));
|
||||
|
||||
if (regionFilter) rows = rows.filter(r => r.region === regionFilter);
|
||||
else if (region) rows = rows.filter(r => r.region === region);
|
||||
if (entrepreneur) rows = rows.filter(r => String(r.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase()));
|
||||
if (nature) rows = rows.filter(r => String(r.nature || '').toLowerCase().includes(nature.toLowerCase()));
|
||||
|
||||
// Matrice 3×3 : delai (1-3) × avancement (1-3)
|
||||
const matrice = {};
|
||||
for (let d = 1; d <= 3; d++) {
|
||||
for (let a = 1; a <= 3; a++) {
|
||||
matrice[`${d}_${a}`] = { score_delai: d, score_avancement: a, items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
const items = rows.map(r => {
|
||||
const delai = getDelaiRestant(r);
|
||||
const sd = scoreDelai(delai);
|
||||
const sa = scoreAvancement(r.taux_phy ?? r.avt_phy);
|
||||
const risque = niveauRisque(r);
|
||||
const m = normalizeMarche(r);
|
||||
return { ...m, score_delai: sd, score_avancement: sa, niveau_risque: risque };
|
||||
});
|
||||
|
||||
// Remplissage matrice
|
||||
for (const item of items) {
|
||||
const key = `${item.score_delai}_${item.score_avancement}`;
|
||||
if (matrice[key]) matrice[key].items.push(item);
|
||||
}
|
||||
|
||||
// Comptage par niveau de risque
|
||||
const parNiveau = { critique: 0, élevé: 0, moyen: 0, faible: 0 };
|
||||
for (const item of items) {
|
||||
const n = item.niveau_risque;
|
||||
if (parNiveau[n] !== undefined) parNiveau[n]++;
|
||||
else parNiveau[n] = 1;
|
||||
}
|
||||
|
||||
res.json({
|
||||
total: items.length,
|
||||
par_niveau: parNiveau,
|
||||
matrice, // grille 3×3
|
||||
items, // liste complète triée par risque décroissant
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* GET /api/par-region
|
||||
* Agrégation des marchés par région
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getMarches } = require('../services/baserow');
|
||||
const {
|
||||
isCloture, parseNum, formatMontant, getDelaiRestant,
|
||||
niveauAlerte, DELAI_ATTENTION,
|
||||
} = require('../services/calc');
|
||||
|
||||
const ALL_REGIONS = ['Gabes', 'Gafsa', 'Kebili', 'Medenine', 'Sfax', 'Tataouine', 'Tozeur'];
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { nature, entrepreneur, statut } = req.query;
|
||||
const regionFilter = req.regionFilter;
|
||||
|
||||
let rows = await getMarches();
|
||||
|
||||
// Filtres complémentaires
|
||||
if (nature) rows = rows.filter(r => String(r.nature || '').toLowerCase().includes(nature.toLowerCase()));
|
||||
if (entrepreneur) rows = rows.filter(r => String(r.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase()));
|
||||
if (statut) rows = rows.filter(r => String(r.statut || '').toLowerCase().includes(statut.toLowerCase()));
|
||||
|
||||
const activeRows = rows.filter(r => !isCloture(r));
|
||||
|
||||
const regions = (regionFilter ? [regionFilter] : ALL_REGIONS).map(reg => {
|
||||
const regRows = activeRows.filter(r => (r.region || r.region_csc || '') === reg);
|
||||
const regTotal = rows.filter(r => (r.region || r.region_csc || '') === reg);
|
||||
const clotures = regTotal.filter(r => isCloture(r)).length;
|
||||
|
||||
const budget = regRows.reduce((s, r) => s + parseNum(r.tot_marche ?? r.totmarche ?? r.montant), 0);
|
||||
const tauxList = regRows.map(r => parseNum(r.taux_phy ?? r.avt_phy)).filter(v => v > 0);
|
||||
const tauxMoyen = tauxList.length
|
||||
? Math.round(tauxList.reduce((a, b) => a + b, 0) / tauxList.length * 10) / 10
|
||||
: 0;
|
||||
|
||||
const alertes = regRows
|
||||
.map(r => ({ ...r, _delai: getDelaiRestant(r) }))
|
||||
.filter(r => r._delai !== null && r._delai <= DELAI_ATTENTION)
|
||||
.map(r => ({ id: r.id, ref: r.ref || '', projet: r.projet || '', delai_restant: r._delai, niveau: niveauAlerte(r._delai) }))
|
||||
.sort((a, b) => a.delai_restant - b.delai_restant);
|
||||
|
||||
const parNature = {};
|
||||
for (const r of regRows) {
|
||||
const n = r.nature || 'Non défini';
|
||||
parNature[n] = (parNature[n] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
region: reg,
|
||||
actifs: regRows.length,
|
||||
clotures,
|
||||
total: regTotal.length,
|
||||
budget_raw: budget,
|
||||
budget: formatMontant(budget),
|
||||
taux_moyen: tauxMoyen,
|
||||
alertes_count: alertes.length,
|
||||
alertes_critique: alertes.filter(a => a.niveau === 'critique').length,
|
||||
alertes,
|
||||
par_nature: parNature,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ count: regions.length, regions });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* GET /api/pilotage-proactif
|
||||
* Pilotage proactif : classement par niveau d'avancement vs seuils
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getMarches } = require('../services/baserow');
|
||||
const {
|
||||
isCloture, normalizeMarche, parseNum,
|
||||
niveauAvancement, getDelaiRestant, niveauAlerte,
|
||||
SEUIL_STANDARD, SEUIL_MODERNISATION, SEUIL_CRITIQUE_PCT,
|
||||
} = require('../services/calc');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { region, entrepreneur, nature, niveau } = req.query;
|
||||
const regionFilter = req.regionFilter;
|
||||
|
||||
let rows = await getMarches();
|
||||
|
||||
rows = rows.filter(r => !isCloture(r));
|
||||
|
||||
// Filtres
|
||||
if (regionFilter) rows = rows.filter(r => r.region === regionFilter);
|
||||
else if (region) rows = rows.filter(r => r.region === region);
|
||||
if (entrepreneur) rows = rows.filter(r => String(r.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase()));
|
||||
if (nature) rows = rows.filter(r => String(r.nature || '').toLowerCase().includes(nature.toLowerCase()));
|
||||
|
||||
const items = rows.map(r => {
|
||||
const m = normalizeMarche(r);
|
||||
const delai = getDelaiRestant(r);
|
||||
return {
|
||||
...m,
|
||||
delai_restant: delai,
|
||||
niveau_alerte: niveauAlerte(delai),
|
||||
niveau_avancement: niveauAvancement(r.taux_phy ?? r.avt_phy, r.nature),
|
||||
};
|
||||
});
|
||||
|
||||
// Groupement
|
||||
const normal = items.filter(r => r.niveau_avancement === 'normal');
|
||||
const sous_avancement = items.filter(r => r.niveau_avancement === 'sous_avancement');
|
||||
const depasse = items.filter(r => r.niveau_avancement === 'dépassé');
|
||||
|
||||
// Si filtre sur niveau
|
||||
let result = items;
|
||||
if (niveau === 'normal') result = normal;
|
||||
else if (niveau === 'sous') result = sous_avancement;
|
||||
else if (niveau === 'depasse') result = depasse;
|
||||
|
||||
res.json({
|
||||
seuils: {
|
||||
standard: SEUIL_STANDARD,
|
||||
modernisation: SEUIL_MODERNISATION,
|
||||
critique: SEUIL_CRITIQUE_PCT,
|
||||
},
|
||||
resume: {
|
||||
normal: normal.length,
|
||||
sous_avancement: sous_avancement.length,
|
||||
depasse: depasse.length,
|
||||
total: items.length,
|
||||
},
|
||||
normal,
|
||||
sous_avancement,
|
||||
depasse,
|
||||
items: result,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,15 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getPipeline } = require('../services/baserow');
|
||||
|
||||
// GET /api/pipeline
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rows = await getPipeline();
|
||||
res.json({ count: rows.length, results: rows });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,80 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getMarches } = require('../services/baserow');
|
||||
|
||||
const DELAI_CRITIQUE = 45;
|
||||
const DELAI_ATTENTION = 90;
|
||||
|
||||
function parseNum(v) {
|
||||
const n = parseFloat(String(v || '').replace(',', '.'));
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
function getDelaiRestant(r) {
|
||||
if (r.delai_restant != null) return parseInt(r.delai_restant, 10);
|
||||
const fin = r.date_fin || r.datefin;
|
||||
if (!fin) return null;
|
||||
const d = new Date(fin);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return Math.ceil((d - new Date()) / 86400000);
|
||||
}
|
||||
|
||||
function isCloture(r) {
|
||||
const obs = String(r.observation || '').toLowerCase();
|
||||
return obs.includes('clôtur') || obs.includes('clotur') || !!r.date_cloture;
|
||||
}
|
||||
|
||||
// GET /api/stats
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rows = await getMarches();
|
||||
|
||||
const actifs = rows.filter(r => !isCloture(r));
|
||||
|
||||
// Nb marchés par statut
|
||||
const parStatut = {};
|
||||
for (const r of rows) {
|
||||
const s = String(r.statut || 'Inconnu');
|
||||
parStatut[s] = (parStatut[s] || 0) + 1;
|
||||
}
|
||||
|
||||
// Taux d'avancement physique moyen (marchés actifs)
|
||||
const tauxList = actifs.map(r => parseNum(r.taux_phy)).filter(v => v > 0);
|
||||
const tauxMoyen = tauxList.length
|
||||
? Math.round(tauxList.reduce((a, b) => a + b, 0) / tauxList.length * 10) / 10
|
||||
: 0;
|
||||
|
||||
// Alertes délais
|
||||
const alertes = actifs
|
||||
.map(r => ({ ...r, _delai: getDelaiRestant(r) }))
|
||||
.filter(r => r._delai !== null && r._delai <= DELAI_ATTENTION)
|
||||
.map(r => ({
|
||||
id: r.id,
|
||||
ref: r.ref || r.reference || '',
|
||||
entrepreneur: r.entrepreneur || '',
|
||||
projet: r.projet || '',
|
||||
region: r.region || '',
|
||||
avt_fin: parseNum(r.avt_fin ?? r.avtfin),
|
||||
delai_restant: r._delai,
|
||||
niveau: r._delai <= DELAI_CRITIQUE ? 'critique' : 'attention',
|
||||
}));
|
||||
|
||||
res.json({
|
||||
total: rows.length,
|
||||
actifs: actifs.length,
|
||||
clotures: rows.length - actifs.length,
|
||||
par_statut: parStatut,
|
||||
taux_avancement_moyen: tauxMoyen,
|
||||
alertes_delais: {
|
||||
count: alertes.length,
|
||||
critique: alertes.filter(a => a.niveau === 'critique').length,
|
||||
attention: alertes.filter(a => a.niveau === 'attention').length,
|
||||
items: alertes,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* GET /api/synthese
|
||||
* Vue synthèse globale : KPIs, répartitions, alertes, projections
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getMarches } = require('../services/baserow');
|
||||
const {
|
||||
parseNum, formatMontant, isCloture,
|
||||
getDelaiRestant, niveauAlerte, niveauAvancement,
|
||||
DELAI_CRITIQUE, DELAI_ATTENTION,
|
||||
} = require('../services/calc');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { region, nature, entrepreneur, projet } = req.query;
|
||||
const regionFilter = req.regionFilter; // set by filterByRegion middleware
|
||||
|
||||
let rows = await getMarches();
|
||||
|
||||
// Filtres
|
||||
if (regionFilter) rows = rows.filter(r => r.region === regionFilter);
|
||||
else if (region) rows = rows.filter(r => r.region === region);
|
||||
if (nature) rows = rows.filter(r => String(r.nature || '').toLowerCase().includes(nature.toLowerCase()));
|
||||
if (entrepreneur) rows = rows.filter(r => String(r.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase()));
|
||||
if (projet) rows = rows.filter(r => String(r.projet || '').toLowerCase().includes(projet.toLowerCase()));
|
||||
|
||||
const actifs = rows.filter(r => !isCloture(r));
|
||||
const clotures = rows.filter(r => isCloture(r));
|
||||
|
||||
// Montants
|
||||
const totalBudget = actifs.reduce((s, r) => s + parseNum(r.tot_marche ?? r.totmarche ?? r.montant), 0);
|
||||
const totalConsomme = actifs.reduce((s, r) => s + parseNum(r.consomme ?? r.montant_consomme ?? 0), 0);
|
||||
|
||||
// Avancement moyen physique
|
||||
const tauxList = actifs.map(r => parseNum(r.taux_phy ?? r.avt_phy)).filter(v => v > 0);
|
||||
const tauxMoyen = tauxList.length
|
||||
? Math.round(tauxList.reduce((a, b) => a + b, 0) / tauxList.length * 10) / 10
|
||||
: 0;
|
||||
|
||||
// Par statut
|
||||
const parStatut = {};
|
||||
for (const r of rows) {
|
||||
const s = String(r.statut || 'Inconnu');
|
||||
parStatut[s] = (parStatut[s] || 0) + 1;
|
||||
}
|
||||
|
||||
// Par région
|
||||
const parRegion = {};
|
||||
for (const r of actifs) {
|
||||
const reg = r.region || 'Inconnu';
|
||||
if (!parRegion[reg]) parRegion[reg] = { count: 0, taux_sum: 0, taux_count: 0 };
|
||||
parRegion[reg].count++;
|
||||
const t = parseNum(r.taux_phy ?? r.avt_phy);
|
||||
if (t > 0) { parRegion[reg].taux_sum += t; parRegion[reg].taux_count++; }
|
||||
}
|
||||
for (const reg of Object.keys(parRegion)) {
|
||||
const d = parRegion[reg];
|
||||
parRegion[reg].taux_moyen = d.taux_count ? Math.round(d.taux_sum / d.taux_count * 10) / 10 : 0;
|
||||
}
|
||||
|
||||
// Par nature (CAPEX/OPEX)
|
||||
const parNature = {};
|
||||
for (const r of actifs) {
|
||||
const n = r.nature || 'Non défini';
|
||||
parNature[n] = (parNature[n] || 0) + 1;
|
||||
}
|
||||
|
||||
// Alertes délais
|
||||
const alertes = actifs
|
||||
.map(r => ({ ...r, _delai: getDelaiRestant(r) }))
|
||||
.filter(r => r._delai !== null && r._delai <= DELAI_ATTENTION)
|
||||
.map(r => ({
|
||||
id: r.id,
|
||||
ref: r.ref || r.reference || '',
|
||||
projet: r.projet || '',
|
||||
region: r.region || '',
|
||||
entrepreneur: r.entrepreneur || '',
|
||||
delai_restant: r._delai,
|
||||
niveau: niveauAlerte(r._delai),
|
||||
}))
|
||||
.sort((a, b) => a.delai_restant - b.delai_restant);
|
||||
|
||||
// Pilotage proactif (niveaux d'avancement)
|
||||
const pilotage = { normal: 0, sous_avancement: 0, depasse: 0 };
|
||||
for (const r of actifs) {
|
||||
const n = niveauAvancement(r.taux_phy ?? r.avt_phy, r.nature);
|
||||
if (n === 'normal') pilotage.normal++;
|
||||
else if (n === 'sous_avancement') pilotage.sous_avancement++;
|
||||
else pilotage.depasse++;
|
||||
}
|
||||
|
||||
res.json({
|
||||
total: rows.length,
|
||||
actifs: actifs.length,
|
||||
clotures: clotures.length,
|
||||
budget: {
|
||||
total: formatMontant(totalBudget),
|
||||
total_raw: totalBudget,
|
||||
consomme: formatMontant(totalConsomme),
|
||||
consomme_raw: totalConsomme,
|
||||
restant: formatMontant(totalBudget - totalConsomme),
|
||||
restant_raw: totalBudget - totalConsomme,
|
||||
},
|
||||
taux_avancement_moyen: tauxMoyen,
|
||||
par_statut: parStatut,
|
||||
par_region: parRegion,
|
||||
par_nature: parNature,
|
||||
alertes_delais: {
|
||||
count: alertes.length,
|
||||
critique: alertes.filter(a => a.niveau === 'critique').length,
|
||||
attention: alertes.filter(a => a.niveau === 'attention').length,
|
||||
items: alertes,
|
||||
},
|
||||
pilotage,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,53 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { getUsers, saveUsers } = require('../services/users');
|
||||
|
||||
// GET /api/users — liste sans mot de passe
|
||||
router.get('/', (req, res) => {
|
||||
const users = getUsers().map(({ password, ...u }) => u);
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
// POST /api/users — créer un utilisateur
|
||||
router.post('/', async (req, res) => {
|
||||
const { username, password, role = 'user', region = 'all' } = req.body || {};
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'username et password requis' });
|
||||
}
|
||||
|
||||
const users = getUsers();
|
||||
if (users.find(u => u.username === username)) {
|
||||
return res.status(409).json({ error: 'Identifiant déjà utilisé' });
|
||||
}
|
||||
|
||||
const id = Math.max(0, ...users.map(u => u.id || 0)) + 1;
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const newUser = { id, username, password: hash, role, region };
|
||||
|
||||
users.push(newUser);
|
||||
saveUsers(users);
|
||||
|
||||
res.status(201).json({ id, username, role, region });
|
||||
});
|
||||
|
||||
// DELETE /api/users/:id — supprimer un utilisateur
|
||||
router.delete('/:id', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const users = getUsers();
|
||||
const idx = users.findIndex(u => u.id === id);
|
||||
|
||||
if (idx === -1) {
|
||||
return res.status(404).json({ error: 'Utilisateur introuvable' });
|
||||
}
|
||||
if (users[idx].username === req.user.username) {
|
||||
return res.status(400).json({ error: 'Impossible de supprimer son propre compte' });
|
||||
}
|
||||
|
||||
users.splice(idx, 1);
|
||||
saveUsers(users);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user