1429 lines
81 KiB
HTML
1429 lines
81 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<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="config.js"></script>
|
||
<style>
|
||
:root {
|
||
--primary: #0b2a55; --primary-light: #1e40af; --accent: #00d4ff;
|
||
--success: #10b981; --warning: #f59e0b; --danger: #ef4444;
|
||
--bg-dark: #0f172a; --bg-card: rgba(255,255,255,0.06);
|
||
--text: #f1f5f9; --text-muted: #94a3b8;
|
||
--bg-body: linear-gradient(135deg, #0f172a 0%, #1a1a2e 50%, #16213e 100%);
|
||
--table-header: rgba(0,0,0,0.3); --border-color: rgba(255,255,255,0.1);
|
||
}
|
||
[data-theme="light"] {
|
||
--primary: #0b2a55; --primary-light: #1e40af; --accent: #0284c7;
|
||
--bg-dark: #f8fafc; --bg-card: rgba(255,255,255,0.9);
|
||
--text: #1e293b; --text-muted: #64748b;
|
||
--bg-body: linear-gradient(135deg, #e2e8f0 0%, #f1f5f9 50%, #ffffff 100%);
|
||
--table-header: rgba(11,42,85,0.08); --border-color: rgba(0,0,0,0.1);
|
||
}
|
||
[data-theme="professional"] {
|
||
--primary: #1f2937; --primary-light: #374151; --accent: #2563eb;
|
||
--bg-dark: #ffffff; --bg-card: #f9fafb;
|
||
--text: #111827; --text-muted: #6b7280;
|
||
--bg-body: #f3f4f6; --table-header: #e5e7eb; --border-color: #d1d5db;
|
||
}
|
||
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box;}
|
||
body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg-body);min-height:100vh;color:var(--text);overflow-x:hidden;transition:all 0.3s ease;}
|
||
|
||
/* ── LOGIN ── */
|
||
.login-container{position:fixed;inset:0;background:var(--bg-body);display:flex;justify-content:center;align-items:center;z-index:10000;}
|
||
.login-box{background:var(--bg-card);padding:40px;border-radius:20px;box-shadow:0 20px 60px rgba(0,0,0,0.4);border:1px solid var(--border-color);width:100%;max-width:400px;text-align:center;backdrop-filter:blur(20px);}
|
||
.login-logo-wrap{height:70px;margin-bottom:20px;display:flex;align-items:center;justify-content:center;}
|
||
.login-logo-wrap img{height:70px;object-fit:contain;}
|
||
.login-logo-wrap .logo-svg-fallback{width:70px;height:70px;}
|
||
.login-box h2{color:var(--primary);margin-bottom:8px;font-size:1.5em;}
|
||
[data-theme=""] .login-box h2,[data-theme="dark"] .login-box h2{color:var(--accent);}
|
||
.login-box p{color:var(--text-muted);margin-bottom:25px;font-size:0.9em;}
|
||
.login-box input{width:100%;padding:13px 16px;margin-bottom:14px;border:2px solid var(--border-color);border-radius:10px;font-size:1em;background:rgba(255,255,255,0.08);color:var(--text);transition:all 0.3s;}
|
||
[data-theme="light"] .login-box input,[data-theme="professional"] .login-box input{background:white;}
|
||
.login-box input:focus{border-color:var(--accent);outline:none;background:rgba(255,255,255,0.12);}
|
||
.login-box button{width:100%;padding:14px;background:linear-gradient(135deg,var(--primary),var(--primary-light));color:white;border:none;border-radius:10px;font-size:1.05em;cursor:pointer;transition:all 0.3s;font-weight:600;}
|
||
.login-box button:hover{transform:translateY(-2px);box-shadow:0 5px 20px rgba(0,0,0,0.3);}
|
||
.login-error{color:var(--danger);margin-bottom:14px;font-size:0.88em;display:none;background:rgba(239,68,68,0.1);padding:8px 12px;border-radius:8px;}
|
||
.login-error.visible{display:block;}
|
||
|
||
/* ── APP CONTENT ── */
|
||
.app-content{display:none;}
|
||
.app-content.active{display:block;}
|
||
|
||
/* ── HEADER ── */
|
||
.header{background:linear-gradient(90deg,var(--primary),var(--primary-light));padding:14px 24px;display:flex;justify-content:space-between;align-items:center;box-shadow:0 4px 20px rgba(0,0,0,0.3);position:sticky;top:0;z-index:100;flex-wrap:wrap;gap:12px;}
|
||
.logo-section{display:flex;align-items:center;gap:14px;}
|
||
.logo-section img{height:46px;object-fit:contain;}
|
||
.logo-section .logo-svg-fallback{width:46px;height:46px;}
|
||
.logo-section h1{font-size:1.2em;font-weight:700;color:white;line-height:1.2;}
|
||
.logo-section .sub{font-size:0.78em;color:rgba(255,255,255,0.7);margin-top:2px;}
|
||
.header-controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap;}
|
||
.theme-selector{display:flex;gap:4px;background:rgba(255,255,255,0.1);padding:4px;border-radius:24px;}
|
||
.theme-btn{padding:6px 11px;border:none;border-radius:18px;cursor:pointer;font-size:0.82em;background:transparent;color:white;transition:all 0.3s;}
|
||
.theme-btn.active{background:rgba(255,255,255,0.2);}
|
||
.theme-btn:hover{background:rgba(255,255,255,0.15);}
|
||
.user-info{display:flex;align-items:center;gap:10px;color:white;font-size:0.88em;}
|
||
.user-badge{background:rgba(255,255,255,0.2);padding:4px 10px;border-radius:20px;font-size:0.8em;}
|
||
.user-badge.superadmin{background:linear-gradient(135deg,#f59e0b,#d97706);}
|
||
.header-btn{padding:7px 14px;border:none;border-radius:20px;color:white;cursor:pointer;font-size:0.82em;transition:all 0.3s;display:inline-flex;align-items:center;gap:6px;}
|
||
.header-btn:hover{filter:brightness(1.15);transform:translateY(-1px);}
|
||
.btn-admin{background:rgba(37,99,235,0.8);}
|
||
.btn-logout{background:rgba(239,68,68,0.8);}
|
||
.header-info{text-align:right;font-size:0.82em;color:rgba(255,255,255,0.75);}
|
||
.header-info .date{color:var(--accent);font-weight:700;}
|
||
|
||
/* ── SLIDE NAV ── */
|
||
.slide-nav{display:flex;justify-content:center;gap:6px;padding:12px 16px;background:rgba(0,0,0,0.2);flex-wrap:wrap;}
|
||
[data-theme="light"] .slide-nav,[data-theme="professional"] .slide-nav{background:rgba(0,0,0,0.05);}
|
||
.slide-nav button{padding:9px 16px;border:none;border-radius:24px;background:var(--bg-card);color:var(--text);cursor:pointer;transition:all 0.3s ease;font-size:0.83em;display:inline-flex;align-items:center;gap:7px;border:1px solid var(--border-color);}
|
||
.slide-nav button:hover{background:var(--primary-light);color:white;transform:translateY(-2px);}
|
||
.slide-nav button.active{background:linear-gradient(135deg,var(--accent),var(--primary-light));color:white;box-shadow:0 4px 15px rgba(0,212,255,0.25);}
|
||
[data-theme="light"] .slide-nav button.active,[data-theme="professional"] .slide-nav button.active{box-shadow:0 4px 15px rgba(2,132,199,0.3);}
|
||
.nav-separator{width:2px;height:28px;background:var(--border-color);margin:0 4px;align-self:center;}
|
||
.export-btn{background:linear-gradient(135deg,#dc2626,#b91c1c) !important;color:white !important;border-color:transparent !important;}
|
||
.export-pptx-btn{background:linear-gradient(135deg,#C65D21,#E07832) !important;color:white !important;border-color:transparent !important;}
|
||
.export-xlsx-btn{background:linear-gradient(135deg,#16a34a,#15803d) !important;color:white !important;border-color:transparent !important;}
|
||
.export-docx-btn{background:linear-gradient(135deg,#1d4ed8,#1e40af) !important;color:white !important;border-color:transparent !important;}
|
||
.refresh-btn{background:linear-gradient(135deg,#0891b2,#0e7490) !important;color:white !important;border-color:transparent !important;}
|
||
.nav-hidden{display:none !important;}
|
||
|
||
/* ── SLIDES ── */
|
||
.slides-container{position:relative;max-width:1400px;margin:0 auto;padding:24px;min-height:75vh;}
|
||
.slide{display:none;animation:slideIn 0.35s ease-out;}
|
||
.slide.active{display:block;}
|
||
@keyframes slideIn{from{opacity:0;transform:translateX(15px)}to{opacity:1;transform:translateX(0)}}
|
||
|
||
/* ── SECTION TITLE ── */
|
||
.section-title{font-size:1.35em;margin-bottom:18px;padding-left:14px;border-left:4px solid var(--accent);display:flex;align-items:center;gap:11px;color:var(--text);}
|
||
|
||
/* ── KPI GRID ── */
|
||
.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px;margin-bottom:22px;}
|
||
.kpi-card{background:var(--bg-card);border-radius:16px;padding:20px;border:1px solid var(--border-color);transition:all 0.3s ease;position:relative;overflow:hidden;backdrop-filter:blur(10px);}
|
||
.kpi-card::before{content:'';position:absolute;top:0;left:0;width:4px;height:100%;background:var(--accent);}
|
||
.kpi-card.capex::before{background:var(--success);}
|
||
.kpi-card.opex::before{background:var(--warning);}
|
||
.kpi-card.alertes::before{background:var(--danger);}
|
||
.kpi-card.clotures::before{background:#6b7280;}
|
||
.kpi-card.proactif-normal::before{background:#059669;}
|
||
.kpi-card.proactif-sous::before{background:#DC2626;}
|
||
.kpi-card.proactif-depasse::before{background:#D97706;}
|
||
.kpi-card.proactif-none::before{background:#64748B;}
|
||
.kpi-card:hover{transform:translateY(-3px);box-shadow:0 8px 30px rgba(0,0,0,0.2);}
|
||
.kpi-card .icon{font-size:2em;margin-bottom:10px;opacity:0.85;}
|
||
.kpi-card .value{font-size:2em;font-weight:800;color:var(--accent);}
|
||
.kpi-card .label{font-size:0.88em;color:var(--text-muted);margin-top:4px;}
|
||
.kpi-card .sub{font-size:0.78em;color:var(--text-muted);margin-top:8px;padding-top:8px;border-top:1px solid var(--border-color);}
|
||
|
||
/* ── CHARTS ── */
|
||
.charts-row{display:grid;grid-template-columns:280px 1fr;gap:16px;margin-bottom:22px;}
|
||
@media(max-width:900px){.charts-row{grid-template-columns:1fr;}}
|
||
.charts-row-3{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:22px;}
|
||
@media(max-width:900px){.charts-row-3{grid-template-columns:1fr;}}
|
||
.chart-card{background:var(--bg-card);border-radius:14px;padding:18px;border:1px solid var(--border-color);backdrop-filter:blur(10px);}
|
||
.chart-card-title{font-size:0.9em;font-weight:700;color:var(--text);margin-bottom:14px;display:flex;align-items:center;gap:8px;}
|
||
.chart-card-title i{color:var(--accent);}
|
||
.chart-container{position:relative;height:220px;}
|
||
.chart-container.tall{height:260px;}
|
||
|
||
/* ── FILTERS ── */
|
||
.filters-bar{display:flex;gap:12px;margin-bottom:18px;flex-wrap:wrap;align-items:center;}
|
||
.filter-group{display:flex;align-items:center;gap:7px;}
|
||
.filter-group label{font-size:0.83em;color:var(--text-muted);font-weight:600;}
|
||
.filter-group select,.filter-select{padding:8px 12px;border:1px solid var(--border-color);border-radius:8px;background:var(--bg-card);color:var(--text);font-size:0.83em;min-width:150px;cursor:pointer;}
|
||
.filter-group select:focus,.filter-select:focus{outline:none;border-color:var(--accent);}
|
||
.search-wrapper{position:relative;}
|
||
.search-wrapper i{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--text-muted);font-size:0.82em;}
|
||
.search-input{padding:8px 12px 8px 32px;border:1px solid var(--border-color);border-radius:8px;background:var(--bg-card);color:var(--text);font-size:0.83em;width:200px;}
|
||
.search-input:focus{outline:none;border-color:var(--accent);}
|
||
.date-input{padding:8px 12px;border:1px solid var(--border-color);border-radius:8px;background:var(--bg-card);color:var(--text);font-size:0.83em;}
|
||
.date-input:focus{outline:none;border-color:var(--accent);}
|
||
|
||
/* ── TABLES ── */
|
||
.table-container{background:var(--bg-card);border-radius:14px;overflow:hidden;border:1px solid var(--border-color);margin-bottom:18px;backdrop-filter:blur(10px);}
|
||
.table-header{background:linear-gradient(90deg,var(--primary),var(--primary-light));padding:13px 18px;display:flex;justify-content:space-between;align-items:center;color:white;}
|
||
.table-header h3{font-size:0.95em;display:flex;align-items:center;gap:9px;}
|
||
.table-header .badge{background:rgba(255,255,255,0.2);padding:3px 10px;border-radius:20px;font-size:0.82em;}
|
||
.table-wrapper{overflow-x:auto;}
|
||
table{width:100%;border-collapse:collapse;min-width:600px;}
|
||
th,td{padding:11px 13px;text-align:left;border-bottom:1px solid var(--border-color);vertical-align:top;}
|
||
th{background:var(--table-header);font-weight:700;font-size:0.74em;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);cursor:pointer;user-select:none;}
|
||
th:hover{color:var(--accent);}
|
||
th .sort-icon{margin-left:4px;opacity:0.4;font-size:0.85em;}
|
||
th.sort-asc .sort-icon, th.sort-desc .sort-icon{opacity:1;color:var(--accent);}
|
||
tr:hover{background:rgba(0,212,255,0.04);}
|
||
[data-theme="light"] tr:hover,[data-theme="professional"] tr:hover{background:rgba(2,132,199,0.04);}
|
||
td{font-size:0.85em;}
|
||
.table-toolbar{padding:13px 16px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;border-bottom:1px solid var(--border-color);}
|
||
.table-toolbar-title{font-size:0.9em;font-weight:700;color:var(--text);flex:1;min-width:100px;}
|
||
.table-pagination{padding:11px 16px;display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--border-color);font-size:0.8em;color:var(--text-muted);flex-wrap:wrap;gap:8px;}
|
||
.pagination-btns{display:flex;gap:4px;align-items:center;}
|
||
.page-btn{width:28px;height:28px;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-card);color:var(--text);font-size:0.8em;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:all 0.15s;}
|
||
.page-btn:hover{border-color:var(--accent);color:var(--accent);}
|
||
.page-btn.active{background:var(--accent);color:white;border-color:var(--accent);}
|
||
.page-size-select{padding:4px 8px;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-card);color:var(--text);font-size:0.8em;cursor:pointer;}
|
||
|
||
/* ── PROGRESS BAR ── */
|
||
.progress-bar{display:flex;align-items:center;gap:8px;}
|
||
.progress-track{flex:1;height:7px;background:var(--border-color);border-radius:4px;overflow:hidden;min-width:60px;}
|
||
.progress-fill{height:100%;border-radius:4px;transition:width 0.6s ease-out;}
|
||
.progress-fill.green{background:linear-gradient(90deg,#10b981,#34d399);}
|
||
.progress-fill.orange{background:linear-gradient(90deg,#f59e0b,#fbbf24);}
|
||
.progress-fill.red{background:linear-gradient(90deg,#ef4444,#f87171);}
|
||
.progress-value{font-weight:700;min-width:38px;text-align:right;font-size:0.88em;}
|
||
|
||
/* ── BADGES ── */
|
||
.status-badge{padding:3px 9px;border-radius:14px;font-size:0.73em;font-weight:700;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;}
|
||
.status-badge.critique{background:rgba(239,68,68,0.18);color:#ef4444;}
|
||
.status-badge.attention{background:rgba(245,158,11,0.18);color:#f59e0b;}
|
||
.status-badge.ok{background:rgba(16,185,129,0.18);color:#10b981;}
|
||
.status-badge.info{background:rgba(59,130,246,0.15);color:#3b82f6;}
|
||
.status-badge.muted{background:rgba(107,114,128,0.18);color:#6b7280;}
|
||
.status-badge.superadmin{background:rgba(245,158,11,0.18);color:#f59e0b;}
|
||
.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);}
|
||
.alert-card.critique{border-left:4px solid var(--danger);}
|
||
.alert-card.attention{border-left:4px solid var(--warning);}
|
||
.alert-days{min-width:56px;text-align:center;font-size:1.5em;font-weight:800;line-height:1;}
|
||
.alert-card.critique .alert-days{color:var(--danger);}
|
||
.alert-card.attention .alert-days{color:var(--warning);}
|
||
.alert-days-label{font-size:0.62em;color:var(--text-muted);font-weight:400;}
|
||
.alert-info{flex:1;min-width:0;}
|
||
.alert-ref{font-weight:700;font-size:0.88em;margin-bottom:3px;}
|
||
.alert-meta{font-size:0.78em;color:var(--text-muted);}
|
||
|
||
/* ── REGION CARDS ── */
|
||
.regions-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;}
|
||
.region-card{background:var(--bg-card);border-radius:14px;padding:16px;border:2px solid #0066CC;transition:all 0.3s ease;backdrop-filter:blur(10px);}
|
||
.region-card:hover{transform:scale(1.01);box-shadow:0 6px 25px rgba(0,102,204,0.2);border-color:#0088FF;}
|
||
.region-header{display:flex;align-items:center;gap:11px;margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid var(--border-color);}
|
||
.region-dot{width:11px;height:11px;border-radius:50%;}
|
||
.region-stats{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;}
|
||
.region-stat{background:var(--table-header);padding:9px;border-radius:8px;text-align:center;}
|
||
.region-stat .value{font-size:1.25em;font-weight:800;color:var(--accent);}
|
||
.region-stat .label{font-size:0.68em;color:var(--text-muted);margin-top:2px;}
|
||
|
||
/* ── ADMIN PANEL ── */
|
||
.admin-form-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;padding:14px 16px;background:var(--table-header);border-bottom:1px solid var(--border-color);}
|
||
.admin-form-row input,.admin-form-row select{padding:8px 11px;border:1px solid var(--border-color);border-radius:8px;background:var(--bg-dark);color:var(--text);font-size:0.83em;}
|
||
.admin-form-row input:focus,.admin-form-row select:focus{outline:none;border-color:var(--accent);}
|
||
.btn-action{padding:6px 12px;border:none;border-radius:7px;cursor:pointer;font-size:0.78em;transition:all 0.2s;display:inline-flex;align-items:center;gap:5px;}
|
||
.btn-primary{background:var(--primary);color:white;}
|
||
.btn-primary:hover{background:var(--primary-light);}
|
||
.btn-warning{background:#f59e0b;color:white;}
|
||
.btn-warning:hover{opacity:0.88;}
|
||
.btn-danger{background:#ef4444;color:white;}
|
||
.btn-danger:hover{opacity:0.88;}
|
||
.btn-secondary{background:var(--bg-card);color:var(--text);border:1px solid var(--border-color);}
|
||
.btn-secondary:hover{border-color:var(--accent);color:var(--accent);}
|
||
.log-success{color:var(--success);font-weight:600;}
|
||
.log-failure{color:var(--danger);font-weight:600;}
|
||
|
||
/* ── MODAL ── */
|
||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:none;justify-content:center;align-items:center;z-index:9000;backdrop-filter:blur(4px);}
|
||
.modal-overlay.active{display:flex;}
|
||
.modal-box{background:var(--bg-card);border:1px solid var(--border-color);border-radius:16px;padding:28px;width:100%;max-width:420px;box-shadow:0 20px 60px rgba(0,0,0,0.4);}
|
||
.modal-title{font-size:1.1em;font-weight:700;margin-bottom:18px;display:flex;align-items:center;gap:9px;color:var(--text);}
|
||
.modal-field{margin-bottom:13px;}
|
||
.modal-field label{display:block;font-size:0.82em;color:var(--text-muted);margin-bottom:5px;font-weight:600;}
|
||
.modal-field input,.modal-field select{width:100%;padding:9px 12px;border:1px solid var(--border-color);border-radius:8px;background:var(--bg-dark);color:var(--text);font-size:0.88em;}
|
||
[data-theme="light"] .modal-field input,[data-theme="professional"] .modal-field input,
|
||
[data-theme="light"] .modal-field select,[data-theme="professional"] .modal-field select{background:white;}
|
||
.modal-field input:focus,.modal-field select:focus{outline:none;border-color:var(--accent);}
|
||
.modal-actions{display:flex;gap:9px;justify-content:flex-end;margin-top:20px;}
|
||
|
||
/* ── TOAST (succès / confirm) ── */
|
||
.toast{position:fixed;bottom:28px;left:50%;transform:translateX(-50%);padding:13px 28px;border-radius:10px;z-index:9999;font-size:0.93em;box-shadow:0 5px 20px rgba(0,0,0,0.35);display:none;max-width:92vw;text-align:center;transition:opacity 0.3s;}
|
||
.toast.active{display:block;}
|
||
.toast.error{background:#dc2626;color:white;}
|
||
.toast.success{background:#059669;color:white;}
|
||
.toast.warning{background:#f59e0b;color:white;}
|
||
|
||
/* ── LOADING ── */
|
||
.loading-overlay{position:fixed;inset:0;background:rgba(15,23,42,0.85);display:none;justify-content:center;align-items:center;flex-direction:column;gap:16px;z-index:9998;}
|
||
.loading-overlay.active{display:flex;}
|
||
.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)}}
|
||
|
||
/* ── 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-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;}
|
||
|
||
@media(max-width:768px){
|
||
.header{flex-direction:column;padding:11px;}
|
||
.header-controls{width:100%;justify-content:center;}
|
||
.slide-nav{padding:9px;}
|
||
.slide-nav button{padding:7px 12px;font-size:0.77em;}
|
||
.slides-container{padding:12px;}
|
||
.kpi-grid{grid-template-columns:1fr 1fr;}
|
||
.kpi-card .value{font-size:1.6em;}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body data-theme="">
|
||
|
||
<!-- ── LOGIN ── -->
|
||
<div class="login-container" id="loginPage">
|
||
<div class="login-box">
|
||
<div class="login-logo-wrap">
|
||
<img src="logo-RLA.svg" alt="RLA Zone Sud" onerror="this.style.display='none'">
|
||
</div>
|
||
<h2>Marchés RLA</h2>
|
||
<p>Zone Sud — Tableau de Bord</p>
|
||
<div class="login-error" id="loginError"><i class="fas fa-exclamation-circle"></i> Identifiants incorrects</div>
|
||
<input type="text" id="username" placeholder="Nom d'utilisateur" autocomplete="username">
|
||
<input type="password" id="password" placeholder="Mot de passe" autocomplete="current-password">
|
||
<button type="button" onclick="handleLogin()"><i class="fas fa-sign-in-alt"></i> Se connecter</button>
|
||
<p style="margin-top:18px;font-size:0.78em;color:var(--text-muted);">Accès réservé aux utilisateurs autorisés</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── APP CONTENT ── -->
|
||
<div class="app-content" id="appContent">
|
||
<div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div style="color:var(--accent);font-size:1.05em;">Chargement des données...</div></div>
|
||
<div class="toast" id="appToast"></div>
|
||
|
||
<!-- MODAL EDITION UTILISATEUR -->
|
||
<div class="modal-overlay" id="editUserModal">
|
||
<div class="modal-box">
|
||
<div class="modal-title"><i class="fas fa-user-edit" style="color:var(--accent)"></i> Modifier l'utilisateur</div>
|
||
<input type="hidden" id="editUserId">
|
||
<div class="modal-field"><label>Identifiant</label><input type="text" id="editUsername" readonly style="opacity:0.6"></div>
|
||
<div class="modal-field"><label>Nouveau mot de passe <span style="color:var(--text-muted);font-weight:400">(laisser vide = inchangé)</span></label><input type="password" id="editPassword" placeholder="••••••••"></div>
|
||
<div class="modal-field"><label>Rôle</label>
|
||
<select id="editRole">
|
||
<option value="user">user</option>
|
||
<option value="admin">admin</option>
|
||
<option value="superadmin">superadmin</option>
|
||
</select>
|
||
</div>
|
||
<div class="modal-field"><label>Région</label>
|
||
<select id="editRegion">
|
||
<option value="all">Toutes régions</option>
|
||
</select>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn-action btn-secondary" onclick="closeEditModal()">Annuler</button>
|
||
<button class="btn-action btn-primary" onclick="saveEditUser()"><i class="fas fa-save"></i> Enregistrer</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- HEADER -->
|
||
<header class="header">
|
||
<div class="logo-section">
|
||
<img src="logo-RLA.svg" alt="RLA Zone Sud" onerror="this.style.display='none'">
|
||
<div>
|
||
<h1>Marchés RLA</h1>
|
||
<div class="sub">Zone Sud — Tableau de Bord</div>
|
||
</div>
|
||
</div>
|
||
<div class="header-controls">
|
||
<div class="theme-selector">
|
||
<button class="theme-btn" data-theme="" onclick="setTheme('')" title="Sombre"><i class="fas fa-moon"></i></button>
|
||
<button class="theme-btn" data-theme="light" onclick="setTheme('light')" title="Clair"><i class="fas fa-sun"></i></button>
|
||
<button class="theme-btn" data-theme="professional" onclick="setTheme('professional')" title="Professionnel"><i class="fas fa-briefcase"></i></button>
|
||
</div>
|
||
<div class="user-info">
|
||
<i class="fas fa-user-circle" style="font-size:1.4em;"></i>
|
||
<span id="currentUser">—</span>
|
||
<span class="user-badge" id="userRole">—</span>
|
||
<button class="header-btn btn-admin" id="adminBtn" onclick="showSlide(7)" style="display:none"><i class="fas fa-users-cog"></i> Utilisateurs</button>
|
||
<button class="header-btn btn-logout" onclick="handleLogout()"><i class="fas fa-sign-out-alt"></i> Déconnexion</button>
|
||
</div>
|
||
</div>
|
||
<div class="header-info">
|
||
<div>Dernière mise à jour</div>
|
||
<div class="date" id="lastUpdate">—</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- SLIDE NAVIGATION -->
|
||
<nav class="slide-nav">
|
||
<button class="active" id="btn-slide-0" onclick="showSlide(0)"><i class="fas fa-chart-pie"></i> Vue Générale</button>
|
||
<button id="btn-slide-1" onclick="showSlide(1)"><i class="fas fa-exclamation-triangle"></i> Alertes <span id="badge-alertes" style="background:var(--danger);color:white;border-radius:10px;padding:1px 7px;font-size:0.8em;display:none">0</span></button>
|
||
<button id="btn-slide-2" onclick="showSlide(2)"><i class="fas fa-check-circle"></i> En Service</button>
|
||
<button id="btn-slide-3" onclick="showSlide(3)"><i class="fas fa-rocket"></i> Pilotage Proactif</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-6" class="nav-hidden" onclick="showSlide(6)"><i class="fas fa-stream"></i> Pipeline AO</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>
|
||
<span class="nav-separator"></span>
|
||
<button class="export-btn" onclick="downloadPDF()" title="Export PDF"><i class="fas fa-file-pdf"></i> PDF</button>
|
||
<button class="export-pptx-btn nav-hidden" id="btnExportPPTX" onclick="exportPPTX()" title="Export PPTX"><i class="fas fa-file-powerpoint"></i> PPTX</button>
|
||
<button class="export-xlsx-btn nav-hidden" id="btnExportXLSX" onclick="exportXLSX()" title="Export XLSX"><i class="fas fa-file-excel"></i> XLSX</button>
|
||
<button class="export-docx-btn nav-hidden" id="btnExportDOCX" onclick="exportDOCX()" title="Export DOCX"><i class="fas fa-file-word"></i> DOCX</button>
|
||
<span class="nav-separator"></span>
|
||
<button class="refresh-btn" onclick="loadData()" title="Actualiser"><i class="fas fa-sync-alt"></i></button>
|
||
</nav>
|
||
|
||
<!-- SLIDES CONTAINER -->
|
||
<main class="slides-container">
|
||
|
||
<!-- ── 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>
|
||
|
||
<!-- 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="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">45–90j</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>
|
||
|
||
<!-- 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>
|
||
|
||
<!-- 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>
|
||
|
||
<!-- ── SLIDE 1 : ALERTES ── -->
|
||
<section class="slide" id="slide-1">
|
||
<h2 class="section-title"><i class="fas fa-exclamation-triangle" style="color:var(--danger)"></i> Alertes Délais</h2>
|
||
<div class="table-container">
|
||
<div class="table-header" style="background:linear-gradient(90deg,#b91c1c,#dc2626);">
|
||
<h3><i class="fas fa-fire"></i> Marchés à surveiller</h3>
|
||
<span class="badge" id="alertes-count">0 alertes</span>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead><tr>
|
||
<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="alertes-table"><tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SLIDE 2 : EN SERVICE ── -->
|
||
<section class="slide" id="slide-2">
|
||
<h2 class="section-title"><i class="fas fa-check-circle" style="color:var(--success)"></i> Marchés En Service</h2>
|
||
<div class="filters-bar">
|
||
<div class="filter-group"><label><i class="fas fa-map-marker-alt"></i> Région</label>
|
||
<select class="filter-select" id="serviceFilterRegion" onchange="onServiceRegionChange()">
|
||
<option value="">Toutes régions</option>
|
||
</select>
|
||
</div>
|
||
<div class="filter-group"><label><i class="fas fa-hard-hat"></i> Entrepreneur</label>
|
||
<select class="filter-select" id="serviceFilterEntrepreneur" onchange="renderService()"><option value="">Tous entrepreneurs</option></select>
|
||
</div>
|
||
</div>
|
||
<div class="table-container">
|
||
<div class="table-header" style="background:linear-gradient(90deg,#047857,#10b981);">
|
||
<h3><i class="fas fa-play-circle"></i> En Cours d'Exécution</h3>
|
||
<span class="badge" id="service-count">0 marchés</span>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead><tr>
|
||
<th>Référence</th><th>Projet</th><th>Région</th><th>Entrepreneur</th>
|
||
<th>Montant Max</th><th>Période</th><th>Avt. Phy.</th><th>Délai Rest.</th>
|
||
</tr></thead>
|
||
<tbody id="service-table"><tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SLIDE 3 : PILOTAGE PROACTIF ── -->
|
||
<section class="slide" id="slide-3">
|
||
<h2 class="section-title"><i class="fas fa-rocket" style="color:#6366F1"></i> Pilotage Proactif — Avancement Physique</h2>
|
||
<div class="kpi-grid" id="proactif-kpi-grid">
|
||
<div class="kpi-card proactif-normal"><div class="icon" style="color:#059669;"><i class="fas fa-check-double"></i></div><div class="value" id="proactif-kpi-normal" style="color:#059669;">—</div><div class="label">Normal</div><div class="sub">Avt. ≥ seuil standard (70%)</div></div>
|
||
<div class="kpi-card proactif-sous"><div class="icon" style="color:#DC2626;"><i class="fas fa-arrow-down"></i></div><div class="value" id="proactif-kpi-sous" style="color:#DC2626;">—</div><div class="label">Sous Avancement</div><div class="sub">Avt. physique < seuil (70%)</div></div>
|
||
<div class="kpi-card proactif-depasse"><div class="icon" style="color:#D97706;"><i class="fas fa-arrow-up"></i></div><div class="value" id="proactif-kpi-depasse" style="color:#D97706;">—</div><div class="label">Dépassement</div><div class="sub">Avt. physique ≥ critique (90%)</div></div>
|
||
<div class="kpi-card proactif-none"><div class="icon" style="color:#64748B;"><i class="fas fa-question-circle"></i></div><div class="value" id="proactif-kpi-none" style="color:#64748B;">—</div><div class="label">Non déterminé</div><div class="sub">Données insuffisantes</div></div>
|
||
</div>
|
||
<div class="filters-bar">
|
||
<div class="filter-group"><label><i class="fas fa-map-marker-alt"></i> Région</label>
|
||
<select class="filter-select" id="proactifFilterRegion" onchange="renderProactif()"><option value="">Toutes régions</option></select>
|
||
</div>
|
||
<div class="filter-group"><label><i class="fas fa-tachometer-alt"></i> État</label>
|
||
<select class="filter-select" id="proactifFilterEtat" onchange="renderProactif()">
|
||
<option value="">Tous états</option>
|
||
<option value="Normal">Normal</option>
|
||
<option value="Sous Avancement">Sous Avancement</option>
|
||
<option value="Dépassement">Dépassement</option>
|
||
<option value="Non déterminé">Non déterminé</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="table-container">
|
||
<div class="table-header" style="background:linear-gradient(90deg,#4f46e5,#6366f1);">
|
||
<h3><i class="fas fa-th"></i> Détail par marché</h3>
|
||
<span class="badge" id="proactif-count">0 marchés</span>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead><tr>
|
||
<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>Résultat</th>
|
||
</tr></thead>
|
||
<tbody id="proactif-table"><tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SLIDE 4 : PAR RÉGION ── -->
|
||
<section class="slide" id="slide-4">
|
||
<h2 class="section-title"><i class="fas fa-map-marker-alt" style="color:var(--accent)"></i> Détail par Région</h2>
|
||
<div class="regions-grid" id="regions-grid">
|
||
<p style="color:var(--text-muted);">Chargement...</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SLIDE 5 : MARCHÉS ── -->
|
||
<section class="slide" id="slide-5">
|
||
<h2 class="section-title"><i class="fas fa-list-alt" style="color:var(--accent)"></i> Liste des Marchés</h2>
|
||
<div class="table-container">
|
||
<div class="table-toolbar">
|
||
<div class="table-toolbar-title">Marchés <span id="marchesCount" style="color:var(--text-muted);font-weight:400"></span></div>
|
||
<div class="search-wrapper">
|
||
<i class="fas fa-search"></i>
|
||
<input class="search-input" type="text" id="searchMarches" placeholder="Rechercher..." oninput="filterMarches()">
|
||
</div>
|
||
<select class="filter-select" id="filterRegion" onchange="filterMarches()">
|
||
<option value="">Toutes régions</option>
|
||
</select>
|
||
<select class="filter-select" id="filterEntrepreneur" onchange="filterMarches()"><option value="">Tous entrepreneurs</option></select>
|
||
<select class="filter-select" id="filterStatut" onchange="filterMarches()"><option value="">Tous statuts</option></select>
|
||
<div class="filter-group">
|
||
<label><i class="fas fa-calendar"></i> Du</label>
|
||
<input type="date" class="date-input" id="filterDateDebut" onchange="filterMarches()">
|
||
</div>
|
||
<div class="filter-group">
|
||
<label>Au</label>
|
||
<input type="date" class="date-input" id="filterDateFin" onchange="filterMarches()">
|
||
</div>
|
||
<button class="btn-action btn-secondary" onclick="resetFilters()" title="Réinitialiser filtres"><i class="fas fa-times"></i></button>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th onclick="sortTable('id_marche')">Référence <i class="fas fa-sort sort-icon"></i></th>
|
||
<th onclick="sortTable('region')">Région <i class="fas fa-sort sort-icon"></i></th>
|
||
<th onclick="sortTable('entrepreneur')">Entrepreneur <i class="fas fa-sort sort-icon"></i></th>
|
||
<th onclick="sortTable('projet')">Projet <i class="fas fa-sort sort-icon"></i></th>
|
||
<th onclick="sortTable('observation')">Statut <i class="fas fa-sort sort-icon"></i></th>
|
||
<th onclick="sortTable('taux_phy')">Avt. Phy. <i class="fas fa-sort sort-icon"></i></th>
|
||
<th onclick="sortTable('date_fin')">Période <i class="fas fa-sort sort-icon"></i></th>
|
||
<th onclick="sortTable('tot_marche')">Montant <i class="fas fa-sort sort-icon"></i></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="marchesBody"><tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="table-pagination" id="marchesPagination"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SLIDE 6 : PIPELINE AO ── -->
|
||
<section class="slide" id="slide-6">
|
||
<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-header" style="background:linear-gradient(90deg,#4F46E5,#6366F1);">
|
||
<h3><i class="fas fa-rocket"></i> Projets en Préparation</h3>
|
||
<span class="badge" id="pipeline-count">0 projets</span>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead><tr>
|
||
<th>Description du projet</th><th>Régions</th><th>Estimation (DT)</th>
|
||
<th>Durée (mois)</th><th>Date prévisionnelle DCA</th>
|
||
</tr></thead>
|
||
<tbody id="pipelineBody"><tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SLIDE 7 : ADMIN UTILISATEURS ── -->
|
||
<section class="slide" id="slide-7">
|
||
<h2 class="section-title"><i class="fas fa-users-cog"></i> Gestion des Utilisateurs</h2>
|
||
<div class="table-container">
|
||
<div class="table-toolbar">
|
||
<div class="table-toolbar-title">Utilisateurs</div>
|
||
<button class="btn-action btn-primary" onclick="toggleAddUserForm()"><i class="fas fa-user-plus"></i> Ajouter</button>
|
||
</div>
|
||
<div class="admin-form-row" id="addUserForm" style="display:none">
|
||
<input type="text" id="newUsername" placeholder="Identifiant">
|
||
<input type="password" id="newPassword" placeholder="Mot de passe">
|
||
<select id="newRole"><option value="user">user</option><option value="admin">admin</option><option value="superadmin">superadmin</option></select>
|
||
<select id="newRegion"><option value="all">Toutes régions</option></select>
|
||
<button class="btn-action btn-primary" onclick="saveNewUser()"><i class="fas fa-save"></i> Créer</button>
|
||
<button class="btn-action btn-secondary" onclick="toggleAddUserForm()">Annuler</button>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead><tr><th>#</th><th>Identifiant</th><th>Rôle</th><th>Région</th><th>Actions</th></tr></thead>
|
||
<tbody id="usersBody"><tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SLIDE 8 : ADMIN LOGS ── -->
|
||
<section class="slide" id="slide-8">
|
||
<h2 class="section-title"><i class="fas fa-history"></i> Historique des Connexions</h2>
|
||
<div class="table-container">
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead><tr><th>Date & heure</th><th>Utilisateur</th><th>Rôle</th><th>IP</th><th>Résultat</th></tr></thead>
|
||
<tbody id="logsBody"><tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
</main>
|
||
|
||
<footer class="footer">
|
||
<div class="footer-avatar">ND</div>
|
||
<div><strong style="color:var(--accent);">Nabil Derouiche</strong></div>
|
||
<div>Responsable Achats Zone Sud — Tunisie Telecom</div>
|
||
<div style="margin-top:7px;font-size:0.8em;">
|
||
<a href="mailto:Nabil.Derouiche@tunisietelecom.tn" style="color:var(--accent);text-decoration:none;">
|
||
<i class="fas fa-envelope"></i> Nabil.Derouiche@tunisietelecom.tn
|
||
</a>
|
||
</div>
|
||
</footer>
|
||
</div>
|
||
|
||
<script>
|
||
/* ── CONFIG HELPERS ── */
|
||
const CFG = (typeof CONFIG !== 'undefined') ? CONFIG : {};
|
||
const ALL_REGIONS = CFG.ALL_REGIONS || ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'];
|
||
const REGION_COLORS = CFG.REGION_COLORS || {};
|
||
|
||
/* ── THEME ── */
|
||
function setTheme(theme) {
|
||
document.body.setAttribute('data-theme', theme);
|
||
document.querySelectorAll('.theme-btn').forEach(b => b.classList.toggle('active', b.dataset.theme === theme));
|
||
localStorage.setItem('rla_theme', theme);
|
||
}
|
||
function loadTheme() { setTheme(localStorage.getItem('rla_theme') ?? (CFG.DEFAULT_THEME || 'light')); }
|
||
|
||
/* ── SLIDE NAVIGATION ── */
|
||
let currentSlide = 0;
|
||
function showSlide(n) {
|
||
document.querySelectorAll('.slide').forEach((s, i) => s.classList.toggle('active', i === n));
|
||
// Only toggle btn-slide-N buttons to avoid mis-indexing export buttons
|
||
document.querySelectorAll('[id^="btn-slide-"]').forEach(b => {
|
||
const idx = parseInt(b.id.replace('btn-slide-', ''), 10);
|
||
b.classList.toggle('active', idx === n);
|
||
});
|
||
if (n === 7) renderAdminUsers();
|
||
if (n === 8) renderAdminLogs();
|
||
currentSlide = n;
|
||
}
|
||
|
||
/* ── UTILITIES ── */
|
||
function showLoading(show) { document.getElementById('loadingOverlay').classList.toggle('active', !!show); }
|
||
function showToast(msg, type = 'error') {
|
||
const t = document.getElementById('appToast');
|
||
t.textContent = msg; t.className = `toast ${type} active`;
|
||
setTimeout(() => t.classList.remove('active'), 4500);
|
||
}
|
||
function parseNum(v) {
|
||
const n = parseFloat(String(v ?? '').replace(/\s/g, '').replace(',', '.'));
|
||
return Number.isFinite(n) ? n : 0;
|
||
}
|
||
function formatMontant(v) { return parseNum(v).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + ' DT'; }
|
||
function formatDateFR(d) {
|
||
if (!d) return '—';
|
||
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()}`;
|
||
}
|
||
function escapeHtml(s) {
|
||
return String(s ?? '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||
}
|
||
function getProgressBar(pct) {
|
||
const c = pct >= 90 ? 'red' : pct >= 70 ? 'orange' : 'green';
|
||
return `<div class="progress-bar"><div class="progress-track"><div class="progress-fill ${c}" style="width:${Math.min(100,pct)}%"></div></div><span class="progress-value">${pct}%</span></div>`;
|
||
}
|
||
function buildRegionOptions(selectId, includeAll = true) {
|
||
const sel = document.getElementById(selectId);
|
||
if (!sel) return;
|
||
sel.innerHTML = (includeAll ? '<option value="">Toutes régions</option>' : '') +
|
||
ALL_REGIONS.map(r => `<option>${escapeHtml(r)}</option>`).join('');
|
||
}
|
||
|
||
/* ── NORMALIZE API FIELDS ── */
|
||
function normalizeMarche(r) {
|
||
return {
|
||
...r,
|
||
id_marche: r.id_marche || r.reference || '',
|
||
region: r.region || r.region_csc || '',
|
||
taux_phy: parseNum(r.taux_phy ?? r.avt_phy ?? 0),
|
||
tot_marche: parseNum(r.tot_marche ?? r.m_max ?? r.totmarche ?? 0),
|
||
date_debut: r.date_debut || r.debut_marche || '',
|
||
date_fin: r.date_fin || r.date_fin_marche || r.datefin || '',
|
||
observation: typeof r.observation === 'object' ? (r.observation?.value || '') : String(r.observation || ''),
|
||
};
|
||
}
|
||
function obsVal(r) { return r.observation || ''; }
|
||
function isCloture(r){ const o = obsVal(r).toLowerCase(); return o.includes('clôtur') || o.includes('clotur') || !!r.date_cloture; }
|
||
function getDelaiRestant(r) {
|
||
if (r.delai_restant != null) return parseInt(r.delai_restant, 10);
|
||
if (!r.date_fin) return null;
|
||
const d = new Date(r.date_fin);
|
||
if (isNaN(d.getTime())) return null;
|
||
return Math.ceil((d - new Date()) / 86400000);
|
||
}
|
||
|
||
/* ── AUTH ── */
|
||
const API_BASE = '/api';
|
||
let jwtToken = null, currentUser = null;
|
||
|
||
async function handleLogin() {
|
||
const username = document.getElementById('username').value.trim();
|
||
const password = document.getElementById('password').value;
|
||
const errorEl = document.getElementById('loginError');
|
||
errorEl.classList.remove('visible');
|
||
try {
|
||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
if (!res.ok) { errorEl.classList.add('visible'); return; }
|
||
const data = await res.json();
|
||
jwtToken = data.token;
|
||
localStorage.setItem('rla_jwt', jwtToken);
|
||
currentUser = decodeJwt(jwtToken);
|
||
showApp(); loadData();
|
||
} catch { errorEl.classList.add('visible'); }
|
||
}
|
||
function handleLogout() {
|
||
jwtToken = null; currentUser = null;
|
||
localStorage.removeItem('rla_jwt');
|
||
document.getElementById('appContent').classList.remove('active');
|
||
document.getElementById('loginPage').style.display = 'flex';
|
||
document.getElementById('username').value = '';
|
||
document.getElementById('password').value = '';
|
||
document.title = 'Marchés RLA - Zone Sud | Tunisie Telecom';
|
||
}
|
||
function showApp() {
|
||
document.getElementById('loginPage').style.display = 'none';
|
||
document.getElementById('appContent').classList.add('active');
|
||
document.getElementById('currentUser').textContent = currentUser?.username || '?';
|
||
buildRegionOptions('filterRegion');
|
||
buildRegionOptions('serviceFilterRegion');
|
||
buildRegionOptions('proactifFilterRegion');
|
||
buildRegionOptions('newRegion', false);
|
||
buildRegionOptions('editRegion', false);
|
||
document.getElementById('newRegion').insertAdjacentHTML('afterbegin','<option value="all">Toutes régions</option>');
|
||
document.getElementById('editRegion').insertAdjacentHTML('afterbegin','<option value="all">Toutes régions</option>');
|
||
applyRoleUI();
|
||
}
|
||
function applyRoleUI() {
|
||
const role = currentUser?.role || 'user';
|
||
const region = currentUser?.region || '';
|
||
const roleLabels = { superadmin:'Super Admin', admin:'Admin', user: region || 'User' };
|
||
const roleEl = document.getElementById('userRole');
|
||
roleEl.textContent = roleLabels[role] || role;
|
||
roleEl.className = `user-badge ${role}`;
|
||
document.getElementById('adminBtn').style.display = role === 'superadmin' ? 'inline-flex' : 'none';
|
||
const showAdmin = role !== 'user';
|
||
const showSuper = role === 'superadmin';
|
||
document.getElementById('btn-slide-6').classList.toggle('nav-hidden', !showAdmin);
|
||
document.getElementById('btn-slide-7').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('btnExportXLSX').classList.toggle('nav-hidden', !showSuper);
|
||
document.getElementById('btnExportDOCX').classList.toggle('nav-hidden', !showSuper);
|
||
}
|
||
function decodeJwt(token) {
|
||
try {
|
||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||
return { username: payload.sub||payload.username||'?', role: payload.role||'user', region: payload.region||'all', id: payload.id||null, exp: payload.exp||null };
|
||
} catch { return null; }
|
||
}
|
||
function checkSession() {
|
||
const saved = localStorage.getItem('rla_jwt');
|
||
if (!saved) return;
|
||
const user = decodeJwt(saved);
|
||
if (!user) { localStorage.removeItem('rla_jwt'); return; }
|
||
if (user.exp && user.exp * 1000 < Date.now()) { localStorage.removeItem('rla_jwt'); return; }
|
||
jwtToken = saved; currentUser = user;
|
||
showApp(); loadData();
|
||
}
|
||
function apiHeaders() { return {'Authorization':`Bearer ${jwtToken}`,'Content-Type':'application/json'}; }
|
||
function handle401() {
|
||
showToast('Session expirée — veuillez vous reconnecter', 'warning');
|
||
setTimeout(handleLogout, 1500);
|
||
}
|
||
|
||
/* ── DATA ── */
|
||
let allData = [], filteredData = [], pipelineData = [], proactifData = null, statsData = null;
|
||
let sortField = null, sortAsc = true, currentPage = 1, pageSize = 25;
|
||
|
||
async function loadData() {
|
||
showLoading(true);
|
||
try {
|
||
const isUser = currentUser?.role === 'user';
|
||
const [rMarches, rStats, rPilotage, rPipeline] = await Promise.all([
|
||
fetch(`${API_BASE}/marches`, { headers: apiHeaders() }),
|
||
fetch(`${API_BASE}/stats`, { headers: apiHeaders() }),
|
||
fetch(`${API_BASE}/pilotage-proactif`, { headers: apiHeaders() }),
|
||
isUser ? null : fetch(`${API_BASE}/pipeline`, { headers: apiHeaders() }),
|
||
]);
|
||
if (rMarches.status === 401) { handle401(); return; }
|
||
if (!rMarches.ok) throw new Error('Erreur marchés ' + rMarches.status);
|
||
const marchesJson = await rMarches.json();
|
||
statsData = rStats?.ok ? await rStats.json() : null;
|
||
proactifData = rPilotage?.ok ? await rPilotage.json() : null;
|
||
const pipelineJson = (!isUser && rPipeline?.ok) ? await rPipeline.json() : { results: [] };
|
||
allData = (marchesJson.results || marchesJson).map(normalizeMarche);
|
||
filteredData = [...allData];
|
||
pipelineData = pipelineJson.results || pipelineJson;
|
||
document.getElementById('lastUpdate').textContent =
|
||
new Date().toLocaleTimeString('fr-FR', { hour:'2-digit', minute:'2-digit' });
|
||
renderAll();
|
||
} catch (e) {
|
||
showToast('Erreur chargement : ' + e.message, 'error'); console.error(e);
|
||
} finally { showLoading(false); }
|
||
}
|
||
|
||
function renderAll() {
|
||
renderKPIs(); renderSynthese(); renderService(); renderProactif();
|
||
renderRegions(); renderMarches(); renderPipeline(); updateBadges();
|
||
}
|
||
|
||
/* ── KPIs ── */
|
||
function renderKPIs() {
|
||
if (!statsData) return;
|
||
document.getElementById('kpiTotal').textContent = statsData.total ?? '—';
|
||
document.getElementById('kpiActifs').textContent = statsData.actifs ?? '—';
|
||
document.getElementById('kpiClotures').textContent = statsData.clotures ?? '—';
|
||
document.getElementById('kpiAlertes').textContent = statsData.alertes_delais?.count ?? '—';
|
||
document.getElementById('kpiAvt').textContent = `Avancement moy. : ${statsData.taux_avancement_moyen ?? '—'}%`;
|
||
document.getElementById('kpiCritiques').textContent = `Critiques (≤45j) : ${statsData.alertes_delais?.critique ?? '—'}`;
|
||
}
|
||
|
||
/* ── 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 (45–90j)</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>
|
||
<td><strong>${escapeHtml(r.id_marche||'—')}</strong></td>
|
||
<td>${escapeHtml(r.entrepreneur||'—')}</td>
|
||
<td>${escapeHtml(r.projet||'—')}</td>
|
||
<td>${escapeHtml(r.region||'—')}</td>
|
||
<td>${getProgressBar(r.taux_phy)}</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 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(--success);padding:28px;"><i class="fas fa-check-circle"></i> Aucune alerte.</td></tr>';
|
||
}
|
||
|
||
/* ── EN SERVICE ── */
|
||
function onServiceRegionChange() {
|
||
// Reset entrepreneur filter then re-populate
|
||
document.getElementById('serviceFilterEntrepreneur').innerHTML = '<option value="">Tous entrepreneurs</option>';
|
||
renderService();
|
||
}
|
||
function renderService() {
|
||
const reg = document.getElementById('serviceFilterRegion')?.value || '';
|
||
const entr = document.getElementById('serviceFilterEntrepreneur')?.value || '';
|
||
let rows = allData.filter(r => !isCloture(r) && obsVal(r).toLowerCase().includes('en service'));
|
||
const allEnService = [...rows];
|
||
if (reg) rows = rows.filter(r => r.region === reg);
|
||
if (entr) rows = rows.filter(r => (r.entrepreneur || '') === entr);
|
||
|
||
const entrSel = document.getElementById('serviceFilterEntrepreneur');
|
||
if (entrSel && entrSel.options.length <= 1) {
|
||
const pool = reg ? allEnService.filter(r => r.region === reg) : allEnService;
|
||
const entrs = [...new Set(pool.map(r => r.entrepreneur).filter(Boolean))].sort();
|
||
entrSel.innerHTML = '<option value="">Tous entrepreneurs</option>' +
|
||
entrs.map(e => `<option>${escapeHtml(e)}</option>`).join('');
|
||
}
|
||
|
||
document.getElementById('service-count').textContent = `${rows.length} marchés`;
|
||
document.getElementById('service-table').innerHTML = rows.length
|
||
? rows.map(r => {
|
||
const delai = getDelaiRestant(r);
|
||
return `<tr>
|
||
<td><strong>${escapeHtml(r.id_marche||'—')}</strong></td>
|
||
<td>${escapeHtml(r.projet||'—')}</td>
|
||
<td>${escapeHtml(r.region||'—')}</td>
|
||
<td>${escapeHtml(r.entrepreneur||'—')}</td>
|
||
<td style="white-space:nowrap">${r.tot_marche > 0 ? formatMontant(r.tot_marche) : '—'}</td>
|
||
<td style="white-space:nowrap;font-size:0.82em">${formatDateFR(r.date_debut)} → ${formatDateFR(r.date_fin)}</td>
|
||
<td>${getProgressBar(r.taux_phy)}</td>
|
||
<td><strong>${delai !== null ? delai + 'j' : '—'}</strong></td>
|
||
</tr>`;
|
||
}).join('')
|
||
: '<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:28px;">Aucun marché en service.</td></tr>';
|
||
}
|
||
|
||
/* ── PILOTAGE PROACTIF ── */
|
||
function renderProactif() {
|
||
const resume = proactifData?.resume || {};
|
||
const items = proactifData?.items || [];
|
||
|
||
document.getElementById('proactif-kpi-normal').textContent = resume.normal ?? 0;
|
||
document.getElementById('proactif-kpi-sous').textContent = resume.sous_avancement ?? resume.sous_min ?? 0;
|
||
document.getElementById('proactif-kpi-depasse').textContent = resume.depassement ?? 0;
|
||
document.getElementById('proactif-kpi-none').textContent = resume.non_determine ?? 0;
|
||
|
||
const filterReg = document.getElementById('proactifFilterRegion')?.value || '';
|
||
const filterEtat = document.getElementById('proactifFilterEtat')?.value || '';
|
||
|
||
let filtered = items;
|
||
if (filterReg) filtered = filtered.filter(r => (r.region||'') === filterReg);
|
||
if (filterEtat) filtered = filtered.filter(r => (r.resultat||'') === filterEtat);
|
||
|
||
document.getElementById('proactif-count').textContent = `${filtered.length} marchés`;
|
||
|
||
const badges = {
|
||
'Normal': ['ok', 'Normal'],
|
||
'Sous Avancement': ['critique', 'Sous Avancement'],
|
||
'Dépassement': ['attention','Dépassement'],
|
||
'Non déterminé': ['muted', 'Non déterminé'],
|
||
'Sous Min': ['critique', 'Sous Min'],
|
||
};
|
||
document.getElementById('proactif-table').innerHTML = filtered.length
|
||
? filtered.map(r => {
|
||
const [bc, bl] = badges[r.resultat] || ['info', r.resultat || '—'];
|
||
const pct = parseNum(r.taux_phy_raw ?? r.taux_phy);
|
||
return `<tr>
|
||
<td><strong>${escapeHtml(r.ref||'—')}</strong></td>
|
||
<td>${escapeHtml(r.entrepreneur||'—')}</td>
|
||
<td>${escapeHtml(r.projet||'—')}</td>
|
||
<td>${escapeHtml(r.region||'—')}</td>
|
||
<td>${getProgressBar(pct)}</td>
|
||
<td>${r.delai_restant != null ? `<strong>${r.delai_restant}j</strong>` : '—'}</td>
|
||
<td><span class="status-badge ${bc}">${escapeHtml(bl)}</span></td>
|
||
</tr>`;
|
||
}).join('')
|
||
: '<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:28px;">Aucune donnée.</td></tr>';
|
||
}
|
||
|
||
/* ── PAR RÉGION ── */
|
||
function renderRegions() {
|
||
const actifs = allData.filter(r => !isCloture(r));
|
||
document.getElementById('regions-grid').innerHTML = ALL_REGIONS.map(reg => {
|
||
const rows = actifs.filter(r => r.region === reg);
|
||
const avts = rows.map(r => r.taux_phy).filter(v => v > 0);
|
||
const avg = avts.length ? Math.round(avts.reduce((a,b) => a+b,0) / avts.length) : 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 color = REGION_COLORS[reg] || '#888';
|
||
return `<div class="region-card">
|
||
<div class="region-header">
|
||
<div class="region-dot" style="background:${color}"></div>
|
||
<strong style="font-size:1.05em;">${reg}</strong>
|
||
<span class="status-badge info" style="margin-left:auto">${rows.length} marchés</span>
|
||
</div>
|
||
<div class="region-stats">
|
||
<div class="region-stat"><div class="value" style="color:${color}">${avg}%</div><div class="label">Avt. moy. phy.</div></div>
|
||
<div class="region-stat"><div class="value">${budget > 0 ? (budget/1000000).toFixed(1) + 'M' : '—'}</div><div class="label">Budget (MDT)</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>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
/* ── MARCHÉS TABLE ── */
|
||
function resetFilters() {
|
||
document.getElementById('searchMarches').value = '';
|
||
document.getElementById('filterRegion').value = '';
|
||
document.getElementById('filterEntrepreneur').value = '';
|
||
document.getElementById('filterStatut').value = '';
|
||
document.getElementById('filterDateDebut').value = '';
|
||
document.getElementById('filterDateFin').value = '';
|
||
filterMarches();
|
||
}
|
||
function filterMarches() {
|
||
const search = document.getElementById('searchMarches').value.toLowerCase();
|
||
const region = document.getElementById('filterRegion').value;
|
||
const entrepreneur = document.getElementById('filterEntrepreneur').value;
|
||
const statut = document.getElementById('filterStatut').value;
|
||
const dateDebut = document.getElementById('filterDateDebut').value;
|
||
const dateFin = document.getElementById('filterDateFin').value;
|
||
filteredData = allData.filter(r => {
|
||
const text = `${r.id_marche} ${r.region} ${r.entrepreneur||''} ${r.projet||''}`.toLowerCase();
|
||
if (search && !text.includes(search)) return false;
|
||
if (region && r.region !== region) return false;
|
||
if (entrepreneur && (r.entrepreneur||'') !== entrepreneur) return false;
|
||
if (statut && obsVal(r) !== statut) return false;
|
||
if (dateDebut && r.date_fin && r.date_fin < dateDebut) return false;
|
||
if (dateFin && r.date_debut && r.date_debut > dateFin) return false;
|
||
return true;
|
||
});
|
||
currentPage = 1; renderMarchesTable();
|
||
}
|
||
function sortTable(field) {
|
||
// Update sort icons
|
||
document.querySelectorAll('#slide-5 th').forEach(th => {
|
||
th.classList.remove('sort-asc','sort-desc');
|
||
const icon = th.querySelector('.sort-icon');
|
||
if (icon) { icon.className = 'fas fa-sort sort-icon'; }
|
||
});
|
||
const colMap = { id_marche:0, region:1, entrepreneur:2, projet:3, observation:4, taux_phy:5, date_fin:6, tot_marche:7 };
|
||
const idx = colMap[field];
|
||
const th = document.querySelectorAll('#slide-5 th')[idx];
|
||
if (sortField === field) { sortAsc = !sortAsc; } else { sortField = field; sortAsc = true; }
|
||
if (th) {
|
||
th.classList.add(sortAsc ? 'sort-asc' : 'sort-desc');
|
||
const icon = th.querySelector('.sort-icon');
|
||
if (icon) icon.className = `fas fa-sort-${sortAsc ? 'up' : 'down'} sort-icon`;
|
||
}
|
||
filteredData.sort((a, b) => {
|
||
const va = a[field] ?? '', vb = b[field] ?? '';
|
||
const na = parseFloat(va), nb = parseFloat(vb);
|
||
if (!isNaN(na) && !isNaN(nb)) return sortAsc ? na - nb : nb - na;
|
||
return sortAsc ? String(va).localeCompare(String(vb),'fr') : String(vb).localeCompare(String(va),'fr');
|
||
});
|
||
renderMarchesTable();
|
||
}
|
||
function renderMarches() {
|
||
const statuts = [...new Set(allData.map(r => obsVal(r)).filter(Boolean))].sort();
|
||
document.getElementById('filterStatut').innerHTML =
|
||
'<option value="">Tous statuts</option>' + statuts.map(s => `<option>${escapeHtml(s)}</option>`).join('');
|
||
const entrs = [...new Set(allData.map(r => r.entrepreneur).filter(Boolean))].sort((a,b)=>a.localeCompare(b,'fr'));
|
||
document.getElementById('filterEntrepreneur').innerHTML =
|
||
'<option value="">Tous entrepreneurs</option>' + entrs.map(e => `<option>${escapeHtml(e)}</option>`).join('');
|
||
filteredData = [...allData]; renderMarchesTable();
|
||
}
|
||
function renderMarchesTable() {
|
||
const total = filteredData.length;
|
||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||
if (currentPage > pages) currentPage = pages;
|
||
const slice = filteredData.slice((currentPage-1)*pageSize, currentPage*pageSize);
|
||
document.getElementById('marchesCount').textContent = `(${total})`;
|
||
document.getElementById('marchesBody').innerHTML = slice.length
|
||
? slice.map(r => {
|
||
const statut = obsVal(r);
|
||
const delai = getDelaiRestant(r);
|
||
const delaiBadge = delai === null ? '' :
|
||
`<br><span class="status-badge ${delai<=45?'critique':delai<=90?'attention':'ok'}">${delai}j</span>`;
|
||
return `<tr>
|
||
<td><strong>${escapeHtml(r.id_marche||'—')}</strong></td>
|
||
<td>${escapeHtml(r.region||'—')}</td>
|
||
<td>${escapeHtml(r.entrepreneur||'—')}</td>
|
||
<td>${escapeHtml(r.projet||'—')}</td>
|
||
<td>${statut ? `<span class="status-badge info">${escapeHtml(statut)}</span>` : '—'}</td>
|
||
<td>${getProgressBar(r.taux_phy)}</td>
|
||
<td style="white-space:nowrap;font-size:0.82em">${formatDateFR(r.date_debut)} → ${formatDateFR(r.date_fin)}${delaiBadge}</td>
|
||
<td>${formatMontant(r.tot_marche)}</td>
|
||
</tr>`;
|
||
}).join('')
|
||
: '<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:28px;">Aucun résultat.</td></tr>';
|
||
|
||
// Pagination
|
||
let btns = '';
|
||
for (let i = 1; i <= pages; i++) {
|
||
if (i === 1 || i === pages || Math.abs(i-currentPage) <= 2)
|
||
btns += `<button class="page-btn ${i===currentPage?'active':''}" onclick="goPage(${i})">${i}</button>`;
|
||
else if (Math.abs(i-currentPage) === 3)
|
||
btns += `<span style="padding:0 4px;color:var(--text-muted)">…</span>`;
|
||
}
|
||
const pag = document.getElementById('marchesPagination');
|
||
pag.innerHTML = `
|
||
<span>${(currentPage-1)*pageSize+1}–${Math.min(currentPage*pageSize,total)} sur ${total}</span>
|
||
<div class="pagination-btns">
|
||
${btns}
|
||
<select class="page-size-select" onchange="changePageSize(this.value)" title="Lignes par page">
|
||
${[10,25,50,100].map(n=>`<option value="${n}"${n===pageSize?' selected':''}>${n}/page</option>`).join('')}
|
||
</select>
|
||
</div>`;
|
||
}
|
||
function goPage(p) { currentPage = p; renderMarchesTable(); }
|
||
function changePageSize(n) { pageSize = parseInt(n,10); currentPage = 1; renderMarchesTable(); }
|
||
|
||
/* ── 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() {
|
||
document.getElementById('pipeline-count').textContent = `${pipelineData.length} projets`;
|
||
document.getElementById('pipelineBody').innerHTML = pipelineData.length
|
||
? pipelineData.map(r => {
|
||
const desc = r['Description du projet'] || r.description || r.projet || '—';
|
||
const regs = pipelineRegions(r) || '—';
|
||
const est = r['Estimation'] ?? r.estimation ?? '';
|
||
const dur = r['Duree'] ?? r.Duree ?? r.duree ?? '';
|
||
const date = r['Date_previsionnelle_de_la_communication_du_projet_a_la_DCA'] || r.date_prevue || '';
|
||
return `<tr>
|
||
<td><strong>${escapeHtml(desc)}</strong></td>
|
||
<td>${escapeHtml(regs)}</td>
|
||
<td>${est ? escapeHtml(String(est)) : '—'}</td>
|
||
<td>${dur ? escapeHtml(String(dur)) : '—'}</td>
|
||
<td>${formatDateFR(date)}</td>
|
||
</tr>`;
|
||
}).join('')
|
||
: '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:28px;">Pipeline vide.</td></tr>';
|
||
}
|
||
|
||
/* charts supprimés — remplacés par jauges CSS dans renderSynthese() */
|
||
|
||
/* ── BADGES & TITLE ── */
|
||
function updateBadges() {
|
||
const count = statsData?.alertes_delais?.count ?? 0;
|
||
const badge = document.getElementById('badge-alertes');
|
||
badge.textContent = count;
|
||
badge.style.display = count > 0 ? 'inline-block' : 'none';
|
||
document.title = count > 0
|
||
? `⚠️ ${count} alerte${count>1?'s':''} — Marchés RLA Zone Sud`
|
||
: 'Marchés RLA - Zone Sud | Tunisie Telecom';
|
||
}
|
||
|
||
/* ── ADMIN USERS ── */
|
||
let showingAddForm = false;
|
||
function toggleAddUserForm() {
|
||
showingAddForm = !showingAddForm;
|
||
document.getElementById('addUserForm').style.display = showingAddForm ? 'grid' : 'none';
|
||
}
|
||
async function renderAdminUsers() {
|
||
const tbody = document.getElementById('usersBody');
|
||
try {
|
||
const res = await fetch(`${API_BASE}/users`, { headers: apiHeaders() });
|
||
if (res.status === 401) { handle401(); return; }
|
||
if (!res.ok) throw new Error(res.status);
|
||
const users = await res.json();
|
||
tbody.innerHTML = users.map(u => `<tr>
|
||
<td>${u.id}</td>
|
||
<td><strong>${escapeHtml(u.username)}</strong></td>
|
||
<td><span class="status-badge ${u.role==='superadmin'?'superadmin':u.role==='admin'?'admin':'ok'}">${u.role}</span></td>
|
||
<td>${escapeHtml(u.region === 'all' ? 'Toutes' : u.region)}</td>
|
||
<td style="display:flex;gap:6px">
|
||
<button class="btn-action btn-warning" onclick="openEditModal(${u.id},'${escapeHtml(u.username)}','${u.role}','${u.region}')">
|
||
<i class="fas fa-edit"></i>
|
||
</button>
|
||
<button class="btn-action btn-danger" onclick="confirmDeleteUser(${u.id},'${escapeHtml(u.username)}')">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</td>
|
||
</tr>`).join('');
|
||
} catch (e) {
|
||
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;color:var(--danger);padding:20px;">Erreur : ${e.message}</td></tr>`;
|
||
}
|
||
}
|
||
async function saveNewUser() {
|
||
const body = {
|
||
username: document.getElementById('newUsername').value.trim(),
|
||
password: document.getElementById('newPassword').value,
|
||
role: document.getElementById('newRole').value,
|
||
region: document.getElementById('newRegion').value,
|
||
};
|
||
if (!body.username || !body.password) { showToast('Identifiant et mot de passe requis', 'warning'); return; }
|
||
try {
|
||
const res = await fetch(`${API_BASE}/users`, { method:'POST', headers:apiHeaders(), body:JSON.stringify(body) });
|
||
if (!res.ok) { const d = await res.json(); throw new Error(d.error || res.status); }
|
||
toggleAddUserForm();
|
||
document.getElementById('newUsername').value = '';
|
||
document.getElementById('newPassword').value = '';
|
||
showToast('Utilisateur créé', 'success');
|
||
renderAdminUsers();
|
||
} catch (e) { showToast('Erreur création : ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* Edit user modal */
|
||
function openEditModal(id, username, role, region) {
|
||
document.getElementById('editUserId').value = id;
|
||
document.getElementById('editUsername').value = username;
|
||
document.getElementById('editPassword').value = '';
|
||
document.getElementById('editRole').value = role;
|
||
const regSel = document.getElementById('editRegion');
|
||
regSel.value = region;
|
||
document.getElementById('editUserModal').classList.add('active');
|
||
}
|
||
function closeEditModal() {
|
||
document.getElementById('editUserModal').classList.remove('active');
|
||
}
|
||
async function saveEditUser() {
|
||
const id = document.getElementById('editUserId').value;
|
||
const body = {
|
||
role: document.getElementById('editRole').value,
|
||
region: document.getElementById('editRegion').value,
|
||
};
|
||
const pw = document.getElementById('editPassword').value;
|
||
if (pw) body.password = pw;
|
||
try {
|
||
const res = await fetch(`${API_BASE}/users/${id}`, { method:'PATCH', headers:apiHeaders(), body:JSON.stringify(body) });
|
||
if (!res.ok) { const d = await res.json(); throw new Error(d.error || res.status); }
|
||
closeEditModal();
|
||
showToast('Utilisateur mis à jour', 'success');
|
||
renderAdminUsers();
|
||
} catch (e) { showToast('Erreur modification : ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* Delete with toast confirm */
|
||
let _pendingDeleteId = null;
|
||
function confirmDeleteUser(id, username) {
|
||
_pendingDeleteId = id;
|
||
const t = document.getElementById('appToast');
|
||
t.innerHTML = `Supprimer <strong>${escapeHtml(username)}</strong> ?
|
||
<button onclick="executeDeleteUser()" style="margin-left:12px;padding:4px 10px;background:white;color:#dc2626;border:none;border-radius:6px;cursor:pointer;font-weight:700">Oui</button>
|
||
<button onclick="document.getElementById('appToast').classList.remove('active')" style="margin-left:6px;padding:4px 10px;background:rgba(255,255,255,0.3);color:white;border:none;border-radius:6px;cursor:pointer">Non</button>`;
|
||
t.className = 'toast error active';
|
||
}
|
||
async function executeDeleteUser() {
|
||
document.getElementById('appToast').classList.remove('active');
|
||
try {
|
||
const res = await fetch(`${API_BASE}/users/${_pendingDeleteId}`, { method:'DELETE', headers:apiHeaders() });
|
||
if (!res.ok) { const d = await res.json(); throw new Error(d.error || res.status); }
|
||
showToast('Utilisateur supprimé', 'success');
|
||
renderAdminUsers();
|
||
} catch (e) { showToast('Erreur suppression : ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ── ADMIN LOGS ── */
|
||
async function renderAdminLogs() {
|
||
const tbody = document.getElementById('logsBody');
|
||
try {
|
||
const res = await fetch(`${API_BASE}/logs`, { headers: apiHeaders() });
|
||
if (res.status === 401) { handle401(); return; }
|
||
if (!res.ok) throw new Error(res.status);
|
||
const logs = await res.json();
|
||
tbody.innerHTML = logs.map(l => {
|
||
const dt = new Date(l.timestamp);
|
||
const date = dt.toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric'});
|
||
const time = dt.toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||
return `<tr>
|
||
<td>${date} <span style="color:var(--text-muted)">${time}</span></td>
|
||
<td><strong>${escapeHtml(l.username)}</strong></td>
|
||
<td>${l.role ? `<span class="status-badge info">${escapeHtml(l.role)}</span>` : '—'}</td>
|
||
<td style="font-size:0.8em;color:var(--text-muted)">${escapeHtml(l.ip||'—')}</td>
|
||
<td class="${l.success?'log-success':'log-failure'}"><i class="fas fa-${l.success?'check':'times'}"></i> ${l.success?'Succès':'Échec'}</td>
|
||
</tr>`;
|
||
}).join('') || '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px;">Aucun log.</td></tr>';
|
||
} catch (e) {
|
||
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;color:var(--danger);padding:20px;">Erreur : ${e.message}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
/* ── EXPORTS ── */
|
||
function getCurrentView() {
|
||
const views = {
|
||
0:'synthese', 1:'alertes', 2:'en-service', 3:'pilotage',
|
||
4:'par-region', 5:'en-cours', 6:'pipeline', 7:'synthese', 8:'synthese',
|
||
};
|
||
return views[currentSlide] || 'synthese';
|
||
}
|
||
function getFilterParams() {
|
||
const region = document.getElementById('filterRegion')?.value || '';
|
||
const entrepreneur = document.getElementById('filterEntrepreneur')?.value || '';
|
||
const params = new URLSearchParams();
|
||
if (region) params.set('region', region);
|
||
if (entrepreneur) params.set('entrepreneur', entrepreneur);
|
||
return params.toString() ? '&' + params.toString() : '';
|
||
}
|
||
async function triggerExport(format) {
|
||
const url = `${API_BASE}/export/${format}?view=${getCurrentView()}${getFilterParams()}`;
|
||
try {
|
||
const res = await fetch(url, { headers: { 'Authorization': `Bearer ${jwtToken}` } });
|
||
if (res.status === 401) { handle401(); return; }
|
||
if (res.status === 403) { showToast('Export réservé au SuperAdmin', 'warning'); return; }
|
||
if (!res.ok) { const d = await res.json().catch(()=>{}); showToast(d?.error || 'Erreur export ' + res.status, 'error'); return; }
|
||
const blob = await res.blob();
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `RLA_${getCurrentView()}_${new Date().toISOString().slice(0,10)}.${format}`;
|
||
a.click(); URL.revokeObjectURL(a.href);
|
||
} catch (e) { showToast('Erreur export : ' + e.message, 'error'); }
|
||
}
|
||
function downloadPDF() { triggerExport('pdf'); }
|
||
function exportPPTX() { triggerExport('pptx'); }
|
||
function exportXLSX() { triggerExport('xlsx'); }
|
||
function exportDOCX() { triggerExport('docx'); }
|
||
|
||
/* ── AUTO-REFRESH ── */
|
||
setInterval(() => { if (jwtToken) loadData(); }, (CFG.REFRESH_INTERVAL || 60) * 60 * 1000);
|
||
|
||
/* ── KEYBOARD ── */
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' && document.getElementById('loginPage').style.display !== 'none') handleLogin();
|
||
if (e.key === 'Escape') closeEditModal();
|
||
});
|
||
|
||
/* ── MODAL OUTSIDE CLICK ── */
|
||
document.getElementById('editUserModal').addEventListener('click', e => {
|
||
if (e.target === e.currentTarget) closeEditModal();
|
||
});
|
||
|
||
/* ── INIT ── */
|
||
loadTheme();
|
||
checkSession();
|
||
</script>
|
||
</body>
|
||
</html>
|