feat: implement interactive dashboard and Excel export for pending interventions and observations

This commit is contained in:
kdeterme 2026-08-24 16:37:37 +02:00
parent cfdcc41929
commit a50bebe427
8 changed files with 1239 additions and 5 deletions

View file

@ -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 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: Returns:
dict contenant: 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 interventions.models import Intervention, InterventionTimeLine, InterventionDocument
from observations.models import Observation, ObservationPhoto from observations.models import Observation, ObservationPhoto
from common.models import UserConfig 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() now = timezone.now()
start_date = now - relativedelta(months=months) 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 # 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, status__in=PENDING_INTERVENTION_STATUSES,
creation_time__gte=start_date, creation_time__gte=start_date,
).select_related( ).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 # 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, status__in=PENDING_OBSERVATION_STATUSES,
created_at__gte=start_date, created_at__gte=start_date,
).select_related( ).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. 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, thematic_code=thematic_code,
contract_number=contract_number, contract_number=contract_number,
provider_id=provider_id, provider_id=provider_id,
user=user,
) )
wb = openpyxl.Workbook() wb = openpyxl.Workbook()

View file

@ -59,5 +59,28 @@
<span class="ms-2 text d-none d-md-inline">{% translate "Nombre d'observations" %}</span> <span class="ms-2 text d-none d-md-inline">{% translate "Nombre d'observations" %}</span>
</a> </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 %} {% endblock sidebar %}

View 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 %}

View 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)

View file

@ -13,6 +13,7 @@ from .views import (
ReportingObservationsView, ReportingObservationsView,
ReportingObservationsVolumeTimeseriesView, ReportingObservationsVolumeTimeseriesView,
ReportingObservationsMapLocationsView, ReportingObservationsMapLocationsView,
ReportingPendingSummaryView,
) )
urlpatterns = [ urlpatterns = [
@ -28,5 +29,6 @@ urlpatterns = [
path("observations/", ReportingObservationsView.as_view(), name="reporting-observations"), path("observations/", ReportingObservationsView.as_view(), name="reporting-observations"),
path("observations/volume-timeseries/", ReportingObservationsVolumeTimeseriesView.as_view(), name="reporting-observations-volume-timeseries"), 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("observations/map-locations/", ReportingObservationsMapLocationsView.as_view(), name="reporting-observations-map-locations"),
path("pending/summary/", ReportingPendingSummaryView.as_view(), name="reporting-pending-summary"),
] ]

View file

@ -8,5 +8,7 @@ urlpatterns = [
path("", views_dashboard.dashboard, name="dashboard"), path("", views_dashboard.dashboard, name="dashboard"),
path("volume/", views_dashboard.volume_dashboard, name="volume-dashboard"), path("volume/", views_dashboard.volume_dashboard, name="volume-dashboard"),
path("observations/", views_dashboard.observations_dashboard, name="observations-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"),
] ]

View file

@ -1392,3 +1392,99 @@ class ReportingObservationsMapLocationsView(ReportingBaseView):
"type": "FeatureCollection", "type": "FeatureCollection",
"features": features "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],
})

View file

@ -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