loko/streetup/reporting/templates/reporting/dashboard.html
2026-07-22 14:48:40 +02:00

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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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">&laquo;</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">&raquo;</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 %}