feat: Vue Générale — synthèse exécutive complète

- Phrase de situation dynamique (actifs, avancement moyen, alertes)
- 3 blocs statut colorés : Critiques / Attention / Dans les délais
- Jauges d'avancement par région (CSS, couleurs config.js)
- Table complète de TOUS les marchés en alerte par ordre de priorité
  avec numéro de priorité, délai coloré rouge/orange
- Suppression donut chart, bar charts et alertes-preview limités
- Suppression Chart.js (plus nécessaire)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nabil Derouiche 2026-04-19 10:17:54 +01:00
parent b02a5c3f9f
commit 2074893978
1 changed files with 167 additions and 109 deletions

View File

@ -6,7 +6,6 @@
<title>Marchés RLA - Zone Sud | Tunisie Telecom</title>
<link rel="icon" type="image/svg+xml" href="logo-RLA.svg">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js" defer></script>
<script src="config.js"></script>
<style>
:root {
@ -186,6 +185,40 @@
.status-badge.admin{background:rgba(59,130,246,0.15);color:#3b82f6;}
.status-badge.user{background:rgba(16,185,129,0.18);color:#10b981;}
/* ── SITUATION PHRASE ── */
.situation-phrase{background:var(--bg-card);border:1px solid var(--border-color);border-left:4px solid var(--accent);border-radius:10px;padding:14px 20px;margin-bottom:20px;font-size:0.97em;color:var(--text);backdrop-filter:blur(10px);line-height:1.6;}
.situation-phrase strong{color:var(--accent);}
.situation-phrase .situ-danger{color:var(--danger);font-weight:700;}
.situation-phrase .situ-warn{color:var(--warning);font-weight:700;}
.situation-phrase .situ-ok{color:var(--success);font-weight:700;}
/* ── STATUT BLOCS + SYNTHESE ROW ── */
.synthese-row{display:grid;grid-template-columns:220px 1fr;gap:16px;margin-bottom:22px;align-items:start;}
@media(max-width:900px){.synthese-row{grid-template-columns:1fr;}}
.statut-blocs{display:flex;flex-direction:column;gap:10px;}
.statut-bloc{border-radius:14px;padding:16px 18px;text-align:center;border:1px solid var(--border-color);backdrop-filter:blur(10px);}
.statut-bloc.critique{background:rgba(239,68,68,0.12);border-color:rgba(239,68,68,0.35);}
.statut-bloc.attention{background:rgba(245,158,11,0.12);border-color:rgba(245,158,11,0.35);}
.statut-bloc.ok{background:rgba(16,185,129,0.10);border-color:rgba(16,185,129,0.35);}
.statut-bloc-value{font-size:2.2em;font-weight:800;line-height:1;}
.statut-bloc.critique .statut-bloc-value{color:var(--danger);}
.statut-bloc.attention .statut-bloc-value{color:var(--warning);}
.statut-bloc.ok .statut-bloc-value{color:var(--success);}
.statut-bloc-label{font-size:0.8em;color:var(--text-muted);margin-top:5px;}
/* ── REGION JAUGES ── */
.region-jauge-row{display:flex;align-items:center;gap:10px;margin-bottom:9px;}
.region-jauge-name{width:72px;font-size:0.8em;font-weight:600;color:var(--text);text-align:right;flex-shrink:0;}
.region-jauge-track{flex:1;height:16px;background:var(--border-color);border-radius:8px;overflow:hidden;position:relative;}
.region-jauge-fill{height:100%;border-radius:8px;transition:width 0.7s ease-out;display:flex;align-items:center;justify-content:flex-end;padding-right:6px;}
.region-jauge-fill span{font-size:0.72em;font-weight:700;color:white;white-space:nowrap;}
.region-jauge-meta{font-size:0.73em;color:var(--text-muted);width:56px;flex-shrink:0;}
/* ── PRIORITE BADGE ── */
.prio-badge{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;font-size:0.75em;font-weight:800;}
.prio-badge.p1{background:var(--danger);color:white;}
.prio-badge.p2{background:var(--warning);color:white;}
/* ── ALERT CARDS ── */
.alert-list{display:flex;flex-direction:column;gap:10px;}
.alert-card{background:var(--bg-card);border:1px solid var(--border-color);border-radius:12px;padding:14px 16px;display:flex;align-items:center;gap:14px;backdrop-filter:blur(10px);}
@ -371,30 +404,62 @@
<!-- ── SLIDE 0 : VUE GÉNÉRALE ── -->
<section class="slide active" id="slide-0">
<h2 class="section-title"><i class="fas fa-chart-pie"></i> Vue Générale</h2>
<!-- Phrase de situation -->
<div class="situation-phrase" id="situationPhrase">
<i class="fas fa-circle-notch fa-spin" style="color:var(--accent)"></i> Chargement de la situation...
</div>
<!-- KPIs -->
<div class="kpi-grid">
<div class="kpi-card"><div class="icon" style="color:var(--accent);"><i class="fas fa-folder-open"></i></div><div class="value" id="kpiTotal"></div><div class="label">Total Marchés</div></div>
<div class="kpi-card capex"><div class="icon" style="color:var(--success);"><i class="fas fa-play-circle"></i></div><div class="value" id="kpiActifs"></div><div class="label">Marchés Actifs</div><div class="sub" id="kpiAvt">Avancement moy. : —</div></div>
<div class="kpi-card alertes"><div class="icon" style="color:var(--danger);"><i class="fas fa-exclamation-triangle"></i></div><div class="value" id="kpiAlertes"></div><div class="label">Alertes Délais</div><div class="sub" id="kpiCritiques">Critiques (≤45j) : —</div></div>
<div class="kpi-card clotures"><div class="icon" style="color:#6b7280;"><i class="fas fa-archive"></i></div><div class="value" id="kpiClotures"></div><div class="label">Clôturés</div></div>
</div>
<div class="charts-row">
<div class="chart-card">
<div class="chart-card-title"><i class="fas fa-chart-donut"></i> Répartition par statut</div>
<div class="chart-container"><canvas id="chartStatut"></canvas></div>
<!-- Blocs statut + Jauges région -->
<div class="synthese-row">
<!-- 3 blocs de statut -->
<div class="statut-blocs">
<div class="statut-bloc critique">
<div class="statut-bloc-value" id="blocCritique"></div>
<div class="statut-bloc-label"><i class="fas fa-fire"></i> Critiques <span style="opacity:0.7;font-size:0.85em">≤ 45j</span></div>
</div>
<div class="chart-card">
<div class="chart-card-title"><i class="fas fa-exclamation-triangle" style="color:var(--danger)"></i> Marchés en alerte — délais proches</div>
<div class="alert-list" id="alertesPreview"><p style="color:var(--text-muted);font-size:0.85em;padding:8px 0;">Chargement...</p></div>
<div class="statut-bloc attention">
<div class="statut-bloc-value" id="blocAttention"></div>
<div class="statut-bloc-label"><i class="fas fa-exclamation-triangle"></i> Attention <span style="opacity:0.7;font-size:0.85em">4590j</span></div>
</div>
<div class="statut-bloc ok">
<div class="statut-bloc-value" id="blocOk"></div>
<div class="statut-bloc-label"><i class="fas fa-check-circle"></i> Dans les délais</div>
</div>
</div>
<div class="charts-row-3">
<div class="chart-card">
<div class="chart-card-title"><i class="fas fa-map-marker-alt"></i> Avancement moyen par région</div>
<div class="chart-container tall"><canvas id="chartRegion"></canvas></div>
<!-- Jauges par région -->
<div class="chart-card" style="flex:1">
<div class="chart-card-title"><i class="fas fa-map-marker-alt"></i> Avancement physique par région</div>
<div id="regionJauges"></div>
</div>
<div class="chart-card">
<div class="chart-card-title"><i class="fas fa-chart-bar"></i> Marchés actifs par région</div>
<div class="chart-container tall"><canvas id="chartRegionCount"></canvas></div>
</div>
<!-- Tous les marchés en alerte par priorité -->
<div class="table-container" id="syntheseAlerteContainer">
<div class="table-header" style="background:linear-gradient(90deg,#b91c1c,#dc2626);">
<h3><i class="fas fa-fire"></i> Marchés à surveiller — par ordre de priorité</h3>
<span class="badge" id="syntheseAlerteBadge">0 alertes</span>
</div>
<div class="table-wrapper">
<table>
<thead><tr>
<th>Priorité</th><th>Référence</th><th>Entrepreneur</th><th>Projet</th>
<th>Région</th><th>Avt. Phy.</th><th>Délai Rest.</th><th>Niveau</th>
</tr></thead>
<tbody id="syntheseAlerteTable"><tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
</table>
</div>
<div style="padding:10px 16px;text-align:right;border-top:1px solid var(--border-color);">
<button class="btn-action btn-secondary" onclick="showSlide(1)"><i class="fas fa-arrow-right"></i> Voir slide Alertes complète</button>
</div>
</div>
</section>
@ -812,9 +877,8 @@ async function loadData() {
}
function renderAll() {
renderKPIs(); renderAlertes(); renderService(); renderProactif();
renderRegions(); renderMarches(); renderPipeline();
renderChartStatut(); renderChartRegion(); updateBadges();
renderKPIs(); renderSynthese(); renderService(); renderProactif();
renderRegions(); renderMarches(); renderPipeline(); updateBadges();
}
/* ── KPIs ── */
@ -828,7 +892,85 @@ function renderKPIs() {
document.getElementById('kpiCritiques').textContent = `Critiques (≤45j) : ${statsData.alertes_delais?.critique ?? '—'}`;
}
/* ── ALERTES ── */
/* ── SYNTHÈSE VUE GÉNÉRALE ── */
function buildAlertList() {
return allData
.filter(r => !isCloture(r))
.map(r => ({ r, delai: getDelaiRestant(r) }))
.filter(x => x.delai !== null && x.delai <= 90)
.sort((a, b) => a.delai - b.delai);
}
function renderSynthese() {
const alertes = buildAlertList();
const actifs = allData.filter(r => !isCloture(r));
const critiques = alertes.filter(x => x.delai <= 45).length;
const attention = alertes.filter(x => x.delai > 45 && x.delai <= 90).length;
const dansDelais = actifs.length - alertes.length;
const avts = actifs.map(r => r.taux_phy).filter(v => v > 0);
const avgAvt = avts.length ? Math.round(avts.reduce((a,b) => a+b,0) / avts.length) : 0;
// Phrase de situation
let phraseClass = critiques > 0 ? 'situ-danger' : attention > 0 ? 'situ-warn' : 'situ-ok';
let phraseIcon = critiques > 0 ? '🔴' : attention > 0 ? '🟠' : '🟢';
let phraseAlerte = critiques > 0
? `<span class="situ-danger">${critiques} marché${critiques>1?'s':''} critique${critiques>1?'s':''} (≤ 45j)</span>${attention > 0 ? ` et <span class="situ-warn">${attention} en attention</span>` : ''}`
: attention > 0
? `<span class="situ-warn">${attention} marché${attention>1?'s':''} en attention (4590j)</span>`
: `<span class="situ-ok">aucune alerte délai</span>`;
document.getElementById('situationPhrase').innerHTML =
`${phraseIcon} <strong>${actifs.length} marchés actifs</strong> — avancement physique moyen <strong>${avgAvt}%</strong>. ` +
`${phraseAlerte}. <strong>${allData.filter(r => isCloture(r)).length} clôturé${allData.filter(r=>isCloture(r)).length>1?'s':''}</strong>.`;
// Blocs statut
document.getElementById('blocCritique').textContent = critiques;
document.getElementById('blocAttention').textContent = attention;
document.getElementById('blocOk').textContent = Math.max(0, dansDelais);
// Jauges par région
document.getElementById('regionJauges').innerHTML = ALL_REGIONS.map(reg => {
const rows = actifs.filter(r => r.region === reg);
const avts2 = rows.map(r => r.taux_phy).filter(v => v > 0);
const avg2 = avts2.length ? Math.round(avts2.reduce((a,b)=>a+b,0)/avts2.length) : 0;
const color = REGION_COLORS[reg] || '#888';
const alReg = alertes.filter(x => x.r.region === reg).length;
const alerteIcon = alReg > 0 ? `<span style="color:var(--danger);font-weight:700">⚠ ${alReg}</span>` : `<span style="color:var(--success)"></span>`;
return `<div class="region-jauge-row">
<div class="region-jauge-name">${reg}</div>
<div class="region-jauge-track">
<div class="region-jauge-fill" style="width:${avg2}%;background:${color}">
${avg2 >= 30 ? `<span>${avg2}%</span>` : ''}
</div>
</div>
<div class="region-jauge-meta">${avg2 < 30 ? avg2+'% ' : ''}${alerteIcon} <span style="color:var(--text-muted)">(${rows.length})</span></div>
</div>`;
}).join('');
// Table alertes complète par priorité
document.getElementById('syntheseAlerteBadge').textContent = `${alertes.length} alerte${alertes.length>1?'s':''}`;
document.getElementById('syntheseAlerteTable').innerHTML = alertes.length
? alertes.map((x, i) => {
const niveau = x.delai <= 45 ? 'critique' : 'attention';
const pClass = x.delai <= 45 ? 'p1' : 'p2';
return `<tr>
<td><span class="prio-badge ${pClass}">${i+1}</span></td>
<td><strong>${escapeHtml(x.r.id_marche||'—')}</strong></td>
<td>${escapeHtml(x.r.entrepreneur||'—')}</td>
<td>${escapeHtml(x.r.projet||'—')}</td>
<td>${escapeHtml(x.r.region||'—')}</td>
<td>${getProgressBar(x.r.taux_phy)}</td>
<td><strong style="color:${x.delai<=45?'var(--danger)':'var(--warning)'}">${x.delai}j</strong></td>
<td><span class="status-badge ${niveau}">${niveau==='critique'?'Critique':'Attention'}</span></td>
</tr>`;
}).join('')
: '<tr><td colspan="8" style="text-align:center;color:var(--success);padding:28px;"><i class="fas fa-check-circle"></i> Aucune alerte délai — situation normale.</td></tr>';
// Slide 1 : table alertes (réutilise buildAlertList)
renderAlertesSlide(alertes);
}
/* ── SLIDE 1 : ALERTES ── */
function alerteRowHTML(r, delai) {
const niveau = delai <= 45 ? 'critique' : 'attention';
return `<tr>
@ -837,34 +979,15 @@ function alerteRowHTML(r, delai) {
<td>${escapeHtml(r.projet||'—')}</td>
<td>${escapeHtml(r.region||'—')}</td>
<td>${getProgressBar(r.taux_phy)}</td>
<td><strong>${delai}j</strong></td>
<td><strong style="color:${delai<=45?'var(--danger)':'var(--warning)'}">${delai}j</strong></td>
<td><span class="status-badge ${niveau}">${niveau==='critique'?'Critique':'Attention'}</span></td>
</tr>`;
}
function alerteCardHTML(r, delai) {
const niveau = delai <= 45 ? 'critique' : 'attention';
return `<div class="alert-card ${niveau}">
<div class="alert-days">${delai}<div class="alert-days-label">jours</div></div>
<div class="alert-info">
<div class="alert-ref">${escapeHtml(r.id_marche||'—')}</div>
<div class="alert-meta">${escapeHtml(r.entrepreneur||'—')} • ${escapeHtml(r.region||'—')} • Avt. phy: ${r.taux_phy}%</div>
</div>
<span class="status-badge ${niveau}">${niveau === 'critique' ? 'Critique' : 'Attention'}</span>
</div>`;
}
function renderAlertes() {
const alertes = allData
.filter(r => !isCloture(r))
.map(r => ({ r, delai: getDelaiRestant(r) }))
.filter(x => x.delai !== null && x.delai <= 90)
.sort((a, b) => a.delai - b.delai);
document.getElementById('alertes-count').textContent = `${alertes.length} alertes`;
function renderAlertesSlide(alertes) {
document.getElementById('alertes-count').textContent = `${alertes.length} alerte${alertes.length>1?'s':''}`;
document.getElementById('alertes-table').innerHTML = alertes.length
? alertes.map(x => alerteRowHTML(x.r, x.delai)).join('')
: '<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:28px;">Aucune alerte.</td></tr>';
document.getElementById('alertesPreview').innerHTML = alertes.length
? alertes.slice(0, 4).map(x => alerteCardHTML(x.r, x.delai)).join('')
: '<p style="color:var(--text-muted);font-size:0.85em;padding:8px 0;">Aucune alerte.</p>';
: '<tr><td colspan="7" style="text-align:center;color:var(--success);padding:28px;"><i class="fas fa-check-circle"></i> Aucune alerte.</td></tr>';
}
/* ── EN SERVICE ── */
@ -1110,72 +1233,7 @@ function renderPipeline() {
: '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:28px;">Pipeline vide.</td></tr>';
}
/* ── CHARTS ── */
let chartStatutInstance = null, chartRegionInstance = null, chartRegionCountInstance = null;
function renderChartStatut() {
if (!statsData?.par_statut) return;
const labels = Object.keys(statsData.par_statut);
const values = Object.values(statsData.par_statut);
const palette = ['#002D62','#E31837','#10b981','#f59e0b','#6366f1','#06b6d4','#8b5cf6'];
const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim() || '#f1f5f9';
const ctx = document.getElementById('chartStatut').getContext('2d');
if (chartStatutInstance) chartStatutInstance.destroy();
chartStatutInstance = new Chart(ctx, {
type: 'doughnut',
data: { labels, datasets: [{ data: values, backgroundColor: palette, borderWidth: 2, borderColor: 'transparent' }] },
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position:'bottom', labels:{ color: textColor, font:{size:10}, padding:10 } },
tooltip: { callbacks: { label: c => ` ${c.label}: ${c.parsed}` } },
},
cutout: '60%',
},
});
}
function renderChartRegion() {
const actifs = allData.filter(r => !isCloture(r));
const avgs = ALL_REGIONS.map(reg => {
const rows = actifs.filter(r => r.region === reg);
const avts = rows.map(r => r.taux_phy).filter(v => v > 0);
return avts.length ? Math.round(avts.reduce((a,b)=>a+b,0)/avts.length) : 0;
});
const counts = ALL_REGIONS.map(reg => actifs.filter(r => r.region === reg).length);
const colors = ALL_REGIONS.map(reg => REGION_COLORS[reg] || '#888');
const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim() || '#f1f5f9';
const gridColor = 'rgba(255,255,255,0.08)';
const commonOpts = {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks:{ color: textColor, font:{size:10} }, grid:{ color: gridColor } },
y: { ticks:{ color: textColor, font:{size:10} }, grid:{ color: gridColor }, beginAtZero: true },
},
};
const ctx1 = document.getElementById('chartRegion')?.getContext('2d');
if (ctx1) {
if (chartRegionInstance) chartRegionInstance.destroy();
chartRegionInstance = new Chart(ctx1, {
type: 'bar',
data: { labels: ALL_REGIONS, datasets: [{ label:'Avt. moy. (%)', data: avgs, backgroundColor: colors, borderRadius: 6 }] },
options: { ...commonOpts, plugins: { ...commonOpts.plugins, tooltip:{ callbacks:{ label: c => ` ${c.parsed.y}%` } } } },
});
}
const ctx2 = document.getElementById('chartRegionCount')?.getContext('2d');
if (ctx2) {
if (chartRegionCountInstance) chartRegionCountInstance.destroy();
chartRegionCountInstance = new Chart(ctx2, {
type: 'bar',
data: { labels: ALL_REGIONS, datasets: [{ label:'Marchés actifs', data: counts, backgroundColor: colors, borderRadius: 6 }] },
options: { ...commonOpts, plugins: { ...commonOpts.plugins, tooltip:{ callbacks:{ label: c => ` ${c.parsed.y} marchés` } } } },
});
}
}
/* charts supprimés — remplacés par jauges CSS dans renderSynthese() */
/* ── BADGES & TITLE ── */
function updateBadges() {