Gestion-des-Marches-RLA/index.html

1763 lines
102 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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<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>
/* ── VARIABLES ── */
:root {
--primary:#2563eb; --primary-light:#1d4ed8; --accent:#2563eb;
--success:#16a34a; --warning:#d97706; --danger:#dc2626;
--bg-body:#f9fafb; --bg-card:#ffffff; --bg-dark:#f9fafb;
--text:#111827; --text-muted:#6b7280;
--border-color:#e5e7eb; --table-header:#f9fafb;
--header-bg:#ffffff; --header-text:#111827; --header-border:#e5e7eb;
--sidebar-bg:#ffffff; --sidebar-border:#e5e7eb;
--shadow-card:0 1px 3px rgba(0,0,0,0.08);
--radius-card:12px; --transition:180ms ease;
--sidebar-w:240px; --header-h:64px; --topbar-h:var(--header-h);
}
[data-theme=""] , [data-theme="dark"] {
--primary:#1e40af; --primary-light:#3b82f6; --accent:#38bdf8;
--bg-body:linear-gradient(135deg,#0f172a 0%,#1a1a2e 50%,#16213e 100%);
--bg-card:rgba(255,255,255,0.06); --bg-dark:#0f172a;
--text:#f1f5f9; --text-muted:#94a3b8;
--border-color:rgba(255,255,255,0.1); --table-header:rgba(0,0,0,0.3);
--header-bg:linear-gradient(90deg,#0b2a55,#1e40af); --header-text:#ffffff; --header-border:rgba(255,255,255,0.1);
--sidebar-bg:#1e293b; --sidebar-border:rgba(255,255,255,0.08);
--shadow-card:0 1px 3px rgba(0,0,0,0.3);
}
[data-theme="professional"] {
--primary:#1f2937; --primary-light:#374151; --accent:#2563eb;
--bg-body:#f3f4f6; --bg-card:#ffffff; --bg-dark:#ffffff;
--text:#111827; --text-muted:#6b7280;
--border-color:#d1d5db; --table-header:#f9fafb;
--header-bg:#1f2937; --header-text:#ffffff; --header-border:#374151;
--sidebar-bg:#ffffff; --sidebar-border:#d1d5db;
}
body.has-ticker { --topbar-h:calc(var(--header-h) + 36px); }
/* ── RESET ── */
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box;}
body{font-family:'Inter',system-ui,-apple-system,sans-serif;background:var(--bg-body);min-height:100vh;color:var(--text);overflow-x:hidden;transition:background var(--transition),color var(--transition);font-size:14px;line-height:1.5;}
<style>
/* ── 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 8px 32px rgba(0,0,0,0.12);border:1px solid var(--border-color);width:100%;max-width:400px;text-align:center;}
.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-box h2{color:var(--primary);margin-bottom:8px;font-size:1.5em;font-weight:700;}
[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:11px 14px;margin-bottom:12px;border:1.5px solid var(--border-color);border-radius:8px;font-size:0.95em;background:var(--bg-card);color:var(--text);transition:border-color var(--transition);font-family:inherit;}
.login-box input:focus{border-color:var(--accent);outline:none;box-shadow:0 0 0 3px rgba(37,99,235,0.1);}
.login-box button{width:100%;padding:12px;background:var(--primary);color:white;border:none;border-radius:8px;font-size:1em;cursor:pointer;transition:background var(--transition);font-weight:600;font-family:inherit;}
.login-box button:hover{background:var(--primary-light);}
.login-error{color:var(--danger);margin-bottom:14px;font-size:0.88em;display:none;background:#fef2f2;padding:8px 12px;border-radius:8px;border:1px solid #fecaca;}
.login-error.visible{display:block;}
[data-theme=""] .login-error,[data-theme="dark"] .login-error{background:rgba(239,68,68,0.1);border-color:rgba(239,68,68,0.3);}
/* ── APP CONTENT ── */
.app-content{display:none;}
.app-content.active{display:block;}
/* ── HEADER ── */
.header{background:var(--header-bg);color:var(--header-text);padding:0 20px;height:var(--header-h);display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--header-border);box-shadow:var(--shadow-card);position:sticky;top:0;z-index:100;gap:12px;}
.header-left{display:flex;align-items:center;gap:10px;}
.sidebar-toggle{width:36px;height:36px;border:none;border-radius:8px;background:transparent;cursor:pointer;color:inherit;display:flex;align-items:center;justify-content:center;font-size:1.25em;transition:background var(--transition);flex-shrink:0;opacity:0.8;}
.sidebar-toggle:hover{background:rgba(128,128,128,0.15);opacity:1;}
[data-theme=""] .sidebar-toggle,[data-theme="dark"] .sidebar-toggle{color:white;}
.logo-section{display:flex;align-items:center;gap:12px;}
.logo-section img{height:40px;object-fit:contain;}
.logo-section h1{font-size:1.05em;font-weight:700;color:inherit;line-height:1.2;}
.logo-section .sub{font-size:0.72em;color:var(--text-muted);margin-top:1px;}
[data-theme=""] .logo-section .sub,[data-theme="dark"] .logo-section .sub{color:rgba(255,255,255,0.6);}
[data-theme="light"] .logo-section h1{color:#111827;}
.header-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
.theme-selector{display:flex;gap:3px;background:rgba(128,128,128,0.1);padding:3px;border-radius:20px;}
.theme-btn{padding:5px 10px;border:none;border-radius:16px;cursor:pointer;font-size:0.78em;background:transparent;color:inherit;transition:background var(--transition);opacity:0.7;}
.theme-btn.active{background:rgba(128,128,128,0.2);opacity:1;}
.theme-btn:hover{background:rgba(128,128,128,0.15);opacity:1;}
.user-info{display:flex;align-items:center;gap:8px;font-size:0.85em;color:inherit;}
[data-theme=""] .user-info,[data-theme="dark"] .user-info{color:white;}
.user-badge{background:rgba(128,128,128,0.15);padding:3px 9px;border-radius:16px;font-size:0.78em;font-weight:600;}
.user-badge.superadmin{background:linear-gradient(135deg,#f59e0b,#d97706);color:white;}
.header-btn{padding:6px 12px;border:none;border-radius:16px;color:white;cursor:pointer;font-size:0.78em;transition:all var(--transition);display:inline-flex;align-items:center;gap:5px;font-family:inherit;font-weight:500;}
.header-btn:hover{filter:brightness(1.1);transform:translateY(-1px);}
.btn-admin{background:#2563eb;}
.btn-logout{background:#dc2626;}
.header-info{text-align:right;font-size:0.78em;color:var(--text-muted);white-space:nowrap;}
[data-theme=""] .header-info,[data-theme="dark"] .header-info{color:rgba(255,255,255,0.6);}
.header-info .date{color:var(--accent);font-weight:700;}
/* ── ALERT TICKER ── */
.alert-ticker{position:sticky;top:var(--header-h);z-index:95;background:#dc2626;color:white;height:36px;overflow:hidden;display:none;align-items:center;}
.alert-ticker.active{display:flex;}
.ticker-track{width:100%;overflow:hidden;height:36px;display:flex;align-items:center;}
.ticker-content{display:inline-block;white-space:nowrap;font-size:0.82em;font-weight:500;animation:marquee 20s linear infinite;padding-right:60px;}
@keyframes marquee{0%{transform:translateX(100vw)}100%{transform:translateX(-100%)}}
/* ── APP LAYOUT ── */
.app-layout{display:flex;min-height:calc(100vh - var(--header-h));}
/* ── SIDEBAR ── */
.sidebar{width:var(--sidebar-w);flex-shrink:0;background:var(--sidebar-bg);border-right:1px solid var(--sidebar-border);position:fixed;left:0;top:var(--topbar-h);height:calc(100vh - var(--topbar-h));overflow-y:auto;transform:translateX(0);transition:transform var(--transition);display:flex;flex-direction:column;z-index:90;}
.sidebar.hidden{transform:translateX(-100%);}
.sidebar-nav{padding:12px 8px;flex:1;display:flex;flex-direction:column;gap:2px;}
.sidebar-section-label{font-size:0.68em;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:var(--text-muted);padding:8px 10px 4px;margin-top:8px;}
.sidebar-section-label:first-child{margin-top:0;}
.sidebar-btn{width:100%;padding:9px 12px;border:none;border-radius:8px;background:transparent;color:var(--text);cursor:pointer;transition:all var(--transition);font-size:0.84em;display:flex;align-items:center;gap:9px;text-align:left;font-family:inherit;font-weight:500;}
.sidebar-btn:hover{background:rgba(37,99,235,0.08);color:var(--primary);}
.sidebar-btn.active{background:rgba(37,99,235,0.1);color:var(--primary);font-weight:600;}
[data-theme=""] .sidebar-btn:hover,[data-theme="dark"] .sidebar-btn:hover{background:rgba(255,255,255,0.08);color:var(--accent);}
[data-theme=""] .sidebar-btn.active,[data-theme="dark"] .sidebar-btn.active{background:rgba(56,189,248,0.12);color:var(--accent);}
.sidebar-btn i{width:16px;text-align:center;opacity:0.7;font-size:0.9em;}
.sidebar-btn.active i{opacity:1;}
.sidebar-divider{height:1px;background:var(--border-color);margin:10px 8px;}
.sidebar-export{padding:8px;display:flex;flex-direction:column;gap:4px;border-top:1px solid var(--border-color);}
.sidebar-export-label{font-size:0.68em;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:var(--text-muted);padding:4px 10px 6px;}
.sidebar-export-row{display:flex;gap:4px;flex-wrap:wrap;padding:0 2px;}
.export-btn-sm{flex:1;padding:6px 8px;border:none;border-radius:6px;color:white;cursor:pointer;font-size:0.72em;font-weight:600;display:inline-flex;align-items:center;justify-content:center;gap:4px;transition:opacity var(--transition);font-family:inherit;min-width:50px;}
.export-btn-sm:hover{opacity:0.88;transform:translateY(-1px);}
.export-btn-sm.pdf-btn{background:#dc2626;}
.export-btn-sm.pptx-btn{background:#C65D21;}
.export-btn-sm.xlsx-btn{background:#16a34a;}
.export-btn-sm.docx-btn{background:#1d4ed8;}
.export-btn-sm.refresh-btn-sm{background:var(--accent);}
.nav-hidden{display:none !important;}
/* ── MAIN WRAPPER ── */
.main-wrapper{flex:1;min-width:0;margin-left:var(--sidebar-w);transition:margin-left var(--transition);}
/* ── SLIDES ── */
.slides-container{max-width:1400px;margin:0 auto;padding:24px;min-height:75vh;}
.slide{display:none;animation:slideIn 0.3s ease-out;}
.slide.active{display:block;}
@keyframes slideIn{from{opacity:0;transform:translateX(12px)}to{opacity:1;transform:translateX(0)}}
/* ── SECTION TITLE ── */
.section-title{font-size:1.25em;font-weight:700;margin-bottom:20px;padding-left:14px;border-left:4px solid var(--accent);display:flex;align-items:center;gap:10px;color:var(--text);}
/* ── KPI GRID ── */
.kpi-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:24px;}
@media(max-width:1024px){.kpi-grid{grid-template-columns:repeat(2,1fr);}}
@media(max-width:640px){.kpi-grid{grid-template-columns:1fr;}}
.kpi-card{background:var(--bg-card);border-radius:var(--radius-card);padding:20px;border:1px solid var(--border-color);box-shadow:var(--shadow-card);transition:all var(--transition);display:flex;align-items:center;gap:16px;}
.kpi-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.12);}
.kpi-icon{width:52px;height:52px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:1.4em;flex-shrink:0;}
.kpi-icon.blue{background:#eff6ff;color:#2563eb;}
.kpi-icon.green{background:#f0fdf4;color:#16a34a;}
.kpi-icon.red{background:#fef2f2;color:#dc2626;}
.kpi-icon.gray{background:#f9fafb;color:#6b7280;}
[data-theme=""] .kpi-icon.blue,[data-theme="dark"] .kpi-icon.blue{background:rgba(37,99,235,0.15);}
[data-theme=""] .kpi-icon.green,[data-theme="dark"] .kpi-icon.green{background:rgba(22,163,74,0.15);}
[data-theme=""] .kpi-icon.red,[data-theme="dark"] .kpi-icon.red{background:rgba(220,38,38,0.15);}
[data-theme=""] .kpi-icon.gray,[data-theme="dark"] .kpi-icon.gray{background:rgba(107,114,128,0.15);}
.kpi-body{flex:1;min-width:0;}
.kpi-card .value{font-size:2rem;font-weight:700;line-height:1;color:var(--text);}
.kpi-card .label{font-size:0.82em;color:#6b7280;margin-top:4px;}
.kpi-card .sub{font-size:0.75em;color:var(--text-muted);margin-top:6px;padding-top:6px;border-top:1px solid var(--border-color);}
/* legacy compat — proactif cards keep ::before accent */
.kpi-card.proactif-normal,.kpi-card.proactif-sous,.kpi-card.proactif-depasse,.kpi-card.proactif-none{position:relative;overflow:hidden;}
.kpi-card.proactif-normal::before{content:'';position:absolute;top:0;left:0;width:4px;height:100%;background:#059669;}
.kpi-card.proactif-sous::before{content:'';position:absolute;top:0;left:0;width:4px;height:100%;background:#DC2626;}
.kpi-card.proactif-depasse::before{content:'';position:absolute;top:0;left:0;width:4px;height:100%;background:#D97706;}
.kpi-card.proactif-none::before{content:'';position:absolute;top:0;left:0;width:4px;height:100%;background:#64748B;}
/* ── 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:var(--radius-card);padding:18px;border:1px solid var(--border-color);box-shadow:var(--shadow-card);}
.chart-card-title{font-size:0.88em;font-weight:600;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;}
/* ── SEARCH BAR FULL WIDTH ── */
.search-bar-full{position:relative;margin-bottom:12px;}
.search-bar-full i{position:absolute;left:14px;top:50%;transform:translateY(-50%);color:var(--text-muted);font-size:1em;}
.search-input-full{width:100%;padding:11px 14px 11px 42px;border:1.5px solid var(--border-color);border-radius:10px;background:var(--bg-card);color:var(--text);font-size:0.9em;font-family:inherit;transition:border-color var(--transition),box-shadow var(--transition);box-shadow:var(--shadow-card);}
.search-input-full:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,0.1);}
.search-input-full::placeholder{color:var(--text-muted);}
/* ── FILTERS ── */
.filters-bar{display:flex;gap:10px;margin-bottom:18px;flex-wrap:wrap;align-items:center;}
.filter-group{display:flex;align-items:center;gap:6px;}
.filter-group label{font-size:0.8em;color:var(--text-muted);font-weight:600;white-space:nowrap;}
.filter-group select,.filter-select{padding:8px 12px;border:1.5px solid var(--border-color);border-radius:8px;background:var(--bg-card);color:var(--text);font-size:0.82em;min-width:140px;cursor:pointer;font-family:inherit;transition:border-color var(--transition);}
.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 30px;border:1.5px solid var(--border-color);border-radius:8px;background:var(--bg-card);color:var(--text);font-size:0.82em;width:200px;font-family:inherit;}
.search-input:focus{outline:none;border-color:var(--accent);}
.date-input{padding:8px 12px;border:1.5px solid var(--border-color);border-radius:8px;background:var(--bg-card);color:var(--text);font-size:0.82em;font-family:inherit;}
.date-input:focus{outline:none;border-color:var(--accent);}
/* ── TABLES ── */
.table-container{background:var(--bg-card);border-radius:var(--radius-card);overflow:hidden;border:1px solid var(--border-color);box-shadow:var(--shadow-card);margin-bottom:20px;}
.table-header{background:var(--primary);padding:12px 18px;display:flex;justify-content:space-between;align-items:center;color:white;}
.table-header h3{font-size:0.9em;font-weight:600;display:flex;align-items:center;gap:8px;}
.table-header .badge{background:rgba(255,255,255,0.2);padding:2px 9px;border-radius:16px;font-size:0.78em;font-weight:600;}
.table-wrapper{overflow-x:auto;overflow-y:auto;max-height:560px;}
table{width:100%;border-collapse:collapse;min-width:600px;}
th,td{padding:12px 16px;text-align:left;border-bottom:1px solid var(--border-color);vertical-align:middle;}
th{background:var(--table-header);font-weight:600;font-size:0.72em;text-transform:uppercase;letter-spacing:0.6px;color:var(--text-muted);cursor:pointer;user-select:none;white-space:nowrap;position:sticky;top:0;z-index:2;}
th:hover{color:var(--accent);}
th .sort-icon{margin-left:4px;opacity:0.35;font-size:0.85em;}
th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--accent);}
tbody tr:hover{background:#f1f5f9;}
[data-theme=""] tbody tr:hover,[data-theme="dark"] tbody tr:hover{background:rgba(255,255,255,0.04);}
td{font-size:0.84em;}
.table-toolbar{padding:12px 16px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;border-bottom:1px solid var(--border-color);}
.table-toolbar-title{font-size:0.88em;font-weight:600;color:var(--text);flex:1;min-width:100px;}
.table-pagination{padding:10px 16px;display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--border-color);font-size:0.78em;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 var(--transition);}
.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.78em;cursor:pointer;font-family:inherit;}
/* ── PROGRESS BAR ── */
.progress-bar{display:flex;align-items:center;gap:8px;}
.progress-track{flex:1;height:6px;background:var(--border-color);border-radius:4px;overflow:hidden;min-width:60px;}
.progress-fill{height:100%;border-radius:4px;transition:width 0.5s ease-out;}
.progress-fill.green{background:#16a34a;}
.progress-fill.orange{background:#d97706;}
.progress-fill.red{background:#dc2626;}
.progress-value{font-weight:700;min-width:36px;text-align:right;font-size:0.85em;}
/* ── BADGES ── */
.status-badge{padding:3px 10px;border-radius:20px;font-size:0.72em;font-weight:600;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;}
.status-badge.critique{background:#fee2e2;color:#dc2626;}
.status-badge.attention{background:#fef3c7;color:#d97706;}
.status-badge.ok{background:#dcfce7;color:#16a34a;}
.status-badge.info{background:#eff6ff;color:#2563eb;}
.status-badge.muted{background:#f3f4f6;color:#6b7280;}
.status-badge.superadmin{background:#fef3c7;color:#d97706;}
.status-badge.admin{background:#eff6ff;color:#2563eb;}
.status-badge.user{background:#dcfce7;color:#16a34a;}
[data-theme=""] .status-badge.critique,[data-theme="dark"] .status-badge.critique{background:rgba(220,38,38,0.2);}
[data-theme=""] .status-badge.attention,[data-theme="dark"] .status-badge.attention{background:rgba(217,119,6,0.2);}
[data-theme=""] .status-badge.ok,[data-theme="dark"] .status-badge.ok{background:rgba(22,163,74,0.2);}
[data-theme=""] .status-badge.info,[data-theme="dark"] .status-badge.info{background:rgba(37,99,235,0.2);}
[data-theme=""] .status-badge.muted,[data-theme="dark"] .status-badge.muted{background:rgba(107,114,128,0.2);}
/* ── SITUATION PHRASE ── */
.situation-phrase{background:var(--bg-card);border:1px solid var(--border-color);border-left:4px solid var(--accent);border-radius:var(--radius-card);padding:14px 20px;margin-bottom:20px;font-size:0.93em;color:var(--text);line-height:1.6;box-shadow:var(--shadow-card);}
.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:var(--radius-card);padding:16px 18px;text-align:center;border:1px solid var(--border-color);box-shadow:var(--shadow-card);background:var(--bg-card);}
.statut-bloc.critique{border-left:4px solid var(--danger);}
.statut-bloc.attention{border-left:4px solid var(--warning);}
.statut-bloc.ok{border-left:4px solid var(--success);}
.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:14px;background:var(--border-color);border-radius:8px;overflow:hidden;}
.region-jauge-fill{height:100%;border-radius:8px;transition:width 0.6s ease-out;display:flex;align-items:center;justify-content:flex-end;padding-right:6px;}
.region-jauge-fill span{font-size:0.7em;font-weight:700;color:white;white-space:nowrap;}
.region-jauge-meta{font-size:0.72em;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:var(--radius-card);padding:14px 16px;display:flex;align-items:center;gap:14px;box-shadow:var(--shadow-card);}
.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:var(--radius-card);padding:16px;border:2px solid #0066CC;box-shadow:var(--shadow-card);transition:all var(--transition);}
.region-card:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,102,204,0.15);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:10px;height:10px;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.2em;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-card);color:var(--text);font-size:0.83em;font-family:inherit;}
.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 var(--transition);display:inline-flex;align-items:center;gap:5px;font-family:inherit;font-weight:500;}
.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.5);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.2);}
.modal-title{font-size:1.05em;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.8em;color:var(--text-muted);margin-bottom:5px;font-weight:600;}
.modal-field input,.modal-field select{width:100%;padding:9px 12px;border:1.5px solid var(--border-color);border-radius:8px;background:var(--bg-card);color:var(--text);font-size:0.88em;font-family:inherit;}
.modal-field input:focus,.modal-field select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,0.1);}
.modal-actions{display:flex;gap:9px;justify-content:flex-end;margin-top:20px;}
/* ── TOAST ── */
.toast{position:fixed;bottom:28px;left:50%;transform:translateX(-50%);padding:12px 24px;border-radius:10px;z-index:9999;font-size:0.9em;box-shadow:0 4px 16px rgba(0,0,0,0.2);display:none;max-width:92vw;text-align:center;transition:opacity var(--transition);}
.toast.active{display:block;}
.toast.error{background:#dc2626;color:white;}
.toast.success{background:#16a34a;color:white;}
.toast.warning{background:#d97706;color:white;}
/* ── LOADING ── */
.loading-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:none;justify-content:center;align-items:center;flex-direction:column;gap:16px;z-index:9998;backdrop-filter:blur(3px);}
.loading-overlay.active{display:flex;}
.spinner{width:44px;height:44px;border:3px solid rgba(255,255,255,0.2);border-top-color:#2563eb;border-radius:50%;animation:spin 0.8s linear infinite;}
@keyframes spin{to{transform:rotate(360deg)}}
/* ── PIPELINE AO ── */
.phase-badge{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:10px;font-size:0.72em;font-weight:700;color:white;}
.pipeline-total{padding:10px 16px;text-align:right;border-top:1px solid var(--border-color);font-size:0.84em;color:var(--text-muted);}
.pipeline-total strong{color:var(--accent);font-size:1em;font-weight:700;}
.region-tag{display:inline-flex;align-items:center;padding:2px 7px;border-radius:8px;font-size:0.7em;font-weight:700;color:white;margin:1px;}
/* ── REGION CARD — AO SUIVANT ── */
.region-suivant{margin-top:10px;padding-top:10px;border-top:1px dashed var(--border-color);}
.region-suivant-title{font-size:0.7em;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);margin-bottom:6px;display:flex;align-items:center;gap:5px;}
.region-suivant-item{font-size:0.78em;color:var(--text);padding:5px 8px;background:var(--table-header);border-radius:6px;margin-bottom:4px;display:flex;justify-content:space-between;align-items:center;gap:8px;}
/* ── MODERNISATION SUCCESSION ── */
.moderni-region-block{background:var(--bg-card);border-radius:var(--radius-card);border:1px solid var(--border-color);box-shadow:var(--shadow-card);margin-bottom:18px;overflow:hidden;}
.moderni-region-header{background:var(--primary);padding:11px 16px;display:flex;align-items:center;gap:10px;color:white;font-weight:600;}
.moderni-region-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0;}
.moderni-body{display:grid;grid-template-columns:1fr 1fr;gap:0;}
@media(max-width:900px){.moderni-body{grid-template-columns:1fr;}}
.moderni-col{padding:14px 16px;}
.moderni-col.actuel{border-right:1px solid var(--border-color);}
@media(max-width:900px){.moderni-col.actuel{border-right:none;border-bottom:1px solid var(--border-color);}}
.moderni-col-title{font-size:0.72em;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:10px;display:flex;align-items:center;gap:6px;}
.moderni-col-title.actuel{color:var(--success);}
.moderni-col-title.suivant{color:#8b5cf6;}
.moderni-card{background:var(--table-header);border-radius:8px;padding:10px 12px;margin-bottom:8px;font-size:0.83em;}
.moderni-card:last-child{margin-bottom:0;}
.moderni-card .mc-ref{font-weight:700;color:var(--accent);margin-bottom:4px;font-size:0.9em;}
.moderni-card .mc-row{display:flex;justify-content:space-between;color:var(--text-muted);margin-top:3px;font-size:0.88em;}
.moderni-card .mc-row span:last-child{color:var(--text);font-weight:600;}
.moderni-empty{color:var(--text-muted);font-size:0.83em;font-style:italic;padding:8px 0;}
.moderni-arrow{display:flex;align-items:center;justify-content:center;font-size:1.4em;color:var(--border-color);padding:0 4px;}
@media(max-width:900px){.moderni-arrow{display:none;}}
/* ── FOOTER ── */
.footer{text-align:center;padding:22px;color:var(--text-muted);font-size:0.82em;border-top:1px solid var(--border-color);margin-top:28px;}
.footer-avatar{width:40px;height:40px;border-radius:50%;border:2px solid var(--accent);margin-bottom:7px;display:inline-flex;align-items:center;justify-content:center;background:var(--primary);color:white;font-size:1em;font-weight:700;}
/* ── RESPONSIVE ── */
@media(max-width:768px){
.header{padding:0 12px;height:56px;}
.header-info{display:none;}
.logo-section h1{font-size:0.95em;}
.slides-container{padding:12px;}
.kpi-grid{grid-template-columns:1fr 1fr;}
.sidebar{top:56px;height:calc(100vh - 56px);}
.main-wrapper{margin-left:0 !important;}
}
</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="header-left">
<button class="sidebar-toggle" id="sidebarToggle" onclick="toggleSidebar()" title="Menu"></button>
<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>
<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="Pro"><i class="fas fa-briefcase"></i></button>
</div>
<div class="user-info">
<i class="fas fa-user-circle" style="font-size:1.3em;"></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 MAJ</div>
<div class="date" id="lastUpdate"></div>
</div>
</header>
<!-- ALERT TICKER -->
<div class="alert-ticker" id="alertTicker">
<div class="ticker-track">
<div class="ticker-content" id="tickerContent"></div>
</div>
</div>
<!-- APP LAYOUT: sidebar + main -->
<div class="app-layout">
<aside class="sidebar" id="appSidebar">
<nav class="sidebar-nav">
<div class="sidebar-section-label">Tableaux de bord</div>
<button class="sidebar-btn active" id="btn-slide-0" onclick="showSlide(0)"><i class="fas fa-chart-pie"></i> Vue Générale</button>
<button class="sidebar-btn" 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.78em;margin-left:auto;display:none">0</span></button>
<button class="sidebar-btn" id="btn-slide-2" onclick="showSlide(2)"><i class="fas fa-check-circle"></i> En Service</button>
<button class="sidebar-btn" id="btn-slide-3" onclick="showSlide(3)"><i class="fas fa-rocket"></i> Pilotage Proactif</button>
<button class="sidebar-btn" id="btn-slide-4" onclick="showSlide(4)"><i class="fas fa-map-marker-alt"></i> Par Région</button>
<button class="sidebar-btn" id="btn-slide-5" onclick="showSlide(5)"><i class="fas fa-list-alt"></i> Marchés</button>
<div class="sidebar-section-label">Administration</div>
<button class="sidebar-btn nav-hidden" id="btn-slide-6" onclick="showSlide(6)"><i class="fas fa-stream"></i> Pipeline AO</button>
<button class="sidebar-btn nav-hidden" id="btn-slide-9" onclick="showSlide(9)"><i class="fas fa-link"></i> Modernisation</button>
<div class="sidebar-section-label">Super Admin</div>
<button class="sidebar-btn nav-hidden" id="btn-slide-7" onclick="showSlide(7)"><i class="fas fa-users-cog"></i> Utilisateurs</button>
<button class="sidebar-btn nav-hidden" id="btn-slide-8" onclick="showSlide(8)"><i class="fas fa-history"></i> Logs</button>
</nav>
<div class="sidebar-export">
<div class="sidebar-export-label">Export &amp; Actions</div>
<div class="sidebar-export-row">
<button class="export-btn-sm pdf-btn" onclick="downloadPDF()" title="Export PDF"><i class="fas fa-file-pdf"></i> PDF</button>
<button class="export-btn-sm pptx-btn nav-hidden" id="btnExportPPTX" onclick="exportPPTX()" title="Export PPTX"><i class="fas fa-file-powerpoint"></i> PPTX</button>
<button class="export-btn-sm xlsx-btn nav-hidden" id="btnExportXLSX" onclick="exportXLSX()" title="Export XLSX"><i class="fas fa-file-excel"></i> XLSX</button>
<button class="export-btn-sm docx-btn nav-hidden" id="btnExportDOCX" onclick="exportDOCX()" title="Export DOCX"><i class="fas fa-file-word"></i> DOCX</button>
<button class="export-btn-sm refresh-btn-sm" onclick="loadData()" title="Actualiser"><i class="fas fa-sync-alt"></i> Sync</button>
</div>
</div>
</aside>
<div class="main-wrapper" id="mainWrapper">
<!-- 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="kpi-icon blue"><i class="fas fa-folder-open"></i></div>
<div class="kpi-body"><div class="value" id="kpiTotal"></div><div class="label">Total Marchés</div></div>
</div>
<div class="kpi-card">
<div class="kpi-icon green"><i class="fas fa-play-circle"></i></div>
<div class="kpi-body"><div class="value" id="kpiActifs"></div><div class="label">Marchés Actifs</div><div class="sub" id="kpiAvt">Avancement moy. : —</div></div>
</div>
<div class="kpi-card">
<div class="kpi-icon red"><i class="fas fa-exclamation-triangle"></i></div>
<div class="kpi-body"><div class="value" id="kpiAlertes"></div><div class="label">Alertes Délais</div><div class="sub" id="kpiCritiques">Critiques (≤45j) : —</div></div>
</div>
<div class="kpi-card">
<div class="kpi-icon gray"><i class="fas fa-archive"></i></div>
<div class="kpi-body"><div class="value" id="kpiClotures"></div><div class="label">Clôturés</div></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="search-bar-full">
<i class="fas fa-search"></i>
<input class="search-input-full" type="text" id="searchMarches" placeholder="Rechercher par référence, entrepreneur, projet, région..." oninput="filterMarches()">
</div>
<div class="filters-bar">
<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-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>
<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="search-bar-full">
<i class="fas fa-search"></i>
<input class="search-input-full" type="text" id="searchPipeline" placeholder="Rechercher par N° AO, description, région..." oninput="filterPipeline()">
</div>
<div class="filters-bar">
<select class="filter-select" id="pipelineFilterRegion" onchange="filterPipeline()"><option value="">Toutes régions</option></select>
<select class="filter-select" id="pipelineFilterPhase" onchange="filterPipeline()"><option value="">Toutes phases</option></select>
</div>
<div class="table-container">
<div class="table-header" style="background:linear-gradient(90deg,#4F46E5,#6366F1);">
<h3><i class="fas fa-rocket"></i> AO en cours de lancement</h3>
<span class="badge" id="pipeline-count">0 projets</span>
</div>
<div class="table-wrapper">
<table>
<thead><tr>
<th>N° AO</th><th>Description</th><th>Phase</th><th>Régions</th>
<th>Estimation (DT)</th><th>Durée</th><th>Date limite</th><th>Jours rest.</th>
</tr></thead>
<tbody id="pipelineBody"><tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
</table>
</div>
<div class="pipeline-total">Estimation totale : <strong id="pipelineTotalEstimation"></strong></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>
<!-- ── SLIDE 9 : MODERNISATION ── -->
<section class="slide" id="slide-9">
<h2 class="section-title"><i class="fas fa-link" style="color:#8b5cf6"></i> Modernisation — Succession des Marchés</h2>
<p style="color:var(--text-muted);font-size:0.85em;margin-bottom:18px;">
Croisement entre les marchés <strong>Modernisation actifs</strong> (table 856) et les <strong>AO en cours de lancement</strong> (table 872), par région.
</p>
<div id="modernisationGrid"><p style="color:var(--text-muted);">Chargement...</p></div>
</section>
</main>
<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><!-- /main-wrapper -->
</div><!-- /app-layout -->
</div><!-- /app-content -->
<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-9').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, modernisationData = 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, rModerni] = 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() }),
isUser ? null : fetch(`${API_BASE}/modernisation`, { 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() : { count:0, total_estimation:0, results:[] };
modernisationData = (!isUser && rModerni?.ok) ? await rModerni.json() : null;
allData = (marchesJson.results || marchesJson).map(normalizeMarche);
filteredData = [...allData];
pipelineData = pipelineJson.results || pipelineJson;
if (pipelineJson.total_estimation) pipelineData._total_estimation = pipelineJson.total_estimation;
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(); renderModernisation();
updateBadges(); updateTicker();
}
/* ── 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';
// AO en lancement pour cette région
const aoReg = pipelineData.filter(p => (p._regions||[]).some(rg => rg.name === reg));
const aoHtml = aoReg.length > 0
? `<div class="region-suivant">
<div class="region-suivant-title"><i class="fas fa-rocket"></i> AO en lancement</div>
${aoReg.map(p => {
const phase = p._phase || {};
const jours = p._jours_limite;
const joursStr = jours === null ? '' : jours < 0 ? ' — passé' : ` ${jours}j`;
return `<div class="region-suivant-item">
<span>${escapeHtml(p['num-ao']||'—')} · ${escapeHtml(p['Description du projet']||'')}</span>
<span class="phase-badge" style="background:${phase.color||'#888'};font-size:0.68em">${phase.label||''}${joursStr}</span>
</div>`;
}).join('')}
</div>`
: '';
return `<div class="region-card">
<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>
${aoHtml}
</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 renderPipeline() {
const total = pipelineData._total_estimation ?? 0;
document.getElementById('pipelineTotalEstimation').textContent =
total > 0 ? parseNum(total).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g,' ') + ' DT' : '—';
// Peupler filtre regions
const regSel = document.getElementById('pipelineFilterRegion');
if (regSel) {
const regs = [...new Set(pipelineData.flatMap(r => (r._regions||[]).map(rg=>rg.name)))].sort();
regSel.innerHTML = '<option value="">Toutes r\u00e9gions</option>' + regs.map(r=>'<option>'+escapeHtml(r)+'</option>').join('');
}
// Peupler filtre phases
const phSel = document.getElementById('pipelineFilterPhase');
if (phSel) {
const phases = [...new Set(pipelineData.map(r => r._phase?.label).filter(Boolean))].sort();
phSel.innerHTML = '<option value="">Toutes phases</option>' + phases.map(p=>'<option>'+escapeHtml(p)+'</option>').join('');
}
_pipelineFiltered = [...pipelineData];
const sq = document.getElementById('searchPipeline');
if (sq) sq.value = '';
renderPipelineTable(_pipelineFiltered);
}
/* ── MODERNISATION ── */
function renderModernisation() {
const grid = document.getElementById('modernisationGrid');
if (!grid) return;
if (!modernisationData) {
grid.innerHTML = '<p style="color:var(--text-muted);font-size:0.85em;">Données non disponibles (accès admin requis).</p>';
return;
}
const regions = modernisationData.regions || [];
if (!regions.length) {
grid.innerHTML = '<p style="color:var(--text-muted);">Aucun marché modernisation trouvé.</p>';
return;
}
grid.innerHTML = regions.map(reg => {
const color = REGION_COLORS[reg.region] || '#888';
const actuelsHtml = reg.actuels.length
? reg.actuels.map(m => `
<div class="moderni-card">
<div class="mc-ref">${escapeHtml(m.ref||'—')}</div>
<div class="mc-row"><span>Projet</span><span>${escapeHtml(m.projet||'—')}</span></div>
<div class="mc-row"><span>Entrepreneur</span><span>${escapeHtml(m.entrepreneur||'—')}</span></div>
<div class="mc-row"><span>Avt. physique</span><span>${escapeHtml(String(m.taux_phy||'—'))}</span></div>
<div class="mc-row"><span>Délai restant</span><span style="color:${(m.delai_restant??999)<=45?'var(--danger)':(m.delai_restant??999)<=90?'var(--warning)':'var(--success)'}">${m.delai_restant!=null?m.delai_restant+'j':'—'}</span></div>
<div class="mc-row"><span>Fin</span><span>${escapeHtml(m.date_fin||'—')}</span></div>
<div class="mc-row"><span>Montant</span><span>${escapeHtml(m.montant||'—')}</span></div>
</div>`).join('')
: '<div class="moderni-empty">Aucun marché modernisation actif</div>';
const suivantsHtml = reg.suivants.length
? reg.suivants.map(s => {
const jours = s.jours_limite;
const joursStyle = jours===null?'' : jours<0?'color:var(--text-muted)' : jours<=7?'color:var(--danger)' : jours<=30?'color:var(--warning)':'color:var(--success)';
return `
<div class="moderni-card">
<div class="mc-ref" style="color:#8b5cf6">${escapeHtml(s.num_ao||'—')}</div>
<div class="mc-row"><span>Description</span><span>${escapeHtml(s.description||'—')}</span></div>
<div class="mc-row"><span>Phase</span><span><span class="phase-badge" style="background:${s.phase?.color||'#888'};font-size:0.7em">${s.phase?.label||'—'}</span></span></div>
<div class="mc-row"><span>Estimation</span><span>${s.estimation>0?(s.estimation).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g,' ')+' DT':'—'}</span></div>
<div class="mc-row"><span>Durée</span><span>${escapeHtml(s.duree||'—')}</span></div>
<div class="mc-row"><span>Date limite</span><span style="${joursStyle}">${escapeHtml(s.date_limite||'—')}${jours!==null?' ('+jours+'j)':''}</span></div>
${s.date_ouverture?`<div class="mc-row"><span>Ouverture</span><span>${escapeHtml(s.date_ouverture)}</span></div>`:''}
</div>`;
}).join('')
: '<div class="moderni-empty">Aucun AO en cours de lancement</div>';
return `<div class="moderni-region-block">
<div class="moderni-region-header">
<div class="moderni-region-dot" style="background:${color}"></div>
<span>${reg.region}</span>
<span style="margin-left:auto;opacity:0.75;font-size:0.82em">${reg.actuels.length} actuel${reg.actuels.length>1?'s':''} · ${reg.suivants.length} AO suivant${reg.suivants.length>1?'s':''}</span>
</div>
<div class="moderni-body">
<div class="moderni-col actuel">
<div class="moderni-col-title actuel"><i class="fas fa-play-circle"></i> Marché actuel</div>
${actuelsHtml}
</div>
<div class="moderni-col suivant">
<div class="moderni-col-title suivant"><i class="fas fa-arrow-right"></i> AO en préparation / lancement</div>
${suivantsHtml}
</div>
</div>
</div>`;
}).join('');
}
/* charts supprimés — remplacés par jauges CSS dans renderSynthese() */
/* ── 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'); }
/* ── SIDEBAR ── */
let _sidebarVisible = localStorage.getItem('rla_sidebar_state') !== 'hidden';
function toggleSidebar() {
_sidebarVisible = !_sidebarVisible;
localStorage.setItem('rla_sidebar_state', _sidebarVisible ? 'visible' : 'hidden');
applySidebarState();
}
function applySidebarState() {
const sb = document.getElementById('appSidebar');
const mw = document.getElementById('mainWrapper');
if (sb) sb.classList.toggle('hidden', !_sidebarVisible);
if (mw) mw.style.marginLeft = _sidebarVisible ? 'var(--sidebar-w)' : '0';
}
/* ── ALERT TICKER ── */
function updateTicker() {
const critiques = allData
.filter(r => !isCloture(r))
.map(r => ({ r, delai: getDelaiRestant(r) }))
.filter(x => x.delai !== null && x.delai <= 45)
.sort((a, b) => a.delai - b.delai);
const ticker = document.getElementById('alertTicker');
const content = document.getElementById('tickerContent');
if (!ticker || !content) return;
if (!critiques.length) {
ticker.classList.remove('active');
document.body.classList.remove('has-ticker');
return;
}
const text = critiques.map(x =>
`${x.r.region}${x.r.id_marche} : ${x.delai} jours restants`
).join(' • ');
content.textContent = text;
const duration = Math.min(45, Math.max(15, Math.ceil(text.length / 80)));
content.style.animationDuration = `${duration}s`;
ticker.classList.add('active');
document.body.classList.add('has-ticker');
}
/* ── PIPELINE SEARCH ── */
let _pipelineFiltered = [];
function filterPipeline() {
const q = (document.getElementById('searchPipeline')?.value || '').toLowerCase();
const region = document.getElementById('pipelineFilterRegion')?.value || '';
const phase = document.getElementById('pipelineFilterPhase')?.value || '';
_pipelineFiltered = pipelineData.filter(r => {
if (q) {
const text = `${r['num-ao']||''} ${r['Description du projet']||''} ${(r._regions||[]).map(rg=>rg.name).join(' ')}`.toLowerCase();
if (!text.includes(q)) return false;
}
if (region && !(r._regions||[]).some(rg => rg.name === region)) return false;
if (phase && (r._phase?.label || '') !== phase) return false;
return true;
});
renderPipelineTable(_pipelineFiltered);
}
function renderPipelineTable(data) {
document.getElementById('pipeline-count').textContent = `${data.length} projets`;
document.getElementById('pipelineBody').innerHTML = data.length
? data.map(r => {
const phase = r._phase || {};
const jours = r._jours_limite;
const regs = (r._regions || []);
const est = r._estimation || parseFloat(r.Estimation || 0) || 0;
const numAO = r['num-ao'] || '—';
const desc = r['Description du projet'] || r.description || '—';
const dur = r['Duree'] || r.duree || '—';
const dateLim = r['date-limite'] ? formatDateFR(r['date-limite']) : '—';
const joursHtml = jours === null ? '—'
: jours < 0 ? `<span class="status-badge muted">Passé</span>`
: jours <= 7 ? `<strong style="color:var(--danger)">${jours}j</strong>`
: jours <= 30? `<strong style="color:var(--warning)">${jours}j</strong>`
: `<span style="color:var(--success)">${jours}j</span>`;
const regHtml = regs.map(rg =>
`<span class="region-tag" style="background:${rg.color}">${escapeHtml(rg.name)}</span>`
).join('');
return `<tr>
<td><strong>${escapeHtml(numAO)}</strong></td>
<td>${escapeHtml(desc)}</td>
<td><span class="phase-badge" style="background:${phase.color||'#888'}">${escapeHtml(phase.label||'—')}</span></td>
<td>${regHtml || '—'}</td>
<td>${est > 0 ? est.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g,' ') + ' DT' : '—'}</td>
<td style="font-size:0.82em">${escapeHtml(dur)}</td>
<td style="white-space:nowrap">${dateLim}</td>
<td>${joursHtml}</td>
</tr>`;
}).join('')
: '<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:28px;">Aucun résultat.</td></tr>';
}
/* ── 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();
applySidebarState();
checkSession();
</script>
</body>
</html>