Gestion-des-Marches-RLA/index.html

1780 lines
78 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="fr" 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 &amp; heure</th><th>Utilisateur</th><th>Rôle</th><th>IP</th><th>Résultat</th></tr>
</thead>
<tbody id="logsBody">
<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding: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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
}
function getProgressBar(pct) {
const c = pct >= 90 ? 'red' : pct >= 70 ? 'orange' : 'green';
return `<div class="progress-bar">
<div class="progress-track"><div class="progress-fill ${c}" style="width:${Math.min(100,pct)}%"></div></div>
<span class="progress-value">${pct}%</span>
</div>`;
}
/* ─── 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 || '—')} &bull;
${escapeHtml(r.region || '—')} &bull;
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 (&lt;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 (7090%)</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>