Gestion-des-Marches-RLA/index.html

1107 lines
64 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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="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 {
--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-box .login-logo{height:70px;margin-bottom:20px;object-fit:contain;}
.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 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:300px 1fr;gap:16px;margin-bottom:22px;}
@media(max-width:900px){.charts-row{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;}
/* ── 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);}
/* ── 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);}
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;}
.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);}
/* ── 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;}
/* ── 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-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;}
/* ── LOADING & TOAST ── */
.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)}}
.error-toast{position:fixed;bottom:28px;left:50%;transform:translateX(-50%);background:#dc2626;color:white;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;}
.error-toast.active{display:block;}
/* ── 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 img{width:42px;height:42px;border-radius:50%;border:2px solid var(--accent);margin-bottom:7px;object-fit:cover;}
@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">
<img src="logo-TT.png" alt="Tunisie Telecom" class="login-logo">
<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="error-toast" id="errorToast"></div>
<!-- HEADER -->
<header class="header">
<div class="logo-section">
<img src="logo-TT.png" alt="Tunisie Telecom">
<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 active" 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>
<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>
</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>
</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>
<div class="alert-list" id="alertesList" style="display:none"></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="renderService()">
<option value="">Toutes régions</option>
<option>Gabes</option><option>Gafsa</option><option>Kebili</option>
<option>Medenine</option><option>Sfax</option><option>Tataouine</option><option>Tozeur</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 &lt; 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="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 / EN COURS ── -->
<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>
<option>Gabes</option><option>Gafsa</option><option>Kebili</option>
<option>Medenine</option><option>Sfax</option><option>Tataouine</option><option>Tozeur</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>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th onclick="sortTable('id_marche')">Référence <i class="fas fa-sort"></i></th>
<th onclick="sortTable('region')">Région <i class="fas fa-sort"></i></th>
<th onclick="sortTable('entrepreneur')">Entrepreneur <i class="fas fa-sort"></i></th>
<th onclick="sortTable('projet')">Projet <i class="fas fa-sort"></i></th>
<th onclick="sortTable('observation')">Statut <i class="fas fa-sort"></i></th>
<th onclick="sortTable('taux_phy')">Avt. Phy. <i class="fas fa-sort"></i></th>
<th onclick="sortTable('date_fin')">Période <i class="fas fa-sort"></i></th>
<th onclick="sortTable('tot_marche')">Montant <i class="fas fa-sort"></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><option>Gabes</option><option>Gafsa</option><option>Kebili</option><option>Medenine</option><option>Sfax</option><option>Tataouine</option><option>Tozeur</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 &amp; 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><!-- .slides-container -->
<footer class="footer">
<img src="Nabil.Derouiche.jpg" alt="Nabil Derouiche">
<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><!-- #appContent -->
<script>
/* ── 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') || ''); }
/* ── SLIDE NAVIGATION ── */
let currentSlide = 0;
function showSlide(n) {
document.querySelectorAll('.slide').forEach((s, i) => s.classList.toggle('active', i === n));
document.querySelectorAll('.slide-nav button').forEach((b, i) => {
if (b.id && b.id.startsWith('btn-slide-')) b.classList.toggle('active', i === n);
});
// Reload on-demand sections
if (n === 7) renderAdminUsers();
if (n === 8) renderAdminLogs();
currentSlide = n;
}
/* ── UTILITIES ── */
function showLoading(show) { document.getElementById('loadingOverlay').classList.toggle('active', !!show); }
function showError(msg) {
const t = document.getElementById('errorToast');
t.textContent = msg || 'Erreur'; t.classList.add('active');
setTimeout(() => t.classList.remove('active'), 5000);
}
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[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 obsVal(r) { return typeof r.observation === 'object' ? (r.observation?.value || '') : String(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);
const fin = r.date_fin || r.datefin;
if (!fin) return null;
const d = new Date(fin);
if (isNaN(d.getTime())) return null;
return Math.ceil((d - new Date()) / 86400000);
}
/* ── 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 (e) { 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 = '';
}
function showApp() {
document.getElementById('loginPage').style.display = 'none';
document.getElementById('appContent').classList.add('active');
const u = currentUser?.username || '?';
document.getElementById('currentUser').textContent = u;
const now = new Date();
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}`;
// Admin button
document.getElementById('adminBtn').style.display = role === 'superadmin' ? 'inline-flex' : 'none';
// Nav items
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'}; }
/* ── DATA ── */
let allData = [], filteredData = [], pipelineData = [], proactifData = null, statsData = null;
let sortField = null, sortAsc = true, currentPage = 1;
const PAGE_SIZE = 25;
async function loadData() {
showLoading(true);
try {
const isUser = currentUser?.role === 'user';
const reqs = [
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() }),
];
const [rMarches, rStats, rPilotage, rPipeline] = await Promise.all(reqs);
if (rMarches.status === 401) { handleLogout(); 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;
filteredData = [...allData];
pipelineData = pipelineJson.results || pipelineJson;
document.getElementById('lastUpdate').textContent =
new Date().toLocaleTimeString('fr-FR', { hour:'2-digit', minute:'2-digit' });
renderAll();
} catch (e) {
showError('Erreur chargement : ' + e.message); console.error(e);
} finally { showLoading(false); }
}
function renderAll() {
renderKPIs(); renderAlertes(); renderService(); renderProactif();
renderRegions(); renderMarches(); renderPipeline();
renderChartStatut(); 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 ?? '—'}`;
}
/* ── ALERTES ── */
function alerteRowHTML(r, delai) {
const niveau = delai <= 45 ? 'critique' : 'attention';
const pct = parseNum(r.taux_phy || r.avt_phy);
return `<tr>
<td><strong>${escapeHtml(r.ref||r.id_marche||'—')}</strong></td>
<td>${escapeHtml(r.entrepreneur||'—')}</td>
<td>${escapeHtml(r.projet||'—')}</td>
<td>${escapeHtml(r.region||r.region_csc||'—')}</td>
<td>${getProgressBar(pct)}</td>
<td><strong>${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';
const pct = parseNum(r.taux_phy || r.avt_phy);
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.ref||r.id_marche||'—')}</div>
<div class="alert-meta">${escapeHtml(r.entrepreneur||'—')}${escapeHtml(r.region||'—')} • Avt. phy: ${pct}%</div>
</div>
<span class="status-badge ${niveau}">${niveau === 'critique' ? 'Critique' : 'Attention'}</span>
</div>`;
}
function renderAlertes() {
const actifs = allData.filter(r => !isCloture(r));
const alertes = actifs
.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`;
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>';
}
/* ── EN SERVICE ── */
function renderService() {
const reg = document.getElementById('serviceFilterRegion')?.value || '';
const entr = document.getElementById('serviceFilterEntrepreneur')?.value || '';
let rows = allData.filter(r => {
if (isCloture(r)) return false;
const o = obsVal(r).toLowerCase();
return o.includes('en service');
});
if (reg) rows = rows.filter(r => (r.region || r.region_csc || '') === reg);
if (entr) rows = rows.filter(r => (r.entrepreneur || '') === entr);
// Populate entrepreneurs filter
const entrs = [...new Set(allData.filter(r => {
if (isCloture(r)) return false;
return obsVal(r).toLowerCase().includes('en service');
}).map(r => r.entrepreneur).filter(Boolean))].sort();
const entrSel = document.getElementById('serviceFilterEntrepreneur');
if (entrSel && entrSel.options.length <= 1) {
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 pct = parseNum(r.taux_phy || r.avt_phy);
const delai = getDelaiRestant(r);
const delaiTxt = delai === null ? '—' : `${delai}j`;
const dateDebut = formatDateFR(r.date_debut || r.debut_marche);
const dateFin = formatDateFR(r.date_fin || r.date_fin_marche);
const montant = parseNum(r.tot_marche || r.m_max || r.totmarche);
return `<tr>
<td><strong>${escapeHtml(r.id_marche||r.reference||'—')}</strong></td>
<td>${escapeHtml(r.projet||'—')}</td>
<td>${escapeHtml(r.region||r.region_csc||'—')}</td>
<td>${escapeHtml(r.entrepreneur||'—')}</td>
<td style="white-space:nowrap">${montant > 0 ? formatMontant(montant) : '—'}</td>
<td style="white-space:nowrap;font-size:0.82em">${dateDebut}${dateFin}</td>
<td>${getProgressBar(pct)}</td>
<td><strong>${delaiTxt}</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;
document.getElementById('proactif-count').textContent = `${items.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é'],
// compat ancienne API
'Sous Min': ['critique', 'Sous Min'],
};
document.getElementById('proactif-table').innerHTML = items.length
? items.map(r => {
const [bc, bl] = badges[r.resultat] || ['info', r.resultat || '—'];
const pct = parseNum(r.taux_phy_raw ?? r.taux_phy);
const delai = r.delai_restant;
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>${delai !== null && delai !== undefined ? `<strong>${delai}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 ── */
const REGION_COLORS = (typeof CONFIG !== 'undefined' && CONFIG?.REGION_COLORS) || {
Gabes:'#17A2B8',Gafsa:'#22C55E',Kebili:'#9333EA',
Medenine:'#0EA5E9',Sfax:'#002855',Tataouine:'#14B8A6',Tozeur:'#818CF8'
};
const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'];
function renderRegions() {
const actifs = allData.filter(r => !isCloture(r));
const html = ALL_REGIONS.map(reg => {
const rows = actifs.filter(r => (r.region || r.region_csc || '') === reg);
const avts = rows.map(r => parseNum(r.taux_phy || r.avt_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 + parseNum(r.tot_marche || r.m_max || r.totmarche), 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('');
document.getElementById('regions-grid').innerHTML = html || '<p style="color:var(--text-muted)">Aucune donnée.</p>';
}
/* ── MARCHÉS TABLE ── */
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;
filteredData = allData.filter(r => {
const reg = r.region || r.region_csc || '';
const text = `${r.id_marche||''} ${reg} ${r.entrepreneur||''} ${r.projet||''}`.toLowerCase();
if (search && !text.includes(search)) return false;
if (region && reg !== region) return false;
if (entrepreneur && (r.entrepreneur || '') !== entrepreneur) return false;
if (statut && obsVal(r) !== statut) return false;
return true;
});
currentPage = 1; renderMarchesTable();
}
function sortTable(field) {
if (sortField === field) { sortAsc = !sortAsc; } else { sortField = field; sortAsc = true; }
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 / PAGE_SIZE));
if (currentPage > pages) currentPage = pages;
const slice = filteredData.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
document.getElementById('marchesCount').textContent = `(${total})`;
document.getElementById('marchesBody').innerHTML = slice.length
? slice.map(r => {
const pct = parseNum(r.taux_phy || r.avt_phy);
const delai = getDelaiRestant(r);
const statut = obsVal(r);
const region = r.region || r.region_csc || '—';
const periode = `${formatDateFR(r.date_debut)}${formatDateFR(r.date_fin || r.date_fin_marche)}`;
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(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(pct)}</td>
<td style="white-space:nowrap;font-size:0.82em">${periode}${delaiBadge}</td>
<td>${formatMontant(r.tot_marche ?? r.totmarche)}</td>
</tr>`;
}).join('')
: '<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:28px;">Aucun résultat.</td></tr>';
const pag = document.getElementById('marchesPagination');
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>`;
}
pag.innerHTML = `<span>${(currentPage-1)*PAGE_SIZE+1}${Math.min(currentPage*PAGE_SIZE,total)} sur ${total}</span><div class="pagination-btns">${btns}</div>`;
}
function goPage(p) { currentPage = p; 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>';
}
/* ── CHART STATUT ── */
let chartStatutInstance = 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 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: getComputedStyle(document.documentElement).getPropertyValue('--text') || '#f1f5f9', font:{size:10}, padding:10 } },
tooltip: { callbacks: { label: c => ` ${c.label}: ${c.parsed}` } },
},
cutout: '60%',
},
});
}
/* ── BADGES ── */
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';
}
/* ── 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.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><button class="btn-action btn-danger" onclick="deleteUser(${u.id})"><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) { showError('Identifiant et mot de passe requis'); 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(); renderAdminUsers();
} catch (e) { showError('Erreur création : ' + e.message); }
}
async function deleteUser(id) {
if (!confirm('Supprimer cet utilisateur ?')) return;
try {
const res = await fetch(`${API_BASE}/users/${id}`, { method:'DELETE', headers:apiHeaders() });
if (!res.ok) { const d = await res.json(); throw new Error(d.error || res.status); }
renderAdminUsers();
} catch (e) { showError('Erreur suppression : ' + e.message); }
}
/* ── ADMIN LOGS ── */
async function renderAdminLogs() {
const tbody = document.getElementById('logsBody');
try {
const res = await fetch(`${API_BASE}/logs`, { headers: apiHeaders() });
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 = ['synthese','alertes','en-service','pilotage','par-region','en-cours','synthese','synthese','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 view = getCurrentView();
const filters = getFilterParams();
const url = `${API_BASE}/export/${format}?view=${view}${filters}`;
try {
const res = await fetch(url, { headers: { 'Authorization': `Bearer ${jwtToken}` } });
if (res.status === 403) { showError('Export réservé au SuperAdmin'); return; }
if (!res.ok) { const d = await res.json().catch(()=>{}); showError(d?.error || 'Erreur export ' + res.status); return; }
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `RLA_${view}_${new Date().toISOString().slice(0,10)}.${format}`;
a.click(); URL.revokeObjectURL(a.href);
} catch (e) { showError('Erreur export : ' + e.message); }
}
function downloadPDF() { triggerExport('pdf'); }
function exportPPTX() { triggerExport('pptx'); }
function exportXLSX() { triggerExport('xlsx'); }
function exportDOCX() { triggerExport('docx'); }
/* ── AUTO-REFRESH ── */
setInterval(() => { if (jwtToken) loadData(); }, (typeof CONFIG !== 'undefined' ? CONFIG.REFRESH_INTERVAL : 60) * 60 * 1000);
/* ── KEYBOARD ── */
document.addEventListener('keydown', e => {
if (e.key === 'Enter' && document.getElementById('loginPage').style.display !== 'none') handleLogin();
});
/* ── INIT ── */
loadTheme();
checkSession();
</script>
</body>
</html>