1763 lines
102 KiB
HTML
1763 lines
102 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>Marchés RLA - Zone Sud | Tunisie Telecom</title>
|
||
<link rel="icon" type="image/svg+xml" href="logo-RLA.svg">
|
||
<link rel="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 & 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">45–90j</span></div>
|
||
</div>
|
||
<div class="statut-bloc ok">
|
||
<div class="statut-bloc-value" id="blocOk">—</div>
|
||
<div class="statut-bloc-label"><i class="fas fa-check-circle"></i> Dans les délais</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Jauges par région -->
|
||
<div class="chart-card" style="flex:1">
|
||
<div class="chart-card-title"><i class="fas fa-map-marker-alt"></i> Avancement physique par région</div>
|
||
<div id="regionJauges"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tous les marchés en alerte par priorité -->
|
||
<div class="table-container" id="syntheseAlerteContainer">
|
||
<div class="table-header" style="background:linear-gradient(90deg,#b91c1c,#dc2626);">
|
||
<h3><i class="fas fa-fire"></i> Marchés à surveiller — par ordre de priorité</h3>
|
||
<span class="badge" id="syntheseAlerteBadge">0 alertes</span>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead><tr>
|
||
<th>Priorité</th><th>Référence</th><th>Entrepreneur</th><th>Projet</th>
|
||
<th>Région</th><th>Avt. Phy.</th><th>Délai Rest.</th><th>Niveau</th>
|
||
</tr></thead>
|
||
<tbody id="syntheseAlerteTable"><tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
<div style="padding:10px 16px;text-align:right;border-top:1px solid var(--border-color);">
|
||
<button class="btn-action btn-secondary" onclick="showSlide(1)"><i class="fas fa-arrow-right"></i> Voir slide Alertes complète</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SLIDE 1 : ALERTES ── -->
|
||
<section class="slide" id="slide-1">
|
||
<h2 class="section-title"><i class="fas fa-exclamation-triangle" style="color:var(--danger)"></i> Alertes Délais</h2>
|
||
<div class="table-container">
|
||
<div class="table-header" style="background:linear-gradient(90deg,#b91c1c,#dc2626);">
|
||
<h3><i class="fas fa-fire"></i> Marchés à surveiller</h3>
|
||
<span class="badge" id="alertes-count">0 alertes</span>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead><tr>
|
||
<th>Référence</th><th>Entrepreneur</th><th>Projet</th><th>Région</th>
|
||
<th>Avt. Phy.</th><th>Délai Rest.</th><th>Niveau</th>
|
||
</tr></thead>
|
||
<tbody id="alertes-table"><tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SLIDE 2 : EN SERVICE ── -->
|
||
<section class="slide" id="slide-2">
|
||
<h2 class="section-title"><i class="fas fa-check-circle" style="color:var(--success)"></i> Marchés En Service</h2>
|
||
<div class="filters-bar">
|
||
<div class="filter-group"><label><i class="fas fa-map-marker-alt"></i> Région</label>
|
||
<select class="filter-select" id="serviceFilterRegion" onchange="onServiceRegionChange()">
|
||
<option value="">Toutes régions</option>
|
||
</select>
|
||
</div>
|
||
<div class="filter-group"><label><i class="fas fa-hard-hat"></i> Entrepreneur</label>
|
||
<select class="filter-select" id="serviceFilterEntrepreneur" onchange="renderService()"><option value="">Tous entrepreneurs</option></select>
|
||
</div>
|
||
</div>
|
||
<div class="table-container">
|
||
<div class="table-header" style="background:linear-gradient(90deg,#047857,#10b981);">
|
||
<h3><i class="fas fa-play-circle"></i> En Cours d'Exécution</h3>
|
||
<span class="badge" id="service-count">0 marchés</span>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead><tr>
|
||
<th>Référence</th><th>Projet</th><th>Région</th><th>Entrepreneur</th>
|
||
<th>Montant Max</th><th>Période</th><th>Avt. Phy.</th><th>Délai Rest.</th>
|
||
</tr></thead>
|
||
<tbody id="service-table"><tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SLIDE 3 : PILOTAGE PROACTIF ── -->
|
||
<section class="slide" id="slide-3">
|
||
<h2 class="section-title"><i class="fas fa-rocket" style="color:#6366F1"></i> Pilotage Proactif — Avancement Physique</h2>
|
||
<div class="kpi-grid" id="proactif-kpi-grid">
|
||
<div class="kpi-card proactif-normal"><div class="icon" style="color:#059669;"><i class="fas fa-check-double"></i></div><div class="value" id="proactif-kpi-normal" style="color:#059669;">—</div><div class="label">Normal</div><div class="sub">Avt. ≥ seuil standard (70%)</div></div>
|
||
<div class="kpi-card proactif-sous"><div class="icon" style="color:#DC2626;"><i class="fas fa-arrow-down"></i></div><div class="value" id="proactif-kpi-sous" style="color:#DC2626;">—</div><div class="label">Sous Avancement</div><div class="sub">Avt. physique < seuil (70%)</div></div>
|
||
<div class="kpi-card proactif-depasse"><div class="icon" style="color:#D97706;"><i class="fas fa-arrow-up"></i></div><div class="value" id="proactif-kpi-depasse" style="color:#D97706;">—</div><div class="label">Dépassement</div><div class="sub">Avt. physique ≥ critique (90%)</div></div>
|
||
<div class="kpi-card proactif-none"><div class="icon" style="color:#64748B;"><i class="fas fa-question-circle"></i></div><div class="value" id="proactif-kpi-none" style="color:#64748B;">—</div><div class="label">Non déterminé</div><div class="sub">Données insuffisantes</div></div>
|
||
</div>
|
||
<div class="filters-bar">
|
||
<div class="filter-group"><label><i class="fas fa-map-marker-alt"></i> Région</label>
|
||
<select class="filter-select" id="proactifFilterRegion" onchange="renderProactif()"><option value="">Toutes régions</option></select>
|
||
</div>
|
||
<div class="filter-group"><label><i class="fas fa-tachometer-alt"></i> État</label>
|
||
<select class="filter-select" id="proactifFilterEtat" onchange="renderProactif()">
|
||
<option value="">Tous états</option>
|
||
<option value="Normal">Normal</option>
|
||
<option value="Sous Avancement">Sous Avancement</option>
|
||
<option value="Dépassement">Dépassement</option>
|
||
<option value="Non déterminé">Non déterminé</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="table-container">
|
||
<div class="table-header" style="background:linear-gradient(90deg,#4f46e5,#6366f1);">
|
||
<h3><i class="fas fa-th"></i> Détail par marché</h3>
|
||
<span class="badge" id="proactif-count">0 marchés</span>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead><tr>
|
||
<th>Référence</th><th>Entrepreneur</th><th>Projet</th><th>Région</th>
|
||
<th>Avt. Phy.</th><th>Délai Rest.</th><th>Résultat</th>
|
||
</tr></thead>
|
||
<tbody id="proactif-table"><tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:28px;">Chargement...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SLIDE 4 : PAR RÉGION ── -->
|
||
<section class="slide" id="slide-4">
|
||
<h2 class="section-title"><i class="fas fa-map-marker-alt" style="color:var(--accent)"></i> Détail par Région</h2>
|
||
<div class="regions-grid" id="regions-grid">
|
||
<p style="color:var(--text-muted);">Chargement...</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SLIDE 5 : MARCHÉS ── -->
|
||
<section class="slide" id="slide-5">
|
||
<h2 class="section-title"><i class="fas fa-list-alt" style="color:var(--accent)"></i> Liste des Marchés</h2>
|
||
<div class="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 & 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 (45–90j)</span>`
|
||
: `<span class="situ-ok">aucune alerte délai</span>`;
|
||
|
||
document.getElementById('situationPhrase').innerHTML =
|
||
`${phraseIcon} <strong>${actifs.length} marchés actifs</strong> — avancement physique moyen <strong>${avgAvt}%</strong>. ` +
|
||
`${phraseAlerte}. <strong>${allData.filter(r => isCloture(r)).length} clôturé${allData.filter(r=>isCloture(r)).length>1?'s':''}</strong>.`;
|
||
|
||
// Blocs statut
|
||
document.getElementById('blocCritique').textContent = critiques;
|
||
document.getElementById('blocAttention').textContent = attention;
|
||
document.getElementById('blocOk').textContent = Math.max(0, dansDelais);
|
||
|
||
// Jauges par région
|
||
document.getElementById('regionJauges').innerHTML = ALL_REGIONS.map(reg => {
|
||
const rows = actifs.filter(r => r.region === reg);
|
||
const avts2 = rows.map(r => r.taux_phy).filter(v => v > 0);
|
||
const avg2 = avts2.length ? Math.round(avts2.reduce((a,b)=>a+b,0)/avts2.length) : 0;
|
||
const color = REGION_COLORS[reg] || '#888';
|
||
const alReg = alertes.filter(x => x.r.region === reg).length;
|
||
const alerteIcon = alReg > 0 ? `<span style="color:var(--danger);font-weight:700">⚠ ${alReg}</span>` : `<span style="color:var(--success)">✓</span>`;
|
||
return `<div class="region-jauge-row">
|
||
<div class="region-jauge-name">${reg}</div>
|
||
<div class="region-jauge-track">
|
||
<div class="region-jauge-fill" style="width:${avg2}%;background:${color}">
|
||
${avg2 >= 30 ? `<span>${avg2}%</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="region-jauge-meta">${avg2 < 30 ? avg2+'% ' : ''}${alerteIcon} <span style="color:var(--text-muted)">(${rows.length})</span></div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Table alertes complète par priorité
|
||
document.getElementById('syntheseAlerteBadge').textContent = `${alertes.length} alerte${alertes.length>1?'s':''}`;
|
||
document.getElementById('syntheseAlerteTable').innerHTML = alertes.length
|
||
? alertes.map((x, i) => {
|
||
const niveau = x.delai <= 45 ? 'critique' : 'attention';
|
||
const pClass = x.delai <= 45 ? 'p1' : 'p2';
|
||
return `<tr>
|
||
<td><span class="prio-badge ${pClass}">${i+1}</span></td>
|
||
<td><strong>${escapeHtml(x.r.id_marche||'—')}</strong></td>
|
||
<td>${escapeHtml(x.r.entrepreneur||'—')}</td>
|
||
<td>${escapeHtml(x.r.projet||'—')}</td>
|
||
<td>${escapeHtml(x.r.region||'—')}</td>
|
||
<td>${getProgressBar(x.r.taux_phy)}</td>
|
||
<td><strong style="color:${x.delai<=45?'var(--danger)':'var(--warning)'}">${x.delai}j</strong></td>
|
||
<td><span class="status-badge ${niveau}">${niveau==='critique'?'Critique':'Attention'}</span></td>
|
||
</tr>`;
|
||
}).join('')
|
||
: '<tr><td colspan="8" style="text-align:center;color:var(--success);padding:28px;"><i class="fas fa-check-circle"></i> Aucune alerte délai — situation normale.</td></tr>';
|
||
|
||
// Slide 1 : table alertes (réutilise buildAlertList)
|
||
renderAlertesSlide(alertes);
|
||
}
|
||
|
||
/* ── SLIDE 1 : ALERTES ── */
|
||
function alerteRowHTML(r, delai) {
|
||
const niveau = delai <= 45 ? 'critique' : 'attention';
|
||
return `<tr>
|
||
<td><strong>${escapeHtml(r.id_marche||'—')}</strong></td>
|
||
<td>${escapeHtml(r.entrepreneur||'—')}</td>
|
||
<td>${escapeHtml(r.projet||'—')}</td>
|
||
<td>${escapeHtml(r.region||'—')}</td>
|
||
<td>${getProgressBar(r.taux_phy)}</td>
|
||
<td><strong style="color:${delai<=45?'var(--danger)':'var(--warning)'}">${delai}j</strong></td>
|
||
<td><span class="status-badge ${niveau}">${niveau==='critique'?'Critique':'Attention'}</span></td>
|
||
</tr>`;
|
||
}
|
||
function renderAlertesSlide(alertes) {
|
||
document.getElementById('alertes-count').textContent = `${alertes.length} alerte${alertes.length>1?'s':''}`;
|
||
document.getElementById('alertes-table').innerHTML = alertes.length
|
||
? alertes.map(x => alerteRowHTML(x.r, x.delai)).join('')
|
||
: '<tr><td colspan="7" style="text-align:center;color:var(--success);padding:28px;"><i class="fas fa-check-circle"></i> Aucune alerte.</td></tr>';
|
||
}
|
||
|
||
/* ── EN SERVICE ── */
|
||
function onServiceRegionChange() {
|
||
// Reset entrepreneur filter then re-populate
|
||
document.getElementById('serviceFilterEntrepreneur').innerHTML = '<option value="">Tous entrepreneurs</option>';
|
||
renderService();
|
||
}
|
||
function renderService() {
|
||
const reg = document.getElementById('serviceFilterRegion')?.value || '';
|
||
const entr = document.getElementById('serviceFilterEntrepreneur')?.value || '';
|
||
let rows = allData.filter(r => !isCloture(r) && obsVal(r).toLowerCase().includes('en service'));
|
||
const allEnService = [...rows];
|
||
if (reg) rows = rows.filter(r => r.region === reg);
|
||
if (entr) rows = rows.filter(r => (r.entrepreneur || '') === entr);
|
||
|
||
const entrSel = document.getElementById('serviceFilterEntrepreneur');
|
||
if (entrSel && entrSel.options.length <= 1) {
|
||
const pool = reg ? allEnService.filter(r => r.region === reg) : allEnService;
|
||
const entrs = [...new Set(pool.map(r => r.entrepreneur).filter(Boolean))].sort();
|
||
entrSel.innerHTML = '<option value="">Tous entrepreneurs</option>' +
|
||
entrs.map(e => `<option>${escapeHtml(e)}</option>`).join('');
|
||
}
|
||
|
||
document.getElementById('service-count').textContent = `${rows.length} marchés`;
|
||
document.getElementById('service-table').innerHTML = rows.length
|
||
? rows.map(r => {
|
||
const delai = getDelaiRestant(r);
|
||
return `<tr>
|
||
<td><strong>${escapeHtml(r.id_marche||'—')}</strong></td>
|
||
<td>${escapeHtml(r.projet||'—')}</td>
|
||
<td>${escapeHtml(r.region||'—')}</td>
|
||
<td>${escapeHtml(r.entrepreneur||'—')}</td>
|
||
<td style="white-space:nowrap">${r.tot_marche > 0 ? formatMontant(r.tot_marche) : '—'}</td>
|
||
<td style="white-space:nowrap;font-size:0.82em">${formatDateFR(r.date_debut)} → ${formatDateFR(r.date_fin)}</td>
|
||
<td>${getProgressBar(r.taux_phy)}</td>
|
||
<td><strong>${delai !== null ? delai + 'j' : '—'}</strong></td>
|
||
</tr>`;
|
||
}).join('')
|
||
: '<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:28px;">Aucun marché en service.</td></tr>';
|
||
}
|
||
|
||
/* ── PILOTAGE PROACTIF ── */
|
||
function renderProactif() {
|
||
const resume = proactifData?.resume || {};
|
||
const items = proactifData?.items || [];
|
||
|
||
document.getElementById('proactif-kpi-normal').textContent = resume.normal ?? 0;
|
||
document.getElementById('proactif-kpi-sous').textContent = resume.sous_avancement ?? resume.sous_min ?? 0;
|
||
document.getElementById('proactif-kpi-depasse').textContent = resume.depassement ?? 0;
|
||
document.getElementById('proactif-kpi-none').textContent = resume.non_determine ?? 0;
|
||
|
||
const filterReg = document.getElementById('proactifFilterRegion')?.value || '';
|
||
const filterEtat = document.getElementById('proactifFilterEtat')?.value || '';
|
||
|
||
let filtered = items;
|
||
if (filterReg) filtered = filtered.filter(r => (r.region||'') === filterReg);
|
||
if (filterEtat) filtered = filtered.filter(r => (r.resultat||'') === filterEtat);
|
||
|
||
document.getElementById('proactif-count').textContent = `${filtered.length} marchés`;
|
||
|
||
const badges = {
|
||
'Normal': ['ok', 'Normal'],
|
||
'Sous Avancement': ['critique', 'Sous Avancement'],
|
||
'Dépassement': ['attention','Dépassement'],
|
||
'Non déterminé': ['muted', 'Non déterminé'],
|
||
'Sous Min': ['critique', 'Sous Min'],
|
||
};
|
||
document.getElementById('proactif-table').innerHTML = filtered.length
|
||
? filtered.map(r => {
|
||
const [bc, bl] = badges[r.resultat] || ['info', r.resultat || '—'];
|
||
const pct = parseNum(r.taux_phy_raw ?? r.taux_phy);
|
||
return `<tr>
|
||
<td><strong>${escapeHtml(r.ref||'—')}</strong></td>
|
||
<td>${escapeHtml(r.entrepreneur||'—')}</td>
|
||
<td>${escapeHtml(r.projet||'—')}</td>
|
||
<td>${escapeHtml(r.region||'—')}</td>
|
||
<td>${getProgressBar(pct)}</td>
|
||
<td>${r.delai_restant != null ? `<strong>${r.delai_restant}j</strong>` : '—'}</td>
|
||
<td><span class="status-badge ${bc}">${escapeHtml(bl)}</span></td>
|
||
</tr>`;
|
||
}).join('')
|
||
: '<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:28px;">Aucune donnée.</td></tr>';
|
||
}
|
||
|
||
/* ── PAR RÉGION ── */
|
||
function renderRegions() {
|
||
const actifs = allData.filter(r => !isCloture(r));
|
||
document.getElementById('regions-grid').innerHTML = ALL_REGIONS.map(reg => {
|
||
const rows = actifs.filter(r => r.region === reg);
|
||
const avts = rows.map(r => r.taux_phy).filter(v => v > 0);
|
||
const avg = avts.length ? Math.round(avts.reduce((a,b) => a+b,0) / avts.length) : 0;
|
||
const budget = rows.reduce((s,r) => s + r.tot_marche, 0);
|
||
const alertes = rows.filter(r => { const d = getDelaiRestant(r); return d !== null && d <= 90; });
|
||
const color = REGION_COLORS[reg] || '#888';
|
||
// 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>
|