1780 lines
78 KiB
HTML
1780 lines
78 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr" data-theme="tt">
|
||
<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">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/frappe-gantt@0.6.1/dist/frappe-gantt.css">
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js" defer></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/frappe-gantt@0.6.1/dist/frappe-gantt.min.js" defer></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/pptxgenjs@3.12.0/dist/pptxgen.bundle.js" defer></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js" defer></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.1/jspdf.plugin.autotable.min.js" defer></script>
|
||
<script src="config.js"></script>
|
||
<style>
|
||
/* ─── THEME VARIABLES ──────────────────────────────────────────── */
|
||
:root {
|
||
/* Tunisie Telecom (default) */
|
||
--sidebar-bg: #002D62;
|
||
--sidebar-text: rgba(255,255,255,0.85);
|
||
--sidebar-active: rgba(255,255,255,0.15);
|
||
--sidebar-hover: rgba(255,255,255,0.08);
|
||
--sidebar-accent: #E31837;
|
||
--topbar-bg: #002D62;
|
||
--topbar-text: #ffffff;
|
||
--main-bg: #f0f4f8;
|
||
--card-bg: #ffffff;
|
||
--card-border: #e2e8f0;
|
||
--card-shadow: 0 2px 12px rgba(0,0,0,0.07);
|
||
--text: #1e293b;
|
||
--text-muted: #64748b;
|
||
--accent: #002D62;
|
||
--accent2: #E31837;
|
||
--success: #10b981;
|
||
--warning: #f59e0b;
|
||
--danger: #ef4444;
|
||
--table-head: #f8fafc;
|
||
--table-border: #e2e8f0;
|
||
--table-row-hover: #f1f5f9;
|
||
--input-bg: #ffffff;
|
||
--input-border: #cbd5e1;
|
||
--badge-radius: 6px;
|
||
}
|
||
|
||
[data-theme="dark"] {
|
||
/* Dark Pro */
|
||
--sidebar-bg: #0d1117;
|
||
--sidebar-text: rgba(255,255,255,0.8);
|
||
--sidebar-active: rgba(0,212,255,0.15);
|
||
--sidebar-hover: rgba(255,255,255,0.06);
|
||
--sidebar-accent: #00d4ff;
|
||
--topbar-bg: #161b22;
|
||
--topbar-text: #f0f6fc;
|
||
--main-bg: #1a1a2e;
|
||
--card-bg: #1e2433;
|
||
--card-border: rgba(255,255,255,0.08);
|
||
--card-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||
--text: #e2e8f0;
|
||
--text-muted: #8b949e;
|
||
--accent: #00d4ff;
|
||
--accent2: #38bdf8;
|
||
--success: #22c55e;
|
||
--warning: #f59e0b;
|
||
--danger: #f87171;
|
||
--table-head: rgba(255,255,255,0.05);
|
||
--table-border: rgba(255,255,255,0.08);
|
||
--table-row-hover: rgba(255,255,255,0.04);
|
||
--input-bg: #0d1117;
|
||
--input-border: rgba(255,255,255,0.15);
|
||
}
|
||
|
||
[data-theme="light"] {
|
||
/* Light Clean */
|
||
--sidebar-bg: #1e293b;
|
||
--sidebar-text: rgba(255,255,255,0.85);
|
||
--sidebar-active: rgba(37,99,235,0.2);
|
||
--sidebar-hover: rgba(255,255,255,0.07);
|
||
--sidebar-accent: #60a5fa;
|
||
--topbar-bg: #ffffff;
|
||
--topbar-text: #1e293b;
|
||
--main-bg: #f8fafc;
|
||
--card-bg: #ffffff;
|
||
--card-border: #e2e8f0;
|
||
--card-shadow: 0 1px 8px rgba(0,0,0,0.06);
|
||
--text: #111827;
|
||
--text-muted: #6b7280;
|
||
--accent: #2563eb;
|
||
--accent2: #3b82f6;
|
||
--success: #059669;
|
||
--warning: #d97706;
|
||
--danger: #dc2626;
|
||
--table-head: #f9fafb;
|
||
--table-border: #e5e7eb;
|
||
--table-row-hover: #f3f4f6;
|
||
--input-bg: #ffffff;
|
||
--input-border: #d1d5db;
|
||
}
|
||
|
||
[data-theme="mckinsey"] {
|
||
/* McKinsey & Company — Bleu pétrole / Gris sobre */
|
||
--sidebar-bg: #1C2B4B; /* bleu marine profond */
|
||
--sidebar-text: rgba(255,255,255,0.88);
|
||
--sidebar-active: rgba(255,255,255,0.14);
|
||
--sidebar-hover: rgba(255,255,255,0.07);
|
||
--sidebar-accent: #2B8AC4; /* bleu acier */
|
||
--topbar-bg: #1C2B4B;
|
||
--topbar-text: #ffffff;
|
||
--main-bg: #F4F5F7; /* gris très clair */
|
||
--card-bg: #FFFFFF;
|
||
--card-border: #DDE1E7;
|
||
--card-shadow: 0 2px 10px rgba(28,43,75,0.08);
|
||
--text: #1C2B4B;
|
||
--text-muted: #7A8599;
|
||
--accent: #1C2B4B;
|
||
--accent2: #2B8AC4;
|
||
--success: #0D8A5E;
|
||
--warning: #D4870A;
|
||
--danger: #C0392B;
|
||
--table-head: #EEF1F5;
|
||
--table-border: #DDE1E7;
|
||
--table-row-hover: #F0F4F8;
|
||
--input-bg: #FFFFFF;
|
||
--input-border: #BFC6D2;
|
||
--badge-radius: 4px;
|
||
}
|
||
|
||
/* ─── RESET & BASE ─────────────────────────────────────────────── */
|
||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||
background: var(--main-bg);
|
||
color: var(--text);
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
transition: background 0.3s, color 0.3s;
|
||
}
|
||
|
||
/* ─── LOGIN ─────────────────────────────────────────────────────── */
|
||
#loginPage {
|
||
position: fixed; inset: 0; z-index: 1000;
|
||
background: linear-gradient(135deg, #002D62 0%, #1e40af 60%, #0f172a 100%);
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
[data-theme="dark"] #loginPage {
|
||
background: linear-gradient(135deg, #0d1117 0%, #1a1a2e 60%, #0f0f1a 100%);
|
||
}
|
||
[data-theme="light"] #loginPage {
|
||
background: linear-gradient(135deg, #1e293b 0%, #2563eb 60%, #1d4ed8 100%);
|
||
}
|
||
.login-card {
|
||
background: var(--card-bg);
|
||
border-radius: 20px;
|
||
padding: 44px 40px;
|
||
width: 100%; max-width: 420px;
|
||
box-shadow: 0 24px 64px rgba(0,0,0,0.35);
|
||
border: 1px solid var(--card-border);
|
||
text-align: center;
|
||
}
|
||
.login-card .login-logo { height: 65px; margin-bottom: 22px; object-fit: contain; }
|
||
.login-card h2 { font-size: 1.4em; color: var(--accent); margin-bottom: 6px; }
|
||
.login-card p { color: var(--text-muted); font-size: 0.9em; margin-bottom: 28px; }
|
||
.login-field {
|
||
position: relative; margin-bottom: 16px; text-align: left;
|
||
}
|
||
.login-field i {
|
||
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
|
||
color: var(--text-muted); font-size: 0.95em;
|
||
}
|
||
.login-field input {
|
||
width: 100%; padding: 13px 14px 13px 40px;
|
||
border: 2px solid var(--input-border); border-radius: 10px;
|
||
background: var(--input-bg); color: var(--text);
|
||
font-size: 0.95em; transition: border-color 0.2s;
|
||
}
|
||
.login-field input:focus { outline: none; border-color: var(--accent); }
|
||
.login-submit {
|
||
width: 100%; padding: 14px;
|
||
background: linear-gradient(135deg, #002D62, #1e40af);
|
||
color: white; border: none; border-radius: 10px;
|
||
font-size: 1em; font-weight: 600; cursor: pointer;
|
||
transition: opacity 0.2s, transform 0.2s;
|
||
margin-top: 8px;
|
||
}
|
||
[data-theme="light"] .login-submit { background: linear-gradient(135deg, #1e40af, #2563eb); }
|
||
.login-submit:hover { opacity: 0.92; transform: translateY(-1px); }
|
||
.login-submit:active { transform: translateY(0); }
|
||
.login-error {
|
||
color: var(--danger); font-size: 0.85em;
|
||
background: rgba(239,68,68,0.1); border-radius: 8px;
|
||
padding: 10px 14px; margin-bottom: 14px; display: none;
|
||
}
|
||
.login-error.visible { display: block; }
|
||
|
||
/* ─── APP LAYOUT ────────────────────────────────────────────────── */
|
||
#app {
|
||
display: none; height: 100vh;
|
||
flex-direction: row;
|
||
}
|
||
#app.active { display: flex; }
|
||
|
||
/* ─── SIDEBAR ───────────────────────────────────────────────────── */
|
||
#sidebar {
|
||
width: 240px; min-width: 240px;
|
||
background: var(--sidebar-bg);
|
||
display: flex; flex-direction: column;
|
||
transition: background 0.3s;
|
||
overflow: hidden;
|
||
}
|
||
.sidebar-header {
|
||
padding: 20px 20px 16px;
|
||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||
display: flex; align-items: center; gap: 12px;
|
||
}
|
||
.sidebar-header img { height: 40px; object-fit: contain; filter: brightness(0) invert(1); }
|
||
.sidebar-header .sidebar-title { color: #fff; font-size: 0.85em; font-weight: 600; line-height: 1.3; }
|
||
.sidebar-header .sidebar-title small { display: block; font-weight: 400; opacity: 0.65; font-size: 0.85em; margin-top: 2px; }
|
||
|
||
nav.sidebar-nav {
|
||
flex: 1; padding: 12px 10px; overflow-y: auto;
|
||
}
|
||
nav.sidebar-nav::-webkit-scrollbar { width: 4px; }
|
||
nav.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
|
||
|
||
.nav-section-label {
|
||
color: rgba(255,255,255,0.35); font-size: 0.7em; font-weight: 700;
|
||
text-transform: uppercase; letter-spacing: 0.08em;
|
||
padding: 14px 10px 6px;
|
||
}
|
||
.nav-item {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 10px 12px; border-radius: 10px;
|
||
color: var(--sidebar-text); cursor: pointer;
|
||
transition: background 0.15s, color 0.15s;
|
||
font-size: 0.9em; user-select: none;
|
||
margin-bottom: 2px;
|
||
}
|
||
.nav-item:hover { background: var(--sidebar-hover); }
|
||
.nav-item.active {
|
||
background: var(--sidebar-active);
|
||
color: #fff; font-weight: 600;
|
||
}
|
||
.nav-item.active .nav-icon { color: var(--sidebar-accent); }
|
||
.nav-icon { width: 20px; text-align: center; font-size: 0.95em; opacity: 0.8; flex-shrink: 0; }
|
||
.nav-item.active .nav-icon { opacity: 1; }
|
||
.nav-badge {
|
||
margin-left: auto; background: var(--danger);
|
||
color: white; font-size: 0.7em; font-weight: 700;
|
||
padding: 2px 7px; border-radius: 20px; min-width: 20px; text-align: center;
|
||
}
|
||
.nav-badge.warn { background: var(--warning); }
|
||
|
||
.sidebar-footer {
|
||
padding: 14px 10px;
|
||
border-top: 1px solid rgba(255,255,255,0.08);
|
||
}
|
||
.theme-switcher {
|
||
display: flex; gap: 4px; background: rgba(0,0,0,0.2);
|
||
border-radius: 10px; padding: 4px; margin-bottom: 12px;
|
||
}
|
||
.theme-btn {
|
||
flex: 1; padding: 7px 4px; border: none; border-radius: 7px;
|
||
background: transparent; color: rgba(255,255,255,0.55);
|
||
cursor: pointer; font-size: 0.7em; font-weight: 600;
|
||
text-transform: uppercase; letter-spacing: 0.04em;
|
||
transition: all 0.2s;
|
||
}
|
||
.theme-btn.active { background: rgba(255,255,255,0.15); color: #fff; }
|
||
.theme-btn:hover:not(.active) { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.8); }
|
||
|
||
.sidebar-user {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 8px 10px; border-radius: 10px;
|
||
background: rgba(0,0,0,0.2); color: var(--sidebar-text);
|
||
}
|
||
.sidebar-user-avatar {
|
||
width: 32px; height: 32px; border-radius: 50%;
|
||
background: var(--sidebar-accent); color: white;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 0.9em; font-weight: 700; flex-shrink: 0;
|
||
}
|
||
.sidebar-user-info { flex: 1; min-width: 0; }
|
||
.sidebar-user-name { font-size: 0.85em; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.sidebar-user-role { font-size: 0.72em; color: rgba(255,255,255,0.5); }
|
||
.sidebar-logout {
|
||
background: none; border: none; color: rgba(255,255,255,0.45);
|
||
cursor: pointer; padding: 4px; border-radius: 6px;
|
||
transition: color 0.2s;
|
||
}
|
||
.sidebar-logout:hover { color: var(--danger); }
|
||
|
||
/* ─── MAIN AREA ─────────────────────────────────────────────────── */
|
||
#main {
|
||
flex: 1; display: flex; flex-direction: column;
|
||
overflow: hidden; background: var(--main-bg);
|
||
transition: background 0.3s;
|
||
}
|
||
|
||
/* ─── TOPBAR ─────────────────────────────────────────────────────── */
|
||
.topbar {
|
||
background: var(--topbar-bg);
|
||
border-bottom: 1px solid var(--card-border);
|
||
padding: 0 28px;
|
||
height: 60px; min-height: 60px;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
gap: 20px;
|
||
box-shadow: var(--card-shadow);
|
||
transition: background 0.3s;
|
||
}
|
||
[data-theme="tt"] .topbar { box-shadow: 0 2px 12px rgba(0,45,98,0.15); }
|
||
.topbar-left { display: flex; align-items: center; gap: 14px; }
|
||
.topbar-title { font-size: 1.1em; font-weight: 700; color: var(--topbar-text); }
|
||
.topbar-subtitle { font-size: 0.8em; color: var(--text-muted); }
|
||
.topbar-right { display: flex; align-items: center; gap: 10px; }
|
||
.topbar-date { font-size: 0.82em; color: var(--text-muted); white-space: nowrap; }
|
||
.topbar-date strong { color: var(--accent); }
|
||
|
||
.btn-icon {
|
||
width: 36px; height: 36px;
|
||
background: var(--card-bg); border: 1px solid var(--card-border);
|
||
border-radius: 8px; color: var(--text-muted);
|
||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||
font-size: 0.9em; transition: all 0.2s;
|
||
}
|
||
.btn-icon:hover { border-color: var(--accent); color: var(--accent); }
|
||
|
||
.btn {
|
||
padding: 8px 16px; border: none; border-radius: 8px;
|
||
font-size: 0.85em; font-weight: 600; cursor: pointer;
|
||
display: inline-flex; align-items: center; gap: 7px;
|
||
transition: all 0.2s;
|
||
}
|
||
.btn-primary { background: var(--accent); color: white; }
|
||
.btn-primary:hover { opacity: 0.88; }
|
||
.btn-danger { background: var(--danger); color: white; }
|
||
.btn-danger:hover { opacity: 0.88; }
|
||
.btn-secondary {
|
||
background: var(--card-bg); color: var(--text);
|
||
border: 1px solid var(--card-border);
|
||
}
|
||
.btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
|
||
|
||
/* ─── CONTENT ────────────────────────────────────────────────────── */
|
||
.content {
|
||
flex: 1; overflow-y: auto; padding: 24px 28px;
|
||
}
|
||
.content::-webkit-scrollbar { width: 6px; }
|
||
.content::-webkit-scrollbar-thumb { background: var(--card-border); border-radius: 6px; }
|
||
|
||
.section { display: none; }
|
||
.section.active { display: block; }
|
||
|
||
/* ─── SECTION HEADER ─────────────────────────────────────────────── */
|
||
.section-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
margin-bottom: 22px; flex-wrap: wrap; gap: 12px;
|
||
}
|
||
.section-heading {
|
||
font-size: 1.3em; font-weight: 700; color: var(--text);
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.section-heading::before {
|
||
content: ''; width: 4px; height: 22px;
|
||
background: var(--accent); border-radius: 4px;
|
||
}
|
||
.section-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
|
||
/* ─── KPI GRID ────────────────────────────────────────────────────── */
|
||
.kpi-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 16px; margin-bottom: 22px;
|
||
}
|
||
.kpi-card {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--card-border);
|
||
border-radius: 14px; padding: 20px;
|
||
box-shadow: var(--card-shadow);
|
||
position: relative; overflow: hidden;
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
}
|
||
.kpi-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.1); }
|
||
.kpi-card::after {
|
||
content: ''; position: absolute; top: 0; left: 0;
|
||
width: 100%; height: 3px; background: var(--kpi-color, var(--accent));
|
||
}
|
||
.kpi-card.kpi-total { --kpi-color: var(--accent); }
|
||
.kpi-card.kpi-actif { --kpi-color: var(--success); }
|
||
.kpi-card.kpi-alerte { --kpi-color: var(--danger); }
|
||
.kpi-card.kpi-cloture { --kpi-color: var(--text-muted); }
|
||
.kpi-card.kpi-montant { --kpi-color: var(--warning); }
|
||
|
||
.kpi-icon {
|
||
width: 42px; height: 42px; border-radius: 10px;
|
||
background: rgba(0,0,0,0.06); color: var(--kpi-color, var(--accent));
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 1.1em; margin-bottom: 14px;
|
||
}
|
||
[data-theme="dark"] .kpi-icon { background: rgba(255,255,255,0.07); }
|
||
.kpi-value { font-size: 2em; font-weight: 800; color: var(--text); line-height: 1; }
|
||
.kpi-label { font-size: 0.82em; color: var(--text-muted); margin-top: 6px; font-weight: 500; }
|
||
.kpi-sub {
|
||
font-size: 0.75em; color: var(--text-muted);
|
||
margin-top: 12px; padding-top: 10px;
|
||
border-top: 1px solid var(--card-border);
|
||
}
|
||
|
||
/* ─── CHARTS ROW ─────────────────────────────────────────────────── */
|
||
.charts-row {
|
||
display: grid;
|
||
grid-template-columns: 320px 1fr;
|
||
gap: 16px; margin-bottom: 22px;
|
||
}
|
||
@media (max-width: 1100px) {
|
||
.charts-row { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
.chart-card {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--card-border);
|
||
border-radius: 14px; padding: 20px;
|
||
box-shadow: var(--card-shadow);
|
||
}
|
||
.chart-card-title {
|
||
font-size: 0.9em; font-weight: 700; color: var(--text);
|
||
margin-bottom: 16px; display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.chart-card-title i { color: var(--accent); }
|
||
.chart-container { position: relative; height: 240px; }
|
||
.gantt-wrapper { overflow-x: auto; min-height: 240px; }
|
||
|
||
/* ─── TABLE SECTION ──────────────────────────────────────────────── */
|
||
.table-card {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--card-border);
|
||
border-radius: 14px;
|
||
box-shadow: var(--card-shadow);
|
||
overflow: hidden;
|
||
}
|
||
.table-toolbar {
|
||
padding: 14px 18px; display: flex; align-items: center;
|
||
gap: 12px; flex-wrap: wrap;
|
||
border-bottom: 1px solid var(--table-border);
|
||
}
|
||
.table-toolbar-title { font-size: 0.9em; font-weight: 700; color: var(--text); flex: 1; min-width: 120px; }
|
||
.search-input {
|
||
padding: 8px 14px 8px 34px; border: 1px solid var(--input-border);
|
||
border-radius: 8px; background: var(--input-bg); color: var(--text);
|
||
font-size: 0.85em; width: 220px; position: relative;
|
||
}
|
||
.search-wrapper { position: relative; }
|
||
.search-wrapper i { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-muted); font-size: 0.85em; }
|
||
.search-input:focus { outline: none; border-color: var(--accent); }
|
||
.filter-select {
|
||
padding: 8px 12px; border: 1px solid var(--input-border);
|
||
border-radius: 8px; background: var(--input-bg); color: var(--text);
|
||
font-size: 0.85em; cursor: pointer;
|
||
}
|
||
.filter-select:focus { outline: none; border-color: var(--accent); }
|
||
|
||
.table-wrapper { overflow-x: auto; }
|
||
table.data-table {
|
||
width: 100%; border-collapse: collapse; font-size: 0.85em;
|
||
}
|
||
table.data-table thead th {
|
||
background: var(--table-head); color: var(--text-muted);
|
||
padding: 11px 14px; text-align: left; font-weight: 600;
|
||
font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.04em;
|
||
border-bottom: 1px solid var(--table-border); white-space: nowrap;
|
||
cursor: pointer; user-select: none;
|
||
}
|
||
table.data-table thead th:hover { color: var(--accent); }
|
||
table.data-table thead th .sort-icon { opacity: 0.4; margin-left: 4px; }
|
||
table.data-table thead th.sorted .sort-icon { opacity: 1; color: var(--accent); }
|
||
table.data-table tbody tr { border-bottom: 1px solid var(--table-border); transition: background 0.15s; }
|
||
table.data-table tbody tr:hover { background: var(--table-row-hover); }
|
||
table.data-table tbody td { padding: 11px 14px; color: var(--text); }
|
||
table.data-table tbody tr:last-child { border-bottom: none; }
|
||
.table-pagination {
|
||
padding: 12px 18px; display: flex; align-items: center;
|
||
justify-content: space-between; border-top: 1px solid var(--table-border);
|
||
font-size: 0.82em; color: var(--text-muted); flex-wrap: wrap; gap: 10px;
|
||
}
|
||
.pagination-btns { display: flex; gap: 4px; }
|
||
.page-btn {
|
||
width: 30px; height: 30px; border: 1px solid var(--card-border);
|
||
border-radius: 6px; background: var(--card-bg); color: var(--text);
|
||
font-size: 0.82em; cursor: pointer; display: 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); }
|
||
|
||
/* ─── STATUS BADGES ──────────────────────────────────────────────── */
|
||
.badge {
|
||
display: inline-flex; align-items: center;
|
||
padding: 3px 9px; border-radius: var(--badge-radius);
|
||
font-size: 0.78em; font-weight: 600; white-space: nowrap;
|
||
}
|
||
.badge-success { background: rgba(16,185,129,0.12); color: var(--success); }
|
||
.badge-danger { background: rgba(239,68,68,0.12); color: var(--danger); }
|
||
.badge-warning { background: rgba(245,158,11,0.12); color: var(--warning); }
|
||
.badge-info { background: rgba(59,130,246,0.12); color: #3b82f6; }
|
||
.badge-muted { background: rgba(107,114,128,0.12); color: var(--text-muted); }
|
||
|
||
/* ─── ROLE BADGES (topbar) ───────────────────────────────────── */
|
||
.role-badge {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
padding: 4px 12px; border-radius: 20px;
|
||
font-size: 0.78em; font-weight: 700; white-space: nowrap;
|
||
}
|
||
.role-badge.superadmin { background: rgba(239,68,68,0.15); color: var(--danger); }
|
||
.role-badge.admin { background: rgba(37,99,235,0.15); color: #2563eb; }
|
||
.role-badge.user { background: rgba(16,185,129,0.15); color: var(--success); }
|
||
[data-theme="tt"] .role-badge.admin { background: rgba(0,45,98,0.12); color: #fff; }
|
||
|
||
/* ─── ADMIN SECTIONS ─────────────────────────────────────────── */
|
||
.admin-table-toolbar {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 14px 18px; border-bottom: 1px solid var(--table-border); flex-wrap: wrap;
|
||
}
|
||
.admin-form-row {
|
||
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||
gap: 10px; padding: 16px 18px;
|
||
background: var(--table-head); border-bottom: 1px solid var(--table-border);
|
||
}
|
||
.admin-form-row input, .admin-form-row select {
|
||
padding: 8px 12px; border: 1px solid var(--input-border);
|
||
border-radius: 8px; background: var(--input-bg); color: var(--text); font-size: 0.85em;
|
||
}
|
||
.admin-form-row input:focus, .admin-form-row select:focus { outline: none; border-color: var(--accent); }
|
||
.log-success { color: var(--success); font-weight: 600; }
|
||
.log-failure { color: var(--danger); font-weight: 600; }
|
||
|
||
/* ─── ALERT CARDS (section alertes) ─────────────────────────────── */
|
||
.alert-list { display: flex; flex-direction: column; gap: 10px; }
|
||
.alert-card {
|
||
background: var(--card-bg); border: 1px solid var(--card-border);
|
||
border-radius: 12px; padding: 16px 18px;
|
||
display: flex; align-items: center; gap: 16px;
|
||
box-shadow: var(--card-shadow);
|
||
}
|
||
.alert-card.critique { border-left: 4px solid var(--danger); }
|
||
.alert-card.attention { border-left: 4px solid var(--warning); }
|
||
.alert-days {
|
||
min-width: 60px; text-align: center;
|
||
font-size: 1.6em; 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.65em; color: var(--text-muted); font-weight: 400; }
|
||
.alert-info { flex: 1; min-width: 0; }
|
||
.alert-ref { font-weight: 700; font-size: 0.9em; margin-bottom: 4px; }
|
||
.alert-meta { font-size: 0.8em; color: var(--text-muted); }
|
||
|
||
/* ─── PROGRESS BAR ───────────────────────────────────────────────── */
|
||
.progress-bar { display: flex; align-items: center; gap: 8px; }
|
||
.progress-track { flex: 1; height: 6px; background: var(--card-border); border-radius: 6px; overflow: hidden; }
|
||
.progress-fill { height: 100%; border-radius: 6px; transition: width 0.4s; }
|
||
.progress-fill.green { background: var(--success); }
|
||
.progress-fill.orange { background: var(--warning); }
|
||
.progress-fill.red { background: var(--danger); }
|
||
.progress-value { font-size: 0.8em; font-weight: 600; color: var(--text); white-space: nowrap; }
|
||
|
||
/* ─── LOADING & TOAST ────────────────────────────────────────────── */
|
||
#loadingOverlay {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
|
||
display: none; align-items: center; justify-content: center;
|
||
z-index: 500;
|
||
}
|
||
#loadingOverlay.active { display: flex; }
|
||
.spinner {
|
||
width: 44px; height: 44px; border: 4px solid rgba(255,255,255,0.2);
|
||
border-top-color: #fff; border-radius: 50%;
|
||
animation: spin 0.7s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
#errorToast {
|
||
position: fixed; bottom: 24px; right: 24px; z-index: 600;
|
||
background: var(--danger); color: white;
|
||
padding: 12px 20px; border-radius: 10px;
|
||
font-size: 0.9em; box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||
transform: translateY(80px); opacity: 0;
|
||
transition: all 0.3s;
|
||
}
|
||
#errorToast.active { transform: translateY(0); opacity: 1; }
|
||
|
||
/* ─── REGION GRID (Par Région) ───────────────────────────────────── */
|
||
.region-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
.region-card {
|
||
background: var(--card-bg); border: 1px solid var(--card-border);
|
||
border-radius: 14px; padding: 18px; box-shadow: var(--card-shadow);
|
||
cursor: pointer; transition: transform 0.2s, box-shadow 0.2s;
|
||
}
|
||
.region-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.12); }
|
||
.region-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||
.region-name { font-weight: 700; font-size: 1em; }
|
||
.region-dot { width: 12px; height: 12px; border-radius: 50%; }
|
||
.region-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||
.region-stat { text-align: center; padding: 8px; background: var(--main-bg); border-radius: 8px; }
|
||
.region-stat-val { font-size: 1.4em; font-weight: 800; color: var(--accent); }
|
||
.region-stat-lbl { font-size: 0.72em; color: var(--text-muted); margin-top: 2px; }
|
||
|
||
/* ─── RESPONSIVE ─────────────────────────────────────────────────── */
|
||
@media (max-width: 768px) {
|
||
#sidebar { width: 60px; min-width: 60px; }
|
||
.sidebar-header .sidebar-title,
|
||
.nav-item span,
|
||
.sidebar-user-info,
|
||
.theme-switcher,
|
||
.nav-section-label { display: none; }
|
||
.nav-item { justify-content: center; padding: 12px; }
|
||
.nav-icon { width: auto; font-size: 1.1em; }
|
||
.sidebar-user { justify-content: center; }
|
||
.content { padding: 16px; }
|
||
.topbar { padding: 0 16px; }
|
||
.kpi-grid { grid-template-columns: 1fr 1fr; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ─── LOGIN PAGE ─────────────────────────────────────────────────────── -->
|
||
<div id="loginPage">
|
||
<div class="login-card">
|
||
<img src="logo-TT.png" alt="Tunisie Telecom" class="login-logo">
|
||
<h2>Marchés RLA</h2>
|
||
<p>Zone Sud — Tunisie Telecom</p>
|
||
<div class="login-error" id="loginError">
|
||
<i class="fas fa-exclamation-circle"></i> Identifiants incorrects
|
||
</div>
|
||
<div class="login-field">
|
||
<i class="fas fa-user"></i>
|
||
<input type="text" id="username" placeholder="Identifiant" autocomplete="username">
|
||
</div>
|
||
<div class="login-field">
|
||
<i class="fas fa-lock"></i>
|
||
<input type="password" id="password" placeholder="Mot de passe" autocomplete="current-password">
|
||
</div>
|
||
<button class="login-submit" onclick="handleLogin()">
|
||
<i class="fas fa-sign-in-alt"></i> Connexion
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ─── APP ───────────────────────────────────────────────────────────────── -->
|
||
<div id="app">
|
||
|
||
<!-- SIDEBAR -->
|
||
<aside id="sidebar">
|
||
<div class="sidebar-header">
|
||
<img src="logo-TT.png" alt="TT">
|
||
<div class="sidebar-title">
|
||
Marchés RLA
|
||
<small>Zone Sud</small>
|
||
</div>
|
||
</div>
|
||
|
||
<nav class="sidebar-nav">
|
||
<div class="nav-section-label">Tableau de bord</div>
|
||
<div class="nav-item active" data-section="dashboard" onclick="showSection('dashboard')">
|
||
<i class="fas fa-chart-pie nav-icon"></i>
|
||
<span>Vue générale</span>
|
||
</div>
|
||
<div class="nav-item" data-section="alertes" onclick="showSection('alertes')">
|
||
<i class="fas fa-exclamation-triangle nav-icon"></i>
|
||
<span>Alertes délais</span>
|
||
<span class="nav-badge" id="badge-alertes">0</span>
|
||
</div>
|
||
|
||
<div class="nav-section-label">Marchés</div>
|
||
<div class="nav-item" data-section="marches" onclick="showSection('marches')">
|
||
<i class="fas fa-list-alt nav-icon"></i>
|
||
<span>Liste marchés</span>
|
||
</div>
|
||
<div class="nav-item" data-section="service" onclick="showSection('service')">
|
||
<i class="fas fa-check-circle nav-icon"></i>
|
||
<span>En service</span>
|
||
</div>
|
||
<div class="nav-item" data-section="proactif" onclick="showSection('proactif')">
|
||
<i class="fas fa-rocket nav-icon"></i>
|
||
<span>Pilotage proactif</span>
|
||
</div>
|
||
<div class="nav-item" data-section="regions" onclick="showSection('regions')">
|
||
<i class="fas fa-map-marker-alt nav-icon"></i>
|
||
<span>Par région</span>
|
||
</div>
|
||
|
||
<div class="nav-section-label nav-admin-up" id="nav-pipeline-label">Pipeline</div>
|
||
<div class="nav-item nav-admin-up" id="nav-pipeline-item" data-section="pipeline" onclick="showSection('pipeline')">
|
||
<i class="fas fa-stream nav-icon"></i>
|
||
<span>Pipeline AO</span>
|
||
</div>
|
||
|
||
<div class="nav-section-label nav-superadmin" style="display:none">Administration</div>
|
||
<div class="nav-item nav-superadmin" style="display:none" data-section="admin-users" onclick="showSection('admin-users')">
|
||
<i class="fas fa-users-cog nav-icon"></i>
|
||
<span>Utilisateurs</span>
|
||
</div>
|
||
<div class="nav-item nav-superadmin" style="display:none" data-section="admin-logs" onclick="showSection('admin-logs')">
|
||
<i class="fas fa-history nav-icon"></i>
|
||
<span>Historique connexions</span>
|
||
</div>
|
||
|
||
<div class="nav-section-label">Outils</div>
|
||
<div class="nav-item" id="btnExportPDF" onclick="downloadPDF()">
|
||
<i class="fas fa-file-pdf nav-icon"></i>
|
||
<span>Export PDF</span>
|
||
</div>
|
||
<div class="nav-item" id="btnExportPPTX" style="display:none" onclick="exportPPTX()">
|
||
<i class="fas fa-file-powerpoint nav-icon"></i>
|
||
<span>Export PPTX</span>
|
||
</div>
|
||
<div class="nav-item" id="btnExportXLSX" style="display:none" onclick="exportXLSX()">
|
||
<i class="fas fa-file-excel nav-icon"></i>
|
||
<span>Export XLSX</span>
|
||
</div>
|
||
<div class="nav-item" id="btnExportDOCX" style="display:none" onclick="exportDOCX()">
|
||
<i class="fas fa-file-word nav-icon"></i>
|
||
<span>Export DOCX</span>
|
||
</div>
|
||
</nav>
|
||
|
||
<div class="sidebar-footer">
|
||
<div class="theme-switcher">
|
||
<button class="theme-btn active" data-theme="tt" onclick="setTheme('tt')">TT</button>
|
||
<button class="theme-btn" data-theme="dark" onclick="setTheme('dark')">Dark</button>
|
||
<button class="theme-btn" data-theme="light" onclick="setTheme('light')">Light</button>
|
||
<button class="theme-btn" data-theme="mckinsey" onclick="setTheme('mckinsey')">MK</button>
|
||
</div>
|
||
<div class="sidebar-user">
|
||
<div class="sidebar-user-avatar" id="userAvatar">N</div>
|
||
<div class="sidebar-user-info">
|
||
<div class="sidebar-user-name" id="currentUser">Utilisateur</div>
|
||
<div class="sidebar-user-role" id="userRole">—</div>
|
||
</div>
|
||
<button class="sidebar-logout" onclick="handleLogout()" title="Déconnexion">
|
||
<i class="fas fa-sign-out-alt"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- MAIN -->
|
||
<div id="main">
|
||
|
||
<!-- TOPBAR -->
|
||
<header class="topbar">
|
||
<div class="topbar-left">
|
||
<div>
|
||
<div class="topbar-title" id="pageTitle">Vue générale</div>
|
||
<div class="topbar-subtitle" id="currentDate">—</div>
|
||
</div>
|
||
</div>
|
||
<div class="topbar-right">
|
||
<span class="role-badge" id="roleBadge" style="display:none"></span>
|
||
<div class="topbar-date">
|
||
Mis à jour : <strong id="lastUpdate">—</strong>
|
||
</div>
|
||
<button class="btn-icon" onclick="loadData()" title="Rafraîchir">
|
||
<i class="fas fa-sync-alt"></i>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- CONTENT -->
|
||
<div class="content">
|
||
|
||
<!-- ── SECTION : VUE GÉNÉRALE (dashboard) ── -->
|
||
<section id="sec-dashboard" class="section active">
|
||
<div class="section-header">
|
||
<div class="section-heading">Vue générale</div>
|
||
<div class="section-actions">
|
||
<button class="btn btn-secondary" onclick="loadData()">
|
||
<i class="fas fa-sync-alt"></i> Actualiser
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- KPIs -->
|
||
<div class="kpi-grid" id="kpiGrid">
|
||
<div class="kpi-card kpi-total">
|
||
<div class="kpi-icon"><i class="fas fa-folder-open"></i></div>
|
||
<div class="kpi-value" id="kpiTotal">—</div>
|
||
<div class="kpi-label">Total marchés</div>
|
||
</div>
|
||
<div class="kpi-card kpi-actif">
|
||
<div class="kpi-icon"><i class="fas fa-play-circle"></i></div>
|
||
<div class="kpi-value" id="kpiActifs">—</div>
|
||
<div class="kpi-label">Marchés actifs</div>
|
||
<div class="kpi-sub" id="kpiAvt">Avancement moyen : —</div>
|
||
</div>
|
||
<div class="kpi-card kpi-alerte">
|
||
<div class="kpi-icon"><i class="fas fa-exclamation-triangle"></i></div>
|
||
<div class="kpi-value" id="kpiAlertes">—</div>
|
||
<div class="kpi-label">Alertes délais</div>
|
||
<div class="kpi-sub" id="kpiCritiques">Critiques (≤45j) : —</div>
|
||
</div>
|
||
<div class="kpi-card kpi-cloture">
|
||
<div class="kpi-icon"><i class="fas fa-archive"></i></div>
|
||
<div class="kpi-value" id="kpiClotures">—</div>
|
||
<div class="kpi-label">Clôturés</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Charts row -->
|
||
<div class="charts-row">
|
||
<!-- Donut par statut -->
|
||
<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>
|
||
|
||
<!-- Gantt timeline -->
|
||
<div class="chart-card">
|
||
<div class="chart-card-title">
|
||
<i class="fas fa-calendar-alt"></i> Timeline marchés actifs
|
||
</div>
|
||
<div class="gantt-wrapper" id="ganttContainer">
|
||
<p style="color:var(--text-muted);font-size:0.85em;padding:20px;">
|
||
<i class="fas fa-hourglass-half"></i> Chargement du diagramme…
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Top alertes (aperçu 3 items) -->
|
||
<div class="chart-card" style="margin-bottom:0">
|
||
<div class="chart-card-title">
|
||
<i class="fas fa-exclamation-triangle" style="color:var(--danger)"></i>
|
||
Marchés en alerte — délais critiques
|
||
</div>
|
||
<div class="alert-list" id="alertesPreview">
|
||
<p style="color:var(--text-muted);font-size:0.85em;padding:8px 0;">Chargement…</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SECTION : ALERTES ── -->
|
||
<section id="sec-alertes" class="section">
|
||
<div class="section-header">
|
||
<div class="section-heading">Alertes délais</div>
|
||
</div>
|
||
<div class="alert-list" id="alertesList">
|
||
<p style="color:var(--text-muted);font-size:0.85em;">Chargement…</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SECTION : LISTE MARCHÉS ── -->
|
||
<section id="sec-marches" class="section">
|
||
<div class="section-header">
|
||
<div class="section-heading">Liste des marchés</div>
|
||
</div>
|
||
<div class="table-card">
|
||
<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 class="data-table" id="marchesTable">
|
||
<thead>
|
||
<tr>
|
||
<th onclick="sortTable('id_marche')">Référence <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')">Avancement <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="7" style="text-align:center;color:var(--text-muted);padding:32px;">Chargement…</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="table-pagination" id="marchesPagination"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SECTION : EN SERVICE ── -->
|
||
<section id="sec-service" class="section">
|
||
<div class="section-header">
|
||
<div class="section-heading">Marchés en service</div>
|
||
</div>
|
||
<div class="table-card">
|
||
<div class="table-wrapper">
|
||
<table class="data-table" id="serviceTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Référence</th><th>Entrepreneur</th><th>Région</th>
|
||
<th>Avancement</th><th>Date fin</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="serviceBody">
|
||
<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:32px;">Chargement…</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SECTION : PILOTAGE PROACTIF ── -->
|
||
<section id="sec-proactif" class="section">
|
||
<div class="section-header">
|
||
<div class="section-heading">Pilotage proactif</div>
|
||
</div>
|
||
<div class="kpi-grid" id="proactifKpis"></div>
|
||
<div class="table-card">
|
||
<div class="table-wrapper">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr><th>Référence</th><th>Entrepreneur</th><th>Région</th><th>Avancement</th><th>Statut proactif</th></tr>
|
||
</thead>
|
||
<tbody id="proactifBody">
|
||
<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:32px;">Chargement…</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SECTION : PAR RÉGION ── -->
|
||
<section id="sec-regions" class="section">
|
||
<div class="section-header">
|
||
<div class="section-heading">Vue par région</div>
|
||
</div>
|
||
<div class="region-grid" id="regionGrid">
|
||
<p style="color:var(--text-muted);font-size:0.85em;">Chargement…</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SECTION : PIPELINE ── -->
|
||
<section id="sec-pipeline" class="section">
|
||
<div class="section-header">
|
||
<div class="section-heading">Pipeline appels d'offres</div>
|
||
</div>
|
||
<div class="table-card">
|
||
<div class="table-wrapper">
|
||
<table class="data-table" id="pipelineTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Référence AO</th><th>Projet</th><th>Région</th>
|
||
<th>Statut</th><th>Date prévue</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="pipelineBody">
|
||
<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:32px;">Chargement…</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SECTION : ADMIN — UTILISATEURS ── -->
|
||
<section id="sec-admin-users" class="section">
|
||
<div class="section-header">
|
||
<div class="section-heading">Gestion des utilisateurs</div>
|
||
<div class="section-actions">
|
||
<button class="btn btn-primary" onclick="toggleAddUserForm()">
|
||
<i class="fas fa-user-plus"></i> Ajouter
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="table-card">
|
||
<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 btn-primary" onclick="saveNewUser()"><i class="fas fa-save"></i> Créer</button>
|
||
<button class="btn btn-secondary" onclick="toggleAddUserForm()">Annuler</button>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table class="data-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:32px;">Chargement…</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── SECTION : ADMIN — LOGS ── -->
|
||
<section id="sec-admin-logs" class="section">
|
||
<div class="section-header">
|
||
<div class="section-heading">Historique des connexions</div>
|
||
</div>
|
||
<div class="table-card">
|
||
<div class="table-wrapper">
|
||
<table class="data-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:32px;">Chargement…</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
</div><!-- .content -->
|
||
</div><!-- #main -->
|
||
</div><!-- #app -->
|
||
|
||
<!-- LOADING OVERLAY -->
|
||
<div id="loadingOverlay"><div class="spinner"></div></div>
|
||
|
||
<!-- ERROR TOAST -->
|
||
<div id="errorToast"></div>
|
||
|
||
<script>
|
||
/* ─── SECTION TITLES MAP ──────────────────────────────────────── */
|
||
const SECTION_TITLES = {
|
||
dashboard: 'Vue générale',
|
||
alertes: 'Alertes délais',
|
||
marches: 'Liste des marchés',
|
||
service: 'Marchés en service',
|
||
proactif: 'Pilotage proactif',
|
||
regions: 'Vue par région',
|
||
pipeline: 'Pipeline AO',
|
||
'admin-users':'Gestion des utilisateurs',
|
||
'admin-logs': 'Historique des connexions',
|
||
};
|
||
|
||
/* ─── THEME ──────────────────────────────────────────────────── */
|
||
function setTheme(theme) {
|
||
document.documentElement.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') || 'tt');
|
||
}
|
||
|
||
/* ─── SECTION NAVIGATION ─────────────────────────────────────── */
|
||
function showSection(name) {
|
||
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
|
||
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
|
||
const sec = document.getElementById('sec-' + name);
|
||
if (sec) sec.classList.add('active');
|
||
const navItem = document.querySelector(`.nav-item[data-section="${name}"]`);
|
||
if (navItem) navItem.classList.add('active');
|
||
document.getElementById('pageTitle').textContent = SECTION_TITLES[name] || name;
|
||
// Chargement à la demande pour les sections admin
|
||
if (name === 'admin-users') renderAdminUsers();
|
||
if (name === 'admin-logs') renderAdminLogs();
|
||
}
|
||
|
||
/* ─── 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>`;
|
||
}
|
||
|
||
/* ─── AUTH ───────────────────────────────────────────────────── */
|
||
const API_BASE = '/api';
|
||
let jwtToken = null;
|
||
let 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('app').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('app').classList.add('active');
|
||
const u = currentUser?.username || '?';
|
||
document.getElementById('currentUser').textContent = u;
|
||
document.getElementById('userAvatar').textContent = u.charAt(0).toUpperCase();
|
||
const now = new Date();
|
||
document.getElementById('currentDate').textContent =
|
||
now.toLocaleDateString('fr-FR', { weekday:'long', day:'numeric', month:'long', year:'numeric' });
|
||
applyRoleUI();
|
||
}
|
||
|
||
function applyRoleUI() {
|
||
const role = currentUser?.role || 'user';
|
||
const region = currentUser?.region || '';
|
||
|
||
// Sidebar user role label
|
||
const roleLabels = { superadmin: 'Super Admin', admin: 'Admin', user: region || 'User' };
|
||
document.getElementById('userRole').textContent = roleLabels[role] || role;
|
||
|
||
// Topbar role badge
|
||
const badge = document.getElementById('roleBadge');
|
||
badge.style.display = '';
|
||
if (role === 'superadmin') {
|
||
badge.textContent = 'Super Admin';
|
||
badge.className = 'role-badge superadmin';
|
||
} else if (role === 'admin') {
|
||
badge.textContent = 'Admin';
|
||
badge.className = 'role-badge admin';
|
||
} else {
|
||
badge.textContent = `Région : ${region || '?'}`;
|
||
badge.className = 'role-badge user';
|
||
}
|
||
|
||
// Items visibles superadmin uniquement
|
||
document.querySelectorAll('.nav-superadmin').forEach(el => {
|
||
el.style.display = role === 'superadmin' ? '' : 'none';
|
||
});
|
||
|
||
// Items visibles admin + superadmin (masqués pour user)
|
||
document.querySelectorAll('.nav-admin-up').forEach(el => {
|
||
el.style.display = role === 'user' ? 'none' : '';
|
||
});
|
||
|
||
// Exports SuperAdmin seulement (PPTX, XLSX, DOCX)
|
||
const superOnly = role === 'superadmin' ? '' : 'none';
|
||
document.getElementById('btnExportPPTX').style.display = superOnly;
|
||
document.getElementById('btnExportXLSX').style.display = superOnly;
|
||
document.getElementById('btnExportDOCX').style.display = superOnly;
|
||
}
|
||
|
||
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 = [], 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() }),
|
||
isUser ? null : fetch(`${API_BASE}/pipeline`, { headers: apiHeaders() }),
|
||
];
|
||
const [rMarches, rStats, 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;
|
||
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);
|
||
}
|
||
}
|
||
|
||
/* ─── RENDER ALL ─────────────────────────────────────────────── */
|
||
function renderAll() {
|
||
renderKPIs();
|
||
renderAlertes();
|
||
renderMarches();
|
||
renderService();
|
||
renderProactif();
|
||
renderRegions();
|
||
renderPipeline();
|
||
renderChartStatut();
|
||
renderGantt();
|
||
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 moyen : ${statsData.taux_avancement_moyen ?? '—'}%`;
|
||
document.getElementById('kpiCritiques').textContent =
|
||
`Critiques (≤45j) : ${statsData.alertes_delais?.critique ?? '—'}`;
|
||
}
|
||
|
||
/* ─── ALERTES ─────────────────────────────────────────────────── */
|
||
function isCloture(r) {
|
||
const o = String(r.observation || '').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);
|
||
}
|
||
|
||
function alerteCardHTML(r, delai) {
|
||
const niveau = delai <= 45 ? 'critique' : 'attention';
|
||
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.reference || '—')}</div>
|
||
<div class="alert-meta">
|
||
${escapeHtml(r.entrepreneur || '—')} •
|
||
${escapeHtml(r.region || '—')} •
|
||
Avt phy: ${parseNum(r.taux_phy)}%
|
||
</div>
|
||
</div>
|
||
<span class="badge ${niveau === 'critique' ? 'badge-danger' : 'badge-warning'}">
|
||
${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);
|
||
|
||
const html = alertes.length
|
||
? alertes.map(x => alerteCardHTML(x.r, x.delai)).join('')
|
||
: '<p style="color:var(--text-muted);padding:16px 0;">Aucune alerte.</p>';
|
||
|
||
document.getElementById('alertesList').innerHTML = html;
|
||
document.getElementById('alertesPreview').innerHTML = alertes.length
|
||
? alertes.slice(0, 3).map(x => alerteCardHTML(x.r, x.delai)).join('')
|
||
: '<p style="color:var(--text-muted);font-size:0.85em;padding:8px 0;">Aucune alerte.</p>';
|
||
}
|
||
|
||
/* ─── MARCHÉS TABLE ──────────────────────────────────────────── */
|
||
function obsVal(r) {
|
||
return typeof r.observation === 'object' ? (r.observation?.value || '') : String(r.observation || '');
|
||
}
|
||
|
||
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 text = `${r.id_marche||''} ${r.region_csc||''} ${r.entrepreneur||''} ${r.projet||''}`.toLowerCase();
|
||
if (search && !text.includes(search)) return false;
|
||
if (region && (r.region_csc || '') !== 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 entreprs = [...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>' +
|
||
entreprs.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})`;
|
||
|
||
const tbody = document.getElementById('marchesBody');
|
||
if (!slice.length) {
|
||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:32px;">Aucun résultat</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = slice.map(r => {
|
||
const delai = getDelaiRestant(r);
|
||
const pct = parseNum(r.taux_phy);
|
||
const isCrit = delai !== null && delai <= 45;
|
||
const isAtt = delai !== null && delai > 45 && delai <= 90;
|
||
const statut = obsVal(r);
|
||
const periode = `${formatDateFR(r.date_debut)} → ${formatDateFR(r.date_fin)}`;
|
||
const delaiBadge = delai === null ? '' : `<br><span class="badge ${isCrit ? 'badge-danger' : isAtt ? 'badge-warning' : 'badge-success'}">${delai}j</span>`;
|
||
return `<tr>
|
||
<td><strong>${escapeHtml(r.id_marche || '—')}</strong><br><small style="color:var(--text-muted);font-size:0.8em">${escapeHtml(r.region_csc || '')}</small></td>
|
||
<td>${escapeHtml(r.entrepreneur || '—')}</td>
|
||
<td>${escapeHtml(r.projet || '—')}</td>
|
||
<td>${statut ? `<span class="badge badge-info">${escapeHtml(statut)}</span>` : '—'}</td>
|
||
<td>${getProgressBar(pct)}<small style="color:var(--text-muted);font-size:0.75em">Fin: ${parseNum(r.taux_fin)}%</small></td>
|
||
<td style="white-space:nowrap;font-size:0.85em">${periode}${delaiBadge}</td>
|
||
<td>${formatMontant(r.tot_marche ?? r.totmarche)}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
// Pagination
|
||
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(); }
|
||
|
||
/* ─── EN SERVICE ─────────────────────────────────────────────── */
|
||
function renderService() {
|
||
const rows = allData.filter(r => {
|
||
if (isCloture(r)) return false;
|
||
const o = String(r.observation || '').toLowerCase();
|
||
const s = String(r.statut || '').toLowerCase();
|
||
return o.includes('en service') || s.includes('en service');
|
||
});
|
||
document.getElementById('serviceBody').innerHTML = rows.length
|
||
? rows.map(r => `<tr>
|
||
<td><strong>${escapeHtml(r.ref || r.reference || '—')}</strong></td>
|
||
<td>${escapeHtml(r.entrepreneur || '—')}</td>
|
||
<td>${escapeHtml(r.region || '—')}</td>
|
||
<td>${getProgressBar(parseNum(r.taux_phy))}<small style="color:var(--text-muted);font-size:0.75em">Fin: ${parseNum(r.taux_fin)}%</small></td>
|
||
<td>${formatDateFR(r.date_fin)}</td>
|
||
</tr>`).join('')
|
||
: '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:32px;">Aucun marché en service</td></tr>';
|
||
}
|
||
|
||
/* ─── PILOTAGE PROACTIF ──────────────────────────────────────── */
|
||
function renderProactif() {
|
||
const actifs = allData.filter(r => !isCloture(r));
|
||
const normal = actifs.filter(r => { const p = parseNum(r.taux_phy); return p >= 0 && p < 70; });
|
||
const sous = actifs.filter(r => parseNum(r.taux_phy) >= 70 && parseNum(r.taux_phy) < 90);
|
||
const depasse = actifs.filter(r => parseNum(r.taux_phy) >= 90);
|
||
|
||
document.getElementById('proactifKpis').innerHTML = `
|
||
<div class="kpi-card" style="--kpi-color:#10b981">
|
||
<div class="kpi-icon" style="color:#10b981"><i class="fas fa-check"></i></div>
|
||
<div class="kpi-value">${normal.length}</div>
|
||
<div class="kpi-label">Dans les normes (<70%)</div>
|
||
</div>
|
||
<div class="kpi-card" style="--kpi-color:#f59e0b">
|
||
<div class="kpi-icon" style="color:#f59e0b"><i class="fas fa-exclamation"></i></div>
|
||
<div class="kpi-value">${sous.length}</div>
|
||
<div class="kpi-label">Sous avancement (70–90%)</div>
|
||
</div>
|
||
<div class="kpi-card" style="--kpi-color:#ef4444">
|
||
<div class="kpi-icon" style="color:#ef4444"><i class="fas fa-times"></i></div>
|
||
<div class="kpi-value">${depasse.length}</div>
|
||
<div class="kpi-label">Dépassé (≥90%)</div>
|
||
</div>`;
|
||
|
||
document.getElementById('proactifBody').innerHTML = actifs.length
|
||
? actifs.map(r => {
|
||
const pct = parseNum(r.taux_phy);
|
||
const niveau = pct >= 90 ? ['badge-danger','Dépassé'] : pct >= 70 ? ['badge-warning','Attention'] : ['badge-success','Normal'];
|
||
return `<tr>
|
||
<td><strong>${escapeHtml(r.ref || r.reference || '—')}</strong></td>
|
||
<td>${escapeHtml(r.entrepreneur || '—')}</td>
|
||
<td>${escapeHtml(r.region || '—')}</td>
|
||
<td>${getProgressBar(pct)}</td>
|
||
<td><span class="badge ${niveau[0]}">${niveau[1]}</span></td>
|
||
</tr>`;
|
||
}).join('')
|
||
: '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:32px;">Aucune donnée</td></tr>';
|
||
}
|
||
|
||
/* ─── PAR RÉGION ─────────────────────────────────────────────── */
|
||
const REGION_COLORS = 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 || '') === reg);
|
||
const avts = rows.map(r => parseNum(r.taux_phy)).filter(v => v > 0);
|
||
const avg = avts.length ? Math.round(avts.reduce((a,b) => a+b,0) / avts.length) : 0;
|
||
return `<div class="region-card">
|
||
<div class="region-card-header">
|
||
<div class="region-name">${reg}</div>
|
||
<div class="region-dot" style="background:${REGION_COLORS[reg] || '#888'}"></div>
|
||
</div>
|
||
<div class="region-stats">
|
||
<div class="region-stat">
|
||
<div class="region-stat-val" style="color:${REGION_COLORS[reg]||'var(--accent)'}">${rows.length}</div>
|
||
<div class="region-stat-lbl">Marchés actifs</div>
|
||
</div>
|
||
<div class="region-stat">
|
||
<div class="region-stat-val">${avg}%</div>
|
||
<div class="region-stat-lbl">Avancement moy.</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
document.getElementById('regionGrid').innerHTML = html || '<p style="color:var(--text-muted)">Aucune donnée</p>';
|
||
}
|
||
|
||
/* ─── PIPELINE ───────────────────────────────────────────────── */
|
||
function renderPipeline() {
|
||
document.getElementById('pipelineBody').innerHTML = pipelineData.length
|
||
? pipelineData.map(r => `<tr>
|
||
<td><strong>${escapeHtml(r.ref || r.reference || '—')}</strong></td>
|
||
<td>${escapeHtml(r.projet || '—')}</td>
|
||
<td>${escapeHtml(r.region || '—')}</td>
|
||
<td>${r.statut ? `<span class="badge badge-info">${escapeHtml(r.statut)}</span>` : '—'}</td>
|
||
<td>${formatDateFR(r.date_prevue || r.date_debut)}</td>
|
||
</tr>`).join('')
|
||
: '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:32px;">Pipeline vide</td></tr>';
|
||
}
|
||
|
||
/* ─── CHART.JS DONUT ─────────────────────────────────────────── */
|
||
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'), font: { size: 11 }, padding: 12 },
|
||
},
|
||
tooltip: { callbacks: { label: ctx => ` ${ctx.label}: ${ctx.parsed}` } },
|
||
},
|
||
cutout: '62%',
|
||
},
|
||
});
|
||
}
|
||
|
||
/* ─── FRAPPE GANTT ───────────────────────────────────────────── */
|
||
let ganttInstance = null;
|
||
function renderGantt() {
|
||
const actifs = allData
|
||
.filter(r => !isCloture(r) && (r.date_debut || r.date_fin || r.datefin))
|
||
.slice(0, 15);
|
||
|
||
if (!actifs.length || typeof Gantt === 'undefined') return;
|
||
|
||
const tasks = actifs.map((r, i) => ({
|
||
id: String(r.id || i),
|
||
name: r.ref || r.reference || `Marché ${i+1}`,
|
||
start: r.date_debut ? new Date(r.date_debut).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
|
||
end: (() => { const f = r.date_fin || r.datefin; if (!f) return new Date(Date.now()+30*86400000).toISOString().split('T')[0]; return new Date(f).toISOString().split('T')[0]; })(),
|
||
progress: parseNum(r.taux_phy),
|
||
}));
|
||
|
||
const container = document.getElementById('ganttContainer');
|
||
container.innerHTML = '<svg id="ganttSvg"></svg>';
|
||
try {
|
||
ganttInstance = new Gantt('#ganttSvg', tasks, { view_mode: 'Month', language: 'fr' });
|
||
} catch (_) {
|
||
container.innerHTML = '<p style="color:var(--text-muted);font-size:0.85em;padding:20px;">Diagramme non disponible</p>';
|
||
}
|
||
}
|
||
|
||
/* ─── BADGES ─────────────────────────────────────────────────── */
|
||
function updateBadges() {
|
||
const count = statsData?.alertes_delais?.count ?? 0;
|
||
const el = document.getElementById('badge-alertes');
|
||
el.textContent = count;
|
||
el.style.display = count > 0 ? 'inline-block' : 'none';
|
||
}
|
||
|
||
/* ─── ADMIN : UTILISATEURS ──────────────────────────────────── */
|
||
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="badge ${u.role==='superadmin'?'badge-danger':u.role==='admin'?'badge-info':'badge-success'}">${u.role}</span></td>
|
||
<td>${escapeHtml(u.region === 'all' ? 'Toutes' : u.region)}</td>
|
||
<td>
|
||
<button class="btn btn-danger" style="padding:5px 10px;font-size:0.78em" 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="badge badge-info">${escapeHtml(l.role)}</span>` : '—'}</td>
|
||
<td style="font-size:0.82em;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 ────────────────────────────────────────────────── */
|
||
|
||
// Détermine la vue courante pour l'export
|
||
function getCurrentView() {
|
||
const active = document.querySelector('.section.active');
|
||
if (!active) return 'synthese';
|
||
const map = {
|
||
'sec-dashboard': 'synthese',
|
||
'sec-alertes': 'alertes',
|
||
'sec-service': 'en-service',
|
||
'sec-marches': 'en-cours',
|
||
'sec-proactif': 'pilotage',
|
||
'sec-regions': 'par-region',
|
||
'sec-pipeline': 'synthese',
|
||
};
|
||
return map[active.id] || 'synthese';
|
||
}
|
||
|
||
// Filtres actifs → query string
|
||
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('Accès refusé : 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);
|
||
const ext = { pdf:'pdf', xlsx:'xlsx', pptx:'pptx', docx:'docx' }[format] || format;
|
||
a.download = `RLA_${view}_${new Date().toISOString().slice(0,10)}.${ext}`;
|
||
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();
|
||
}, (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>
|