loko/loko/inspections/templates/inspections/inspections_list.html

780 lines
42 KiB
HTML

{% extends base_template|default:"base.html" %}
{% load i18n static l10n short_name inspections_tags %}
{% block title %}{% translate "Inspections d'assets" %}{% endblock %}
{% block extra_css %}
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
<style>
.inspection-card.is-cancelled {
background-color: #f8f9fa !important;
border: 1px dashed #adb5bd !important;
opacity: 0.7;
}
.inspection-card.is-cancelled .card-title,
.inspection-card.is-cancelled h6 {
color: #6c757d !important;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
<div>
<h3 class="mb-0 fw-bold">
<i class="bi bi-search text-primary me-2"></i>{% translate "Inspections" %}
</h3>
<p class="text-muted mb-0 small">{% translate "Suivi et historique des inspections et vérifications sur le terrain" %}</p>
</div>
{% if can_inspect %}
<div>
<a href="{% if is_mobile %}{% url 'mobile:quick_inspection_map_mobile' %}{% else %}{% url 'inspections:quick_inspection_map' %}{% endif %}" class="btn btn-primary shadow-sm">
<i class="bi bi-geo-alt-fill me-1"></i>{% translate "Nouvelle inspection" %}
</a>
</div>
{% endif %}
</div>
<!-- Navigation par Onglets -->
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link {% if filter_type == 'my' %}active fw-bold{% endif %}" href="?tab=my">
<i class="bi bi-person-check me-1"></i>{% translate "Mes inspections" %}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if filter_type == 'all' %}active fw-bold{% endif %}" href="?tab=all">
<i class="bi bi-collection me-1"></i>{% translate "Toutes les inspections" %}
</a>
</li>
</ul>
<!-- Barre de filtres -->
<div class="card mb-3 shadow-sm border-0 bg-light">
<div class="card-body p-3">
<form method="get" class="row g-2 align-items-center">
<input type="hidden" name="tab" value="{{ filter_type }}">
<div class="col-12 col-md-3">
<div class="input-group input-group-sm">
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
<input type="text" name="q" value="{{ search_q }}" class="form-control" placeholder="{% translate 'Rechercher par note, asset, ...' %}">
</div>
</div>
<div class="col-12 col-sm-6 col-md-2">
<select name="thematic" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Thématiques --" %}</option>
{% for t in thematics %}
<option value="{{ t.code }}" {% if selected_thematic == t.code %}selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-sm-6 col-md-2">
<select name="nature" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Natures --" %}</option>
{% for val, label in nature_choices %}
<option value="{{ val }}" {% if selected_nature == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-sm-6 col-md-3">
<select name="status" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Tous les résultats --" %}</option>
{% for val, label in result_choices %}
<option value="{{ val }}" {% if selected_status == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-sm-6 col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-sm btn-secondary flex-grow-1">{% translate "Filtrer" %}</button>
<a href="?tab={{ filter_type }}" class="btn btn-sm btn-outline-secondary" title="{% translate 'Réinitialiser' %}"><i class="bi bi-x-circle"></i></a>
</div>
</form>
</div>
</div>
<!-- Bouton Flottant (FAB) Carte des résultats -->
<button type="button" class="btn btn-primary rounded-circle shadow-lg position-fixed bottom-0 end-0 m-4 d-flex align-items-center justify-content-center"
style="width: 56px; height: 56px; z-index: 1050; cursor: pointer; transition: transform 0.2s ease, box-shadow 0.2s ease;"
data-bs-toggle="modal"
data-bs-target="#inspectionsMapModal"
id="btn-open-inspections-map-fab"
title="{% translate 'Carte des résultats' %}"
aria-label="{% translate 'Carte des résultats' %}">
<i class="bi bi-map fs-4"></i>
</button>
{% if inspections %}
<!-- Liste des inspections sous forme de Cards -->
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="fw-semibold text-muted small">
{% blocktranslate count counter=total_count %}{{ counter }} inspection trouvée{% plural %}{{ counter }} inspections trouvées{% endblocktranslate %}
<span class="fst-italic text-secondary">({% translate "limité aux 100 plus récentes" %})</span>
</span>
</div>
<div class="row g-3 mb-4">
{% for insp in inspections %}
<div class="col-12">
<div class="card shadow-sm border-0 h-100 inspection-card cursor-pointer {% if insp.is_cancelled %}is-cancelled{% endif %}"
style="cursor: pointer; transition: transform 0.15s ease-in-out, box-shadow 0.15s ease-in-out;"
data-bs-toggle="modal"
data-bs-target="#inspectionDetailModal{{ insp.id }}">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div>
<!-- Entête card : Thématique/Type + Badge résultat -->
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="badge bg-light text-dark border d-inline-flex align-items-center me-2">
<i class="bi {{ insp.thematic_icon }} me-2 text-primary"></i>
{{ insp.asset_content_type.name }}
</span>
<span class="badge {{ insp.status_badge_class }}">
<i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }}
</span>
</div>
<!-- Nom de la cible tronqué à 100 caractères -->
<h6 class="fw-bold text-dark mb-1 text-break">
{{ insp.target_display|truncatechars:100 }}
</h6>
<!-- Résumé des constats si présent -->
{% if insp.details_summary and insp.details_summary != "-" %}
<p class="text-muted small mb-2 text-truncate mt-1">
<i class="bi bi-ui-checks me-1 text-secondary"></i>{{ insp.details_summary }}
</p>
{% endif %}
</div>
<!-- Ligne de pied card : Date & Inspecteur -->
<div class="d-flex justify-content-between align-items-center pt-2 border-top mt-2 text-muted extra-small">
<span><i class="bi bi-clock me-1"></i>{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
<div class="d-flex align-items-center gap-2">
{% if insp.documents.all %}
<span class="badge bg-light text-secondary border" title="{% translate 'Photos / Documents joints' %}">
<i class="bi bi-paperclip me-1"></i>{{ insp.documents.count }}
</span>
{% endif %}
{% if insp.inspector %}
<span><i class="bi bi-person me-1"></i>{{ insp.inspector|short_name }}</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Modal de détail de l'inspection -->
<div class="modal fade" id="inspectionDetailModal{{ insp.id }}" tabindex="-1" aria-labelledby="inspectionDetailModalLabel{{ insp.id }}" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold" id="inspectionDetailModalLabel{{ insp.id }}">
<i class="bi {{ insp.thematic_icon }} me-2 text-primary"></i>{% translate "Détail de l'inspection" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-3">
{% if insp.is_cancelled %}
<div class="alert alert-secondary py-2 px-3 small d-flex align-items-center mb-3">
<i class="bi bi-slash-circle me-2 fs-6"></i>
<div>
<strong>{% translate "Inspection annulée" %}</strong> — {% translate "Cette inspection a été annulée et n'est plus prise en compte dans la conformité active de la cible." %}
</div>
</div>
{% endif %}
<!-- Boutons d'action : asset et/ou intervention -->
{% if insp.asset or insp.related_intervention %}
<div class="row g-2 mb-3">
{% if insp.asset %}
<div class="col-12 {% if insp.related_intervention %}col-sm-6{% endif %}">
<a href="{% if is_mobile %}{{ insp.mobile_asset_url }}{% else %}{{ insp.asset.get_absolute_url }}{% endif %}" class="btn btn-primary w-100 fw-semibold">
<i class="bi bi-box-arrow-up-right me-2"></i>{% translate "Consulter la fiche de l'asset" %}
</a>
</div>
{% endif %}
{% if insp.related_intervention %}
<div class="col-12 {% if insp.asset %}col-sm-6{% endif %}">
<a href="{% if is_mobile %}{{ insp.mobile_related_intervention_url }}{% else %}{{ insp.related_intervention_url }}{% endif %}" class="btn btn-outline-primary w-100 fw-semibold">
<i class="bi bi-tools me-2"></i>{% translate "Intervention" %} {{ insp.related_intervention.code }}
</a>
</div>
{% endif %}
</div>
{% endif %}
<ul class="list-group list-group-flush mb-3">
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-geo-alt me-1"></i>{% translate "Cible" %}</span>
<span class="fw-bold text-end ms-2">{{ insp.target_display }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-layers me-1"></i>{% translate "Type d'asset" %}</span>
<span class="badge bg-secondary">{{ insp.asset_content_type.name }}</span>
</li>
{% if insp.related_intervention %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-tools me-1"></i>{% translate "Intervention liée" %}</span>
<span class="fw-semibold text-end ms-2">
<a href="{% if is_mobile %}{{ insp.mobile_related_intervention_url }}{% else %}{{ insp.related_intervention_url }}{% endif %}" class="text-decoration-none fw-bold">
<i class="bi bi-box-arrow-up-right me-1"></i>{{ insp.related_intervention.code }}
</a>
{% if insp.related_intervention.sub_type %}
<span class="badge bg-light text-dark border ms-1">{{ insp.related_intervention.sub_type.get_name }}</span>
{% endif %}
</span>
</li>
{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span>
<span class="fw-semibold">{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-shield-check me-1"></i>{% translate "Résultat du contrôle" %}</span>
<span class="badge {{ insp.status_badge_class }}"><i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-eye me-1"></i>{% translate "Nature" %}</span>
<span class="badge bg-light text-dark border">{{ insp.get_nature_display }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
<span class="fw-semibold">{{ insp.inspector|short_name|default:"-" }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span>
<span class="fw-semibold">{{ insp.updated_at|date:"d/m/Y H:i" }}</span>
</li>
</ul>
{% get_inspection_detail_template insp as detail_template %}
{% if detail_template %}
<div class="mt-3">
{% include detail_template with insp=insp %}
</div>
{% endif %}
{% if insp.notes %}
<div class="card bg-light border-0 p-3 mb-3">
<h6 class="fw-bold text-secondary mb-2"><i class="bi bi-journal-text me-2"></i>{% translate "Remarques" %}</h6>
<div class="text-dark small">{{ insp.notes }}</div>
</div>
{% endif %}
{% if insp.verified_obsolescence %}
<div class="card bg-warning-subtle border-0 p-3 mb-3">
<h6 class="fw-bold text-warning-emphasis mb-1"><i class="bi bi-exclamation-triangle me-2"></i>{% translate "Obsolescence" %}</h6>
<div class="text-warning-emphasis small">{{ insp.verified_obsolescence }}</div>
</div>
{% endif %}
{% if insp.documents.exists %}
<div class="card bg-light border-0 p-3 mb-3">
<h6 class="fw-bold text-secondary mb-2">
<i class="bi bi-images me-2 text-primary"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }})
</h6>
{% with docs=insp.documents.all %}
<!-- Carrousel Photos Bootstrap -->
<div id="carouselInsp{{ insp.id }}" class="carousel slide rounded overflow-hidden shadow-sm bg-black mb-3" data-bs-ride="false">
<div class="carousel-indicators">
{% for doc in docs %}
{% if doc.is_image %}
<button type="button" data-bs-target="#carouselInsp{{ insp.id }}" data-bs-slide-to="{{ forloop.counter0 }}" {% if forloop.first %}class="active" aria-current="true"{% endif %} aria-label="Slide {{ forloop.counter }}"></button>
{% endif %}
{% endfor %}
</div>
<div class="carousel-inner" style="max-height: 300px; min-height: 180px;">
{% for doc in docs %}
{% if doc.is_image %}
<div class="carousel-item {% if forloop.first %}active{% endif %} h-100">
<div class="d-flex align-items-center justify-content-center bg-black position-relative" style="height: 260px;">
<img data-src="{{ doc.file_url }}" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3C/svg%3E" loading="lazy" class="d-block img-fluid mh-100 mw-100 list-insp-carousel-trigger" style="object-fit: contain; max-height: 260px; cursor: pointer;" data-insp-id="{{ insp.id }}" data-img-index="{{ forloop.counter0 }}" alt="{{ doc.filename }}">
<button type="button" class="btn btn-sm btn-dark bg-opacity-75 text-white rounded-circle p-1.5 shadow list-insp-carousel-trigger position-absolute bottom-0 end-0 m-2" style="z-index: 10; cursor: pointer;" data-insp-id="{{ insp.id }}" data-img-index="{{ forloop.counter0 }}" title="Plein écran">
<i class="bi bi-arrows-fullscreen"></i>
</button>
</div>
</div>
{% endif %}
{% endfor %}
</div>
<button class="carousel-control-prev" type="button" data-bs-target="#carouselInsp{{ insp.id }}" data-bs-slide="prev">
<span class="carousel-control-prev-icon p-2 rounded-circle bg-dark bg-opacity-50" aria-hidden="true"></span>
<span class="visually-hidden">Précédent</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#carouselInsp{{ insp.id }}" data-bs-slide="next">
<span class="carousel-control-next-icon p-2 rounded-circle bg-dark bg-opacity-50" aria-hidden="true"></span>
<span class="visually-hidden">Suivant</span>
</button>
</div>
<!-- Documents non-images -->
<div class="d-flex flex-wrap gap-2">
{% for doc in docs %}
{% if not doc.is_image %}
<a href="{{ doc.file_url }}" target="_blank" class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center gap-1">
<i class="bi bi-file-earmark-arrow-down text-primary"></i>
<span class="text-truncate" style="max-width: 160px;">{{ doc.filename }}</span>
</a>
{% endif %}
{% endfor %}
</div>
{% endwith %}
</div>
{% endif %}
</div>
<div class="modal-footer bg-light py-2">
{% can_edit_inspection insp request.user as user_can_edit %}
{% if user_can_edit %}
{% if insp.is_cancelled %}
<button type="button" class="btn btn-outline-success btn-sm fw-semibold" onclick="handleToggleCancelInspection({{ insp.id }}, 'restore')">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Rétablir l'inspection" %}
</button>
{% else %}
<button type="button" class="btn btn-outline-danger btn-sm fw-semibold" onclick="handleToggleCancelInspection({{ insp.id }}, 'cancel')">
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler l'inspection" %}
</button>
{% endif %}
<button type="button" class="btn btn-outline-primary btn-sm fw-semibold" onclick="openEditInspectionModal({{ insp.id }})" data-bs-dismiss="modal">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier l'inspection" %}
</button>
{% endif %}
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card shadow-sm border-0 p-5 text-center text-muted">
<i class="bi bi-inbox fs-1 d-block mb-3 text-secondary"></i>
<h5>{% translate "Aucune inspection trouvée" %}</h5>
<p class="small text-muted mb-0">{% translate "Aucune inspection ne correspond à vos critères de recherche." %}</p>
</div>
{% endif %}
</div>
<!-- Overlay Carrousel Plein Écran pour Photos (avec bouton fermer) -->
<div id="fullscreenPhotoOverlay" class="d-none position-fixed top-0 start-0 w-100 h-100 bg-black" style="z-index: 999999;">
<!-- Bouton Fermer -->
<button type="button" class="btn-close btn-close-white position-absolute top-0 end-0 m-3 fs-4 shadow p-2" id="btn-close-photo-overlay" style="z-index: 1000005; cursor: pointer;" aria-label="Fermer"></button>
<!-- Conteneur Carrousel -->
<div id="fullscreenPhotoCarousel" class="carousel slide w-100 h-100" data-bs-ride="false">
<div class="carousel-indicators mb-3" id="fullscreen-carousel-indicators" style="z-index: 1000001;"></div>
<div class="carousel-inner w-100 h-100" id="fullscreen-carousel-inner"></div>
<button class="carousel-control-prev" type="button" data-bs-target="#fullscreenPhotoCarousel" data-bs-slide="prev" style="z-index: 1000000;">
<span class="carousel-control-prev-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
<span class="visually-hidden">Précédent</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#fullscreenPhotoCarousel" data-bs-slide="next" style="z-index: 1000000;">
<span class="carousel-control-next-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
<span class="visually-hidden">Suivant</span>
</button>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const listTriggers = document.querySelectorAll('.list-insp-carousel-trigger');
listTriggers.forEach(trig => {
trig.addEventListener('click', function(e) {
e.preventDefault();
const inspId = this.getAttribute('data-insp-id');
const startIndex = parseInt(this.getAttribute('data-img-index'), 10) || 0;
const carouselEl = document.getElementById('carouselInsp' + inspId);
if (!carouselEl) return;
const imgs = carouselEl.querySelectorAll('img.list-insp-carousel-trigger');
const imagesData = [];
imgs.forEach(img => {
imagesData.push({
url: img.getAttribute('src'),
filename: img.getAttribute('alt') || 'Photo'
});
});
const overlay = document.getElementById('fullscreenPhotoOverlay');
const fsInner = document.getElementById('fullscreen-carousel-inner');
const fsIndicators = document.getElementById('fullscreen-carousel-indicators');
if (!overlay || !fsInner) return;
fsInner.innerHTML = '';
if (fsIndicators) fsIndicators.innerHTML = '';
imagesData.forEach((doc, idx) => {
const activeClass = idx === startIndex ? 'active' : '';
const item = document.createElement('div');
item.className = `carousel-item ${activeClass} w-100 h-100`;
item.innerHTML = `
<div class="w-100 h-100 d-flex flex-column align-items-center justify-content-center p-3">
<img src="${doc.url}" class="img-fluid mh-100 mw-100 shadow-lg" style="object-fit: contain; max-height: 85vh;" alt="${doc.filename}">
<div class="text-white small mt-2 bg-dark bg-opacity-75 px-3 py-1 rounded-pill">${doc.filename}</div>
</div>
`;
fsInner.appendChild(item);
if (fsIndicators && imagesData.length > 1) {
const ind = document.createElement('button');
ind.type = 'button';
ind.setAttribute('data-bs-target', '#fullscreenPhotoCarousel');
ind.setAttribute('data-bs-slide-to', idx.toString());
if (idx === startIndex) {
ind.className = 'active';
ind.setAttribute('aria-current', 'true');
}
ind.setAttribute('aria-label', `Photo ${idx + 1}`);
fsIndicators.appendChild(ind);
}
});
overlay.classList.remove('d-none');
const btnClose = document.getElementById('btn-close-photo-overlay');
if (btnClose) {
const closeHandler = function(e) {
if (e) e.preventDefault();
overlay.classList.add('d-none');
btnClose.removeEventListener('click', closeHandler);
};
btnClose.addEventListener('click', closeHandler);
}
const escHandler = function(e) {
if (e.key === 'Escape') {
overlay.classList.add('d-none');
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
});
});
});
</script>
<!-- Modale Carte des Inspections filtrées -->
<div class="modal fade" id="inspectionsMapModal" tabindex="-1" aria-labelledby="inspectionsMapModalLabel" aria-hidden="true" style="z-index: 1070;">
<div class="modal-dialog modal-xl modal-dialog-centered mx-auto" style="width: 92vw; max-width: 1200px;">
<div class="modal-content shadow-lg border-0 rounded-3">
<div class="modal-header bg-dark text-white py-2.5">
<h5 class="modal-title fs-6 fw-bold d-flex align-items-center gap-2" id="inspectionsMapModalLabel">
<i class="bi bi-map text-info fs-5"></i>
{% translate "Carte des résultats d'inspections" %}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0 position-relative">
<div id="inspections-list-modal-map" style="width: 100%; height: 75vh; border-radius: 0 0 0.5rem 0.5rem;"></div>
</div>
</div>
</div>
</div>
<script src="{% static 'common/maplibre-gl.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const modalEl = document.getElementById('inspectionsMapModal');
if (!modalEl) return;
let mapModalInstance = null;
const rawInspectionsData = [
{% for insp in inspections %}
{% if insp.lon_lat %}
{
id: {{ insp.id }},
lon: {{ insp.lon_lat.0|unlocalize }},
lat: {{ insp.lon_lat.1|unlocalize }},
title: "{{ insp.target_display|escapejs }}",
date: "{{ insp.inspection_date|date:'d/m/Y H:i' }}",
date_raw: "{{ insp.inspection_date|date:'Y-m-d H:i:s' }}",
result: "{{ insp.get_result_status_display|escapejs }}",
result_code: "{{ insp.result_status|escapejs }}",
badge_class: "{{ insp.status_badge_class|escapejs }}",
inspector: "{{ insp.inspector|short_name|escapejs }}",
nature: "{{ insp.get_nature_display|escapejs }}",
summary: "{{ insp.details_summary|escapejs }}"
}{% if not forloop.last %},{% endif %}
{% endif %}
{% endfor %}
];
// Regroupement des inspections par emplacement (lon, lat)
const groupedMap = {};
rawInspectionsData.forEach(item => {
const key = item.lon.toFixed(6) + '_' + item.lat.toFixed(6);
if (!groupedMap[key]) {
groupedMap[key] = {
lon: item.lon,
lat: item.lat,
title: item.title,
inspections: []
};
}
groupedMap[key].inspections.push(item);
});
const groupedList = Object.values(groupedMap);
groupedList.forEach(grp => {
grp.inspections.sort((a, b) => b.date_raw.localeCompare(a.date_raw));
grp.latest_result_code = grp.inspections[0].result_code;
grp.inspections_count = grp.inspections.length;
grp.inspections_json = JSON.stringify(grp.inspections);
});
modalEl.addEventListener('shown.bs.modal', function() {
if (!mapModalInstance) {
mapModalInstance = new maplibregl.Map({
container: 'inspections-list-modal-map',
style: window.MAP_STYLE_LIGHT || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
center: [4.35, 50.85],
zoom: 12
});
mapModalInstance.addControl(new maplibregl.NavigationControl(), 'bottom-right');
mapModalInstance.on('load', function() {
const geojson = {
type: 'FeatureCollection',
features: groupedList.map(m => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [m.lon, m.lat]
},
properties: {
title: m.title,
latest_result_code: m.latest_result_code,
inspections_count: m.inspections_count,
inspections_json: m.inspections_json
}
}))
};
mapModalInstance.addSource('inspections-list-modal-source', {
type: 'geojson',
data: geojson
});
mapModalInstance.addLayer({
id: 'inspections-list-modal-layer',
type: 'circle',
source: 'inspections-list-modal-source',
paint: {
'circle-color': [
'match',
['get', 'latest_result_code'],
'compliant', '#198754',
'non_compliant', '#ffc107',
'damaged', '#dc3545',
'missing', '#212529',
'#0d6efd'
],
'circle-radius': 8,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
});
mapModalInstance.on('mouseenter', 'inspections-list-modal-layer', function() {
mapModalInstance.getCanvas().style.cursor = 'pointer';
});
mapModalInstance.on('mouseleave', 'inspections-list-modal-layer', function() {
mapModalInstance.getCanvas().style.cursor = '';
});
mapModalInstance.on('click', 'inspections-list-modal-layer', function(e) {
if (!e.features || !e.features.length) return;
const clickPoint = e.point;
const rendered = mapModalInstance.queryRenderedFeatures(clickPoint, { layers: ['inspections-list-modal-layer'] });
let combinedInspections = [];
let popupTitle = '';
rendered.forEach(f => {
try {
const insps = JSON.parse(f.properties.inspections_json || '[]');
if (!popupTitle && f.properties.title) popupTitle = f.properties.title;
combinedInspections = combinedInspections.concat(insps);
} catch (err) {}
});
// Déduplication & tri par date décroissante
const dedup = {};
combinedInspections.forEach(item => { dedup[item.id] = item; });
const sortedInspections = Object.values(dedup).sort((a, b) => b.date_raw.localeCompare(a.date_raw));
if (!sortedInspections.length) return;
const coords = e.features[0].geometry.coordinates.slice();
let contentHtml = '';
if (sortedInspections.length === 1) {
const insp = sortedInspections[0];
contentHtml = `
<div class="p-1" style="min-width: 240px;">
<div class="d-flex justify-content-between align-items-center mb-1 gap-2">
<span class="badge ${insp.badge_class}">${insp.result}</span>
<small class="text-muted">${insp.date}</small>
</div>
<h6 class="fw-bold mb-1 text-dark">${insp.title}</h6>
<div class="small text-muted mb-1"><i class="bi bi-person me-1"></i>${insp.inspector}</div>
<p class="small text-secondary mb-2">${insp.summary}</p>
<button type="button" class="btn btn-xs btn-outline-primary w-100 py-1 open-detail-from-modal" data-insp-id="${insp.id}">
<i class="bi bi-eye me-1"></i>Détail
</button>
</div>
`;
} else {
let rowsHtml = '';
sortedInspections.forEach(insp => {
rowsHtml += `
<div class="p-2 border-bottom d-flex align-items-center justify-content-between gap-2">
<div class="min-w-0 flex-grow-1">
<div class="d-flex flex-wrap align-items-center gap-1 mb-1">
<span class="badge ${insp.badge_class} text-truncate flex-shrink-0" style="font-size: 0.7rem; max-width: 140px;">${insp.result}</span>
<span class="fw-semibold text-primary text-nowrap insp-date-modal-link open-detail-from-modal" style="cursor: pointer; font-size: 0.78rem;" data-insp-id="${insp.id}">
📅 ${insp.date}
</span>
</div>
<div class="small text-muted text-truncate" style="max-width: 220px;"><i class="bi bi-person me-1"></i>${insp.inspector}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary px-2 py-1 flex-shrink-0 open-detail-from-modal ms-1" data-insp-id="${insp.id}" style="font-size: 0.75rem; border-radius: 0.375rem;" title="Voir l'inspection">
<i class="bi bi-eye me-1"></i>Détail
</button>
</div>
`;
});
contentHtml = `
<div class="p-1" style="min-width: 320px; max-width: 360px;">
<div class="d-flex justify-content-between align-items-center mb-2 pb-1 border-bottom">
<h6 class="fw-bold mb-0 text-dark text-truncate" style="max-width: 230px;">${popupTitle || 'Équipement'}</h6>
<span class="badge bg-secondary rounded-pill small">${sortedInspections.length} inspections</span>
</div>
<div class="overflow-auto" style="max-height: 240px;">
${rowsHtml}
</div>
</div>
`;
}
new maplibregl.Popup({ closeButton: true, maxWidth: '380px' })
.setLngLat(coords)
.setHTML(contentHtml)
.addTo(mapModalInstance);
});
if (groupedList.length > 0) {
const bounds = new maplibregl.LngLatBounds();
groupedList.forEach(m => bounds.extend([m.lon, m.lat]));
mapModalInstance.fitBounds(bounds, { padding: 50, maxZoom: 17 });
}
});
} else {
mapModalInstance.resize();
if (groupedList.length > 0) {
const bounds = new maplibregl.LngLatBounds();
groupedList.forEach(m => bounds.extend([m.lon, m.lat]));
mapModalInstance.fitBounds(bounds, { padding: 50, maxZoom: 17 });
}
}
});
document.addEventListener('click', function(e) {
const btn = e.target.closest('.open-detail-from-modal');
if (btn) {
const inspId = btn.getAttribute('data-insp-id');
const bsMapModal = bootstrap.Modal.getInstance(modalEl);
if (bsMapModal) bsMapModal.hide();
const detailModalEl = document.getElementById('inspectionDetailModal' + inspId);
if (detailModalEl) {
detailModalEl.setAttribute('data-opened-from-map', 'true');
setTimeout(() => {
const bsDetailModal = bootstrap.Modal.getInstance(detailModalEl) || new bootstrap.Modal(detailModalEl);
bsDetailModal.show();
}, 200);
}
}
});
document.addEventListener('show.bs.modal', function(e) {
const modal = e.target;
if (modal) {
modal.querySelectorAll('img[data-src]').forEach(function(img) {
const dataSrc = img.getAttribute('data-src');
if (dataSrc && (!img.src || img.src.indexOf('data:image') !== -1)) {
img.src = dataSrc;
}
});
}
});
document.addEventListener('hidden.bs.modal', function(e) {
const modalTarget = e.target;
if (modalTarget && modalTarget.getAttribute('data-opened-from-map') === 'true') {
modalTarget.removeAttribute('data-opened-from-map');
setTimeout(() => {
const bsMapModal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
bsMapModal.show();
}, 150);
}
});
window.handleToggleCancelInspection = function(inspectionId, action) {
const confirmMsg = action === 'cancel'
? "{% translate 'Êtes-vous sûr de vouloir annuler cette inspection ?' %}"
: "{% translate 'Êtes-vous sûr de vouloir rétablir cette inspection ?' %}";
if (!confirm(confirmMsg)) return;
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, 10) === 'csrftoken=') {
cookieValue = decodeURIComponent(cookie.substring(10));
break;
}
}
}
const formData = new FormData();
formData.append('action', action);
fetch(`/inspections/api/${inspectionId}/toggle-cancel/`, {
method: 'POST',
headers: {
'X-CSRFToken': cookieValue
},
body: formData
})
.then(res => res.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert(data.error || "{% translate 'Une erreur est survenue.' %}");
}
})
.catch(err => {
console.error(err);
alert("{% translate 'Erreur de communication avec le serveur.' %}");
});
};
});
</script>
{% include "inspections/components/edit_inspection_modal.html" %}
{% endblock %}