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