869 lines
34 KiB
HTML
869 lines
34 KiB
HTML
{% 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>
|
|
#reporting-chart { width: 100%; height: 420px; }
|
|
.filter-bar { background: #f8f9fa; border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1.25rem; }
|
|
.filter-bar .form-label { font-size: .82rem; font-weight: 600; color: #495057; margin-bottom: .25rem; }
|
|
.metric-legend-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; margin-right: 4px; }
|
|
.kpi-card { border-left: 4px solid; border-radius: 6px; padding: .75rem 1rem; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
|
|
.kpi-card .kpi-value { font-size: 1.6rem; font-weight: 700; }
|
|
.kpi-card .kpi-label { font-size: .78rem; color: #6c757d; }
|
|
.kpi-card.interactive { cursor: pointer; transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s; }
|
|
.kpi-card.interactive:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.12); background-color: #f8f9fa; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid py-3">
|
|
|
|
<!-- ── Temps de traitement ─────────────────────────────────────── -->
|
|
<div class="card shadow-sm mb-4">
|
|
<div class="card-header d-flex align-items-center gap-2 py-2">
|
|
<i class="bi bi-clock-history text-primary"></i>
|
|
<span class="fw-semibold">{% translate "Évolution du temps de traitement" %}</span>
|
|
</div>
|
|
<div class="card-body">
|
|
|
|
<!-- Filter bar -->
|
|
<div class="filter-bar row g-2 align-items-start">
|
|
|
|
<!-- Thematics -->
|
|
<div class="col-12 col-md-3">
|
|
<label class="form-label" for="filter-thematics">{% translate "Thématiques" %}</label>
|
|
<select id="filter-thematics" class="form-select form-select-sm" multiple size="4">
|
|
{% for t in accessible_thematics %}
|
|
<option value="{{ t.id }}">{{ t.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<div class="form-text">{% translate "Vide = toutes" %}</div>
|
|
</div>
|
|
|
|
<!-- Contracts -->
|
|
<div class="col-12 col-md-3">
|
|
<label class="form-label" for="filter-contracts">{% translate "Contrats" %}</label>
|
|
<select id="filter-contracts" class="form-select form-select-sm" multiple size="4">
|
|
{% for c in accessible_contracts %}
|
|
<option value="{{ c.id }}" selected>{{ c.number }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<div class="form-text">{% translate "Vide = tous" %}</div>
|
|
</div>
|
|
|
|
<!-- Granularity -->
|
|
<div class="col-6 col-md-2">
|
|
<label class="form-label" for="filter-granularity">{% translate "Granularité" %}</label>
|
|
<select id="filter-granularity" class="form-select form-select-sm">
|
|
<option value="day">{% translate "Jour" %}</option>
|
|
<option value="week" selected>{% translate "Semaine" %}</option>
|
|
<option value="month">{% translate "Mois" %}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Date range -->
|
|
<div class="col-6 col-md-2">
|
|
<label class="form-label" for="filter-date-from">{% translate "Du" %}</label>
|
|
<input type="date" id="filter-date-from" class="form-control form-control-sm">
|
|
</div>
|
|
<div class="col-6 col-md-2">
|
|
<label class="form-label" for="filter-date-to">{% translate "Au" %}</label>
|
|
<input type="date" id="filter-date-to" class="form-control form-control-sm">
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- KPI cards (filled by JS) -->
|
|
<div class="row g-3 mb-3" id="kpi-row" style="display:none!important">
|
|
<div class="col-6 col-md-3">
|
|
<div class="kpi-card interactive" style="border-color:#4e79a7" id="kpi-card-count" title="{% translate 'Cliquer pour voir la liste des interventions' %}">
|
|
<div class="kpi-value text-primary" id="kpi-count">—</div>
|
|
<div class="kpi-label">{% translate "Interventions" %}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-md-3">
|
|
<div class="kpi-card" style="border-color:#f28e2b">
|
|
<div class="kpi-value" id="kpi-response" style="color:#f28e2b">—</div>
|
|
<div class="kpi-label">{% translate "Méd. prise en charge (h)" %}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-md-3">
|
|
<div class="kpi-card" style="border-color:#59a14f">
|
|
<div class="kpi-value" id="kpi-resolution" style="color:#59a14f">—</div>
|
|
<div class="kpi-label">{% translate "Méd. 1ère résolution (h)" %}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-md-3">
|
|
<div class="kpi-card" style="border-color:#e15759">
|
|
<div class="kpi-value" id="kpi-final" style="color:#e15759">—</div>
|
|
<div class="kpi-label">{% translate "Méd. résolution finale (h)" %}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chart -->
|
|
<div id="reporting-chart-wrapper">
|
|
<div id="reporting-chart-placeholder" class="text-center text-muted py-5">
|
|
<i class="bi bi-hourglass-split d-block mb-2" style="font-size:2rem;"></i>
|
|
{% translate "Chargement des données…" %}
|
|
</div>
|
|
<div id="reporting-chart" style="display:none"></div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Interventions -->
|
|
<div class="modal fade" id="interventionsModal" tabindex="-1" aria-labelledby="interventionsModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
<div class="modal-content shadow-lg border-0">
|
|
<div class="modal-header bg-light border-bottom-0 pb-0">
|
|
<h5 class="modal-title fw-bold text-dark d-flex align-items-center gap-2" id="interventionsModalLabel">
|
|
<i class="bi bi-list-task text-primary"></i>
|
|
<span>{% translate "Interventions prises en compte" %}</span>
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body pt-2">
|
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3 bg-light p-2 rounded">
|
|
<div class="text-muted small fw-semibold" id="modal-period-info"></div>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<input type="text" id="modal-search" class="form-control form-control-sm" placeholder="{% translate 'Rechercher...' %}" style="max-width: 200px;">
|
|
<a id="modal-export-link" href="#" target="_blank" class="btn btn-sm btn-outline-primary d-none">
|
|
<i class="bi bi-box-arrow-up-right me-1"></i>{% translate "Voir dans l'application" %}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-responsive" style="max-height: 450px; min-height: 200px;">
|
|
<table class="table table-hover align-middle table-sm" id="modal-table">
|
|
<thead class="table-light sticky-top">
|
|
<tr>
|
|
<th>{% translate "Code" %}</th>
|
|
<th>{% translate "Titre" %}</th>
|
|
<th>{% translate "Date de création" %}</th>
|
|
<th>{% translate "Statut" %}</th>
|
|
<th>{% translate "Priorité" %}</th>
|
|
<th>{% translate "Contrat" %}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="modal-table-body">
|
|
<!-- Rempli par le JS -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination inside modal -->
|
|
<div class="d-flex align-items-center justify-content-between mt-3 pt-2 border-top">
|
|
<div class="text-muted small" id="modal-pagination-info"></div>
|
|
<nav aria-label="Modal pagination">
|
|
<ul class="pagination pagination-sm mb-0" id="modal-pagination-controls">
|
|
<!-- Controls populated by JS -->
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block script %}
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
const TIMESERIES_URL = '/api/reporting/interventions/timeseries/';
|
|
const PREFERENCES_URL = '/api/reporting/preferences/';
|
|
const CSRF = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
|
|
|
|
// Data injected by Django
|
|
const THEMATIC_CONTRACT_MAP = {{ thematic_contract_map_json|safe }};
|
|
const THEMATIC_CODE_MAP = {{ thematic_code_map_json|safe }};
|
|
const SAVED_PREFS = {{ saved_prefs_json|safe }};
|
|
|
|
const COLORS = {
|
|
median_response_time_min: '#f28e2b',
|
|
median_first_resolution_min: '#59a14f',
|
|
median_final_resolution_min: '#e15759',
|
|
median_execution_time_min: '#76b7b2',
|
|
};
|
|
|
|
const LABELS = {
|
|
median_response_time_min: "{% translate 'Prise en charge' %}",
|
|
median_first_resolution_min: "{% translate '1ère résolution' %}",
|
|
median_final_resolution_min: "{% translate 'Résolution finale' %}",
|
|
median_execution_time_min: "{% translate 'Exécution' %}",
|
|
};
|
|
|
|
function minToH(min) {
|
|
if (min === null || min === undefined) return null;
|
|
return Math.round(min / 60 * 10) / 10;
|
|
}
|
|
|
|
function fmtH(h) {
|
|
if (h === null || h === undefined) return '—';
|
|
const totalMin = Math.round(h * 60);
|
|
const hours = Math.floor(totalMin / 60);
|
|
const mins = totalMin % 60;
|
|
if (hours === 0) return `${mins} min`;
|
|
if (mins === 0) return `${hours} h`;
|
|
return `${hours} h ${mins} min`;
|
|
}
|
|
|
|
function fmtHAxis(h) {
|
|
const totalMin = Math.round(h * 60);
|
|
const hours = Math.floor(totalMin / 60);
|
|
const mins = totalMin % 60;
|
|
if (hours === 0) return `${mins} min`;
|
|
if (mins === 0) return `${hours} h`;
|
|
return `${hours}h${mins}`;
|
|
}
|
|
|
|
let chart = null;
|
|
let _resizeHandler = null;
|
|
|
|
// ── Helpers sélection ───────────────────────────────────────────────────
|
|
function getSelected(selectId) {
|
|
return Array.from(document.getElementById(selectId).selectedOptions)
|
|
.map(o => Number(o.value));
|
|
}
|
|
|
|
// ── Filtrage dynamique des contrats selon thématiques choisies ─────────────
|
|
function updateContractOptions() {
|
|
const selectedThematics = getSelected('filter-thematics');
|
|
const contractSel = document.getElementById('filter-contracts');
|
|
const prevSelected = new Set(getSelected('filter-contracts').map(String));
|
|
|
|
let allowedContracts = null; // null = tous
|
|
if (selectedThematics.length > 0) {
|
|
allowedContracts = new Set();
|
|
selectedThematics.forEach(tid => {
|
|
(THEMATIC_CONTRACT_MAP[tid] || []).forEach(cid => allowedContracts.add(cid));
|
|
});
|
|
}
|
|
|
|
Array.from(contractSel.options).forEach(opt => {
|
|
const cid = Number(opt.value);
|
|
const visible = allowedContracts === null || allowedContracts.has(cid);
|
|
opt.hidden = !visible;
|
|
if (!visible) opt.selected = false;
|
|
else if (prevSelected.size === 0) opt.selected = true;
|
|
});
|
|
}
|
|
|
|
// ── Construction des paramètres de requête ─────────────────────────────────
|
|
function buildParams() {
|
|
const params = new URLSearchParams();
|
|
params.set('granularity', document.getElementById('filter-granularity').value);
|
|
const thematicIds = getSelected('filter-thematics');
|
|
if (thematicIds.length) params.set('thematic_id', thematicIds.join(','));
|
|
const contractIds = getSelected('filter-contracts');
|
|
if (contractIds.length) params.set('contract_id', contractIds.join(','));
|
|
const from = document.getElementById('filter-date-from').value;
|
|
const to = document.getElementById('filter-date-to').value;
|
|
if (from) params.set('created_after', from);
|
|
if (to) params.set('created_before', to);
|
|
return params;
|
|
}
|
|
|
|
// ── Sauvegarde des préférences (débouncé 500 ms) ────────────────────────
|
|
let _saveTimer = null;
|
|
function schedulePreferencesSave() {
|
|
clearTimeout(_saveTimer);
|
|
_saveTimer = setTimeout(savePreferences, 500);
|
|
}
|
|
|
|
async function savePreferences() {
|
|
const prefs = {
|
|
granularity: document.getElementById('filter-granularity').value,
|
|
thematic_ids: getSelected('filter-thematics'),
|
|
contract_ids: getSelected('filter-contracts'),
|
|
date_from: document.getElementById('filter-date-from').value,
|
|
date_to: document.getElementById('filter-date-to').value,
|
|
};
|
|
try {
|
|
await fetch(PREFERENCES_URL, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': CSRF,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ page: 'dashboard', prefs }),
|
|
});
|
|
} catch (_) { /* silencieux */ }
|
|
}
|
|
|
|
// ── Restauration des préférences sauvegardées ───────────────────────────
|
|
function restorePreferences() {
|
|
const p = SAVED_PREFS;
|
|
if (!p || !Object.keys(p).length) return;
|
|
|
|
if (p.granularity) {
|
|
document.getElementById('filter-granularity').value = p.granularity;
|
|
}
|
|
if (p.date_from) document.getElementById('filter-date-from').value = p.date_from;
|
|
if (p.date_to) document.getElementById('filter-date-to').value = p.date_to;
|
|
|
|
if (Array.isArray(p.thematic_ids) && p.thematic_ids.length) {
|
|
const thematicSet = new Set(p.thematic_ids.map(Number));
|
|
Array.from(document.getElementById('filter-thematics').options).forEach(opt => {
|
|
opt.selected = thematicSet.has(Number(opt.value));
|
|
});
|
|
updateContractOptions();
|
|
}
|
|
|
|
if (Array.isArray(p.contract_ids) && p.contract_ids.length) {
|
|
const contractSet = new Set(p.contract_ids.map(Number));
|
|
Array.from(document.getElementById('filter-contracts').options).forEach(opt => {
|
|
opt.selected = !opt.hidden && contractSet.has(Number(opt.value));
|
|
});
|
|
}
|
|
}
|
|
|
|
function formatPeriod(ts, granularity) {
|
|
// ts is a millisecond timestamp (from new Date(isoString).getTime())
|
|
const d = new Date(ts);
|
|
if (granularity === 'month') {
|
|
return d.toLocaleDateString(document.documentElement.lang || 'fr', { year: 'numeric', month: 'short' });
|
|
}
|
|
if (granularity === 'week') {
|
|
return d.toLocaleDateString(document.documentElement.lang || 'fr', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
|
}
|
|
return d.toLocaleDateString(document.documentElement.lang || 'fr', { day: '2-digit', month: '2-digit' });
|
|
}
|
|
|
|
function renderChart(data) {
|
|
const wrapper = document.getElementById('reporting-chart');
|
|
const placeholder = document.getElementById('reporting-chart-placeholder');
|
|
const kpiRow = document.getElementById('kpi-row');
|
|
const results = data.results || [];
|
|
const gran = data.granularity || 'week';
|
|
|
|
if (!results.length) {
|
|
placeholder.innerHTML =
|
|
'<i class="bi bi-inbox d-block mb-2" style="font-size:2rem;"></i>' +
|
|
"{% translate 'Aucune donnée disponible pour les filtres sélectionnés.' %}";
|
|
placeholder.style.display = '';
|
|
wrapper.style.display = 'none';
|
|
kpiRow.style.setProperty('display', 'none', 'important');
|
|
return;
|
|
}
|
|
|
|
placeholder.style.display = 'none';
|
|
wrapper.style.display = '';
|
|
kpiRow.style.removeProperty('display');
|
|
|
|
// KPI: sum counts, overall median (median of period medians)
|
|
const totalCount = results.reduce((s, r) => s + (r.count || 0), 0);
|
|
const overallMedianH = key => {
|
|
const vals = results.map(r => r[key]).filter(v => v !== null && v !== undefined).sort((a, b) => a - b);
|
|
if (!vals.length) return null;
|
|
const mid = Math.floor(vals.length / 2);
|
|
const medMin = vals.length % 2 ? vals[mid] : (vals[mid - 1] + vals[mid]) / 2;
|
|
return minToH(medMin);
|
|
};
|
|
document.getElementById('kpi-count').textContent = totalCount.toLocaleString();
|
|
document.getElementById('kpi-response').textContent = fmtH(overallMedianH('median_response_time_min'));
|
|
document.getElementById('kpi-resolution').textContent = fmtH(overallMedianH('median_first_resolution_min'));
|
|
document.getElementById('kpi-final').textContent = fmtH(overallMedianH('median_final_resolution_min'));
|
|
|
|
// Convert ISO period strings to ms timestamps once
|
|
const timestamps = results.map(r => new Date(r.period).getTime());
|
|
|
|
const metricKeys = [
|
|
'median_response_time_min',
|
|
'median_first_resolution_min',
|
|
'median_final_resolution_min',
|
|
'median_execution_time_min',
|
|
];
|
|
|
|
const series = metricKeys.map(key => ({
|
|
name: LABELS[key],
|
|
type: 'line',
|
|
smooth: true,
|
|
connectNulls: true,
|
|
itemStyle: { color: COLORS[key] },
|
|
lineStyle: { color: COLORS[key], width: 2 },
|
|
// [timestamp, value] tuples — required for type:'time' axis
|
|
data: timestamps.map((ts, i) => [ts, minToH(results[i][key])]),
|
|
tooltip: { valueFormatter: v => v !== null ? fmtH(v) : '—' },
|
|
}));
|
|
|
|
// Count as bar on secondary y-axis
|
|
series.push({
|
|
name: "{% translate 'Nombre' %}",
|
|
type: 'bar',
|
|
yAxisIndex: 1,
|
|
itemStyle: { color: '#4e79a7', opacity: 0.35 },
|
|
data: timestamps.map((ts, i) => [ts, results[i].count || 0]),
|
|
tooltip: { valueFormatter: v => v },
|
|
});
|
|
|
|
const option = {
|
|
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
|
legend: {
|
|
bottom: 0,
|
|
data: [...metricKeys.map(k => LABELS[k]), "{% translate 'Nombre' %}"],
|
|
},
|
|
grid: { left: '3%', right: '4%', bottom: '12%', containLabel: true },
|
|
// type:'time' derives its scale entirely from the series data —
|
|
// no internal category list is cached between renders.
|
|
xAxis: {
|
|
type: 'time',
|
|
axisLabel: {
|
|
fontSize: 11,
|
|
rotate: gran === 'day' ? 45 : 0,
|
|
formatter: ts => formatPeriod(ts, gran),
|
|
},
|
|
},
|
|
yAxis: [
|
|
{
|
|
type: 'value',
|
|
name: "{% translate 'Durée' %}",
|
|
nameTextStyle: { fontSize: 11 },
|
|
axisLabel: { fontSize: 10, formatter: fmtHAxis },
|
|
},
|
|
{
|
|
type: 'value',
|
|
name: "{% translate 'Nombre' %}",
|
|
nameTextStyle: { fontSize: 11 },
|
|
splitLine: { show: false },
|
|
},
|
|
],
|
|
series,
|
|
};
|
|
|
|
if (chart) {
|
|
chart.dispose();
|
|
window.removeEventListener('resize', _resizeHandler);
|
|
}
|
|
chart = echarts.init(wrapper);
|
|
_resizeHandler = () => chart.resize();
|
|
window.addEventListener('resize', _resizeHandler);
|
|
chart.setOption(option);
|
|
chart.resize();
|
|
|
|
// Chart click handler to open details modal
|
|
chart.on('click', (params) => {
|
|
if (params.componentType === 'series') {
|
|
const ts = params.value[0];
|
|
const item = results.find(r => new Date(r.period).getTime() === ts);
|
|
if (item && item.period) {
|
|
openInterventionsModal(item.period, gran);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadData() {
|
|
const placeholder = document.getElementById('reporting-chart-placeholder');
|
|
const wrapper = document.getElementById('reporting-chart');
|
|
placeholder.innerHTML =
|
|
'<i class="bi bi-hourglass-split d-block mb-2" style="font-size:2rem;"></i>' +
|
|
"{% translate 'Chargement des données…' %}";
|
|
placeholder.style.display = '';
|
|
wrapper.style.display = 'none';
|
|
|
|
const params = buildParams();
|
|
try {
|
|
const resp = await fetch(`${TIMESERIES_URL}?${params.toString()}`, {
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': CSRF },
|
|
credentials: 'same-origin',
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
placeholder.innerHTML =
|
|
`<i class="bi bi-exclamation-triangle text-danger d-block mb-2" style="font-size:2rem;"></i>` +
|
|
(err.error || "{% translate 'Erreur lors du chargement des données.' %}");
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
renderChart(data);
|
|
} catch (e) {
|
|
placeholder.innerHTML =
|
|
'<i class="bi bi-wifi-off text-danger d-block mb-2" style="font-size:2rem;"></i>' +
|
|
"{% translate 'Impossible de contacter le serveur.' %}";
|
|
}
|
|
}
|
|
|
|
// ── Mappings et outils pour le modal d'interventions ────────────────────
|
|
const STATUS_LABELS = {
|
|
fr: {
|
|
'in_preparation': 'En préparation',
|
|
'to_be_approved': 'A approuver',
|
|
'to_be_planned': 'A planifier',
|
|
'to_be_processed': 'A traiter',
|
|
'assigned': 'Pris en charge',
|
|
'in_progress': 'Démarré',
|
|
'on_pause': 'En pause',
|
|
'finished': 'Terminé',
|
|
'processed': 'Traité',
|
|
'to_be_corrected': 'A corriger',
|
|
'corrected': 'Corrigé',
|
|
'validated': 'Validé',
|
|
'invoiced': 'Comptabilisé',
|
|
'closed': 'Finalisé',
|
|
'canceled': 'Annulé',
|
|
},
|
|
nl: {
|
|
'in_preparation': 'In voorbereiding',
|
|
'to_be_approved': 'Te goedkeuren',
|
|
'to_be_planned': 'Te plannen',
|
|
'to_be_processed': 'Te behandelen',
|
|
'assigned': 'In behandeling',
|
|
'in_progress': 'Gestart',
|
|
'on_pause': 'Gepauzeerd',
|
|
'finished': 'Voltooid',
|
|
'processed': 'Verwerkt',
|
|
'to_be_corrected': 'Te corrigeren',
|
|
'corrected': 'Gecorrigeerd',
|
|
'validated': 'Gevalideerd',
|
|
'invoiced': 'Geboekt',
|
|
'closed': 'Afgerond',
|
|
'canceled': 'Geannuleerd',
|
|
}
|
|
};
|
|
|
|
const STATUS_BADGES = {
|
|
'in_preparation': 'bg-secondary',
|
|
'to_be_approved': 'bg-warning text-dark',
|
|
'to_be_planned': 'bg-warning text-dark',
|
|
'to_be_processed': 'bg-danger',
|
|
'assigned': 'bg-warning text-dark',
|
|
'in_progress': 'bg-primary',
|
|
'on_pause': 'bg-warning text-dark',
|
|
'finished': 'bg-info text-dark',
|
|
'processed': 'bg-info text-dark',
|
|
'to_be_corrected': 'bg-danger',
|
|
'corrected': 'bg-info text-dark',
|
|
'validated': 'bg-success',
|
|
'invoiced': 'bg-dark',
|
|
'closed': 'bg-dark',
|
|
'canceled': 'bg-secondary',
|
|
};
|
|
|
|
const PRIORITY_LABELS = {
|
|
fr: { '1': 'Urgent', '2': 'Élevé', '3': 'Normal', '4': 'Bas' },
|
|
nl: { '1': 'Urgent', '2': 'Hoog', '3': 'Normaal', '4': 'Laag' }
|
|
};
|
|
|
|
const PRIORITY_BADGES = {
|
|
'1': 'bg-danger',
|
|
'2': 'bg-warning text-dark',
|
|
'3': 'bg-success',
|
|
'4': 'bg-secondary'
|
|
};
|
|
|
|
function formatDate(isoStr) {
|
|
if (!isoStr) return '—';
|
|
const d = new Date(isoStr);
|
|
return d.toLocaleDateString(document.documentElement.lang || 'fr', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric'
|
|
});
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return str
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
let currentModalParams = null;
|
|
|
|
async function openInterventionsModal(periodStartStr = null, granularity = null) {
|
|
const periodInfo = document.getElementById('modal-period-info');
|
|
const tableBody = document.getElementById('modal-table-body');
|
|
const searchInput = document.getElementById('modal-search');
|
|
const exportLink = document.getElementById('modal-export-link');
|
|
|
|
// Clear search input
|
|
searchInput.value = '';
|
|
|
|
// Show loading
|
|
tableBody.innerHTML = `
|
|
<tr>
|
|
<td colspan="6" class="text-center py-5">
|
|
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"></div>
|
|
{% translate "Chargement des interventions..." %}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
|
|
// Initialize standard query params
|
|
const params = new URLSearchParams();
|
|
params.set('maintenance_type', 'corrective');
|
|
params.set('per_page', '50');
|
|
|
|
const thematicIds = getSelected('filter-thematics');
|
|
if (thematicIds.length) params.set('thematic_id', thematicIds.join(','));
|
|
const contractIds = getSelected('filter-contracts');
|
|
if (contractIds.length) params.set('contract_id', contractIds.join(','));
|
|
|
|
let dateRangeTitle = '';
|
|
const lang = (document.documentElement.lang || 'fr').substring(0, 2).toLowerCase();
|
|
|
|
if (periodStartStr && granularity) {
|
|
// Period selected from ECharts
|
|
const start = new Date(periodStartStr);
|
|
const end = new Date(start.getTime());
|
|
|
|
if (granularity === 'day') {
|
|
end.setDate(end.getDate() + 1);
|
|
dateRangeTitle = start.toLocaleDateString(lang, { day: '2-digit', month: '2-digit', year: 'numeric' });
|
|
} else if (granularity === 'week') {
|
|
end.setDate(end.getDate() + 7);
|
|
const endDisplay = new Date(end.getTime() - 1000);
|
|
dateRangeTitle = lang === 'nl'
|
|
? `Week van ${start.toLocaleDateString(lang, { day: '2-digit', month: '2-digit' })} tot ${endDisplay.toLocaleDateString(lang, { day: '2-digit', month: '2-digit', year: 'numeric' })}`
|
|
: `Semaine du ${start.toLocaleDateString(lang, { day: '2-digit', month: '2-digit' })} au ${endDisplay.toLocaleDateString(lang, { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
|
} else if (granularity === 'month') {
|
|
end.setMonth(end.getMonth() + 1);
|
|
dateRangeTitle = start.toLocaleDateString(lang, { month: 'long', year: 'numeric' });
|
|
}
|
|
|
|
// Subtract 1 second to end inclusive of the period
|
|
end.setSeconds(end.getSeconds() - 1);
|
|
|
|
const formatLocalISO = (d) => {
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
};
|
|
|
|
params.set('created_after', formatLocalISO(start));
|
|
params.set('created_before', formatLocalISO(end));
|
|
} else {
|
|
// Overall filtered period
|
|
const from = document.getElementById('filter-date-from').value;
|
|
const to = document.getElementById('filter-date-to').value;
|
|
|
|
if (from) params.set('created_after', from);
|
|
if (to) params.set('created_before', to + 'T23:59:59');
|
|
|
|
if (from && to) {
|
|
dateRangeTitle = lang === 'nl'
|
|
? `Periode van ${new Date(from).toLocaleDateString(lang)} tot ${new Date(to).toLocaleDateString(lang)}`
|
|
: `Période du ${new Date(from).toLocaleDateString(lang)} au ${new Date(to).toLocaleDateString(lang)}`;
|
|
} else if (from) {
|
|
dateRangeTitle = lang === 'nl'
|
|
? `Vanaf ${new Date(from).toLocaleDateString(lang)}`
|
|
: `Depuis le ${new Date(from).toLocaleDateString(lang)}`;
|
|
} else if (to) {
|
|
dateRangeTitle = lang === 'nl'
|
|
? `Tot ${new Date(to).toLocaleDateString(lang)}`
|
|
: `Jusqu'au ${new Date(to).toLocaleDateString(lang)}`;
|
|
} else {
|
|
dateRangeTitle = lang === 'nl' ? `Alle interventies` : `Toutes les interventions`;
|
|
}
|
|
}
|
|
|
|
periodInfo.textContent = dateRangeTitle;
|
|
|
|
// Configure export/view link to main application list
|
|
const mainAppUrl = new URL(window.location.origin + '/interventions/');
|
|
mainAppUrl.searchParams.set('maintain_type', 'corrective');
|
|
|
|
const thematicCodes = thematicIds.map(tid => THEMATIC_CODE_MAP[tid]).filter(Boolean);
|
|
thematicCodes.forEach(code => mainAppUrl.searchParams.append('thematic', code));
|
|
contractIds.forEach(cid => mainAppUrl.searchParams.append('contract', cid));
|
|
|
|
if (params.get('created_after')) {
|
|
mainAppUrl.searchParams.set('begin_date_min', params.get('created_after').split('T')[0]);
|
|
}
|
|
if (params.get('created_before')) {
|
|
mainAppUrl.searchParams.set('begin_date_max', params.get('created_before').split('T')[0]);
|
|
}
|
|
|
|
exportLink.href = mainAppUrl.toString();
|
|
exportLink.classList.remove('d-none');
|
|
|
|
// Show the modal
|
|
const modalObj = bootstrap.Modal.getOrCreateInstance(document.getElementById('interventionsModal'));
|
|
modalObj.show();
|
|
|
|
currentModalParams = params;
|
|
loadModalPage(1);
|
|
}
|
|
|
|
async function loadModalPage(page) {
|
|
const tableBody = document.getElementById('modal-table-body');
|
|
const paginationControls = document.getElementById('modal-pagination-controls');
|
|
const paginationInfo = document.getElementById('modal-pagination-info');
|
|
|
|
tableBody.innerHTML = `
|
|
<tr>
|
|
<td colspan="6" class="text-center py-5">
|
|
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"></div>
|
|
{% translate "Chargement des interventions..." %}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
|
|
currentModalParams.set('page', page);
|
|
currentModalParams.set('q', document.getElementById('modal-search').value);
|
|
|
|
try {
|
|
const resp = await fetch(`/api/reporting/interventions/?${currentModalParams.toString()}`, {
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': CSRF },
|
|
credentials: 'same-origin'
|
|
});
|
|
|
|
if (!resp.ok) throw new Error();
|
|
|
|
const data = await resp.json();
|
|
const results = data.results || [];
|
|
|
|
if (results.length === 0) {
|
|
tableBody.innerHTML = `
|
|
<tr>
|
|
<td colspan="6" class="text-center py-4 text-muted">
|
|
<i class="bi bi-inbox d-block mb-1 fs-4"></i>
|
|
{% translate "Aucune intervention trouvée." %}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
paginationInfo.textContent = '';
|
|
paginationControls.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const lang = (document.documentElement.lang || 'fr').substring(0, 2).toLowerCase();
|
|
tableBody.innerHTML = results.map(item => {
|
|
const detailUrl = `/interventions/${item.id}/`;
|
|
const statusLabel = (STATUS_LABELS[lang] || STATUS_LABELS['fr'])[item.status] || item.status;
|
|
const statusBadge = STATUS_BADGES[item.status] || 'bg-secondary';
|
|
const priorityLabel = (PRIORITY_LABELS[lang] || PRIORITY_LABELS['fr'])[item.priority] || item.priority;
|
|
const priorityBadge = PRIORITY_BADGES[item.priority] || 'bg-secondary';
|
|
|
|
return `
|
|
<tr>
|
|
<td>
|
|
<a href="${detailUrl}" target="_blank" class="fw-semibold text-decoration-none">
|
|
${item.code} <i class="bi bi-box-arrow-up-right small ms-1" style="font-size: 0.75rem;"></i>
|
|
</a>
|
|
</td>
|
|
<td class="text-wrap" style="max-width: 250px;">${escapeHtml(item.title)}</td>
|
|
<td>${formatDate(item.dt_to_be_processed)}</td>
|
|
<td><span class="badge ${statusBadge}">${statusLabel}</span></td>
|
|
<td><span class="badge ${priorityBadge}">${priorityLabel}</span></td>
|
|
<td><span class="text-muted small">${item.contract_number || '—'}</span></td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
const totalCount = data.count || 0;
|
|
const perPage = data.per_page || 50;
|
|
const totalPages = data.pages || 1;
|
|
const currentPage = data.page || 1;
|
|
|
|
const startRange = (currentPage - 1) * perPage + 1;
|
|
const endRange = Math.min(currentPage * perPage, totalCount);
|
|
|
|
paginationInfo.textContent = `${startRange} - ${endRange} sur ${totalCount}`;
|
|
|
|
let controlsHtml = '';
|
|
|
|
controlsHtml += `
|
|
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
|
|
<button class="page-link" onclick="loadModalPage(${currentPage - 1})" aria-label="Previous">
|
|
<span aria-hidden="true">«</span>
|
|
</button>
|
|
</li>
|
|
`;
|
|
|
|
const startPage = Math.max(1, currentPage - 2);
|
|
const endPage = Math.min(totalPages, currentPage + 2);
|
|
|
|
if (startPage > 1) {
|
|
controlsHtml += `
|
|
<li class="page-item"><button class="page-link" onclick="loadModalPage(1)">1</button></li>
|
|
${startPage > 2 ? '<li class="page-item disabled"><span class="page-link">...</span></li>' : ''}
|
|
`;
|
|
}
|
|
|
|
for (let i = startPage; i <= endPage; i++) {
|
|
controlsHtml += `
|
|
<li class="page-item ${i === currentPage ? 'active' : ''}">
|
|
<button class="page-link" onclick="loadModalPage(${i})">${i}</button>
|
|
</li>
|
|
`;
|
|
}
|
|
|
|
if (endPage < totalPages) {
|
|
controlsHtml += `
|
|
${endPage < totalPages - 1 ? '<li class="page-item disabled"><span class="page-link">...</span></li>' : ''}
|
|
<li class="page-item"><button class="page-link" onclick="loadModalPage(${totalPages})">${totalPages}</button></li>
|
|
`;
|
|
}
|
|
|
|
controlsHtml += `
|
|
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
|
|
<button class="page-link" onclick="loadModalPage(${currentPage + 1})" aria-label="Next">
|
|
<span aria-hidden="true">»</span>
|
|
</button>
|
|
</li>
|
|
`;
|
|
|
|
paginationControls.innerHTML = controlsHtml;
|
|
|
|
} catch (e) {
|
|
tableBody.innerHTML = `
|
|
<tr>
|
|
<td colspan="6" class="text-center py-4 text-danger">
|
|
<i class="bi bi-exclamation-triangle d-block mb-1 fs-4"></i>
|
|
{% translate "Erreur lors du chargement des interventions." %}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
paginationInfo.textContent = '';
|
|
paginationControls.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
window.loadModalPage = loadModalPage;
|
|
|
|
// Bind click on the Interventions count KPI card
|
|
document.getElementById('kpi-card-count').addEventListener('click', () => {
|
|
openInterventionsModal();
|
|
});
|
|
|
|
// Bind debounced search in the modal
|
|
let searchTimeout = null;
|
|
document.getElementById('modal-search').addEventListener('input', () => {
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => {
|
|
loadModalPage(1);
|
|
}, 300);
|
|
});
|
|
|
|
document.getElementById('filter-granularity').addEventListener('change', () => { schedulePreferencesSave(); loadData(); });
|
|
document.getElementById('filter-date-from').addEventListener('change', () => { schedulePreferencesSave(); loadData(); });
|
|
document.getElementById('filter-date-to').addEventListener('change', () => { schedulePreferencesSave(); loadData(); });
|
|
document.getElementById('filter-thematics').addEventListener('change', () => {
|
|
updateContractOptions();
|
|
schedulePreferencesSave();
|
|
loadData();
|
|
});
|
|
document.getElementById('filter-contracts').addEventListener('change', () => { schedulePreferencesSave(); loadData(); });
|
|
|
|
// Init: restore saved prefs, then load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
restorePreferences();
|
|
loadData();
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|