feat: implement interactive dashboard and Excel export for pending interventions and observations
This commit is contained in:
parent
cfdcc41929
commit
a50bebe427
8 changed files with 1239 additions and 5 deletions
|
|
@ -107,10 +107,10 @@ PAUSE_REASON_LABELS = {
|
|||
}
|
||||
|
||||
|
||||
def get_pending_analysis_data(months=24, thematic_code=None, contract_number=None, provider_id=None):
|
||||
def get_pending_analysis_data(months=24, thematic_code=None, contract_number=None, provider_id=None, user=None):
|
||||
"""
|
||||
Extrait et structure toutes les données d'analyse pour les observations et interventions
|
||||
non terminées sur la période spécifiée.
|
||||
non terminées sur la période spécifiée, avec filtrage optionnel par utilisateur.
|
||||
|
||||
Returns:
|
||||
dict contenant:
|
||||
|
|
@ -123,6 +123,8 @@ def get_pending_analysis_data(months=24, thematic_code=None, contract_number=Non
|
|||
from interventions.models import Intervention, InterventionTimeLine, InterventionDocument
|
||||
from observations.models import Observation, ObservationPhoto
|
||||
from common.models import UserConfig
|
||||
from interventions.permissions import filter_viewable_interventions_for_user
|
||||
from observations.permissions import get_observation_access_context
|
||||
|
||||
now = timezone.now()
|
||||
start_date = now - relativedelta(months=months)
|
||||
|
|
@ -130,7 +132,12 @@ def get_pending_analysis_data(months=24, thematic_code=None, contract_number=Non
|
|||
# -------------------------------------------------------------------------
|
||||
# 1. Requête sur les Interventions non terminées
|
||||
# -------------------------------------------------------------------------
|
||||
itv_qs = Intervention.objects.filter(
|
||||
if user and not user.is_superuser:
|
||||
base_itv_qs = filter_viewable_interventions_for_user(user)
|
||||
else:
|
||||
base_itv_qs = Intervention.objects.all()
|
||||
|
||||
itv_qs = base_itv_qs.filter(
|
||||
status__in=PENDING_INTERVENTION_STATUSES,
|
||||
creation_time__gte=start_date,
|
||||
).select_related(
|
||||
|
|
@ -308,7 +315,13 @@ def get_pending_analysis_data(months=24, thematic_code=None, contract_number=Non
|
|||
# -------------------------------------------------------------------------
|
||||
# 2. Requête sur les Observations non terminées
|
||||
# -------------------------------------------------------------------------
|
||||
obs_qs = Observation.objects.filter(
|
||||
if user and not user.is_superuser:
|
||||
obs_ctx = get_observation_access_context(user)
|
||||
base_obs_qs = obs_ctx.filter_queryset(Observation.objects.all()) if obs_ctx else Observation.objects.none()
|
||||
else:
|
||||
base_obs_qs = Observation.objects.all()
|
||||
|
||||
obs_qs = base_obs_qs.filter(
|
||||
status__in=PENDING_OBSERVATION_STATUSES,
|
||||
created_at__gte=start_date,
|
||||
).select_related(
|
||||
|
|
@ -580,7 +593,7 @@ def get_pending_analysis_data(months=24, thematic_code=None, contract_number=Non
|
|||
}
|
||||
|
||||
|
||||
def generate_pending_analysis_excel(months=24, thematic_code=None, contract_number=None, provider_id=None, output_format='workbook'):
|
||||
def generate_pending_analysis_excel(months=24, thematic_code=None, contract_number=None, provider_id=None, user=None, output_format='workbook'):
|
||||
"""
|
||||
Génère le classeur Excel multi-onglets structuré et stylisé avec Tableaux Croisés Dynamiques.
|
||||
"""
|
||||
|
|
@ -589,6 +602,7 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb
|
|||
thematic_code=thematic_code,
|
||||
contract_number=contract_number,
|
||||
provider_id=provider_id,
|
||||
user=user,
|
||||
)
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
|
|
|
|||
|
|
@ -59,5 +59,28 @@
|
|||
<span class="ms-2 text d-none d-md-inline">{% translate "Nombre d'observations" %}</span>
|
||||
</a>
|
||||
|
||||
<!-- ── Niveau 1 : Encours & Blocages ───────────── -->
|
||||
<div class="d-flex align-items-center px-2 pt-3 pb-1 mt-2"
|
||||
style="cursor:default; pointer-events:none;">
|
||||
<i class="bi bi-hourglass-split text-primary" style="font-size:.95rem;"
|
||||
data-bs-toggle="tooltip" data-bs-placement="right"
|
||||
title="{% translate 'Encours & Blocages' %}"></i>
|
||||
<span class="ms-2 text d-none d-md-inline fw-bold text-dark"
|
||||
style="font-size:.85rem; letter-spacing:.03em;">
|
||||
{% translate "Encours & Blocages" %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Niveau 2 ────────────────────────────────────── -->
|
||||
<a href="{% url 'reporting:pending-dashboard' %}"
|
||||
class="nav-link d-flex align-items-center py-1 px-2 ps-4
|
||||
{% if request.path == '/reporting/pending/' %}active{% endif %}"
|
||||
style="font-size:.82rem;">
|
||||
<i class="bi bi-kanban" style="font-size:.85rem;"
|
||||
data-bs-toggle="tooltip" data-bs-placement="right"
|
||||
title="{% translate 'Tableau de bord encours' %}"></i>
|
||||
<span class="ms-2 text d-none d-md-inline">{% translate "Tableau de bord encours" %}</span>
|
||||
</a>
|
||||
|
||||
{% endblock sidebar %}
|
||||
|
||||
|
|
|
|||
887
loko/reporting/templates/reporting/pending_dashboard.html
Normal file
887
loko/reporting/templates/reporting/pending_dashboard.html
Normal file
|
|
@ -0,0 +1,887 @@
|
|||
{% extends "reporting/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block head %}
|
||||
<!-- ECharts 5.6 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.6.0/dist/echarts.min.js"></script>
|
||||
|
||||
<style>
|
||||
.pending-chart { width: 100%; height: 320px; }
|
||||
|
||||
.filter-bar {
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.filter-bar .form-label {
|
||||
font-size: .82rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
border-left: 4px solid;
|
||||
border-radius: 8px;
|
||||
padding: .85rem 1.1rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
.kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.08);
|
||||
}
|
||||
.kpi-card .kpi-value {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.kpi-card .kpi-label {
|
||||
font-size: .78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .03em;
|
||||
color: #64748b;
|
||||
margin-bottom: .3rem;
|
||||
}
|
||||
.kpi-card .kpi-sub {
|
||||
font-size: .75rem;
|
||||
color: #94a3b8;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
.kpi-primary { border-left-color: #2563eb; }
|
||||
.kpi-primary .kpi-value { color: #1e40af; }
|
||||
|
||||
.kpi-teal { border-left-color: #0d9488; }
|
||||
.kpi-teal .kpi-value { color: #0f766e; }
|
||||
|
||||
.kpi-danger { border-left-color: #dc2626; }
|
||||
.kpi-danger .kpi-value { color: #b91c1c; }
|
||||
|
||||
.kpi-purple { border-left-color: #7c3aed; }
|
||||
.kpi-purple .kpi-value { color: #6d28d9; }
|
||||
|
||||
.kpi-amber { border-left-color: #d97706; }
|
||||
.kpi-amber .kpi-value { color: #b45309; }
|
||||
|
||||
.kpi-rose { border-left-color: #e11d48; }
|
||||
.kpi-rose .kpi-value { color: #be123c; }
|
||||
|
||||
/* Matrice TCD Table Styling */
|
||||
.matrix-table {
|
||||
font-size: .83rem;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.matrix-table th {
|
||||
background-color: #1e293b;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
font-size: .78rem;
|
||||
padding: .65rem .6rem;
|
||||
text-align: center;
|
||||
border-top: 1px solid #334155;
|
||||
border-bottom: 1px solid #334155;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.matrix-table th.th-main {
|
||||
text-align: left;
|
||||
background-color: #0f172a;
|
||||
}
|
||||
.matrix-table td {
|
||||
padding: .55rem .6rem;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
.matrix-table tr:hover td {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
.matrix-table .tr-total td {
|
||||
background-color: #f1f5f9;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
border-top: 2px solid #cbd5e1;
|
||||
border-bottom: 2px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.badge-status {
|
||||
font-size: .74rem;
|
||||
font-weight: 600;
|
||||
padding: .25rem .5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
font-size: .85rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
padding: .6rem 1rem;
|
||||
}
|
||||
.nav-tabs .nav-link.active {
|
||||
color: #2563eb;
|
||||
background: transparent;
|
||||
border-bottom: 3px solid #2563eb;
|
||||
}
|
||||
|
||||
.action-table {
|
||||
font-size: .82rem;
|
||||
}
|
||||
.action-table th {
|
||||
background-color: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
font-size: .78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .02em;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-group-period .btn {
|
||||
font-size: .8rem;
|
||||
font-weight: 600;
|
||||
padding: .35rem .75rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<!-- Header principal -->
|
||||
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between mb-3 gap-2">
|
||||
<div>
|
||||
<h4 class="fw-bold text-dark mb-0">
|
||||
<i class="bi bi-hourglass-split text-primary me-2"></i>
|
||||
{% translate "Tableau de bord des encours & blocages" %}
|
||||
</h4>
|
||||
<p class="text-muted small mb-0 mt-1">
|
||||
{% translate "Suivi des interventions non terminées (< 'finished') et observations ouvertes sur les 24 derniers mois" %}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button id="btn-export-excel" class="btn btn-success d-flex align-items-center gap-2 shadow-sm">
|
||||
<i class="bi bi-file-earmark-excel-fill"></i>
|
||||
<span>{% translate "Exporter en Excel (.xlsx)" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de Filtres -->
|
||||
<div class="filter-bar shadow-sm">
|
||||
<div class="row g-3 align-items-end">
|
||||
|
||||
<!-- Période / Horizon -->
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label d-block">{% translate "Horizon temporel" %}</label>
|
||||
<div class="btn-group btn-group-period w-100" role="group" aria-label="Période">
|
||||
<input type="radio" class="btn-check" name="filter-months" id="p-6" value="6">
|
||||
<label class="btn btn-outline-secondary" for="p-6">6m</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="filter-months" id="p-12" value="12">
|
||||
<label class="btn btn-outline-secondary" for="p-12">12m</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="filter-months" id="p-24" value="24" checked>
|
||||
<label class="btn btn-outline-primary" for="p-24">24 mois</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="filter-months" id="p-48" value="48">
|
||||
<label class="btn btn-outline-secondary" for="p-48">Tout</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Thématique -->
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label" for="filter-thematic">{% translate "Thématique" %}</label>
|
||||
<select id="filter-thematic" class="form-select form-select-sm">
|
||||
<option value="">{% translate "Toutes les thématiques" %}</option>
|
||||
{% for t in accessible_thematics %}
|
||||
<option value="{{ t.code }}">{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Contrat / Prestataire -->
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="filter-contract">{% translate "Contrat / Prestataire" %}</label>
|
||||
<select id="filter-contract" class="form-select form-select-sm">
|
||||
<option value="">{% translate "Tous les contrats et prestataires" %}</option>
|
||||
{% for c in accessible_contracts %}
|
||||
<option value="{{ c.number }}">{{ c.number }}{% if c.company_name %} — {{ c.company_name }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Bouton Réinitialiser -->
|
||||
<div class="col-12 col-md-2 d-flex gap-2">
|
||||
<button id="btn-apply-filters" class="btn btn-primary btn-sm flex-grow-1">
|
||||
<i class="bi bi-funnel-fill me-1"></i>{% translate "Filtrer" %}
|
||||
</button>
|
||||
<button id="btn-reset-filters" class="btn btn-outline-secondary btn-sm" title="{% translate 'Réinitialiser' %}">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cartes KPI (6 indicateurs clés) -->
|
||||
<div class="row g-3 mb-4" id="kpi-container">
|
||||
<div class="col-6 col-md-4 col-xl-2">
|
||||
<div class="kpi-card kpi-primary">
|
||||
<div class="kpi-label">{% translate "Interventions en cours" %}</div>
|
||||
<div class="kpi-value" id="kpi-total-itvs">—</div>
|
||||
<div class="kpi-sub" id="kpi-itv-sub"><span id="kpi-itv-age">—</span>j en moyenne</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-4 col-xl-2">
|
||||
<div class="kpi-card kpi-teal">
|
||||
<div class="kpi-label">{% translate "Observations ouvertes" %}</div>
|
||||
<div class="kpi-value" id="kpi-total-obs">—</div>
|
||||
<div class="kpi-sub" id="kpi-obs-sub"><span id="kpi-obs-age">—</span>j en moyenne</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-4 col-xl-2">
|
||||
<div class="kpi-card kpi-danger">
|
||||
<div class="kpi-label">{% translate "En retard SLA" %}</div>
|
||||
<div class="kpi-value" id="kpi-delayed-itvs">—</div>
|
||||
<div class="kpi-sub" id="kpi-delayed-pct">—% de l'encours</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-4 col-xl-2">
|
||||
<div class="kpi-card kpi-purple">
|
||||
<div class="kpi-label">{% translate "En pause" %}</div>
|
||||
<div class="kpi-value" id="kpi-paused-itvs">—</div>
|
||||
<div class="kpi-sub">{% translate "Attente matériel / tiers" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-4 col-xl-2">
|
||||
<div class="kpi-card kpi-amber">
|
||||
<div class="kpi-label">{% translate "Obs. Orphelines" %}</div>
|
||||
<div class="kpi-value" id="kpi-orphan-obs">—</div>
|
||||
<div class="kpi-sub">{% translate "À traiter sans intervention" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-4 col-xl-2">
|
||||
<div class="kpi-card kpi-rose">
|
||||
<div class="kpi-label">{% translate "Obs. Désynchronisées" %}</div>
|
||||
<div class="kpi-value" id="kpi-desynced-obs">—</div>
|
||||
<div class="kpi-sub">{% translate "Intervention déjà close" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Graphiques ECharts -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Statuts & Entonnoir -->
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-header bg-white py-3 border-0">
|
||||
<h6 class="fw-bold mb-0 text-dark">
|
||||
<i class="bi bi-funnel text-primary me-2"></i>{% translate "Répartition par Statut d'Intervention" %}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div id="chart-status" class="pending-chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pyramide des âges -->
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-header bg-white py-3 border-0">
|
||||
<h6 class="fw-bold mb-0 text-dark">
|
||||
<i class="bi bi-bar-chart text-success me-2"></i>{% translate "Pyramide des Âges" %}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div id="chart-aging" class="pending-chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Motifs de Pause -->
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-header bg-white py-3 border-0">
|
||||
<h6 class="fw-bold mb-0 text-dark">
|
||||
<i class="bi bi-pause-circle text-purple me-2" style="color:#7c3aed;"></i>{% translate "Motifs de Pause (on_pause)" %}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div id="chart-pause" class="pending-chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tableau Croisé Dynamique par Thématique (TCD Web) -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white py-3 border-0 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0 text-dark">
|
||||
<i class="bi bi-grid-3x3-gap-fill text-primary me-2"></i>
|
||||
{% translate "Tableau Croisé Dynamique : Analyse par Thématique" %}
|
||||
</h6>
|
||||
<small class="text-muted">{% translate "Ventilation croisée des statuts, retards, motifs de pause et observations par domaine métier" %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="matrix-table" id="matrix-thematic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="th-main">{% translate "Thématique" %}</th>
|
||||
<th>{% translate "En prép." %}</th>
|
||||
<th>{% translate "À approuver" %}</th>
|
||||
<th>{% translate "À planifier" %}</th>
|
||||
<th>{% translate "À traiter" %}</th>
|
||||
<th>{% translate "Pris en charge" %}</th>
|
||||
<th>{% translate "Démarré" %}</th>
|
||||
<th>{% translate "En pause" %}</th>
|
||||
<th>{% translate "À corriger" %}</th>
|
||||
<th style="background:#2563eb;">{% translate "Total Itv" %}</th>
|
||||
<th style="background:#dc2626;">{% translate "En Retard" %}</th>
|
||||
<th style="background:#b91c1c;">{% translate "% Retard" %}</th>
|
||||
<th style="background:#0f766e;">{% translate "Obs. Ouvertes" %}</th>
|
||||
<th style="background:#d97706;">{% translate "Obs. Orphelines" %}</th>
|
||||
<th>{% translate "Âge moy. (j)" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="matrix-tbody">
|
||||
<tr>
|
||||
<td colspan="15" class="text-center py-4 text-muted">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>{% translate "Chargement des données..." %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listes Opérationnelles Actionnables -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white pt-3 pb-0 border-0">
|
||||
<ul class="nav nav-tabs card-header-tabs" id="actionTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-delayed-btn" data-bs-toggle="tab" data-bs-target="#tab-delayed" type="button" role="tab">
|
||||
<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>
|
||||
{% translate "Interventions en retard & à corriger" %} (<span id="count-tab-delayed">0</span>)
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-paused-btn" data-bs-toggle="tab" data-bs-target="#tab-paused" type="button" role="tab">
|
||||
<i class="bi bi-pause-btn-fill text-purple me-1" style="color:#7c3aed;"></i>
|
||||
{% translate "Interventions en pause" %} (<span id="count-tab-paused">0</span>)
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-obs-btn" data-bs-toggle="tab" data-bs-target="#tab-obs" type="button" role="tab">
|
||||
<i class="bi bi-eye-slash-fill text-warning me-1"></i>
|
||||
{% translate "Observations orphelines & désynchronisées" %} (<span id="count-tab-obs">0</span>)
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-providers-btn" data-bs-toggle="tab" data-bs-target="#tab-providers" type="button" role="tab">
|
||||
<i class="bi bi-building text-primary me-1"></i>
|
||||
{% translate "Synthèse Prestataires & Contrats" %} (<span id="count-tab-providers">0</span>)
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-0">
|
||||
<div class="tab-content">
|
||||
|
||||
<!-- Tab 1: Interventions en retard -->
|
||||
<div class="tab-pane fade show active p-3" id="tab-delayed" role="tabpanel">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle action-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Code" %}</th>
|
||||
<th>{% translate "Titre / Objet" %}</th>
|
||||
<th>{% translate "Thématique" %}</th>
|
||||
<th>{% translate "Statut" %}</th>
|
||||
<th>{% translate "Prestataire / Contrat" %}</th>
|
||||
<th>{% translate "Retard SLA" %}</th>
|
||||
<th>{% translate "Diagnostic / Cause" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="delayed-tbody">
|
||||
<tr><td colspan="7" class="text-center py-3 text-muted">{% translate "Aucune intervention en retard." %}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Interventions en pause -->
|
||||
<div class="tab-pane fade p-3" id="tab-paused" role="tabpanel">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle action-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Code" %}</th>
|
||||
<th>{% translate "Titre / Objet" %}</th>
|
||||
<th>{% translate "Thématique" %}</th>
|
||||
<th>{% translate "Motif de Pause" %}</th>
|
||||
<th>{% translate "Prestataire" %}</th>
|
||||
<th>{% translate "Jours en Pause" %}</th>
|
||||
<th>{% translate "Diagnostic" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="paused-tbody">
|
||||
<tr><td colspan="7" class="text-center py-3 text-muted">{% translate "Aucune intervention en pause." %}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Observations orphelines & désynchronisées -->
|
||||
<div class="tab-pane fade p-3" id="tab-obs" role="tabpanel">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle action-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Code Obs." %}</th>
|
||||
<th>{% translate "Thématique" %}</th>
|
||||
<th>{% translate "Symptôme" %}</th>
|
||||
<th>{% translate "Statut" %}</th>
|
||||
<th>{% translate "Âge" %}</th>
|
||||
<th>{% translate "Itv Liée" %}</th>
|
||||
<th>{% translate "Anomalie / Diagnostic" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="obs-tbody">
|
||||
<tr><td colspan="7" class="text-center py-3 text-muted">{% translate "Aucune observation problématique détectée." %}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 4: Prestataires & Contrats -->
|
||||
<div class="tab-pane fade p-3" id="tab-providers" role="tabpanel">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle action-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Prestataire" %}</th>
|
||||
<th>{% translate "Contrat" %}</th>
|
||||
<th>{% translate "Gestionnaire" %}</th>
|
||||
<th class="text-end">{% translate "Total en cours" %}</th>
|
||||
<th class="text-end">{% translate "En Retard" %}</th>
|
||||
<th class="text-end">{% translate "% Retard" %}</th>
|
||||
<th class="text-end">{% translate "Âge Moyen (j)" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="providers-tbody">
|
||||
<tr><td colspan="7" class="text-center py-3 text-muted">{% translate "Aucune donnée de prestataire." %}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Script Principal du Dashboard -->
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
// Instances ECharts
|
||||
let chartStatus = echarts.init(document.getElementById("chart-status"));
|
||||
let chartAging = echarts.init(document.getElementById("chart-aging"));
|
||||
let chartPause = echarts.init(document.getElementById("chart-pause"));
|
||||
|
||||
// Redimensionnement automatique responsive
|
||||
window.addEventListener("resize", function() {
|
||||
chartStatus.resize();
|
||||
chartAging.resize();
|
||||
chartPause.resize();
|
||||
});
|
||||
|
||||
// Palette de couleurs pour les statuts
|
||||
const STATUS_COLORS = {
|
||||
'in_preparation': '#94a3b8',
|
||||
'to_be_approved': '#f59e0b',
|
||||
'to_be_planned': '#3b82f6',
|
||||
'to_be_processed': '#f97316',
|
||||
'assigned': '#06b6d4',
|
||||
'in_progress': '#10b981',
|
||||
'on_pause': '#8b5cf6',
|
||||
'to_be_corrected': '#ef4444'
|
||||
};
|
||||
|
||||
// Récupération des filtres
|
||||
function getFilterParams() {
|
||||
const periodRadio = document.querySelector("input[name='filter-months']:checked");
|
||||
const months = periodRadio ? periodRadio.value : 24;
|
||||
const thematic = document.getElementById("filter-thematic").value;
|
||||
const contract = document.getElementById("filter-contract").value;
|
||||
|
||||
let params = new URLSearchParams();
|
||||
params.append("months", months);
|
||||
if (thematic) params.append("thematic", thematic);
|
||||
if (contract) params.append("contract", contract);
|
||||
return params;
|
||||
}
|
||||
|
||||
// Chargement des données
|
||||
function loadDashboardData() {
|
||||
const params = getFilterParams();
|
||||
|
||||
// Affichage état de chargement
|
||||
document.getElementById("matrix-tbody").innerHTML = `
|
||||
<tr><td colspan="15" class="text-center py-4 text-muted">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>{% translate "Calcul des indicateurs..." %}
|
||||
</td></tr>
|
||||
`;
|
||||
|
||||
fetch(`/api/reporting/pending/summary/?${params.toString()}`, {
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" }
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error("Erreur de chargement des données");
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
renderKPIs(data.stats);
|
||||
renderStatusChart(data.stats.itv_by_status);
|
||||
renderAgingChart(data.stats.itv_aging_buckets, data.stats.obs_aging_buckets);
|
||||
renderPauseChart(data.stats.pause_reasons_stats);
|
||||
renderThematicMatrix(data.stats.thematic_matrix);
|
||||
renderActionTabs(data);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
document.getElementById("matrix-tbody").innerHTML = `
|
||||
<tr><td colspan="15" class="text-center py-4 text-danger">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>{% translate "Impossible de charger les données d'analyse." %}
|
||||
</td></tr>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Rendu des KPIs
|
||||
function renderKPIs(stats) {
|
||||
document.getElementById("kpi-total-itvs").textContent = (stats.total_interventions || 0).toLocaleString();
|
||||
document.getElementById("kpi-itv-age").textContent = stats.avg_itv_age || 0;
|
||||
|
||||
document.getElementById("kpi-total-obs").textContent = (stats.total_observations || 0).toLocaleString();
|
||||
document.getElementById("kpi-obs-age").textContent = stats.avg_obs_age || 0;
|
||||
|
||||
document.getElementById("kpi-delayed-itvs").textContent = (stats.delayed_itvs_count || 0).toLocaleString();
|
||||
document.getElementById("kpi-delayed-pct").textContent = `${stats.delayed_itvs_pct || 0}% {% translate "de l'encours" %}`;
|
||||
|
||||
document.getElementById("kpi-paused-itvs").textContent = (stats.paused_itvs_count || 0).toLocaleString();
|
||||
document.getElementById("kpi-orphan-obs").textContent = (stats.orphan_obs_count || 0).toLocaleString();
|
||||
document.getElementById("kpi-desynced-obs").textContent = (stats.desynced_obs_count || 0).toLocaleString();
|
||||
}
|
||||
|
||||
// 2. Rendu du graphique des statuts
|
||||
function renderStatusChart(itvByStatus) {
|
||||
const labels = [];
|
||||
const values = [];
|
||||
const colors = [];
|
||||
|
||||
for (const [stKey, stData] of Object.entries(itvByStatus)) {
|
||||
labels.push(stData.label);
|
||||
values.push(stData.count);
|
||||
colors.push(STATUS_COLORS[stKey] || '#3b82f6');
|
||||
}
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: function(params) {
|
||||
const item = params[0];
|
||||
return `<b>${item.name}</b><br/>Nombre : <b>${item.value}</b>`;
|
||||
}
|
||||
},
|
||||
grid: { top: 20, right: 30, bottom: 20, left: 110 },
|
||||
xAxis: { type: 'value', splitLine: { lineStyle: { color: '#f1f5f9' } } },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 11, color: '#475569' }
|
||||
},
|
||||
series: [{
|
||||
name: 'Interventions',
|
||||
type: 'bar',
|
||||
data: values.map((val, idx) => ({
|
||||
value: val,
|
||||
itemStyle: { color: colors[idx], borderRadius: [0, 4, 4, 0] }
|
||||
})),
|
||||
barWidth: '60%',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
color: '#334155'
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
chartStatus.setOption(option);
|
||||
}
|
||||
|
||||
// 3. Rendu du graphique de pyramide des âges
|
||||
function renderAgingChart(itvBuckets, obsBuckets) {
|
||||
const categories = ['< 7j', '7-30j', '30-90j', '90-180j', '> 180j'];
|
||||
const itvVals = [
|
||||
itvBuckets.less_7d, itvBuckets['7_to_30d'], itvBuckets['30_to_90d'],
|
||||
itvBuckets['90_to_180d'], itvBuckets.more_180d
|
||||
];
|
||||
const obsVals = [
|
||||
obsBuckets.less_7d, obsBuckets['7_to_30d'], obsBuckets['30_to_90d'],
|
||||
obsBuckets['90_to_180d'], obsBuckets.more_180d
|
||||
];
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
legend: { data: ['Interventions', 'Observations'], bottom: 0, textStyle: { fontSize: 11 } },
|
||||
grid: { top: 20, right: 20, bottom: 40, left: 40 },
|
||||
xAxis: { type: 'category', data: categories, axisLabel: { fontSize: 10 } },
|
||||
yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f1f5f9' } } },
|
||||
series: [
|
||||
{
|
||||
name: 'Interventions',
|
||||
type: 'bar',
|
||||
data: itvVals,
|
||||
itemStyle: { color: '#2563eb', borderRadius: [4, 4, 0, 0] }
|
||||
},
|
||||
{
|
||||
name: 'Observations',
|
||||
type: 'bar',
|
||||
data: obsVals,
|
||||
itemStyle: { color: '#0d9488', borderRadius: [4, 4, 0, 0] }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
chartAging.setOption(option);
|
||||
}
|
||||
|
||||
// 4. Rendu du graphique des motifs de pause
|
||||
function renderPauseChart(pauseStats) {
|
||||
const data = [];
|
||||
for (const [code, pData] of Object.entries(pauseStats)) {
|
||||
if (pData.count > 0) {
|
||||
data.push({ name: pData.label, value: pData.count });
|
||||
}
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
data.push({ name: "{% translate 'Aucune en pause' %}", value: 0 });
|
||||
}
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: 'item', formatter: '{b} : <b>{c}</b> ({d}%)' },
|
||||
legend: { orient: 'vertical', right: 10, top: 'center', textStyle: { fontSize: 10 } },
|
||||
series: [{
|
||||
name: 'Motifs de Pause',
|
||||
type: 'pie',
|
||||
radius: ['45%', '75%'],
|
||||
center: ['35%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: { show: false },
|
||||
emphasis: { label: { show: true, fontSize: 12, fontWeight: 'bold' } },
|
||||
data: data
|
||||
}]
|
||||
};
|
||||
|
||||
chartPause.setOption(option);
|
||||
}
|
||||
|
||||
// 5. Rendu du Tableau Croisé Dynamique par Thématique (TCD Web)
|
||||
function renderThematicMatrix(thematicMatrix) {
|
||||
const tbody = document.getElementById("matrix-tbody");
|
||||
tbody.innerHTML = "";
|
||||
|
||||
let totalItv = 0, totalDel = 0, totalObs = 0, totalOrph = 0;
|
||||
let stTotals = {
|
||||
in_preparation: 0, to_be_approved: 0, to_be_planned: 0, to_be_processed: 0,
|
||||
assigned: 0, in_progress: 0, on_pause: 0, to_be_corrected: 0
|
||||
};
|
||||
|
||||
for (const [thName, th] of Object.entries(thematicMatrix)) {
|
||||
totalItv += th.itv_total;
|
||||
totalDel += th.itv_delayed_count;
|
||||
totalObs += th.obs_total;
|
||||
totalOrph += th.obs_orphan_count;
|
||||
|
||||
for (const [k, v] of Object.entries(th.itv_st_counts)) {
|
||||
if (stTotals[k] !== undefined) stTotals[k] += v;
|
||||
}
|
||||
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td class="fw-bold text-dark">${th.thematic_name}</td>
|
||||
<td class="text-center">${th.itv_st_counts.in_preparation || 0}</td>
|
||||
<td class="text-center">${th.itv_st_counts.to_be_approved || 0}</td>
|
||||
<td class="text-center">${th.itv_st_counts.to_be_planned || 0}</td>
|
||||
<td class="text-center">${th.itv_st_counts.to_be_processed || 0}</td>
|
||||
<td class="text-center">${th.itv_st_counts.assigned || 0}</td>
|
||||
<td class="text-center">${th.itv_st_counts.in_progress || 0}</td>
|
||||
<td class="text-center ${th.itv_st_counts.on_pause > 0 ? 'fw-bold text-purple' : ''}" style="${th.itv_st_counts.on_pause > 0 ? 'color:#7c3aed;' : ''}">${th.itv_st_counts.on_pause || 0}</td>
|
||||
<td class="text-center ${th.itv_st_counts.to_be_corrected > 0 ? 'fw-bold text-danger' : ''}">${th.itv_st_counts.to_be_corrected || 0}</td>
|
||||
<td class="text-center fw-bold bg-light">${th.itv_total}</td>
|
||||
<td class="text-center ${th.itv_delayed_count > 0 ? 'fw-bold text-danger' : ''}">${th.itv_delayed_count}</td>
|
||||
<td class="text-center ${th.itv_delayed_pct > 20 ? 'fw-bold text-danger' : ''}">${th.itv_delayed_pct}%</td>
|
||||
<td class="text-center fw-bold text-teal" style="color:#0f766e;">${th.obs_total}</td>
|
||||
<td class="text-center ${th.obs_orphan_count > 0 ? 'fw-bold text-warning' : ''}">${th.obs_orphan_count}</td>
|
||||
<td class="text-end">${th.itv_avg_age} j</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
// Ligne de total
|
||||
const totalDelPct = totalItv > 0 ? Math.round(totalDel / totalItv * 1000) / 10 : 0;
|
||||
const trTotal = document.createElement("tr");
|
||||
trTotal.className = "tr-total";
|
||||
trTotal.innerHTML = `
|
||||
<td>{% translate "Total général" %}</td>
|
||||
<td class="text-center">${stTotals.in_preparation}</td>
|
||||
<td class="text-center">${stTotals.to_be_approved}</td>
|
||||
<td class="text-center">${stTotals.to_be_planned}</td>
|
||||
<td class="text-center">${stTotals.to_be_processed}</td>
|
||||
<td class="text-center">${stTotals.assigned}</td>
|
||||
<td class="text-center">${stTotals.in_progress}</td>
|
||||
<td class="text-center">${stTotals.on_pause}</td>
|
||||
<td class="text-center">${stTotals.to_be_corrected}</td>
|
||||
<td class="text-center">${totalItv}</td>
|
||||
<td class="text-center ${totalDel > 0 ? 'text-danger' : ''}">${totalDel}</td>
|
||||
<td class="text-center">${totalDelPct}%</td>
|
||||
<td class="text-center">${totalObs}</td>
|
||||
<td class="text-center">${totalOrph}</td>
|
||||
<td class="text-end">—</td>
|
||||
`;
|
||||
tbody.appendChild(trTotal);
|
||||
}
|
||||
|
||||
// 6. Rendu des Tableaux Actionnables
|
||||
function renderActionTabs(data) {
|
||||
// Tab 1 : Interventions en retard
|
||||
const delayedTbody = document.getElementById("delayed-tbody");
|
||||
document.getElementById("count-tab-delayed").textContent = data.top_delayed_interventions.length;
|
||||
if (data.top_delayed_interventions.length === 0) {
|
||||
delayedTbody.innerHTML = `<tr><td colspan="7" class="text-center py-3 text-muted">{% translate "Aucune intervention en retard." %}</td></tr>`;
|
||||
} else {
|
||||
delayedTbody.innerHTML = data.top_delayed_interventions.map(itv => `
|
||||
<tr>
|
||||
<td><a href="/interventions/?search=${itv.code}" class="fw-bold text-primary text-decoration-none" target="_blank">${itv.code}</a></td>
|
||||
<td><span class="fw-semibold text-dark">${itv.title}</span></td>
|
||||
<td><span class="badge bg-light text-dark border">${itv.thematic_name}</span></td>
|
||||
<td><span class="badge badge-status" style="background-color:${STATUS_COLORS[itv.status] || '#64748b'};color:#fff;">${itv.status_label}</span></td>
|
||||
<td><small class="text-muted">${itv.provider_name} (${itv.contract_number})</small></td>
|
||||
<td><span class="badge bg-danger text-white">+${itv.delay_days}j</span></td>
|
||||
<td><small class="text-danger fw-semibold">${itv.diagnostic}</small></td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
// Tab 2 : Interventions en pause
|
||||
const pausedTbody = document.getElementById("paused-tbody");
|
||||
document.getElementById("count-tab-paused").textContent = data.paused_interventions.length;
|
||||
if (data.paused_interventions.length === 0) {
|
||||
pausedTbody.innerHTML = `<tr><td colspan="7" class="text-center py-3 text-muted">{% translate "Aucune intervention en pause." %}</td></tr>`;
|
||||
} else {
|
||||
pausedTbody.innerHTML = data.paused_interventions.map(itv => `
|
||||
<tr>
|
||||
<td><a href="/interventions/?search=${itv.code}" class="fw-bold text-primary text-decoration-none" target="_blank">${itv.code}</a></td>
|
||||
<td><span class="fw-semibold text-dark">${itv.title}</span></td>
|
||||
<td><span class="badge bg-light text-dark border">${itv.thematic_name}</span></td>
|
||||
<td><span class="badge bg-purple text-white" style="background-color:#7c3aed;">${itv.pause_reason_label || 'En pause'}</span></td>
|
||||
<td><small class="text-muted">${itv.provider_name}</small></td>
|
||||
<td><span class="fw-bold text-dark">${itv.days_in_status}j</span></td>
|
||||
<td><small class="text-muted">${itv.diagnostic}</small></td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
// Tab 3 : Observations problématiques
|
||||
const obsTbody = document.getElementById("obs-tbody");
|
||||
document.getElementById("count-tab-obs").textContent = data.problematic_observations.length;
|
||||
if (data.problematic_observations.length === 0) {
|
||||
obsTbody.innerHTML = `<tr><td colspan="7" class="text-center py-3 text-muted">{% translate "Aucune observation problématique détectée." %}</td></tr>`;
|
||||
} else {
|
||||
obsTbody.innerHTML = data.problematic_observations.map(obs => `
|
||||
<tr>
|
||||
<td><a href="/observations/?search=${obs.code}" class="fw-bold text-teal text-decoration-none" style="color:#0f766e;" target="_blank">${obs.code}</a></td>
|
||||
<td><span class="badge bg-light text-dark border">${obs.thematic_name}</span></td>
|
||||
<td><small>${obs.symptom_name || '—'}</small></td>
|
||||
<td><span class="badge bg-secondary">${obs.status_label}</span></td>
|
||||
<td><small class="fw-bold">${obs.age_days}j</small></td>
|
||||
<td>${obs.intervention_code ? `<a href="/interventions/?search=${obs.intervention_code}" target="_blank" class="fw-bold">${obs.intervention_code}</a> (${obs.intervention_status_label})` : '<span class="badge bg-warning text-dark">Aucune</span>'}</td>
|
||||
<td><small class="text-danger fw-semibold">${obs.diagnostic}</small></td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
// Tab 4 : Prestataires & Contrats
|
||||
const provTbody = document.getElementById("providers-tbody");
|
||||
const provStats = data.stats.provider_contract_stats || [];
|
||||
document.getElementById("count-tab-providers").textContent = provStats.length;
|
||||
if (provStats.length === 0) {
|
||||
provTbody.innerHTML = `<tr><td colspan="7" class="text-center py-3 text-muted">{% translate "Aucun prestataire avec encours." %}</td></tr>`;
|
||||
} else {
|
||||
provStats.sort((a, b) => b.total - a.total);
|
||||
provTbody.innerHTML = provStats.map(p => `
|
||||
<tr>
|
||||
<td class="fw-bold text-dark">${p.provider}</td>
|
||||
<td><span class="badge bg-light text-dark border">${p.contract}</span></td>
|
||||
<td><small class="text-muted">${p.manager || '—'}</small></td>
|
||||
<td class="text-end fw-bold">${p.total}</td>
|
||||
<td class="text-end ${p.delayed > 0 ? 'text-danger fw-bold' : ''}">${p.delayed}</td>
|
||||
<td class="text-end ${p.delayed_pct > 20 ? 'text-danger fw-bold' : ''}">${p.delayed_pct}%</td>
|
||||
<td class="text-end">${p.avg_age} j</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
}
|
||||
|
||||
// Événements boutons et filtres
|
||||
document.getElementById("btn-apply-filters").addEventListener("click", loadDashboardData);
|
||||
|
||||
document.querySelectorAll("input[name='filter-months']").forEach(radio => {
|
||||
radio.addEventListener("change", loadDashboardData);
|
||||
});
|
||||
|
||||
document.getElementById("btn-reset-filters").addEventListener("click", function() {
|
||||
document.getElementById("p-24").checked = true;
|
||||
document.getElementById("filter-thematic").value = "";
|
||||
document.getElementById("filter-contract").value = "";
|
||||
loadDashboardData();
|
||||
});
|
||||
|
||||
document.getElementById("btn-export-excel").addEventListener("click", function() {
|
||||
const params = getFilterParams();
|
||||
window.location.href = `/reporting/pending/export/?${params.toString()}`;
|
||||
});
|
||||
|
||||
// Premier chargement
|
||||
loadDashboardData();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
111
loko/reporting/tests_pending_dashboard.py
Normal file
111
loko/reporting/tests_pending_dashboard.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
from django.test import TestCase, Client
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from common.models import UserConfig, Thematic, Role
|
||||
from interventions.models import Intervention, Symptom
|
||||
from observations.models import Observation
|
||||
from contracts.models import Contract, Company
|
||||
|
||||
|
||||
class PendingDashboardTests(TestCase):
|
||||
def setUp(self):
|
||||
# Création de l'utilisateur avec accès reporting
|
||||
self.user = User.objects.create_user(username="test_admin_pending", password="password123", is_superuser=True)
|
||||
self.client = Client()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.thematic = Thematic.objects.create(code="lighting", name_fr="Éclairage public", name_nl="Openbare verlichting")
|
||||
self.company = Company.objects.create(name="ProELEC SA")
|
||||
self.contract = Contract.objects.create(
|
||||
contract_number="CTR-2025-ELEC",
|
||||
company=self.company,
|
||||
start_date=timezone.now().date() - timedelta(days=365),
|
||||
end_date=timezone.now().date() + timedelta(days=365)
|
||||
)
|
||||
|
||||
self.itv1 = Intervention.objects.create(
|
||||
code="ITV-P-001",
|
||||
title="Luminaire en panne",
|
||||
thematic=self.thematic,
|
||||
contract=self.contract,
|
||||
assigned_provider=self.company,
|
||||
status="to_be_processed",
|
||||
priority="2",
|
||||
creation_time=timezone.now() - timedelta(days=10)
|
||||
)
|
||||
|
||||
self.itv2 = Intervention.objects.create(
|
||||
code="ITV-P-002",
|
||||
title="Câble sectionné",
|
||||
thematic=self.thematic,
|
||||
contract=self.contract,
|
||||
assigned_provider=self.company,
|
||||
status="on_pause",
|
||||
pause_reason="order_material",
|
||||
priority="1",
|
||||
creation_time=timezone.now() - timedelta(days=20)
|
||||
)
|
||||
|
||||
self.obs1 = Observation.objects.create(
|
||||
thematic=self.thematic,
|
||||
status="to_process",
|
||||
latitude=50.8503,
|
||||
longitude=4.3517,
|
||||
created_at=timezone.now() - timedelta(days=5)
|
||||
)
|
||||
|
||||
def test_pending_dashboard_view(self):
|
||||
response = self.client.get("/reporting/pending/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "reporting/pending_dashboard.html")
|
||||
self.assertContains(response, "Tableau de bord des encours & blocages")
|
||||
|
||||
def test_pending_summary_api(self):
|
||||
response = self.client.get("/api/reporting/pending/summary/?months=24")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
self.assertIn("stats", data)
|
||||
self.assertIn("thematic_matrix", data["stats"])
|
||||
self.assertEqual(data["stats"]["total_interventions"], 2)
|
||||
self.assertEqual(data["stats"]["total_observations"], 1)
|
||||
self.assertEqual(data["stats"]["paused_itvs_count"], 1)
|
||||
self.assertEqual(data["stats"]["orphan_obs_count"], 1)
|
||||
|
||||
# Vérification des listes d'action
|
||||
self.assertIn("top_delayed_interventions", data)
|
||||
self.assertIn("paused_interventions", data)
|
||||
self.assertIn("problematic_observations", data)
|
||||
|
||||
def test_export_pending_excel_view(self):
|
||||
response = self.client.get("/reporting/pending/export/?months=24")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response["Content-Type"],
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
self.assertIn("attachment; filename=", response["Content-Disposition"])
|
||||
self.assertGreater(len(response.content), 5000)
|
||||
|
||||
def test_permission_denied_for_unauthorized_user(self):
|
||||
# Utilisateur sans droit reporting
|
||||
unauth_user = User.objects.create_user(username="unauth_user", password="password123")
|
||||
u_cfg = UserConfig.objects.create(user=unauth_user, is_intern=True)
|
||||
# Pas de can_access_view('reporting')
|
||||
|
||||
client_unauth = Client()
|
||||
client_unauth.force_login(unauth_user)
|
||||
|
||||
# 1. Dashboard HTML -> 403
|
||||
res_page = client_unauth.get("/reporting/pending/")
|
||||
self.assertEqual(res_page.status_code, 403)
|
||||
|
||||
# 2. Export Excel -> 403
|
||||
res_export = client_unauth.get("/reporting/pending/export/?months=24")
|
||||
self.assertEqual(res_export.status_code, 403)
|
||||
|
||||
# 3. API Summary -> 403
|
||||
res_api = client_unauth.get("/api/reporting/pending/summary/?months=24")
|
||||
self.assertEqual(res_api.status_code, 403)
|
||||
|
|
@ -13,6 +13,7 @@ from .views import (
|
|||
ReportingObservationsView,
|
||||
ReportingObservationsVolumeTimeseriesView,
|
||||
ReportingObservationsMapLocationsView,
|
||||
ReportingPendingSummaryView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
|
|
@ -28,5 +29,6 @@ urlpatterns = [
|
|||
path("observations/", ReportingObservationsView.as_view(), name="reporting-observations"),
|
||||
path("observations/volume-timeseries/", ReportingObservationsVolumeTimeseriesView.as_view(), name="reporting-observations-volume-timeseries"),
|
||||
path("observations/map-locations/", ReportingObservationsMapLocationsView.as_view(), name="reporting-observations-map-locations"),
|
||||
path("pending/summary/", ReportingPendingSummaryView.as_view(), name="reporting-pending-summary"),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ urlpatterns = [
|
|||
path("", views_dashboard.dashboard, name="dashboard"),
|
||||
path("volume/", views_dashboard.volume_dashboard, name="volume-dashboard"),
|
||||
path("observations/", views_dashboard.observations_dashboard, name="observations-dashboard"),
|
||||
path("pending/", views_dashboard.pending_dashboard, name="pending-dashboard"),
|
||||
path("pending/export/", views_dashboard.export_pending_excel, name="export-pending-excel"),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1392,3 +1392,99 @@ class ReportingObservationsMapLocationsView(ReportingBaseView):
|
|||
"type": "FeatureCollection",
|
||||
"features": features
|
||||
})
|
||||
|
||||
|
||||
class ReportingPendingSummaryView(APIView):
|
||||
"""
|
||||
Retourne les agrégats, matrices thématiques et diagnostics des encours non terminés.
|
||||
"""
|
||||
authentication_classes = [HybridTokenAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
if not _has_reporting_access(request):
|
||||
return Response({"detail": "Accès refusé."}, status=403)
|
||||
|
||||
from interventions.services.pending_analysis_service import get_pending_analysis_data
|
||||
|
||||
try:
|
||||
months = int(request.query_params.get("months", 24))
|
||||
except (ValueError, TypeError):
|
||||
months = 24
|
||||
|
||||
thematic_code = request.query_params.get("thematic") or None
|
||||
contract_number = request.query_params.get("contract") or None
|
||||
provider_id = request.query_params.get("provider") or None
|
||||
|
||||
data = get_pending_analysis_data(
|
||||
months=months,
|
||||
thematic_code=thematic_code,
|
||||
contract_number=contract_number,
|
||||
provider_id=provider_id,
|
||||
user=request.user,
|
||||
)
|
||||
|
||||
# Formattage et sérialisation des listes pour le frontend
|
||||
serialized_itvs = []
|
||||
for itv in data["interventions"]:
|
||||
serialized_itvs.append({
|
||||
"id": itv["id"],
|
||||
"code": itv["code"],
|
||||
"title": itv["title"],
|
||||
"thematic_name": itv["thematic_name"],
|
||||
"status": itv["status"],
|
||||
"status_label": itv["status_label"],
|
||||
"priority_label": itv["priority_label"],
|
||||
"age_days": itv["age_days"],
|
||||
"days_in_status": itv["days_in_status"],
|
||||
"is_delayed": itv["is_delayed"],
|
||||
"delay_days": itv["delay_days"],
|
||||
"sla_status_label": itv["sla_status_label"],
|
||||
"provider_name": itv["provider_name"],
|
||||
"contract_number": itv["contract_number"],
|
||||
"manager_name": itv["manager_name"],
|
||||
"pause_reason_label": itv["pause_reason_label"],
|
||||
"pause_reason_other": itv["pause_reason_other"],
|
||||
"reason_for_correction": itv["reason_for_correction"],
|
||||
"days_since_activity": itv["days_since_activity"],
|
||||
"diagnostic": itv["diagnostic"],
|
||||
})
|
||||
|
||||
serialized_obs = []
|
||||
for obs in data["observations"]:
|
||||
serialized_obs.append({
|
||||
"id": obs["id"],
|
||||
"code": obs["code"],
|
||||
"thematic_name": obs["thematic_name"],
|
||||
"symptom_name": obs["symptom_name"],
|
||||
"status": obs["status"],
|
||||
"status_label": obs["status_label"],
|
||||
"age_days": obs["age_days"],
|
||||
"created_by": obs["created_by"],
|
||||
"intervention_code": obs["intervention_code"],
|
||||
"intervention_status_label": obs["intervention_status_label"],
|
||||
"is_orphan": obs["is_orphan"],
|
||||
"is_desynced": obs["is_desynced"],
|
||||
"diagnostic": obs["diagnostic"],
|
||||
"address": obs["address"],
|
||||
})
|
||||
|
||||
# Top éléments à actionner
|
||||
delayed_itvs = [i for i in serialized_itvs if i["is_delayed"] or i["status"] == "to_be_corrected"]
|
||||
delayed_itvs.sort(key=lambda x: (0 if x["is_delayed"] else 1, -x["delay_days"], -x["age_days"]))
|
||||
|
||||
paused_itvs = [i for i in serialized_itvs if i["status"] == "on_pause"]
|
||||
paused_itvs.sort(key=lambda x: -x["days_in_status"])
|
||||
|
||||
problem_obs = [o for o in serialized_obs if o["is_orphan"] or o["is_desynced"]]
|
||||
problem_obs.sort(key=lambda x: -x["age_days"])
|
||||
|
||||
return Response({
|
||||
"months": months,
|
||||
"start_date": data["start_date"].isoformat(),
|
||||
"now": data["now"].isoformat(),
|
||||
"stats": data["stats"],
|
||||
"top_delayed_interventions": delayed_itvs[:50],
|
||||
"paused_interventions": paused_itvs[:50],
|
||||
"problematic_observations": problem_obs[:50],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -336,3 +336,102 @@ def observations_dashboard(request):
|
|||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def pending_dashboard(request):
|
||||
"""
|
||||
Tableau de bord interactif des encours et blocages (observations & interventions non terminées).
|
||||
"""
|
||||
if not _can_access_reporting(request.user):
|
||||
from django.core.exceptions import PermissionDenied
|
||||
raise PermissionDenied
|
||||
|
||||
try:
|
||||
user_config = UserConfig.objects.prefetch_related("roles").get(user=request.user)
|
||||
is_unrestricted = (
|
||||
request.user.is_superuser
|
||||
or user_config.roles.filter(name__in=["admin", "top_manager", "operator"]).exists()
|
||||
)
|
||||
except UserConfig.DoesNotExist:
|
||||
user_config = None
|
||||
is_unrestricted = request.user.is_superuser
|
||||
|
||||
lang = get_language() or "fr"
|
||||
from common.models import Thematic
|
||||
from contracts.models import Contract
|
||||
|
||||
# Thématiques accessibles
|
||||
thematics_qs = Thematic.objects.all().order_by("name_fr")
|
||||
accessible_thematics = [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name_nl if lang == "nl" else t.name_fr,
|
||||
"name_fr": t.name_fr,
|
||||
"name_nl": t.name_nl,
|
||||
"code": t.code,
|
||||
}
|
||||
for t in thematics_qs
|
||||
]
|
||||
|
||||
# Contrats accessibles
|
||||
if is_unrestricted:
|
||||
contracts_qs = Contract.objects.select_related("company").all().order_by("contract_number")
|
||||
else:
|
||||
user_contract_ids = set(user_config.contracts.values_list("id", flat=True)) if user_config else set()
|
||||
contracts_qs = Contract.objects.filter(id__in=user_contract_ids).select_related("company").order_by("contract_number")
|
||||
|
||||
accessible_contracts = [
|
||||
{
|
||||
"id": c.id,
|
||||
"number": c.contract_number,
|
||||
"company_name": c.company.name if c.company else "",
|
||||
}
|
||||
for c in contracts_qs
|
||||
]
|
||||
|
||||
saved_prefs = {}
|
||||
if user_config:
|
||||
saved_prefs = (user_config.reporting_preferences or {}).get("pending_dashboard", {})
|
||||
|
||||
return render(request, "reporting/pending_dashboard.html", {
|
||||
"accessible_thematics": accessible_thematics,
|
||||
"accessible_contracts": accessible_contracts,
|
||||
"saved_prefs_json": json.dumps(saved_prefs),
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def export_pending_excel(request):
|
||||
"""
|
||||
Génère et télécharge le fichier Excel d'audit des encours avec les filtres demandés.
|
||||
"""
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone
|
||||
from interventions.services.pending_analysis_service import generate_pending_analysis_excel
|
||||
|
||||
if not _can_access_reporting(request.user):
|
||||
from django.core.exceptions import PermissionDenied
|
||||
raise PermissionDenied
|
||||
|
||||
months = int(request.GET.get("months", 24))
|
||||
thematic_code = request.GET.get("thematic") or None
|
||||
contract_number = request.GET.get("contract") or None
|
||||
provider_id = request.GET.get("provider") or None
|
||||
|
||||
output_stream = generate_pending_analysis_excel(
|
||||
months=months,
|
||||
thematic_code=thematic_code,
|
||||
contract_number=contract_number,
|
||||
provider_id=provider_id,
|
||||
user=request.user,
|
||||
output_format='file'
|
||||
)
|
||||
|
||||
filename = f"audit_encours_loko_{timezone.now().strftime('%Y%m%d_%H%M')}.xlsx"
|
||||
response = HttpResponse(
|
||||
output_stream.read(),
|
||||
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue