Gestion-des-Marches-RLA/index.html

1435 lines
81 KiB
HTML
Raw 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="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">4590j</span></div>
</div>
<div class="statut-bloc ok">
<div class="statut-bloc-value" id="blocOk"></div>
<div class="statut-bloc-label"><i class="fas fa-check-circle"></i> Dans les délais</div>
</div>
</div>
<!-- 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 &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="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 &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>
<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 => ({'&':'&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 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 buildRef(r) {
const base = r.id_marche || r.reference || '';
const reg = r.region_csc || r.region || '';
return reg ? `${base} - ${reg}` : base;
}
function normalizeMarche(r) {
return {
...r,
id_marche: buildRef(r),
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 (4590j)</span>`
: `<span class="situ-ok">aucune alerte délai</span>`;
document.getElementById('situationPhrase').innerHTML =
`${phraseIcon} <strong>${actifs.length} marchés actifs</strong> — avancement physique moyen <strong>${avgAvt}%</strong>. ` +
`${phraseAlerte}. <strong>${allData.filter(r => isCloture(r)).length} clôturé${allData.filter(r=>isCloture(r)).length>1?'s':''}</strong>.`;
// Blocs statut
document.getElementById('blocCritique').textContent = critiques;
document.getElementById('blocAttention').textContent = attention;
document.getElementById('blocOk').textContent = Math.max(0, dansDelais);
// Jauges par région
document.getElementById('regionJauges').innerHTML = ALL_REGIONS.map(reg => {
const rows = actifs.filter(r => r.region === reg);
const avts2 = rows.map(r => r.taux_phy).filter(v => v > 0);
const avg2 = avts2.length ? Math.round(avts2.reduce((a,b)=>a+b,0)/avts2.length) : 0;
const color = REGION_COLORS[reg] || '#888';
const alReg = alertes.filter(x => x.r.region === reg).length;
const alerteIcon = alReg > 0 ? `<span style="color:var(--danger);font-weight:700">⚠ ${alReg}</span>` : `<span style="color:var(--success)">✓</span>`;
return `<div class="region-jauge-row">
<div class="region-jauge-name">${reg}</div>
<div class="region-jauge-track">
<div class="region-jauge-fill" style="width:${avg2}%;background:${color}">
${avg2 >= 30 ? `<span>${avg2}%</span>` : ''}
</div>
</div>
<div class="region-jauge-meta">${avg2 < 30 ? avg2+'% ' : ''}${alerteIcon} <span style="color:var(--text-muted)">(${rows.length})</span></div>
</div>`;
}).join('');
// Table alertes complète par priorité
document.getElementById('syntheseAlerteBadge').textContent = `${alertes.length} alerte${alertes.length>1?'s':''}`;
document.getElementById('syntheseAlerteTable').innerHTML = alertes.length
? alertes.map((x, i) => {
const niveau = x.delai <= 45 ? 'critique' : 'attention';
const pClass = x.delai <= 45 ? 'p1' : 'p2';
return `<tr>
<td><span class="prio-badge ${pClass}">${i+1}</span></td>
<td><strong>${escapeHtml(x.r.id_marche||'—')}</strong></td>
<td>${escapeHtml(x.r.entrepreneur||'—')}</td>
<td>${escapeHtml(x.r.projet||'—')}</td>
<td>${escapeHtml(x.r.region||'—')}</td>
<td>${getProgressBar(x.r.taux_phy)}</td>
<td><strong style="color:${x.delai<=45?'var(--danger)':'var(--warning)'}">${x.delai}j</strong></td>
<td><span class="status-badge ${niveau}">${niveau==='critique'?'Critique':'Attention'}</span></td>
</tr>`;
}).join('')
: '<tr><td colspan="8" style="text-align:center;color:var(--success);padding:28px;"><i class="fas fa-check-circle"></i> Aucune alerte délai — situation normale.</td></tr>';
// Slide 1 : table alertes (réutilise buildAlertList)
renderAlertesSlide(alertes);
}
/* ── SLIDE 1 : ALERTES ── */
function alerteRowHTML(r, delai) {
const niveau = delai <= 45 ? 'critique' : 'attention';
return `<tr>
<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>