loko/loko/assets/templates/assets/management_zones/management_zones_detail.html
kdeterme b5b0338e09 feat(assets): add management zones CRUD interfaces and permissions
- Add can_edit_management_zones permission to UserThematics model and admin
- Implement ManagementZoneForm with interactive WGS84 polygon capture and SRID 3812 reprojection
- Add views and routes for listing, creating, editing, detailing, and deleting management zones
- Support document attachments (ManagementZoneDocument) and explicit asset linkages
- Add MapLibre polygon drawing tool in zone form and preview map in detail view
- Integrate management zones shortcuts in nature assets map overlay and index
- Add comprehensive unit tests for permissions, forms, and CRUD operations
2026-10-05 15:35:33 +02:00

435 lines
22 KiB
HTML

{% extends "base.html" %}
{% load static i18n %}
{% block title %}{{ zone.code }} - {{ zone.get_name }} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb & Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="/">{% translate "Accueil" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}">{% translate "Assets" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:management_zones_index' %}?thematic={{ zone.thematic.code }}">{% translate "Zones de gestion" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ zone.code }}</li>
</ol>
</nav>
<div class="d-flex gap-2">
<a href="{% url 'assets:management_zones_index' %}?thematic={{ zone.thematic.code }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la liste" %}
</a>
{% if can_edit %}
<a href="{% url 'assets:management_zone_edit' zone.pk %}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier" %}
</a>
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteZoneModal">
<i class="bi bi-trash me-1"></i>{% translate "Supprimer" %}
</button>
{% endif %}
</div>
</div>
<!-- Zone Banner Card -->
<div class="card shadow-sm mb-4 border-start border-4" style="border-left-color: {{ zone.get_color }} !important;">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-8">
<div class="d-flex align-items-center gap-2 mb-2">
<span style="display:inline-block; width: 16px; height: 16px; border-radius: 4px; background-color: {{ zone.get_color }}; border: 1px solid rgba(0,0,0,0.25);"></span>
<h1 class="h4 mb-0 fw-bold">{{ zone.code }} - {{ zone.get_name }}</h1>
</div>
{% if zone.name_nl and zone.name_nl != zone.name_fr %}
<div class="text-muted small mb-2 fst-italic">{{ zone.name_nl }}</div>
{% endif %}
<div class="d-flex gap-2 flex-wrap">
<span class="badge bg-secondary-subtle text-secondary border">
<i class="bi bi-tag me-1"></i>{{ zone.thematic.name }}
</span>
{% if zone.zone_type %}
<span class="badge" style="background-color: {{ zone.zone_type.default_color }}; color: #fff;">
{{ zone.zone_type.get_name }}
</span>
{% endif %}
{% if zone.is_active %}
<span class="badge bg-success-subtle text-success border">
<i class="bi bi-check-circle me-1"></i>{% translate "Active" %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border">
<i class="bi bi-dash-circle me-1"></i>{% translate "Inactive" %}
</span>
{% endif %}
{% if area_ha %}
<span class="badge bg-light text-dark border">
<i class="bi bi-rulers me-1"></i>{{ area_ha }} ha ({{ area_m2|floatformat:0 }} m²)
</span>
{% endif %}
</div>
</div>
<div class="col-md-4 text-md-end mt-3 mt-md-0">
{% if zone.target_frequency_days %}
<div class="d-inline-block text-center me-3 p-2 bg-light rounded border">
<span class="d-block text-muted small">{% translate "Fréquence cible" %}</span>
<span class="fs-5 fw-bold text-dark">{{ zone.target_frequency_days }} {% translate "j" %}</span>
</div>
{% endif %}
{% if zone.seasonality_notes %}
<div class="d-inline-block text-start p-2 bg-light rounded border">
<span class="d-block text-muted small">{% translate "Période" %}</span>
<span class="small fw-semibold text-dark">{{ zone.seasonality_notes }}</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Consignes et Points d'attention (Callout Banner) -->
{% if zone.instructions %}
<div class="card shadow-sm mb-4 border-warning border-start border-4">
<div class="card-body bg-warning bg-opacity-10">
<div class="d-flex align-items-start gap-2">
<i class="bi bi-exclamation-triangle-fill text-warning fs-4 mt-1"></i>
<div>
<h5 class="h6 fw-bold text-dark mb-1">{% translate "Consignes d'exploitation et points de vigilance" %}</h5>
<div class="text-dark" style="white-space: pre-line;">{{ zone.instructions }}</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Main Grid: Informations & Carte -->
<div class="row g-4 mb-4">
<!-- Informations & Modalités -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-bold">
<i class="bi bi-info-circle me-2 text-success"></i>{% translate "Détails & Caractéristiques" %}
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 text-muted">{% translate "Description" %}</dt>
<dd class="col-sm-8">{{ zone.description|default:_("Aucune description renseignée")|linebreaks }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Thématique" %}</dt>
<dd class="col-sm-8">{{ zone.thematic.name }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Type de zone" %}</dt>
<dd class="col-sm-8">{{ zone.zone_type.get_name|default:"-" }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Couleur de repérage" %}</dt>
<dd class="col-sm-8">
<div class="d-flex align-items-center gap-2">
<span style="display:inline-block; width:18px; height:18px; border-radius:3px; background-color: {{ zone.get_color }}; border: 1px solid rgba(0,0,0,0.25);"></span>
<code>{{ zone.get_color }}</code>
</div>
</dd>
<dt class="col-sm-4 text-muted">{% translate "Superficie estimée" %}</dt>
<dd class="col-sm-8">
{% if area_m2 %}
<strong>{{ area_ha }} hectares</strong> ({{ area_m2|floatformat:0 }} m²)
{% else %}
<span class="text-muted">{% translate "Périmètre non défini" %}</span>
{% endif %}
</dd>
<dt class="col-sm-4 text-muted">{% translate "Créé par" %}</dt>
<dd class="col-sm-8">{% if zone.created_by %}{{ zone.created_by.get_full_name|default:zone.created_by.username }}{% else %}-{% endif %}</dd>
<dt class="col-sm-4 text-muted">{% translate "Date de création" %}</dt>
<dd class="col-sm-8">{{ zone.created_at|date:"d/m/Y H:i" }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Dernière mise à jour" %}</dt>
<dd class="col-sm-8">{{ zone.updated_at|date:"d/m/Y H:i" }}</dd>
</dl>
</div>
</div>
</div>
<!-- Carte du périmètre géographique -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-bold">
<i class="bi bi-map me-2 text-success"></i>{% translate "Périmètre géographique" %}
</span>
{% if zone.geom %}
<span class="badge bg-success">{% translate "Géométrie définie" %}</span>
{% else %}
<span class="badge bg-secondary">{% translate "Aucun périmètre" %}</span>
{% endif %}
</div>
<div class="card-body p-0 position-relative">
<div id="detailZoneMap" style="height: 380px; width: 100%;"></div>
</div>
</div>
</div>
</div>
<!-- Documents & Arrêtés -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-bold">
<i class="bi bi-files me-2 text-success"></i>{% translate "Documents & Fiches techniques" %} ({{ documents.count }})
</span>
{% if can_edit %}
<button type="button" class="btn btn-sm btn-outline-success" data-bs-toggle="modal" data-bs-target="#uploadDocModal">
<i class="bi bi-upload me-1"></i>{% translate "Ajouter des documents" %}
</button>
{% endif %}
</div>
<div class="card-body p-0">
{% if documents %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase text-muted">
<tr>
<th>{% translate "Fichier" %}</th>
<th>{% translate "Date d'ajout" %}</th>
<th>{% translate "Ajouté par" %}</th>
<th class="text-end" style="width: 140px;">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<i class="bi bi-file-earmark-text text-primary fs-5"></i>
<div>
<a href="{{ doc.file.url }}" target="_blank" class="fw-semibold text-decoration-none">
{{ doc.file.name|slice:"25:"|default:doc.file.name }}
</a>
</div>
</div>
</td>
<td>{{ doc.upload_date|date:"d/m/Y H:i" }}</td>
<td>{% if doc.uploaded_by %}{{ doc.uploaded_by.get_full_name|default:doc.uploaded_by.username }}{% else %}-{% endif %}</td>
<td class="text-end">
<a href="{{ doc.file.url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="{% translate 'Télécharger' %}">
<i class="bi bi-download"></i>
</a>
{% if can_edit %}
<form method="post" action="{% url 'assets:management_zone_delete_document' zone.pk doc.pk %}" class="d-inline" onsubmit="return confirm('{% translate 'Supprimer ce document ?' %}');">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted small">
<i class="bi bi-folder2-open fs-2 d-block mb-2 text-muted"></i>
{% translate "Aucun document n'a été rattaché à cette zone de gestion." %}
</div>
{% endif %}
</div>
</div>
<!-- Équipements rattachés (Assets explicites) -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<span class="fw-bold">
<i class="bi bi-layers me-2 text-success"></i>{% translate "Équipements rattachés explicitement" %} ({{ explicit_assets.count }})
</span>
</div>
<div class="card-body p-0">
{% if explicit_assets %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase text-muted">
<tr>
<th>{% translate "Équipement" %}</th>
<th>{% translate "Type" %}</th>
<th>{% translate "Statut de rattachement" %}</th>
<th>{% translate "Notes" %}</th>
</tr>
</thead>
<tbody>
{% for ea in explicit_assets %}
<tr>
<td>
<strong>{{ ea.asset|default:ea.object_id }}</strong>
</td>
<td>{{ ea.content_type.name }}</td>
<td>
{% if ea.is_excluded %}
<span class="badge bg-danger-subtle text-danger border">
<i class="bi bi-slash-circle me-1"></i>{% translate "Exclu de la zone" %}
</span>
{% else %}
<span class="badge bg-success-subtle text-success border">
<i class="bi bi-check-circle me-1"></i>{% translate "Inclus dans la zone" %}
</span>
{% endif %}
</td>
<td class="text-muted small">{{ ea.notes|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted small">
<p class="mb-0">{% translate "Tous les équipements situés dans le périmètre géographique de la zone y sont automatiquement associés par intersection spatiale." %}</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Modal: Upload Documents -->
{% if can_edit and doc_form %}
<div class="modal fade" id="uploadDocModal" tabindex="-1" aria-labelledby="uploadDocModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'assets:management_zone_add_document' zone.pk %}" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="uploadDocModalLabel">
<i class="bi bi-upload me-2 text-success"></i>{% translate "Ajouter des documents" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="{{ doc_form.documents.id_for_label }}" class="form-label fw-semibold">
{% translate "Sélectionner des fichiers" %}
</label>
{{ doc_form.documents }}
<div class="form-text small">
{% translate "Formats acceptés : PDF, Word, Excel, images, etc. Vous pouvez sélectionner plusieurs fichiers simultanément." %}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-success">
<i class="bi bi-check-lg me-1"></i>{% translate "Téléverser" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Modal: Delete Zone Confirmation -->
{% if can_edit %}
<div class="modal fade" id="deleteZoneModal" tabindex="-1" aria-labelledby="deleteZoneModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'assets:management_zone_delete' zone.pk %}">
{% csrf_token %}
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteZoneModalLabel">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% translate "Confirmer la suppression" %}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{% translate "Êtes-vous certain de vouloir supprimer la zone de gestion suivante ?" %}</p>
<div class="alert alert-light border">
<strong>{{ zone.code }}</strong> - {{ zone.get_name }}<br>
<small class="text-muted">{{ zone.thematic.name }} | {{ zone.zone_type.get_name|default:"-" }}</small>
</div>
<p class="text-danger small mb-0">
<i class="bi bi-info-circle me-1"></i>{% translate "Cette action est irréversible. Les documents et historiques liés seront supprimés." %}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>{% translate "Supprimer définitivement" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock content %}
{% block script %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const mapDiv = document.getElementById("detailZoneMap");
if (!mapDiv || typeof maplibregl === "undefined") return;
const zoneColor = "{{ zone.get_color }}";
const geojsonGeometry = {% if zone.geojson_geometry %}{{ zone.geojson_geometry|safe }}{% else %}null{% endif %};
const map = new maplibregl.Map({
container: mapDiv,
style: window.MAP_STYLE_LIGHT,
center: window.MAP_DEFAULT_CENTER || [4.3572, 50.8477],
zoom: 12,
cooperativeGestures: true
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
map.on('load', function () {
if (!geojsonGeometry) {
return;
}
const feature = {
type: "Feature",
geometry: geojsonGeometry
};
map.addSource("detail-zone-source", {
type: "geojson",
data: feature
});
map.addLayer({
id: "detail-zone-fill",
type: "fill",
source: "detail-zone-source",
paint: {
"fill-color": zoneColor,
"fill-opacity": 0.30
}
});
map.addLayer({
id: "detail-zone-line",
type: "line",
source: "detail-zone-source",
paint: {
"line-color": zoneColor,
"line-width": 3,
"line-dasharray": [3, 1.5]
}
});
// Fit bounds to polygon
const bounds = new maplibregl.LngLatBounds();
const coords = geojsonGeometry.coordinates;
const addCoords = (arr) => {
if (typeof arr[0] === 'number') {
bounds.extend(arr);
} else {
arr.forEach(addCoords);
}
};
addCoords(coords);
if (!bounds.isEmpty()) {
map.fitBounds(bounds, { padding: 45, maxZoom: 16 });
}
});
});
</script>
{% endblock script %}