feat: intégration table 872 — Pipeline AO enrichi + Modernisation succession

1. routes/pipeline.js — enrichissement AO
   - Phase calculée (Préparation/Ouvert/Dépouillé/Évaluation/Attribué)
   - Jours restants avant date-limite
   - Régions avec couleurs config
   - Estimation totale agrégée

2. routes/modernisation.js (nouveau)
   - Croise table 856 (nature Modernisation) avec table 872 (pipeline)
   - Jointure par région
   - Retourne actuel + suivant(s) par région

3. server.js — route /api/modernisation (admin+)

4. index.html
   - Slide 6 Pipeline AO : 8 colonnes, phases badges, région tags, jours alerte
   - Slide 4 Par Région : bloc "AO en lancement" sous chaque carte région
   - Slide 9 Modernisation (nouveau) : vue chaîne actuel → suivant par région
   - Nav : bouton Modernisation (admin+)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nabil Derouiche
2026-04-19 10:59:10 +01:00
co-authored by Claude Sonnet 4.6
parent 7ca0eccb96
commit 0a0ffc31cf
4 changed files with 316 additions and 31 deletions
+95
View File
@@ -0,0 +1,95 @@
/**
* routes/modernisation.js
* Vue "Succession des marchés Modernisation"
* Croise table 856 (marchés actifs, nature=Modernisation) avec table 872 (AO en lancement)
* Lien : region commune entre les deux tables
*/
const express = require('express');
const router = express.Router();
const { getMarches, getPipeline } = require('../services/baserow');
const { normalizeMarche, isCloture, selectVal } = require('../services/calc');
const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'];
function isModernisation(r) {
const nature = selectVal(r.nature);
return nature && nature.toLowerCase().includes('moderni');
}
function pipelineRegions(r) {
const v = r['Regions'] || r.regions || [];
if (!Array.isArray(v)) return [];
return v.map(x => (typeof x === 'object' ? x.value : x)).filter(Boolean);
}
function phaseAO(r) {
const now = new Date();
const limit = r['date-limite'] ? new Date(r['date-limite']) : null;
const ouv = r['date-ouverture-adm-tech'] ? new Date(r['date-ouverture-adm-tech']) : null;
const clos = r['date-cloture-evaluation'] ? new Date(r['date-cloture-evaluation']) : null;
if (clos && now > clos) return { label:'Attribué', code:'attribue', color:'#6b7280' };
if (ouv && now > ouv) return { label:'Évaluation', code:'evaluation', color:'#8b5cf6' };
if (limit && now > limit) return { label:'Dépouillé', code:'depouille', color:'#f59e0b' };
if (limit) return { label:'Ouvert', code:'ouvert', color:'#10b981' };
return { label:'Préparation', code:'preparation', color:'#3b82f6' };
}
function fmtDate(d) {
if (!d) return null;
const dt = new Date(d);
if (isNaN(dt.getTime())) return String(d);
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`;
}
// GET /api/modernisation
router.get('/', async (req, res) => {
try {
const [rawMarches, rawPipeline] = await Promise.all([getMarches(), getPipeline()]);
// Marchés modernisation actifs par région (non clôturés)
const modActifs = rawMarches
.filter(r => !isCloture(r) && isModernisation(r))
.map(normalizeMarche);
// Pour chaque région, construire la chaîne actuel → suivant
const regions = ALL_REGIONS.map(reg => {
const actuels = modActifs.filter(r => (r.region || '') === reg);
const suivants = rawPipeline.filter(r => pipelineRegions(r).includes(reg));
return {
region: reg,
actuels: actuels.map(r => ({
ref: r.ref,
projet: r.projet,
entrepreneur: r.entrepreneur,
taux_phy: r.taux_phy,
taux_fin: r.taux_fin,
date_fin: r.date_fin,
delai_restant: r.delai_restant,
montant: r.montant,
statut: r.statut,
})),
suivants: suivants.map(r => ({
num_ao: r['num-ao'] || '',
description: r['Description du projet'] || '',
estimation: parseFloat(r.Estimation || 0) || 0,
duree: r['Duree'] || '',
date_limite: fmtDate(r['date-limite']),
date_ouverture: fmtDate(r['date-ouverture-adm-tech']),
date_evaluation: fmtDate(r['date-cloture-evaluation']),
phase: phaseAO(r),
jours_limite: r['date-limite']
? Math.ceil((new Date(r['date-limite']) - new Date()) / 86400000)
: null,
})),
};
}).filter(r => r.actuels.length > 0 || r.suivants.length > 0);
res.json({ count: regions.length, regions });
} catch (err) {
res.status(502).json({ error: 'Erreur modernisation', detail: err.message });
}
});
module.exports = router;
+45 -3
View File
@@ -1,12 +1,54 @@
const express = require('express');
const router = express.Router();
const router = express.Router();
const { getPipeline } = require('../services/baserow');
const REGION_COLORS = {
Gabes:'#17A2B8', Gafsa:'#22C55E', Kebili:'#9333EA',
Medenine:'#0EA5E9', Sfax:'#002855', Tataouine:'#14B8A6', Tozeur:'#818CF8',
};
function phaseAO(r) {
const now = new Date();
const limit = r['date-limite'] ? new Date(r['date-limite']) : null;
const ouv = r['date-ouverture-adm-tech'] ? new Date(r['date-ouverture-adm-tech']) : null;
const clos = r['date-cloture-evaluation'] ? new Date(r['date-cloture-evaluation']) : null;
if (clos && now > clos) return { label:'Attribué', code:'attribue', color:'#6b7280' };
if (ouv && now > ouv) return { label:'Évaluation', code:'evaluation', color:'#8b5cf6' };
if (limit && now > limit) return { label:'Dépouillé', code:'depouille', color:'#f59e0b' };
if (limit) return { label:'Ouvert', code:'ouvert', color:'#10b981' };
return { label:'Préparation', code:'preparation', color:'#3b82f6' };
}
function joursAvantLimite(r) {
if (!r['date-limite']) return null;
const d = new Date(r['date-limite']);
if (isNaN(d.getTime())) return null;
return Math.ceil((d - new Date()) / 86400000);
}
function formatRegions(r) {
const v = r['Regions'] || r.regions || [];
if (!Array.isArray(v)) return [];
return v.map(x => ({
name: typeof x === 'object' ? (x.value || '') : x,
color: REGION_COLORS[typeof x === 'object' ? x.value : x] || '#888',
})).filter(x => x.name);
}
// GET /api/pipeline
router.get('/', async (req, res) => {
try {
const rows = await getPipeline();
res.json({ count: rows.length, results: rows });
const rows = await getPipeline();
const enriched = rows.map(r => ({
...r,
_phase: phaseAO(r),
_jours_limite: joursAvantLimite(r),
_regions: formatRegions(r),
_estimation: parseFloat(r.Estimation || r.estimation || 0) || 0,
}));
const totalEstimation = enriched.reduce((s, r) => s + r._estimation, 0);
res.json({ count: enriched.length, total_estimation: totalEstimation, results: enriched });
} catch (err) {
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
}