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:
parent
7ca0eccb96
commit
0a0ffc31cf
201
index.html
201
index.html
|
|
@ -285,6 +285,38 @@
|
||||||
.spinner{width:46px;height:46px;border:4px solid var(--border-color);border-top-color:var(--accent);border-radius:50%;animation:spin 0.9s linear infinite;}
|
.spinner{width:46px;height:46px;border:4px solid var(--border-color);border-top-color:var(--accent);border-radius:50%;animation:spin 0.9s linear infinite;}
|
||||||
@keyframes spin{to{transform:rotate(360deg)}}
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
|
||||||
|
/* ── PIPELINE AO ── */
|
||||||
|
.phase-badge{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:12px;font-size:0.73em;font-weight:700;color:white;}
|
||||||
|
.pipeline-total{padding:11px 16px;text-align:right;border-top:1px solid var(--border-color);font-size:0.85em;color:var(--text-muted);}
|
||||||
|
.pipeline-total strong{color:var(--accent);font-size:1.05em;}
|
||||||
|
.region-tag{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:0.72em;font-weight:700;color:white;margin:1px;}
|
||||||
|
|
||||||
|
/* ── REGION CARD — AO SUIVANT ── */
|
||||||
|
.region-suivant{margin-top:10px;padding-top:10px;border-top:1px dashed var(--border-color);}
|
||||||
|
.region-suivant-title{font-size:0.72em;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;display:flex;align-items:center;gap:5px;}
|
||||||
|
.region-suivant-item{font-size:0.78em;color:var(--text);padding:5px 8px;background:var(--table-header);border-radius:6px;margin-bottom:4px;display:flex;justify-content:space-between;align-items:center;gap:8px;}
|
||||||
|
|
||||||
|
/* ── MODERNISATION SUCCESSION ── */
|
||||||
|
.moderni-region-block{background:var(--bg-card);border-radius:14px;border:1px solid var(--border-color);margin-bottom:18px;overflow:hidden;backdrop-filter:blur(10px);}
|
||||||
|
.moderni-region-header{background:linear-gradient(90deg,var(--primary),var(--primary-light));padding:11px 16px;display:flex;align-items:center;gap:10px;color:white;font-weight:700;}
|
||||||
|
.moderni-region-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0;}
|
||||||
|
.moderni-body{display:grid;grid-template-columns:1fr 1fr;gap:0;}
|
||||||
|
@media(max-width:900px){.moderni-body{grid-template-columns:1fr;}}
|
||||||
|
.moderni-col{padding:14px 16px;}
|
||||||
|
.moderni-col.actuel{border-right:1px solid var(--border-color);}
|
||||||
|
@media(max-width:900px){.moderni-col.actuel{border-right:none;border-bottom:1px solid var(--border-color);}}
|
||||||
|
.moderni-col-title{font-size:0.75em;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:10px;display:flex;align-items:center;gap:6px;}
|
||||||
|
.moderni-col-title.actuel{color:var(--success);}
|
||||||
|
.moderni-col-title.suivant{color:#8b5cf6;}
|
||||||
|
.moderni-card{background:var(--table-header);border-radius:8px;padding:10px 12px;margin-bottom:8px;font-size:0.83em;}
|
||||||
|
.moderni-card:last-child{margin-bottom:0;}
|
||||||
|
.moderni-card .mc-ref{font-weight:700;color:var(--accent);margin-bottom:4px;font-size:0.9em;}
|
||||||
|
.moderni-card .mc-row{display:flex;justify-content:space-between;color:var(--text-muted);margin-top:3px;font-size:0.88em;}
|
||||||
|
.moderni-card .mc-row span:last-child{color:var(--text);font-weight:600;}
|
||||||
|
.moderni-empty{color:var(--text-muted);font-size:0.83em;font-style:italic;padding:8px 0;}
|
||||||
|
.moderni-arrow{display:flex;align-items:center;justify-content:center;font-size:1.4em;color:var(--border-color);padding:0 4px;}
|
||||||
|
@media(max-width:900px){.moderni-arrow{display:none;}}
|
||||||
|
|
||||||
/* ── FOOTER ── */
|
/* ── FOOTER ── */
|
||||||
.footer{text-align:center;padding:22px;color:var(--text-muted);font-size:0.83em;border-top:1px solid var(--border-color);margin-top:28px;}
|
.footer{text-align:center;padding:22px;color:var(--text-muted);font-size:0.83em;border-top:1px solid var(--border-color);margin-top:28px;}
|
||||||
.footer-avatar{width:42px;height:42px;border-radius:50%;border:2px solid var(--accent);margin-bottom:7px;display:inline-flex;align-items:center;justify-content:center;background:var(--primary);color:var(--accent);font-size:1.1em;font-weight:700;}
|
.footer-avatar{width:42px;height:42px;border-radius:50%;border:2px solid var(--accent);margin-bottom:7px;display:inline-flex;align-items:center;justify-content:center;background:var(--primary);color:var(--accent);font-size:1.1em;font-weight:700;}
|
||||||
|
|
@ -387,6 +419,7 @@
|
||||||
<button id="btn-slide-4" onclick="showSlide(4)"><i class="fas fa-map-marker-alt"></i> Par Région</button>
|
<button id="btn-slide-4" onclick="showSlide(4)"><i class="fas fa-map-marker-alt"></i> Par Région</button>
|
||||||
<button id="btn-slide-5" onclick="showSlide(5)"><i class="fas fa-list-alt"></i> Marchés</button>
|
<button id="btn-slide-5" onclick="showSlide(5)"><i class="fas fa-list-alt"></i> Marchés</button>
|
||||||
<button id="btn-slide-6" class="nav-hidden" onclick="showSlide(6)"><i class="fas fa-stream"></i> Pipeline AO</button>
|
<button id="btn-slide-6" class="nav-hidden" onclick="showSlide(6)"><i class="fas fa-stream"></i> Pipeline AO</button>
|
||||||
|
<button id="btn-slide-9" class="nav-hidden" onclick="showSlide(9)"><i class="fas fa-link"></i> Modernisation</button>
|
||||||
<button id="btn-slide-7" class="nav-hidden" onclick="showSlide(7)"><i class="fas fa-users-cog"></i> Utilisateurs</button>
|
<button id="btn-slide-7" class="nav-hidden" onclick="showSlide(7)"><i class="fas fa-users-cog"></i> Utilisateurs</button>
|
||||||
<button id="btn-slide-8" class="nav-hidden" onclick="showSlide(8)"><i class="fas fa-history"></i> Logs</button>
|
<button id="btn-slide-8" class="nav-hidden" onclick="showSlide(8)"><i class="fas fa-history"></i> Logs</button>
|
||||||
<span class="nav-separator"></span>
|
<span class="nav-separator"></span>
|
||||||
|
|
@ -613,18 +646,19 @@
|
||||||
<h2 class="section-title"><i class="fas fa-stream" style="color:#6366F1"></i> Pipeline Appels d'Offres</h2>
|
<h2 class="section-title"><i class="fas fa-stream" style="color:#6366F1"></i> Pipeline Appels d'Offres</h2>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<div class="table-header" style="background:linear-gradient(90deg,#4F46E5,#6366F1);">
|
<div class="table-header" style="background:linear-gradient(90deg,#4F46E5,#6366F1);">
|
||||||
<h3><i class="fas fa-rocket"></i> Projets en Préparation</h3>
|
<h3><i class="fas fa-rocket"></i> AO en cours de lancement</h3>
|
||||||
<span class="badge" id="pipeline-count">0 projets</span>
|
<span class="badge" id="pipeline-count">0 projets</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>Description du projet</th><th>Régions</th><th>Estimation (DT)</th>
|
<th>N° AO</th><th>Description</th><th>Phase</th><th>Régions</th>
|
||||||
<th>Durée (mois)</th><th>Date prévisionnelle DCA</th>
|
<th>Estimation (DT)</th><th>Durée</th><th>Date limite</th><th>Jours rest.</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody id="pipelineBody"><tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
|
<tbody id="pipelineBody"><tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pipeline-total">Estimation totale : <strong id="pipelineTotalEstimation">—</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -666,6 +700,15 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ── SLIDE 9 : MODERNISATION ── -->
|
||||||
|
<section class="slide" id="slide-9">
|
||||||
|
<h2 class="section-title"><i class="fas fa-link" style="color:#8b5cf6"></i> Modernisation — Succession des Marchés</h2>
|
||||||
|
<p style="color:var(--text-muted);font-size:0.85em;margin-bottom:18px;">
|
||||||
|
Croisement entre les marchés <strong>Modernisation actifs</strong> (table 856) et les <strong>AO en cours de lancement</strong> (table 872), par région.
|
||||||
|
</p>
|
||||||
|
<div id="modernisationGrid"><p style="color:var(--text-muted);">Chargement...</p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
|
|
@ -824,6 +867,7 @@ function applyRoleUI() {
|
||||||
const showAdmin = role !== 'user';
|
const showAdmin = role !== 'user';
|
||||||
const showSuper = role === 'superadmin';
|
const showSuper = role === 'superadmin';
|
||||||
document.getElementById('btn-slide-6').classList.toggle('nav-hidden', !showAdmin);
|
document.getElementById('btn-slide-6').classList.toggle('nav-hidden', !showAdmin);
|
||||||
|
document.getElementById('btn-slide-9').classList.toggle('nav-hidden', !showAdmin);
|
||||||
document.getElementById('btn-slide-7').classList.toggle('nav-hidden', !showSuper);
|
document.getElementById('btn-slide-7').classList.toggle('nav-hidden', !showSuper);
|
||||||
document.getElementById('btn-slide-8').classList.toggle('nav-hidden', !showSuper);
|
document.getElementById('btn-slide-8').classList.toggle('nav-hidden', !showSuper);
|
||||||
document.getElementById('btnExportPPTX').classList.toggle('nav-hidden', !showSuper);
|
document.getElementById('btnExportPPTX').classList.toggle('nav-hidden', !showSuper);
|
||||||
|
|
@ -852,28 +896,31 @@ function handle401() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── DATA ── */
|
/* ── DATA ── */
|
||||||
let allData = [], filteredData = [], pipelineData = [], proactifData = null, statsData = null;
|
let allData = [], filteredData = [], pipelineData = [], proactifData = null, statsData = null, modernisationData = null;
|
||||||
let sortField = null, sortAsc = true, currentPage = 1, pageSize = 25;
|
let sortField = null, sortAsc = true, currentPage = 1, pageSize = 25;
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
showLoading(true);
|
showLoading(true);
|
||||||
try {
|
try {
|
||||||
const isUser = currentUser?.role === 'user';
|
const isUser = currentUser?.role === 'user';
|
||||||
const [rMarches, rStats, rPilotage, rPipeline] = await Promise.all([
|
const [rMarches, rStats, rPilotage, rPipeline, rModerni] = await Promise.all([
|
||||||
fetch(`${API_BASE}/marches`, { headers: apiHeaders() }),
|
fetch(`${API_BASE}/marches`, { headers: apiHeaders() }),
|
||||||
fetch(`${API_BASE}/stats`, { headers: apiHeaders() }),
|
fetch(`${API_BASE}/stats`, { headers: apiHeaders() }),
|
||||||
fetch(`${API_BASE}/pilotage-proactif`, { headers: apiHeaders() }),
|
fetch(`${API_BASE}/pilotage-proactif`, { headers: apiHeaders() }),
|
||||||
isUser ? null : fetch(`${API_BASE}/pipeline`, { headers: apiHeaders() }),
|
isUser ? null : fetch(`${API_BASE}/pipeline`, { headers: apiHeaders() }),
|
||||||
|
isUser ? null : fetch(`${API_BASE}/modernisation`, { headers: apiHeaders() }),
|
||||||
]);
|
]);
|
||||||
if (rMarches.status === 401) { handle401(); return; }
|
if (rMarches.status === 401) { handle401(); return; }
|
||||||
if (!rMarches.ok) throw new Error('Erreur marchés ' + rMarches.status);
|
if (!rMarches.ok) throw new Error('Erreur marchés ' + rMarches.status);
|
||||||
const marchesJson = await rMarches.json();
|
const marchesJson = await rMarches.json();
|
||||||
statsData = rStats?.ok ? await rStats.json() : null;
|
statsData = rStats?.ok ? await rStats.json() : null;
|
||||||
proactifData = rPilotage?.ok ? await rPilotage.json() : null;
|
proactifData = rPilotage?.ok ? await rPilotage.json() : null;
|
||||||
const pipelineJson = (!isUser && rPipeline?.ok) ? await rPipeline.json() : { results: [] };
|
const pipelineJson = (!isUser && rPipeline?.ok) ? await rPipeline.json() : { count:0, total_estimation:0, results:[] };
|
||||||
|
modernisationData = (!isUser && rModerni?.ok) ? await rModerni.json() : null;
|
||||||
allData = (marchesJson.results || marchesJson).map(normalizeMarche);
|
allData = (marchesJson.results || marchesJson).map(normalizeMarche);
|
||||||
filteredData = [...allData];
|
filteredData = [...allData];
|
||||||
pipelineData = pipelineJson.results || pipelineJson;
|
pipelineData = pipelineJson.results || pipelineJson;
|
||||||
|
if (pipelineJson.total_estimation) pipelineData._total_estimation = pipelineJson.total_estimation;
|
||||||
document.getElementById('lastUpdate').textContent =
|
document.getElementById('lastUpdate').textContent =
|
||||||
new Date().toLocaleTimeString('fr-FR', { hour:'2-digit', minute:'2-digit' });
|
new Date().toLocaleTimeString('fr-FR', { hour:'2-digit', minute:'2-digit' });
|
||||||
renderAll();
|
renderAll();
|
||||||
|
|
@ -884,7 +931,7 @@ async function loadData() {
|
||||||
|
|
||||||
function renderAll() {
|
function renderAll() {
|
||||||
renderKPIs(); renderSynthese(); renderService(); renderProactif();
|
renderKPIs(); renderSynthese(); renderService(); renderProactif();
|
||||||
renderRegions(); renderMarches(); renderPipeline(); updateBadges();
|
renderRegions(); renderMarches(); renderPipeline(); renderModernisation(); updateBadges();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── KPIs ── */
|
/* ── KPIs ── */
|
||||||
|
|
@ -1089,6 +1136,23 @@ function renderRegions() {
|
||||||
const budget = rows.reduce((s,r) => s + r.tot_marche, 0);
|
const budget = rows.reduce((s,r) => s + r.tot_marche, 0);
|
||||||
const alertes = rows.filter(r => { const d = getDelaiRestant(r); return d !== null && d <= 90; });
|
const alertes = rows.filter(r => { const d = getDelaiRestant(r); return d !== null && d <= 90; });
|
||||||
const color = REGION_COLORS[reg] || '#888';
|
const color = REGION_COLORS[reg] || '#888';
|
||||||
|
// AO en lancement pour cette région
|
||||||
|
const aoReg = pipelineData.filter(p => (p._regions||[]).some(rg => rg.name === reg));
|
||||||
|
const aoHtml = aoReg.length > 0
|
||||||
|
? `<div class="region-suivant">
|
||||||
|
<div class="region-suivant-title"><i class="fas fa-rocket"></i> AO en lancement</div>
|
||||||
|
${aoReg.map(p => {
|
||||||
|
const phase = p._phase || {};
|
||||||
|
const jours = p._jours_limite;
|
||||||
|
const joursStr = jours === null ? '' : jours < 0 ? ' — passé' : ` — ${jours}j`;
|
||||||
|
return `<div class="region-suivant-item">
|
||||||
|
<span>${escapeHtml(p['num-ao']||'—')} · ${escapeHtml(p['Description du projet']||'')}</span>
|
||||||
|
<span class="phase-badge" style="background:${phase.color||'#888'};font-size:0.68em">${phase.label||''}${joursStr}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `<div class="region-card">
|
return `<div class="region-card">
|
||||||
<div class="region-header">
|
<div class="region-header">
|
||||||
<div class="region-dot" style="background:${color}"></div>
|
<div class="region-dot" style="background:${color}"></div>
|
||||||
|
|
@ -1101,6 +1165,7 @@ function renderRegions() {
|
||||||
<div class="region-stat"><div class="value" style="color:var(--danger)">${alertes.length}</div><div class="label">Alertes délais</div></div>
|
<div class="region-stat"><div class="value" style="color:var(--danger)">${alertes.length}</div><div class="label">Alertes délais</div></div>
|
||||||
<div class="region-stat"><div class="value">${allData.filter(r => isCloture(r) && r.region === reg).length}</div><div class="label">Clôturés</div></div>
|
<div class="region-stat"><div class="value">${allData.filter(r => isCloture(r) && r.region === reg).length}</div><div class="label">Clôturés</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
${aoHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
@ -1214,29 +1279,111 @@ function goPage(p) { currentPage = p; renderMarchesTable(); }
|
||||||
function changePageSize(n) { pageSize = parseInt(n,10); currentPage = 1; renderMarchesTable(); }
|
function changePageSize(n) { pageSize = parseInt(n,10); currentPage = 1; renderMarchesTable(); }
|
||||||
|
|
||||||
/* ── PIPELINE ── */
|
/* ── PIPELINE ── */
|
||||||
function pipelineRegions(r) {
|
|
||||||
const v = r['Regions'] || r.Regions || [];
|
|
||||||
if (Array.isArray(v)) return v.map(x => (typeof x === 'object' ? x.value : x)).filter(Boolean).join(', ');
|
|
||||||
return String(v || '—');
|
|
||||||
}
|
|
||||||
function renderPipeline() {
|
function renderPipeline() {
|
||||||
|
const total = pipelineData._total_estimation ?? 0;
|
||||||
document.getElementById('pipeline-count').textContent = `${pipelineData.length} projets`;
|
document.getElementById('pipeline-count').textContent = `${pipelineData.length} projets`;
|
||||||
|
document.getElementById('pipelineTotalEstimation').textContent =
|
||||||
|
total > 0 ? parseNum(total).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g,' ') + ' DT' : '—';
|
||||||
|
|
||||||
document.getElementById('pipelineBody').innerHTML = pipelineData.length
|
document.getElementById('pipelineBody').innerHTML = pipelineData.length
|
||||||
? pipelineData.map(r => {
|
? pipelineData.map(r => {
|
||||||
const desc = r['Description du projet'] || r.description || r.projet || '—';
|
const phase = r._phase || {};
|
||||||
const regs = pipelineRegions(r) || '—';
|
const jours = r._jours_limite;
|
||||||
const est = r['Estimation'] ?? r.estimation ?? '';
|
const regs = (r._regions || []);
|
||||||
const dur = r['Duree'] ?? r.Duree ?? r.duree ?? '';
|
const est = r._estimation || parseFloat(r.Estimation || 0) || 0;
|
||||||
const date = r['Date_previsionnelle_de_la_communication_du_projet_a_la_DCA'] || r.date_prevue || '';
|
const numAO = r['num-ao'] || '—';
|
||||||
|
const desc = r['Description du projet'] || r.description || '—';
|
||||||
|
const dur = r['Duree'] || r.duree || '—';
|
||||||
|
const dateLim = r['date-limite'] ? formatDateFR(r['date-limite']) : '—';
|
||||||
|
|
||||||
|
const joursHtml = jours === null ? '—'
|
||||||
|
: jours < 0 ? `<span class="status-badge muted">Passé</span>`
|
||||||
|
: jours <= 7 ? `<strong style="color:var(--danger)">${jours}j</strong>`
|
||||||
|
: jours <= 30? `<strong style="color:var(--warning)">${jours}j</strong>`
|
||||||
|
: `<span style="color:var(--success)">${jours}j</span>`;
|
||||||
|
|
||||||
|
const regHtml = regs.map(rg =>
|
||||||
|
`<span class="region-tag" style="background:${rg.color}">${escapeHtml(rg.name)}</span>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td><strong>${escapeHtml(desc)}</strong></td>
|
<td><strong>${escapeHtml(numAO)}</strong></td>
|
||||||
<td>${escapeHtml(regs)}</td>
|
<td>${escapeHtml(desc)}</td>
|
||||||
<td>${est ? escapeHtml(String(est)) : '—'}</td>
|
<td><span class="phase-badge" style="background:${phase.color||'#888'}">${escapeHtml(phase.label||'—')}</span></td>
|
||||||
<td>${dur ? escapeHtml(String(dur)) : '—'}</td>
|
<td>${regHtml || '—'}</td>
|
||||||
<td>${formatDateFR(date)}</td>
|
<td>${est > 0 ? est.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g,' ') + ' DT' : '—'}</td>
|
||||||
|
<td style="font-size:0.82em">${escapeHtml(dur)}</td>
|
||||||
|
<td style="white-space:nowrap">${dateLim}</td>
|
||||||
|
<td>${joursHtml}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('')
|
}).join('')
|
||||||
: '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:28px;">Pipeline vide.</td></tr>';
|
: '<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:28px;">Pipeline vide.</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── MODERNISATION ── */
|
||||||
|
function renderModernisation() {
|
||||||
|
const grid = document.getElementById('modernisationGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
if (!modernisationData) {
|
||||||
|
grid.innerHTML = '<p style="color:var(--text-muted);font-size:0.85em;">Données non disponibles (accès admin requis).</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const regions = modernisationData.regions || [];
|
||||||
|
if (!regions.length) {
|
||||||
|
grid.innerHTML = '<p style="color:var(--text-muted);">Aucun marché modernisation trouvé.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = regions.map(reg => {
|
||||||
|
const color = REGION_COLORS[reg.region] || '#888';
|
||||||
|
|
||||||
|
const actuelsHtml = reg.actuels.length
|
||||||
|
? reg.actuels.map(m => `
|
||||||
|
<div class="moderni-card">
|
||||||
|
<div class="mc-ref">${escapeHtml(m.ref||'—')}</div>
|
||||||
|
<div class="mc-row"><span>Projet</span><span>${escapeHtml(m.projet||'—')}</span></div>
|
||||||
|
<div class="mc-row"><span>Entrepreneur</span><span>${escapeHtml(m.entrepreneur||'—')}</span></div>
|
||||||
|
<div class="mc-row"><span>Avt. physique</span><span>${escapeHtml(String(m.taux_phy||'—'))}</span></div>
|
||||||
|
<div class="mc-row"><span>Délai restant</span><span style="color:${(m.delai_restant??999)<=45?'var(--danger)':(m.delai_restant??999)<=90?'var(--warning)':'var(--success)'}">${m.delai_restant!=null?m.delai_restant+'j':'—'}</span></div>
|
||||||
|
<div class="mc-row"><span>Fin</span><span>${escapeHtml(m.date_fin||'—')}</span></div>
|
||||||
|
<div class="mc-row"><span>Montant</span><span>${escapeHtml(m.montant||'—')}</span></div>
|
||||||
|
</div>`).join('')
|
||||||
|
: '<div class="moderni-empty">Aucun marché modernisation actif</div>';
|
||||||
|
|
||||||
|
const suivantsHtml = reg.suivants.length
|
||||||
|
? reg.suivants.map(s => {
|
||||||
|
const jours = s.jours_limite;
|
||||||
|
const joursStyle = jours===null?'' : jours<0?'color:var(--text-muted)' : jours<=7?'color:var(--danger)' : jours<=30?'color:var(--warning)':'color:var(--success)';
|
||||||
|
return `
|
||||||
|
<div class="moderni-card">
|
||||||
|
<div class="mc-ref" style="color:#8b5cf6">${escapeHtml(s.num_ao||'—')}</div>
|
||||||
|
<div class="mc-row"><span>Description</span><span>${escapeHtml(s.description||'—')}</span></div>
|
||||||
|
<div class="mc-row"><span>Phase</span><span><span class="phase-badge" style="background:${s.phase?.color||'#888'};font-size:0.7em">${s.phase?.label||'—'}</span></span></div>
|
||||||
|
<div class="mc-row"><span>Estimation</span><span>${s.estimation>0?(s.estimation).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g,' ')+' DT':'—'}</span></div>
|
||||||
|
<div class="mc-row"><span>Durée</span><span>${escapeHtml(s.duree||'—')}</span></div>
|
||||||
|
<div class="mc-row"><span>Date limite</span><span style="${joursStyle}">${escapeHtml(s.date_limite||'—')}${jours!==null?' ('+jours+'j)':''}</span></div>
|
||||||
|
${s.date_ouverture?`<div class="mc-row"><span>Ouverture</span><span>${escapeHtml(s.date_ouverture)}</span></div>`:''}
|
||||||
|
</div>`;
|
||||||
|
}).join('')
|
||||||
|
: '<div class="moderni-empty">Aucun AO en cours de lancement</div>';
|
||||||
|
|
||||||
|
return `<div class="moderni-region-block">
|
||||||
|
<div class="moderni-region-header">
|
||||||
|
<div class="moderni-region-dot" style="background:${color}"></div>
|
||||||
|
<span>${reg.region}</span>
|
||||||
|
<span style="margin-left:auto;opacity:0.75;font-size:0.82em">${reg.actuels.length} actuel${reg.actuels.length>1?'s':''} · ${reg.suivants.length} AO suivant${reg.suivants.length>1?'s':''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="moderni-body">
|
||||||
|
<div class="moderni-col actuel">
|
||||||
|
<div class="moderni-col-title actuel"><i class="fas fa-play-circle"></i> Marché actuel</div>
|
||||||
|
${actuelsHtml}
|
||||||
|
</div>
|
||||||
|
<div class="moderni-col suivant">
|
||||||
|
<div class="moderni-col-title suivant"><i class="fas fa-arrow-right"></i> AO en préparation / lancement</div>
|
||||||
|
${suivantsHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* charts supprimés — remplacés par jauges CSS dans renderSynthese() */
|
/* charts supprimés — remplacés par jauges CSS dans renderSynthese() */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,12 +1,54 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getPipeline } = require('../services/baserow');
|
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
|
// GET /api/pipeline
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const rows = await getPipeline();
|
const rows = await getPipeline();
|
||||||
res.json({ count: rows.length, results: rows });
|
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) {
|
} catch (err) {
|
||||||
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ app.use('/api/export', auth, requireUser, filterByRegion, require('./r
|
||||||
|
|
||||||
// ─── Protégées (admin+) ──────────────────────────────────────────────────────
|
// ─── Protégées (admin+) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.use('/api/pipeline', auth, requireAdmin, require('./routes/pipeline'));
|
app.use('/api/pipeline', auth, requireAdmin, require('./routes/pipeline'));
|
||||||
|
app.use('/api/modernisation', auth, requireAdmin, require('./routes/modernisation'));
|
||||||
|
|
||||||
// ─── Protégées (superadmin) ──────────────────────────────────────────────────
|
// ─── Protégées (superadmin) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue