loko/streetup/observations/templates/observations/observation_form.html

3369 lines
167 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% load static %}
{% load i18n %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% if is_edit_mode %}{% translate "Modifier l'observation" %}{% else %}{% translate "Nouvelle observation" %}{% endif %}</title>
<!-- Bootstrap 5.3.3 (local) -->
<link href="{% static 'common/bootstrap.min.css' %}" rel="stylesheet">
<!-- Bootstrap Icons 1.11.3 (local) -->
<link href="{% static 'common/bootstrap-icons.min.css' %}" rel="stylesheet">
<!-- MapLibre GL 5.7.0 (local) -->
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet" />
<!-- jQuery UI 1.14.1 (local) -->
<link rel="stylesheet" href="{% static 'common/jquery-ui.min.css' %}">
<style>
#map { height: 200px; margin-bottom: 15px; }
#loading-overlay {
position: fixed;
inset: 0;
z-index: 1050;
}
.overlay-background {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.85);
}
.spinner-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.preview-image {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #dee2e6;
}
.preview-wrapper {
position: relative;
display: inline-block;
margin-right: 10px;
}
.remove-btn {
position: absolute;
top: -6px;
right: -6px;
background: rgba(255, 0, 0, 0.7);
border: none;
color: white;
border-radius: 50%;
width: 22px;
height: 22px;
font-size: 14px;
line-height: 18px;
text-align: center;
cursor: pointer;
}
.hr-bluegray {
border: 0;
height: 1px;
background-color: #c6d6e6; /* bleu gris Bootstrap (text-secondary) */
opacity: 1;
}
/* Style pour les options avec icônes */
#id_thematic {
background-position: 10px center;
background-repeat: no-repeat;
background-size: 20px;
padding-left: 40px;
}
/* Styles pour les icônes dans les options (via pseudo-éléments) */
.select-with-icons option {
padding-left: 30px;
}
/* Style pour les boutons de raccourcis */
.shortcut-btn:hover {
transform: scale(1.05);
transition: transform 0.2s ease;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.shortcut-btn:active {
transform: scale(0.98);
}
/* Styles pour les cartes de thématiques */
.thematic-card {
cursor: pointer;
transition: all 0.2s ease;
}
.thematic-card .card {
border: 2px solid #dee2e6;
transition: all 0.2s ease;
background: white;
}
.thematic-card:hover .card {
border-color: #0d6efd;
box-shadow: 0 2px 8px rgba(13, 110, 253, 0.2);
transform: translateY(-2px);
}
.thematic-card.selected .card {
border-color: #0d6efd;
background: linear-gradient(135deg, #e7f1ff 0%, #cfe2ff 100%);
box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.25);
}
.thematic-card .card-body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 90px;
padding: 0.75rem 0.5rem !important;
}
.thematic-card i {
color: #6c757d;
transition: all 0.2s ease;
}
.thematic-card:hover i {
color: #0d6efd;
transform: scale(1.1);
}
.thematic-card.selected i {
color: #0d6efd;
transform: scale(1.15);
}
.thematic-card .small {
font-size: 0.8rem;
line-height: 1.2;
text-align: center;
margin-top: 0.25rem;
}
.thematic-card.selected .small {
font-weight: 600;
color: #0d6efd;
}
.thematic-card:focus {
outline: none;
}
.thematic-card:focus .card {
/* Checklist badge styles */
.checklist-badge, .checklist-badge-choice {
transition: all 0.2s ease;
user-select: none;
}
.checklist-badge:hover, .checklist-badge-choice:hover {
transform: scale(1.05);
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
}
.checklist-badge:active, .checklist-badge-choice:active {
transform: scale(0.98);
}
box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.5);
}
/* Responsive adjustments */
@media (max-width: 576px) {
.thematic-card .card-body {
min-height: 75px;
padding: 0.5rem 0.25rem !important;
}
.thematic-card i {
font-size: 1.5rem !important;
}
.thematic-card .small {
font-size: 0.7rem;
}
}
/* Blocs du formulaire */
.form-block {
border: 1px solid #c6d6e6;
border-radius: 8px;
margin-bottom: 1rem;
background: #fff;
overflow: hidden;
}
.form-block-header {
background: linear-gradient(135deg, #e7f1ff 0%, #d1e8ff 100%);
border-bottom: 1px solid #c6d6e6;
padding: 0.45rem 1rem;
font-weight: 600;
color: #1a4a80;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
/* Photos — teal */
.form-block.block-photos { border-color: #4db6ac; }
.form-block.block-photos .form-block-header {
background: linear-gradient(135deg, #e0f2f1 0%, #b2dfdb 100%);
border-bottom-color: #4db6ac;
color: #004d40;
}
/* Catégorie — bleu */
.form-block.block-category { border-color: #90b8e0; }
.form-block.block-category .form-block-header {
background: linear-gradient(135deg, #e7f1ff 0%, #bbdefb 100%);
border-bottom-color: #90b8e0;
color: #1a4a80;
}
/* Position — indigo */
.form-block.block-position { border-color: #7986cb; }
.form-block.block-position .form-block-header {
background: linear-gradient(135deg, #e8eaf6 0%, #c5cae9 100%);
border-bottom-color: #7986cb;
color: #1a237e;
}
/* Description — ardoise */
.form-block.block-description { border-color: #90a4ae; }
.form-block.block-description .form-block-header {
background: linear-gradient(135deg, #eceff1 0%, #cfd8dc 100%);
border-bottom-color: #90a4ae;
color: #263238;
}
/* Boutons photos — teal */
.btn-photos {
color: #004d40;
border-color: #4db6ac;
background-color: transparent;
}
.btn-photos:hover, .btn-photos:focus {
color: #fff;
background-color: #00897b;
border-color: #00897b;
}
.form-block-body {
padding: 0.75rem 1rem;
}
/* Badges type d'observation */
#obs-type-badges {
display: flex;
gap: 6px;
}
.obs-type-badge {
flex: 1 1 0;
min-width: 0;
transition: all 0.18s ease;
font-size: 0.78rem;
padding: 0.35rem 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.obs-type-badge:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0,0,0,0.12);
}
.obs-type-badge:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* Boutons zoom carte */
.map-zoom-controls {
position: absolute;
top: 10px;
left: 10px;
display: flex;
flex-direction: column;
gap: 4px;
z-index: 10;
}
.map-zoom-btn {
width: 32px;
height: 32px;
padding: 0;
font-size: 1.1rem;
font-weight: bold;
line-height: 1;
border: 1px solid #ced4da !important;
background: rgba(255,255,255,0.92) !important;
}
.map-zoom-btn:hover {
background: #e9f1ff !important;
border-color: #0d6efd !important;
}
/* Autocomplete symptôme */
.ui-autocomplete { z-index: 2000 !important; }
/* ========================================
Popups MapLibre – mobile-first
======================================== */
.maplibregl-popup-content {
border-radius: 14px !important;
padding: 0 !important;
box-shadow: 0 8px 28px rgba(0,0,0,0.22) !important;
font-family: inherit !important;
min-width: 230px;
max-width: 290px;
overflow: hidden;
}
.maplibregl-popup-close-button {
font-size: 20px;
line-height: 1;
width: 34px;
height: 34px;
padding: 0;
top: 4px;
right: 4px;
color: rgba(255,255,255,0.9) !important;
background: rgba(0,0,0,0.18);
border-radius: 50%;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.maplibregl-popup-close-button:hover {
background: rgba(0,0,0,0.32);
color: #fff !important;
}
/* Observation popup */
.obs-popup-header {
padding: 12px 42px 12px 14px;
color: #fff;
font-weight: 700;
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.obs-popup-body {
padding: 12px 14px 14px;
}
.obs-popup-type {
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 6px;
line-height: 1.3;
}
.obs-popup-desc {
font-size: 13px;
color: #555;
margin-bottom: 10px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-clamp: 2;
}
.obs-popup-btn {
display: block;
text-align: center;
background: #0d6efd;
color: #fff !important;
border-radius: 9px;
padding: 11px;
font-size: 14px;
font-weight: 600;
text-decoration: none !important;
margin-top: 4px;
transition: background 0.15s;
}
.obs-popup-btn:hover, .obs-popup-btn:focus {
background: #0b5ed7;
}
/* Taxi stand popup */
.taxi-popup-header {
background: #1a4a80;
color: #fff;
padding: 12px 42px 12px 14px;
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.taxi-popup-body {
padding: 12px 14px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.taxi-popup-field {
display: flex;
flex-direction: column;
}
.taxi-popup-label {
font-size: 11px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1px;
}
.taxi-popup-value {
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
}
</style>
</head>
<body class="bg-light">
<div id="loading-overlay" style="display: none;">
<div class="overlay-background"></div>
<div class="spinner-container text-center" style="min-width: 300px; max-width: 420px; width: 90vw;">
<div class="bg-white rounded-3 shadow p-4">
<!-- Barre de progression (visible pendant le transfert des fichiers) -->
<div id="upload-progress-section" style="display: none;">
<p class="mb-2 fw-bold text-primary" id="upload-progress-text">{% translate "Envoi des fichiers…" %}</p>
<div class="progress mb-2" style="height: 24px; border-radius: 8px;">
<div id="upload-progress-bar"
class="progress-bar progress-bar-striped progress-bar-animated bg-primary"
role="progressbar" style="width: 0%;"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0 %</div>
</div>
<p class="small text-muted mb-0" id="upload-size-text">0,00 Mo / 0,00 Mo</p>
</div>
<!-- Spinner (visible après le transfert ou s'il n'y a pas de fichiers) -->
<div id="processing-section" style="display: none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{% translate "Chargement…" %}</span>
</div>
<p class="mt-2 mb-0 text-muted small">{% translate "Traitement en cours…" %}</p>
</div>
</div>
</div>
</div>
<div class="container py-4">
<div class="card shadow-sm">
<div class="card-header text-primary">
<h4 class="mb-0 text-center">{% if is_edit_mode %}{% translate "Modifier l'observation" %}{% else %}{% translate "Ajouter une observation" %}{% endif %}</h4>
</div>
<div class="card-body">
<form id="observation-form" method="POST" enctype="multipart/form-data" class="needs-validation" novalidate>
{% csrf_token %}
{% if messages %}
{% for message in messages %}
<div class="alert alert-{% if 'error' in message.tags %}danger{% elif 'warning' in message.tags %}warning{% elif 'success' in message.tags %}success{% else %}info{% endif %} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% translate 'Fermer' %}"></button>
</div>
{% endfor %}
{% endif %}
{% if obs_form.non_field_errors %}
<div class="alert alert-danger">
{{ obs_form.non_field_errors }}
</div>
{% endif %}
<!-- Section photos -->
<div class="form-block block-photos mb-4 pb-4">
<div class="form-block-header"><i class="bi bi-camera-fill"></i> {% translate "Photos" %} <span class="text-danger ms-1">*</span></div>
<div class="form-block-body">
<div class="mb-3">
<label class="form-label fw-bold visually-hidden">{% translate "Photos" %}</label>
<div class="d-grid gap-2">
<button type="button" id="open-camera-modal-btn" class="btn btn-photos">
<i class="bi bi-camera-fill me-2"></i>{% translate "Prendre des photos (mode continu)" %}
</button>
<button type="button" id="select-files-btn" class="btn btn-photos">
<i class="bi bi-folder2-open me-2"></i>{% translate "Photos, vidéos, fichiers" %}
</button>
</div>
<input type="file" id="mobile-files-input" name="photos" multiple class="d-none">
<div id="photo-inputs" class="d-none"></div>
</div>
<div id="preview-container" class="row g-2"></div>
</div><!-- /form-block-body -->
</div><!-- /form-block photos -->
<!-- Section thématique -->
<div class="form-block block-category">
<div class="form-block-header"><i class="bi bi-tag-fill"></i> {% translate "Catégorie" %}</div>
<div class="form-block-body">
<!-- Section raccourcis -->
<div class="mb-3">
{% if is_observer or is_editor %}
<div class="mb-4">
<label class="form-label">{% translate "Raccourcis" %}</label>
<div class="d-flex gap-3 flex-wrap">
{% if is_observer %}
{% for shortcut in observer_shortcuts %}
<button type="button"
class="btn btn-outline-primary btn-lg d-flex align-items-center shortcut-btn"
id="{{ shortcut.id }}"
title="{{ shortcut.title }}">
{% if shortcut.icon_type == 'file' %}
<img src="{% static shortcut.icon %}" alt="{{ shortcut.title }}" style="width: 40px; height: 40px;">
{% else %}
<i class="bi {{ shortcut.icon }}" style="font-size: 40px; color: black;"></i>
{% endif %}
</button>
{% endfor %}
{% endif %}
{% if is_editor %}
{% for shortcut in editor_shortcuts %}
<button type="button"
class="btn btn-outline-primary btn-lg d-flex align-items-center shortcut-btn"
id="{{ shortcut.id }}"
title="{{ shortcut.title }}">
{% if shortcut.icon_type == 'file' %}
<img src="{% static shortcut.icon %}" alt="{{ shortcut.title }}" style="width: 36px; height: 36px;">
{% else %}
<i class="bi {{ shortcut.icon }}" style="font-size: 36px; color: black;"></i>
{% endif %}
</button>
{% endfor %}
{% endif %}
</div>
</div>
<hr class="hr-bluegray">
{% endif %}
<!-- Recherche directe par symptôme -->
<div class="mb-4">
<label for="obs-symptom-search" class="form-label">{% translate "Rechercher directement un symptôme" %}</label>
<div class="position-relative">
<span class="position-absolute top-50 translate-middle-y ms-2" style="left:0; pointer-events:none; color:#6c757d;"><i class="bi bi-search"></i></span>
<input type="text" id="obs-symptom-search" class="form-control"
style="padding-left:2rem; padding-right:2rem;"
placeholder="{% translate 'Saisir le nom du symptôme...' %}" autocomplete="off">
<button type="button" id="obs-symptom-clear"
title="{% translate 'Effacer' %}"
style="position:absolute; top:50%; right:0.4rem; transform:translateY(-50%); width:1.4rem; height:1.4rem; padding:0; border-radius:50%; border:1px solid #6c757d; background:#ffffff; color:#6c757d; display:flex; align-items:center; justify-content:center; font-size:0.65rem; line-height:1; cursor:pointer;">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="form-text"><i class="bi bi-info-circle"></i> {% translate "La thématique, la catégorie et le type d’observation seront sélectionnés automatiquement." %}</div>
</div>
<hr class="hr-bluegray">
<label class="form-label fw-bold">{% translate "Thématique" %} <span class="text-danger">*</span></label>
<!-- Hidden input for form submission -->
<input type="hidden" name="thematic" id="id_thematic" required>
{% if obs_form.thematic.errors %}
<div class="alert alert-danger">
{{ obs_form.thematic.errors }}
</div>
{% endif %}
<!-- Visual thematic selector -->
<div id="thematic-cards" class="row g-2">
{% for thematic in thematics_data %}
<div class="col-6 col-md-4 col-lg-3">
<div class="thematic-card"
data-thematic-id="{{ thematic.id }}"
data-thematic-code="{{ thematic.code }}"
role="button"
tabindex="0">
<div class="card h-100 text-center">
<div class="card-body p-2">
{% if thematic.icon %}
<i class="bi {{ thematic.icon }} fs-3 mb-1"></i>
{% else %}
<i class="bi bi-tag fs-3 mb-1"></i>
{% endif %}
<div class="small">{{ thematic.name }}</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="mb-3">
{{ obs_form.category.label_tag }}
{{ obs_form.category }}
<input type="hidden" name="obs_sub_category_id" id="id_obs_sub_category_id">
{% if obs_form.category.errors %}
<div class="alert alert-danger mt-2">
{{ obs_form.category.errors }}
</div>
{% endif %}
<div class="form-text" id="category-help-text">
<i class="bi bi-info-circle"></i> {% translate "Veuillez d'abord sélectionner une thématique" %}
</div>
</div>
<!-- Sous-catégorie (optionnelle, affichée si la catégorie a des enfants) -->
<div class="mb-3" id="sub-category-block" style="display:none;">
<label class="form-label fw-semibold">{% translate "Sous-catégorie" %}</label>
<select id="id_sub_category" class="form-select">
<option value="">{% translate "--- Aucune (utiliser la catégorie parente) ---" %}</option>
</select>
<div class="form-text">
<i class="bi bi-info-circle"></i> {% translate "Optionnel : précisez une sous-catégorie si nécessaire." %}
</div>
</div>
<!-- Type d'observation -->
<div class="mb-3">
<label class="form-label fw-semibold">{% translate "Type d'observation" %}</label><br>
<!-- Select caché pour la soumission Django + compatibilité JS -->
<div style="display:none;">{{ obs_form.observation_type }}</div>
{% if obs_form.observation_type.errors %}
<div class="alert alert-danger mt-2">
{{ obs_form.observation_type.errors }}
</div>
{% endif %}
<div id="obs-type-badges">
<button type="button" class="btn btn-outline-danger obs-type-badge" data-value="incident" data-color="danger" disabled>
<i class="bi bi-exclamation-triangle-fill me-1"></i>{% translate "Incident" %}
</button>
<button type="button" class="btn btn-outline-secondary obs-type-badge" data-value="inventory" data-color="secondary" disabled>
<i class="bi bi-clipboard-check-fill me-1"></i>{% translate "Inventaire" %}
</button>
<button type="button" class="btn btn-outline-info obs-type-badge" data-value="remark" data-color="info" disabled>
<i class="bi bi-chat-fill me-1"></i>{% translate "Remarque" %}
</button>
</div>
</div>
<!-- Symptôme (conditionnel) -->
<div class="mb-3" id="symptom-field" style="display: none;">
{{ obs_form.symptom.label_tag }}
{{ obs_form.symptom }}
{% if obs_form.symptom.errors %}
<div class="alert alert-danger mt-2">
{{ obs_form.symptom.errors }}
</div>
{% endif %}
</div>
<!-- Checklist (conditionnel) -->
<div class="mb-3" id="checklist-section" style="display: none;">
<div class="card border-primary">
<div class="card-header bg-primary text-white py-2">
<i class="bi bi-clipboard-check me-2"></i>
<span id="checklist-title">{% translate "Checklist" %}</span>
</div>
<div class="card-body" id="checklist-items">
<!-- Les éléments de checklist seront chargés ici dynamiquement -->
</div>
</div>
</div>
</div><!-- /form-block-body -->
</div><!-- /form-block thematique -->
<script>
(function() {
// Données en mode édition (définies ici pour être disponibles dans DOMContentLoaded)
const isEditMode = {{ is_edit_mode|yesno:"true,false" }};
const observationData = {{ observation_data_json|safe }};
const existingPhotos = {{ existing_photos_json|safe }};
const existingChecklistResponses = {{ existing_checklist_responses_json|safe }};
// Données des raccourcis
const observerShortcuts = {{ observer_shortcuts_json|safe }};
const editorShortcuts = {{ editor_shortcuts_json|safe }};
// Exposer pour le second bloc de script (carte)
window._obsIsEditMode = isEditMode;
window._obsObservationData = observationData;
window._obsExistingPhotos = existingPhotos;
// Dynamic category filtering based on thematic selection
document.addEventListener('DOMContentLoaded', function() {
const thematicInput = document.getElementById('id_thematic');
const categorySelect = document.getElementById('id_category');
const subCategoryBlock = document.getElementById('sub-category-block');
const subCategorySelect = document.getElementById('id_sub_category');
const thematicCards = document.querySelectorAll('.thematic-card');
const observationTypeSelect = document.getElementById('id_observation_type');
// Cache des catégories chargées (inclut parent_id)
let allCategoriesData = [];
// Retourne l'ID de catégorie effectif : sous-catégorie si sélectionnée, sinon catégorie parente
function getEffectiveCategoryId() {
return subCategorySelect.value || categorySelect.value;
}
function getChildrenOf(parentId) {
return allCategoriesData.filter(c => String(c.parent_id) === String(parentId));
}
function loadSubcategoryOptions(parentId, subCategoryIdToSelect) {
const children = getChildrenOf(parentId);
subCategorySelect.innerHTML = '<option value="">{% translate "--- Aucune (utiliser la catégorie parente) ---" %}</option>';
if (children.length === 0) {
subCategoryBlock.style.display = 'none';
return;
}
children.forEach(child => {
const opt = document.createElement('option');
opt.value = child.id;
opt.text = child.name;
subCategorySelect.appendChild(opt);
});
if (subCategoryIdToSelect) {
subCategorySelect.value = String(subCategoryIdToSelect);
}
// Sync hidden input
const obsSubCatHidden = document.getElementById('id_obs_sub_category_id');
if (obsSubCatHidden) obsSubCatHidden.value = subCategorySelect.value || '';
subCategoryBlock.style.display = '';
}
function clearSubcategory() {
subCategoryBlock.style.display = 'none';
subCategorySelect.innerHTML = '<option value="">---</option>';
}
// ---- Badges type d'observation ----
const obsTypeBadges = document.querySelectorAll('#obs-type-badges .obs-type-badge');
function syncObsTypeBadges(val) {
obsTypeBadges.forEach(function(btn) {
const color = btn.dataset.color;
if (btn.dataset.value === val) {
btn.classList.remove('btn-outline-' + color);
btn.classList.add('btn-' + color);
if (color !== 'warning') btn.classList.add('text-white');
} else {
btn.classList.remove('btn-' + color, 'text-white');
btn.classList.add('btn-outline-' + color);
}
});
}
function syncObsTypeBadgesEnabled(isEnabled) {
obsTypeBadges.forEach(function(btn) { btn.disabled = !isEnabled; });
}
// Intercepter les affectations de .value via le setter
const _obsTypeProto = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
Object.defineProperty(observationTypeSelect, 'value', {
get: function() { return _obsTypeProto.get.call(this); },
set: function(val) {
_obsTypeProto.set.call(this, val);
syncObsTypeBadges(val);
}
});
// Intercepter disabled via MutationObserver
new MutationObserver(function(mutations) {
mutations.forEach(function(m) {
if (m.attributeName === 'disabled') {
syncObsTypeBadgesEnabled(!observationTypeSelect.disabled);
}
});
}).observe(observationTypeSelect, { attributes: true, attributeFilter: ['disabled'] });
// Clics sur les badges
obsTypeBadges.forEach(function(btn) {
btn.addEventListener('click', function() {
if (btn.disabled) return;
observationTypeSelect.value = btn.dataset.value;
observationTypeSelect.dispatchEvent(new Event('change'));
});
});
// Sync état initial (ex: ré-affichage après erreur de validation)
syncObsTypeBadges(observationTypeSelect.value);
// ---- Fin badges type d'observation ----
const symptomField = document.getElementById('symptom-field');
const symptomSelect = document.getElementById('id_symptom');
const checklistSection = document.getElementById('checklist-section');
const checklistTitle = document.getElementById('checklist-title');
const checklistItems = document.getElementById('checklist-items');
// LocalStorage keys
const STORAGE_KEY_PREFIX = 'observation_preferences_'; // per category
const STORAGE_THEMA_PREFIX = 'observation_last_by_thematic_'; // per thematic
// Current checklist template data
let currentChecklistTemplate = null;
let isProcessingShortcut = false; // Flag pour éviter les conflits lors de l'utilisation des raccourcis
// Edit mode restoration data
let editModeData = null;
if (isEditMode && observationData) {
editModeData = {
thematic_id: observationData.thematic_id,
category_id: observationData.category_id,
observation_type: observationData.observation_type,
symptom_id: observationData.symptom_id,
checklist_responses: existingChecklistResponses
};
}
// Helpers to read/write per-thematic preferences
function getThematicPrefs(thematicId) {
if (!thematicId) return null;
try {
const raw = localStorage.getItem(STORAGE_THEMA_PREFIX + thematicId);
return raw ? JSON.parse(raw) : null;
} catch (e) {
console.warn('Thematic prefs parse error', e);
return null;
}
}
function setThematicPrefs(thematicId, data) {
if (!thematicId || !data) return;
try {
localStorage.setItem(STORAGE_THEMA_PREFIX + thematicId, JSON.stringify(data));
} catch (e) {
console.warn('Thematic prefs save error', e);
}
}
// Restore preferences for observation type
function restoreObservationTypeOnly(categoryId) {
if (!categoryId) return;
const key = STORAGE_KEY_PREFIX + categoryId;
const prefs = localStorage.getItem(key);
if (!prefs) return;
try {
const data = JSON.parse(prefs);
if (data.observation_type && observationTypeSelect) {
observationTypeSelect.value = data.observation_type;
}
} catch (e) {
console.error('Error restoring type preference:', e);
}
}
function restoreSymptomOnly(categoryId) {
if (!categoryId) return;
const key = STORAGE_KEY_PREFIX + categoryId;
const prefs = localStorage.getItem(key);
if (!prefs) return;
try {
const data = JSON.parse(prefs);
if (data.symptom_id && symptomSelect) {
symptomSelect.value = data.symptom_id;
}
} catch (e) {
console.error('Error restoring symptom preference:', e);
}
}
// Prefer category-based restore; fallback to last-by-thematic
function restoreObservationTypePreference(categoryId, thematicId) {
// Try category-based first
const catRaw = categoryId ? localStorage.getItem(STORAGE_KEY_PREFIX + categoryId) : null;
if (catRaw) {
try {
const data = JSON.parse(catRaw);
if (data.observation_type) {
observationTypeSelect.value = data.observation_type;
return;
}
} catch {}
}
// Fallback thematic
const tData = getThematicPrefs(thematicId);
if (tData?.observation_type) {
observationTypeSelect.value = tData.observation_type;
}
}
function restoreSymptomPreference(categoryId, thematicId) {
// Try category-based first
const catRaw = categoryId ? localStorage.getItem(STORAGE_KEY_PREFIX + categoryId) : null;
if (catRaw) {
try {
const data = JSON.parse(catRaw);
if (data.symptom_id && symptomSelect) {
const optionExists = Array.from(symptomSelect.options).some(opt => opt.value == data.symptom_id);
if (optionExists) {
symptomSelect.value = data.symptom_id;
return;
}
}
} catch (e) {
console.error('Error parsing category prefs:', e);
}
}
// Fallback thematic
const tData = getThematicPrefs(thematicId);
if (tData?.symptom_id && symptomSelect) {
const optionExists = Array.from(symptomSelect.options).some(opt => opt.value == tData.symptom_id);
if (optionExists) {
symptomSelect.value = tData.symptom_id;
}
}
}
// Save preferences
function savePreferences() {
const categoryId = categorySelect.value;
if (!categoryId) return;
const prefs = {
observation_type: observationTypeSelect.value,
symptom_id: symptomSelect.value
};
const key = STORAGE_KEY_PREFIX + categoryId;
localStorage.setItem(key, JSON.stringify(prefs));
// Also persist for current thematic so we can auto-complete when only thematic is picked
const tId = thematicInput.value;
if (tId) {
setThematicPrefs(tId, {
category_id: categoryId,
observation_type: observationTypeSelect.value,
symptom_id: symptomSelect.value
});
}
}
// Load checklist template for category and observation type
let checklistRequestId = 0; // Track current request to ignore stale responses
function loadChecklist(categoryId, observationType) {
// Incrémenter l'ID de requête pour cette nouvelle requête
const thisRequestId = ++checklistRequestId;
// Masquer immédiatement la section pour éviter d'afficher une ancienne checklist
checklistSection.style.display = 'none';
currentChecklistTemplate = null;
if (!categoryId || !observationType) {
return;
}
fetch('{% url "observations:get_checklist_template" %}?category_id=' + categoryId + '&observation_type=' + observationType)
.then(response => response.json())
.then(data => {
// Ignorer cette réponse si une requête plus récente a été lancée
if (thisRequestId !== checklistRequestId) {
return;
}
if (data.has_checklist && data.items.length > 0) {
currentChecklistTemplate = data;
checklistTitle.textContent = data.template_name;
renderChecklistItems(data.items);
checklistSection.style.display = 'block';
// Apply edit mode checklist responses
if (editModeData && editModeData.checklist_responses && editModeData.checklist_responses.length > 0) {
editModeData.checklist_responses.forEach(response => {
const itemElement = document.querySelector(`[name="checklist_item_${response.item_id}"]`);
if (itemElement) {
if (itemElement.type === 'radio') {
const radioToCheck = document.querySelector(`[name="checklist_item_${response.item_id}"][value="${response.value}"]`);
if (radioToCheck) radioToCheck.checked = true;
} else if (itemElement.type === 'hidden' && itemElement.dataset.isMultiple) {
// Multiple choice
itemElement.value = Array.isArray(response.value) ? response.value.join(',') : response.value;
// Mark badges as selected
const values = Array.isArray(response.value) ? response.value : (response.value ? response.value.split(',') : []);
values.forEach(val => {
const badge = itemElement.parentElement.querySelector(`.checklist-badge-choice[data-key="${val}"]`);
if (badge) {
badge.dataset.selected = 'true';
const color = badge.dataset.color || 'primary';
badge.classList.remove('bg-light', `text-${color}`);
badge.classList.add(`bg-${color}`, 'text-white');
}
});
} else {
itemElement.value = response.value || '';
}
// Set comment
const commentElement = document.querySelector(`[name="checklist_comment_${response.item_id}"]`);
if (commentElement && response.comment) {
commentElement.value = response.comment;
}
}
});
}
} else {
checklistSection.style.display = 'none';
currentChecklistTemplate = null;
}
})
.catch(error => {
// Ignorer les erreurs de requêtes obsolètes
if (thisRequestId !== checklistRequestId) {
return;
}
console.error('Error loading checklist:', error);
checklistSection.style.display = 'none';
currentChecklistTemplate = null;
});
}
// Render checklist items in the form
function renderChecklistItems(items) {
checklistItems.innerHTML = '';
items.forEach(item => {
const itemDiv = document.createElement('div');
itemDiv.className = 'mb-3';
const label = document.createElement('label');
label.className = 'form-label fw-medium';
label.innerHTML = item.name + (item.is_required ? ' <span class="text-danger">*</span>' : '');
if (item.description) {
const desc = document.createElement('div');
desc.className = 'form-text mb-2';
desc.textContent = item.description;
label.appendChild(desc);
}
itemDiv.appendChild(label);
// Create input based on item type
if (item.item_type === 'boolean') {
const badgeContainer = document.createElement('div');
badgeContainer.className = 'd-flex flex-wrap gap-2';
// Hidden input to store the value
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'checklist_item_' + item.id;
hiddenInput.value = '';
badgeContainer.appendChild(hiddenInput);
// Yes badge
const yesBadge = document.createElement('span');
yesBadge.className = 'badge bg-light text-success border border-success py-2 px-3 checklist-badge';
yesBadge.style.cursor = 'pointer';
yesBadge.innerHTML = '<i class="bi bi-check-lg me-1"></i>{% translate "Oui" %}';
yesBadge.dataset.value = 'true';
yesBadge.onclick = function() {
hiddenInput.value = 'true';
badgeContainer.querySelectorAll('.checklist-badge').forEach(b => {
b.classList.remove('bg-success', 'bg-danger', 'bg-secondary', 'text-white');
b.classList.add('bg-light');
});
yesBadge.classList.remove('bg-light', 'text-success');
yesBadge.classList.add('bg-success', 'text-white');
};
// No badge
const noBadge = document.createElement('span');
noBadge.className = 'badge bg-light text-danger border border-danger py-2 px-3 checklist-badge';
noBadge.style.cursor = 'pointer';
noBadge.innerHTML = '<i class="bi bi-x-lg me-1"></i>{% translate "Non" %}';
noBadge.dataset.value = 'false';
noBadge.onclick = function() {
hiddenInput.value = 'false';
badgeContainer.querySelectorAll('.checklist-badge').forEach(b => {
b.classList.remove('bg-success', 'bg-danger', 'bg-secondary', 'text-white');
b.classList.add('bg-light');
});
noBadge.classList.remove('bg-light', 'text-danger');
noBadge.classList.add('bg-danger', 'text-white');
};
// N/A badge
const naBadge = document.createElement('span');
naBadge.className = 'badge bg-secondary text-white border py-2 px-3 checklist-badge';
naBadge.style.cursor = 'pointer';
naBadge.textContent = '{% translate "N/A" %}';
naBadge.dataset.value = '';
naBadge.onclick = function() {
hiddenInput.value = '';
badgeContainer.querySelectorAll('.checklist-badge').forEach(b => {
b.classList.remove('bg-success', 'bg-danger', 'bg-secondary', 'text-white');
b.classList.add('bg-light');
});
naBadge.classList.remove('bg-light');
naBadge.classList.add('bg-secondary', 'text-white');
};
badgeContainer.appendChild(yesBadge);
badgeContainer.appendChild(noBadge);
badgeContainer.appendChild(naBadge);
itemDiv.appendChild(badgeContainer);
} else if (item.item_type === 'text') {
const textarea = document.createElement('textarea');
textarea.className = 'form-control';
textarea.name = 'checklist_item_' + item.id;
textarea.rows = 2;
textarea.placeholder = '{% translate "Entrez votre réponse..." %}';
if (item.is_required) textarea.required = true;
itemDiv.appendChild(textarea);
} else if (item.item_type === 'number') {
const numberInput = document.createElement('input');
numberInput.type = 'number';
numberInput.className = 'form-control';
numberInput.name = 'checklist_item_' + item.id;
numberInput.step = 'any';
numberInput.placeholder = '{% translate "Entrez un nombre..." %}';
if (item.is_required) numberInput.required = true;
itemDiv.appendChild(numberInput);
} else if (item.item_type === 'choice' || item.item_type === 'choice_multiple') {
const isMultiple = item.item_type === 'choice_multiple';
const badgeContainer = document.createElement('div');
badgeContainer.className = 'd-flex flex-wrap gap-2';
// Hidden input to store the value (single key or comma-separated keys)
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'checklist_item_' + item.id;
hiddenInput.value = '';
hiddenInput.dataset.isMultiple = isMultiple;
if (item.is_required) hiddenInput.required = true;
badgeContainer.appendChild(hiddenInput);
// Use choices_with_keys if available
const choices = item.choices_with_keys || item.choices.map(c => ({key: c, label: c, icon: '', color: 'primary', image: null}));
choices.forEach((choiceObj, index) => {
const key = typeof choiceObj === 'object' ? choiceObj.key : choiceObj;
const label = typeof choiceObj === 'object' ? choiceObj.label : choiceObj;
const icon = typeof choiceObj === 'object' ? (choiceObj.icon || '') : '';
const color = typeof choiceObj === 'object' ? (choiceObj.color || 'primary') : 'primary';
const image = typeof choiceObj === 'object' ? choiceObj.image : null;
const badge = document.createElement('span');
badge.className = `badge bg-light text-${color} border border-${color} py-2 px-3 checklist-badge-choice`;
badge.style.cursor = 'pointer';
badge.dataset.key = key;
badge.dataset.color = color;
badge.dataset.selected = 'false';
// Create badge content with image and/or icon
let badgeContent = '';
if (image) {
badgeContent += `<img src="${image}" style="width: 20px; height: 20px; object-fit: cover; border-radius: 3px; margin-right: 6px;">`;
}
if (icon) {
badgeContent += `<i class="${icon} me-1"></i>`;
}
badgeContent += label;
badge.innerHTML = badgeContent;
badge.onclick = function() {
if (isMultiple) {
// Multiple selection mode
const isSelected = badge.dataset.selected === 'true';
if (isSelected) {
// Deselect
badge.dataset.selected = 'false';
badge.classList.remove(`bg-${color}`, 'text-white');
badge.classList.add('bg-light', `text-${color}`);
} else {
// Select
badge.dataset.selected = 'true';
badge.classList.remove('bg-light', `text-${color}`);
badge.classList.add(`bg-${color}`, 'text-white');
}
// Update hidden input with all selected keys
const selectedBadges = badgeContainer.querySelectorAll('.checklist-badge-choice[data-selected="true"]');
const selectedKeys = Array.from(selectedBadges).map(b => b.dataset.key);
hiddenInput.value = selectedKeys.join(',');
} else {
// Single selection mode
hiddenInput.value = key;
// Reset all badges
badgeContainer.querySelectorAll('.checklist-badge-choice').forEach(b => {
const bColor = b.dataset.color || 'primary';
b.dataset.selected = 'false';
b.classList.remove('text-white', `bg-${bColor}`);
b.className = `badge bg-light text-${bColor} border border-${bColor} py-2 px-3 checklist-badge-choice`;
b.style.cursor = 'pointer';
});
// Highlight selected
badge.dataset.selected = 'true';
badge.classList.remove('bg-light', `text-${color}`);
badge.classList.add(`bg-${color}`, 'text-white');
}
};
badgeContainer.appendChild(badge);
});
itemDiv.appendChild(badgeContainer);
}
// Optional comment field
const commentInput = document.createElement('input');
commentInput.type = 'text';
commentInput.className = 'form-control form-control-sm mt-2';
commentInput.name = 'checklist_comment_' + item.id;
commentInput.placeholder = '{% translate "Commentaire (optionnel)..." %}';
itemDiv.appendChild(commentInput);
checklistItems.appendChild(itemDiv);
});
}
// Collect checklist data for form submission
function getChecklistData() {
if (!currentChecklistTemplate || !currentChecklistTemplate.items) {
return [];
}
const responses = [];
currentChecklistTemplate.items.forEach(item => {
let value = null;
const inputName = 'checklist_item_' + item.id;
const commentName = 'checklist_comment_' + item.id;
// Get the input
const input = document.querySelector('[name="' + inputName + '"]');
if (input) {
if (item.item_type === 'boolean') {
if (input.value === 'true') value = true;
else if (input.value === 'false') value = false;
else value = null;
} else if (item.item_type === 'choice_multiple') {
// For multiple choice, split comma-separated values into array
value = input.value ? input.value.split(',').filter(v => v.trim()) : [];
} else {
value = input.value;
}
}
const commentInput = document.querySelector('[name="' + commentName + '"]');
const comment = commentInput ? commentInput.value : '';
responses.push({
item_id: item.id,
value: value,
comment: comment
});
});
return responses;
}
// Handle thematic card selection
function selectThematic(thematicId, thematicCode) {
// Update hidden input
thematicInput.value = thematicId;
// Update visual selection
thematicCards.forEach(card => {
if (card.dataset.thematicId === thematicId) {
card.classList.add('selected');
} else {
card.classList.remove('selected');
}
});
// Trigger category loading
// Reset category & symptom fields for new thematic
categorySelect.innerHTML = `<option value=''>{% translate "--- Sélectionnez une catégorie ---" %}</option>`;
categorySelect.disabled = true;
observationTypeSelect.disabled = true;
symptomField.style.display = 'none';
symptomSelect.innerHTML = '<option value="">{% translate "--- Sélectionnez un symptôme ---" %}</option>';
symptomSelect.disabled = true;
checklistSection.style.display = 'none';
currentChecklistTemplate = null;
// Pre-fill observation type from last-by-thematic if available
const tPrefs = getThematicPrefs(thematicId);
if (tPrefs?.observation_type) {
observationTypeSelect.value = tPrefs.observation_type;
}
return loadCategories(thematicId); // Return the Promise
}
// Handle observation type change
function handleObservationTypeChange(skipRestore = false) {
const obsType = observationTypeSelect.value;
const categoryId = getEffectiveCategoryId();
if (obsType === 'incident' && categoryId) {
symptomField.style.display = 'block';
const symptomsPromise = loadSymptoms(categoryId, skipRestore);
// Don't save prefs here; wait until symptoms are loaded and restored
// Load checklist for current category and observation type
if (categoryId && obsType) {
loadChecklist(categoryId, obsType);
}
return symptomsPromise; // Return the Promise
} else {
symptomField.style.display = 'none';
symptomSelect.disabled = true;
symptomSelect.innerHTML = '<option value="">{% translate "--- Sélectionnez un symptôme ---" %}</option>';
if (!skipRestore) {
savePreferences();
}
// Load checklist for current category and observation type
if (categoryId && obsType) {
loadChecklist(categoryId, obsType);
} else {
checklistSection.style.display = 'none';
currentChecklistTemplate = null;
}
return Promise.resolve();
}
}
// Load symptoms for selected category
let isLoadingSymptoms = false;
let currentSymptomsPromise = null;
let currentLoadingCategoryId = null; // Track which category is being loaded
function loadSymptoms(categoryId, skipRestore = false) {
// If already loading the SAME category, return existing promise
// Otherwise, allow new request (different category)
if (isLoadingSymptoms && currentSymptomsPromise && currentLoadingCategoryId === categoryId) {
return currentSymptomsPromise; // Retourner la Promise en cours pour la même catégorie
}
isLoadingSymptoms = true;
currentLoadingCategoryId = categoryId; // Track current loading category
if (!categoryId) {
symptomSelect.innerHTML = `<option value=''>{% translate "Sélectionnez une catégorie d'abord" %}</option>`;
symptomSelect.disabled = true;
isLoadingSymptoms = false;
currentSymptomsPromise = null;
return Promise.resolve();
}
currentSymptomsPromise = fetch('{% url "observations:get_symptoms_by_category" %}?category_id=' + categoryId)
.then(response => response.json())
.then(data => {
symptomSelect.innerHTML = '<option value="">{% translate "--- Sélectionnez un symptôme ---" %}</option>';
data.symptoms.forEach(symptom => {
const option = document.createElement('option');
option.value = symptom.id;
option.text = symptom.name;
option.dataset.code = symptom.code || ''; // Stocker le code du symptôme
symptomSelect.appendChild(option);
});
symptomSelect.disabled = false;
// Apply edit mode data if available
if (editModeData && editModeData.symptom_id) {
const exists = Array.from(symptomSelect.options).some(opt => opt.value == editModeData.symptom_id);
if (exists) {
symptomSelect.value = editModeData.symptom_id;
}
} else if (!skipRestore) {
// Restore symptom preference only if not skipped (e.g., not from shortcut)
restoreSymptomPreference(categoryId, thematicInput.value);
}
isLoadingSymptoms = false;
currentSymptomsPromise = null;
currentLoadingCategoryId = null;
// Save preferences after restore completes (only if not skipped)
if (!skipRestore) {
savePreferences();
}
return data;
})
.catch(error => {
console.error('Error fetching symptoms:', error);
symptomSelect.innerHTML = '<option value="">{% translate "Erreur lors du chargement" %}</option>';
symptomSelect.disabled = true;
isLoadingSymptoms = false;
currentSymptomsPromise = null;
currentLoadingCategoryId = null;
throw error;
});
return currentSymptomsPromise;
}
// Load categories for selected thematic
function loadCategories(thematicId) {
const helpText = document.getElementById('category-help-text');
clearSubcategory();
if (!thematicId) {
categorySelect.innerHTML = `<option value=''>{% translate "Sélectionnez d'abord une thématique" %}</option>`;
categorySelect.disabled = true;
if (helpText) helpText.style.display = 'block';
return Promise.resolve(); // Return resolved Promise
}
// Fetch categories for selected thematic via AJAX
return fetch('{% url "observations:get_categories_by_thematic" %}?thematic_id=' + thematicId)
.then(response => response.json())
.then(data => {
allCategoriesData = data.categories || [];
categorySelect.innerHTML = '<option value="">{% translate "--- Sélectionnez une catégorie ---" %}</option>';
// Afficher uniquement les catégories de premier niveau (sans parent)
allCategoriesData.filter(c => c.parent_id === null).forEach(category => {
const option = document.createElement('option');
option.value = category.id;
option.text = category.name;
option.dataset.code = category.code || ''; // Stocker le code de la catégorie
categorySelect.appendChild(option);
});
categorySelect.disabled = false;
if (helpText) helpText.style.display = 'none';
// Apply edit mode data if available
if (editModeData && editModeData.category_id) {
// Vérifier si c'est une catégorie de premier niveau
const topExists = Array.from(categorySelect.options).some(opt => opt.value == editModeData.category_id);
if (topExists) {
categorySelect.value = editModeData.category_id;
loadSubcategoryOptions(editModeData.category_id);
observationTypeSelect.disabled = false;
if (editModeData.observation_type) {
observationTypeSelect.value = editModeData.observation_type;
handleObservationTypeChange();
}
return data;
}
// Vérifier si c'est une sous-catégorie (a un parent)
const childCat = allCategoriesData.find(c => String(c.id) === String(editModeData.category_id) && c.parent_id !== null);
if (childCat) {
const parentExists = Array.from(categorySelect.options).some(opt => opt.value == childCat.parent_id);
if (parentExists) {
categorySelect.value = childCat.parent_id;
loadSubcategoryOptions(childCat.parent_id, editModeData.category_id);
observationTypeSelect.disabled = false;
if (editModeData.observation_type) {
observationTypeSelect.value = editModeData.observation_type;
handleObservationTypeChange();
}
return data;
}
}
}
// Auto-select last used category for this thematic if available
const tPrefs = getThematicPrefs(thematicId);
if (tPrefs?.category_id) {
// Vérifier si c'est une catégorie de premier niveau
const topExists = Array.from(categorySelect.options).some(opt => opt.value == tPrefs.category_id);
if (topExists) {
categorySelect.value = String(tPrefs.category_id);
loadSubcategoryOptions(tPrefs.category_id);
restoreObservationTypePreference(categorySelect.value, thematicId);
categorySelect.dispatchEvent(new Event('change'));
} else {
// Peut-être une sous-catégorie sauvegardée
const childCat = allCategoriesData.find(c => String(c.id) === String(tPrefs.category_id) && c.parent_id !== null);
if (childCat) {
const parentExists = Array.from(categorySelect.options).some(opt => opt.value == childCat.parent_id);
if (parentExists) {
categorySelect.value = String(childCat.parent_id);
loadSubcategoryOptions(childCat.parent_id, tPrefs.category_id);
restoreObservationTypePreference(categorySelect.value, thematicId);
observationTypeSelect.disabled = false;
handleObservationTypeChange();
}
}
}
}
return data; // Return data for chaining
})
.catch(error => {
console.error('Error fetching categories:', error);
categorySelect.innerHTML = '<option value="">{% translate "Erreur lors du chargement" %}</option>';
categorySelect.disabled = true;
throw error; // Re-throw for error handling
});
}
// Event listeners
observationTypeSelect.addEventListener('change', handleObservationTypeChange);
categorySelect.addEventListener('change', function() {
// Charger les sous-catégories si la catégorie a des enfants
if (this.value) {
loadSubcategoryOptions(this.value);
} else {
clearSubcategory();
}
// Enable observation type when category is selected
if (this.value) {
observationTypeSelect.disabled = false;
// Skip auto-restore if processing a shortcut, but still allow type change
if (!isProcessingShortcut) {
clearSymptomSearch();
// Restore observation type preference (category first, then thematic) before applying logic
restoreObservationTypePreference(this.value, thematicInput.value);
handleObservationTypeChange();
}
} else {
observationTypeSelect.disabled = true;
symptomField.style.display = 'none';
symptomSelect.disabled = true;
checklistSection.style.display = 'none';
currentChecklistTemplate = null;
}
// Don't call savePreferences() here - it will be called:
// - in handleObservationTypeChange() for non-incident type
// - in loadSymptoms() after symptoms are loaded for incident type
});
// Réagir au changement de sous-catégorie : recharger les symptômes + mettre à jour le champ caché
subCategorySelect.addEventListener('change', function() {
const obsSubCatHidden = document.getElementById('id_obs_sub_category_id');
if (obsSubCatHidden) obsSubCatHidden.value = this.value || '';
const effectiveId = getEffectiveCategoryId();
if (observationTypeSelect.value === 'incident' && effectiveId) {
loadSymptoms(effectiveId, false);
}
if (!isProcessingShortcut) savePreferences();
});
// Supprimer l'ancien listener submit (remplacé par mise à jour en temps réel ci-dessus)
symptomSelect.addEventListener('change', function() {
if (!isProcessingShortcut) clearSymptomSearch();
savePreferences();
});
// Add click event to thematic cards
thematicCards.forEach(card => {
card.addEventListener('click', function() {
selectThematic(this.dataset.thematicId, this.dataset.thematicCode);
});
// Keyboard accessibility
card.addEventListener('keypress', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectThematic(this.dataset.thematicId, this.dataset.thematicCode);
}
});
});
// Fonction générique pour gérer les raccourcis
function setupShortcut(shortcut) {
const btn = document.getElementById(shortcut.id);
if (!btn) return;
btn.addEventListener('click', function() {
clearSymptomSearch();
// Trouver la carte thématique correspondante
const thematicCard = Array.from(thematicCards).find(
card => card.dataset.thematicCode &&
card.dataset.thematicCode.toLowerCase() === shortcut.thematic_code.toLowerCase()
);
if (!thematicCard) {
console.log(`Thématique ${shortcut.thematic_code} non trouvée`);
return;
}
// Sélectionner la thématique
const categoriesPromise = selectThematic(thematicCard.dataset.thematicId, thematicCard.dataset.thematicCode);
// Scroll vers la section thématique
thematicCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
// Attendre le chargement des catégories, puis sélectionner la bonne catégorie
categoriesPromise.then(() => {
// Chercher d'abord par code de catégorie (priorité)
let categoryOption = Array.from(categorySelect.options).find(
option => {
const code = option.dataset.code;
if (code) {
return shortcut.category_search.some(search =>
code.toUpperCase() === search.toUpperCase()
);
}
return false;
}
);
// Si pas trouvé par code, chercher dans le texte (fallback)
if (!categoryOption) {
categoryOption = Array.from(categorySelect.options).find(
option => {
const text = option.text.toUpperCase();
return shortcut.category_search.some(search => text.includes(search.toUpperCase()));
}
);
}
if (categoryOption) {
// Activer le flag avant de changer la catégorie
isProcessingShortcut = true;
categorySelect.value = categoryOption.value;
observationTypeSelect.disabled = false;
// Si un type d'observation est spécifié dans le raccourci
if (shortcut.observation_type) {
observationTypeSelect.value = shortcut.observation_type;
// Utiliser un délai pour s'assurer que tous les changements DOM sont appliqués
// avant de charger les symptômes
Promise.resolve().then(() => {
// Appeler handleObservationTypeChange pour charger les symptômes
// Passer skipRestore=true pour ne pas restaurer les préférences
return handleObservationTypeChange(true);
}).then(() => {
// Une fois les symptômes chargés, sélectionner celui spécifié
if (shortcut.symptom) {
const symptomSelect = document.getElementById('id_symptom');
if (symptomSelect && !symptomSelect.disabled) {
// Chercher d'abord par code de symptôme (priorité)
let symptomOption = Array.from(symptomSelect.options).find(
option => {
const code = option.dataset.code;
if (code) {
return code.toUpperCase() === shortcut.symptom.toUpperCase();
}
return false;
}
);
// Si pas trouvé par code, chercher par value (ID) en fallback
if (!symptomOption) {
symptomOption = Array.from(symptomSelect.options).find(
option => option.value === shortcut.symptom
);
}
if (symptomOption) {
symptomSelect.value = symptomOption.value;
symptomSelect.dispatchEvent(new Event('change'));
// Sauvegarder les nouvelles préférences après sélection du raccourci
savePreferences();
} else {
console.warn(`✗ Symptôme ${shortcut.symptom} non trouvé dans les options disponibles`);
}
} else {
console.warn("Le select des symptômes n'est pas disponible ou est désactivé");
}
}
// Désactiver le flag après traitement
isProcessingShortcut = false;
}).catch(error => {
console.error('Erreur lors du chargement des symptômes:', error);
// Désactiver le flag en cas d'erreur
isProcessingShortcut = false;
});
} else {
// Pas de type d'observation spécifié, restaurer les préférences normalement
isProcessingShortcut = false;
restoreObservationTypePreference(categorySelect.value, thematicInput.value);
handleObservationTypeChange();
}
} else {
console.log(`Catégorie pour ${shortcut.title} non trouvée`);
isProcessingShortcut = false;
}
}).catch(error => {
console.error('Erreur lors du chargement des catégories:', error);
isProcessingShortcut = false;
});
});
}
// Configurer tous les raccourcis
[...observerShortcuts, ...editorShortcuts].forEach(setupShortcut);
// Initialize category dropdown state - disable by default
categorySelect.disabled = true;
categorySelect.innerHTML = `<option value=''>{% translate "Sélectionnez d'abord une thématique" %}</option>`;
// Initialize observation type - disable until category selected
observationTypeSelect.disabled = true;
// Initialize symptom field as hidden
symptomField.style.display = 'none';
symptomSelect.disabled = true;
// Initialize in edit mode - just select the thematic, the rest cascades automatically
if (editModeData && editModeData.thematic_id) {
const thematicId = String(editModeData.thematic_id);
const thematicCard = document.querySelector(`.thematic-card[data-thematic-id="${thematicId}"]`);
if (thematicCard) {
thematicInput.value = thematicId;
thematicCard.classList.add('selected');
selectThematic(thematicId, thematicCard.dataset.thematicCode);
}
}
// ----- Autocomplete de recherche de symptôme -----
const SYMPTOM_SEARCH_DATA = JSON.parse(document.getElementById('observation-symptoms-list').textContent);
const normalizeStr = s => (s || "").normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
function clearSymptomSearch() {
document.getElementById('obs-symptom-search').value = '';
}
document.getElementById('obs-symptom-search').addEventListener('input', function() {
// nothing extra needed
});
document.getElementById('obs-symptom-clear').addEventListener('click', function() {
clearSymptomSearch();
document.getElementById('obs-symptom-search').focus();
});
$("#obs-symptom-search").autocomplete({
source: function(request, response) {
const term = normalizeStr(request.term);
const items = SYMPTOM_SEARCH_DATA.filter(o => normalizeStr(o.value).includes(term));
response(items.slice(0, 20));
},
minLength: 2,
delay: 100,
focus: function(event, ui) {
$("#obs-symptom-search").val(ui.item.value);
return false;
},
select: function(event, ui) {
event.preventDefault();
$("#obs-symptom-search").val(ui.item.value);
applySymptomAutocomplete(ui.item);
}
});
function applySymptomAutocomplete(symptomData) {
isProcessingShortcut = true;
// 1. Sélectionner la thématique
selectThematic(String(symptomData.thematic_id), symptomData.thematic_code)
.then(() => {
// 2. Trouver la catégorie correspondante
let categoryOption = null;
for (const catId of (symptomData.category_ids || [])) {
categoryOption = Array.from(categorySelect.options).find(opt => opt.value == catId);
if (categoryOption) break;
}
if (!categoryOption) {
console.warn('Catégorie non trouvée pour le symptôme', symptomData);
isProcessingShortcut = false;
return Promise.resolve();
}
// 3. Sélectionner la catégorie et définir le type d'observation sur "incident"
categorySelect.value = categoryOption.value;
observationTypeSelect.disabled = false;
observationTypeSelect.value = 'incident';
// 4. Charger les symptômes pour la catégorie (skipRestore=true)
return Promise.resolve().then(() => handleObservationTypeChange(true));
})
.then(() => {
if (!symptomSelect || symptomSelect.disabled) { isProcessingShortcut = false; return; }
// 5. Trouver et sélectionner le symptôme dans le select
let symptomOption = null;
if (symptomData.code) {
symptomOption = Array.from(symptomSelect.options).find(
opt => opt.dataset.code &&
opt.dataset.code.toUpperCase() === symptomData.code.toUpperCase()
);
}
if (!symptomOption) {
symptomOption = Array.from(symptomSelect.options).find(
opt => opt.value === String(symptomData.id)
);
}
if (symptomOption) {
symptomSelect.value = symptomOption.value;
symptomSelect.dispatchEvent(new Event('change'));
savePreferences();
} else {
console.warn('Symptôme non trouvé dans les options:', symptomData);
}
isProcessingShortcut = false;
// Faire défiler vers le champ symptôme
symptomField.scrollIntoView({ behavior: 'smooth', block: 'center' });
})
.catch(err => {
console.error('Erreur autocomplete symptôme:', err);
isProcessingShortcut = false;
});
}
// ----- Fin autocomplete symptôme -----
// Expose getChecklistData globally for form submission
window.getChecklistData = getChecklistData;
});
})();
</script>
<!-- Carte -->
<div class="form-block block-position">
<div class="form-block-header"><i class="bi bi-geo-alt-fill"></i> {% translate "Position" %}</div>
<div class="form-block-body">
{{ obs_form.latitude }} {{ obs_form.longitude }} {{ obs_form.address }}
{% if obs_form.latitude.errors or obs_form.longitude.errors or obs_form.address.errors %}
<div class="alert alert-danger">
{% if obs_form.latitude.errors %}{{ obs_form.latitude.errors }}{% endif %}
{% if obs_form.longitude.errors %}{{ obs_form.longitude.errors }}{% endif %}
{% if obs_form.address.errors %}{{ obs_form.address.errors }}{% endif %}
</div>
{% endif %}
<div class="mb-2">
<label class="form-label fw-bold mb-3">{% translate "Position géographique" %} <small class="text-muted">({% translate "cliquer ou déplacer le marqueur" %})</small></label>
<div class="mb-3">
<div class="d-flex align-items-center position-relative">
<label for="address" class="form-label me-2 mb-0" style="white-space: nowrap;">{% translate "Adresse" %}</label>
<div class="position-relative flex-grow-1">
<input type="text" id="address" class="form-control" style="padding-right:2rem;" placeholder="{% translate 'ex: 100 souverain' %}" autocomplete="off">
<button type="button" id="clear-btn"
title="{% translate 'Effacer' %}"
style="position:absolute; top:50%; right:0.4rem; transform:translateY(-50%); width:1.4rem; height:1.4rem; padding:0; border-radius:50%; border:1px solid #6c757d; background:#fff; color:#6c757d; display:flex; align-items:center; justify-content:center; font-size:0.65rem; line-height:1; cursor:pointer; z-index:2;">
<i class="bi bi-x-lg"></i>
</button>
</div>
<ul id="autocomplete-list" class="list-group position-absolute w-100 d-none" style="top: calc(100% + 2px); z-index: 1050;"></ul>
</div>
<div id="address_result" class="text-primary fst-italic mt-2"></div>
</div>
<div style="position: relative;">
<div id="map" style="height: 300px; border-radius: 8px; border: 1px solid #dee2e6;"></div>
<div class="map-zoom-controls">
<button type="button" id="zoom-in-btn" class="btn btn-light btn-sm map-zoom-btn" title="{% translate 'Zoom avant' %}">+</button>
<button type="button" id="zoom-out-btn" class="btn btn-light btn-sm map-zoom-btn" title="{% translate 'Zoom arrière' %}">&minus;</button>
</div>
</div>
<div class="d-flex align-items-center gap-2 mt-1 flex-wrap" id="form-map-layers-bar" style="display:none !important;">
<button type="button" id="toggle-form-interventions" class="btn btn-sm btn-secondary"
title="{% translate 'Afficher/masquer les interventions récentes de la même catégorie' %}">
<i class="bi bi-tools"></i> {% translate "Interventions récentes" %}
</button>
<span class="small text-muted" id="form-interv-count"></span>
</div>
</div>
</div><!-- /form-block-body -->
</div><!-- /form-block position -->
<!-- Description -->
<div class="form-block block-description">
<div class="form-block-header"><i class="bi bi-pencil-fill"></i> {% translate "Description" %}</div>
<div class="form-block-body">
<div class="mb-0">
<label for="{{ obs_form.description.id_for_label }}" class="form-label fw-bold visually-hidden">{{ obs_form.description.label }}</label>
{{ obs_form.description }}
{% if obs_form.description.errors %}
<div class="alert alert-danger mt-2">
{{ obs_form.description.errors }}
</div>
{% endif %}
</div>
</div><!-- /form-block-body -->
</div><!-- /form-block description -->
<!-- Hidden field for checklist data -->
<input type="hidden" name="checklist_data" id="checklist_data" value="">
<div class="text-center mt-4">
<button id="save-btn" type="submit" name="save" class="btn btn-secondary px-4 me-2">
<i class="bi bi-save me-1"></i> {% translate "Sauvegarder" %}
</button>
<button id="send-btn" type="submit" name="send" class="btn btn-success px-4">
<i class="bi bi-send me-1"></i> {% translate "Envoyer" %}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal caméra Canvas (mode continu) -->
<div class="modal fade" id="cameraModal" tabindex="-1" aria-labelledby="cameraModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen-sm-down d-flex m-0 h-100 mx-auto">
<div class="modal-content bg-dark text-white d-flex flex-column">
<div class="modal-header border-0 flex-shrink-0 py-2">
<h5 class="modal-title" id="cameraModalLabel">
<i class="bi bi-camera-video"></i> {% translate "Caméra" %}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}" id="cameraModalCloseBtn"></button>
</div>
<div class="modal-body d-flex flex-column p-2 flex-grow-1" style="overflow: hidden; min-height: 0;">
<div class="flex-grow-1 d-flex align-items-center justify-content-center" style="min-height: 0; overflow: hidden;">
<div class="position-relative w-100 h-100 d-flex align-items-center justify-content-center" style="border-radius: 0.5rem; overflow: hidden;">
<video id="cameraPreview" playsinline autoplay style="max-width: 100%; max-height: 100%; object-fit: contain;"></video>
<div id="cameraFlashOverlay" style="position: absolute; inset: 0; background: rgba(255, 255, 255, 0.9); opacity: 0; pointer-events: none; transition: opacity 120ms ease-out; z-index: 2;"></div>
<canvas id="cameraCanvas" class="d-none"></canvas>
</div>
</div>
<div id="cameraStatus" class="small text-center text-muted my-2 flex-shrink-0"></div>
<div class="d-flex justify-content-between align-items-center gap-2 mb-2 flex-shrink-0">
<button type="button" class="btn btn-outline-light flex-fill" id="cameraStopBtn">
<i class="bi bi-x-circle"></i> {% translate "Terminer" %}
</button>
<button type="button" class="btn btn-primary flex-fill" id="cameraCaptureBtn">
<i class="bi bi-camera-fill"></i> {% translate "Capturer" %}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modal compression photos volumineuses -->
<div class="modal fade" id="compressionModal" tabindex="-1" aria-labelledby="compressionModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="compressionModalLabel">
<i class="bi bi-exclamation-triangle-fill text-warning me-2"></i>{% translate "Photos volumineuses détectées" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}" id="compress-cancel-btn"></button>
</div>
<div class="modal-body">
<p id="compress-modal-info" class="mb-3"></p>
<p class="mb-0">{% translate "Souhaitez-vous compresser les photos volumineuses avant l'envoi ?" %}</p>
</div>
<div class="modal-footer flex-column flex-sm-row gap-2">
<button type="button" class="btn btn-outline-secondary w-100 w-sm-auto" id="compress-skip-btn">
<i class="bi bi-upload me-1"></i>{% translate "Envoyer sans compression" %}
</button>
<button type="button" class="btn btn-primary w-100 w-sm-auto" id="compress-confirm-btn">
<i class="bi bi-file-zip me-1"></i>{% translate "Compresser" %}
</button>
</div>
</div>
</div>
</div>
{{ symptoms_autocomplete|json_script:"observation-symptoms-list" }}
<!-- MapLibre GL 5.7.0 (local) -->
<script src="{% static 'common/maplibre-gl.js' %}"></script>
<script src="{% static 'interventions/status_colors.js' %}"></script>
<!-- jQuery 3.7.1 (local) -->
<script src="{% static 'common/jquery.min.js' %}"></script>
<!-- jQuery UI 1.14.1 (local) -->
<script src="{% static 'common/jquery-ui.min.js' %}"></script>
<!-- Bootstrap 5.3.3 (local) -->
<script src="{% static 'common/bootstrap.bundle.min.js' %}"></script>
<script>
window.MAP_STYLE_LIGHT = "{{ MAP_STYLE_LIGHT|escapejs }}";
window.MAP_STYLE_STANDARD = "{{ MAP_STYLE_STANDARD|escapejs }}";
window.MAP_DEFAULT_CENTER = {{ map_default_center|safe }};
window.MAP_DEFAULT_ZOOM = {{ map_default_zoom }};
window.MAP_DEFAULT_EXTENT = {% if map_default_extent %}{{ map_default_extent|safe }}{% else %}null{% endif %};
window.MAP_ORTHO_WMS_URL = "{{ map_ortho_wms_url|escapejs }}";
window.MAP_ORTHO_WMS_NAME = "{{ map_ortho_wms_name|escapejs }}";
window.MAP_ORTHO_WMS_ATTRIBUTION = "{{ map_ortho_wms_attribution|escapejs }}";
(function() {
let _maplibregl = window.maplibregl;
Object.defineProperty(window, 'maplibregl', {
get: function() {
return _maplibregl;
},
set: function(val) {
_maplibregl = val;
if (_maplibregl && _maplibregl.Map && !_maplibregl.Map._isPatched) {
const OriginalMap = _maplibregl.Map;
_maplibregl.Map = function(options) {
options = options || {};
const isDefaultCenter = !options.center ||
(Array.isArray(options.center) &&
Math.abs(options.center[0] - 4.3572) < 0.0001 &&
Math.abs(options.center[1] - 50.8477) < 0.0001);
if (isDefaultCenter && window.MAP_DEFAULT_CENTER) {
options.center = window.MAP_DEFAULT_CENTER;
}
const isDefaultZoom = typeof options.zoom === 'undefined' || options.zoom === 11 || options.zoom === 12;
if (isDefaultZoom && typeof window.MAP_DEFAULT_ZOOM !== 'undefined') {
options.zoom = window.MAP_DEFAULT_ZOOM;
}
if (options.maxBounds === undefined && window.MAP_DEFAULT_EXTENT) {
options.maxBounds = window.MAP_DEFAULT_EXTENT;
}
return new OriginalMap(options);
};
_maplibregl.Map.prototype = OriginalMap.prototype;
_maplibregl.Map._isPatched = true;
const originalAddSource = OriginalMap.prototype.addSource;
OriginalMap.prototype.addSource = function(id, source) {
if (id === 'urbis' && source && window.MAP_ORTHO_WMS_URL) {
if (source.tiles) {
source.tiles = [window.MAP_ORTHO_WMS_URL];
}
if (source.attribution && window.MAP_ORTHO_WMS_ATTRIBUTION) {
source.attribution = window.MAP_ORTHO_WMS_ATTRIBUTION;
}
}
return originalAddSource.call(this, id, source);
};
}
},
configurable: true
});
if (window.maplibregl) {
window.maplibregl = window.maplibregl;
}
})();
(function() {
// Lire les données partagées depuis le premier bloc de script
const isEditMode = window._obsIsEditMode || false;
const observationData = window._obsObservationData || null;
const existingPhotos = window._obsExistingPhotos || [];
let pendingPosition = null; // Temporaire en attendant que la carte soit prête
// Pré-remplir les champs en mode édition
if (isEditMode && observationData) {
// Les champs du formulaire Django sont déjà pré-remplis
// On va initialiser les valeurs pour la carte et le checklist
document.getElementById('id_latitude').value = observationData.latitude || '';
document.getElementById('id_longitude').value = observationData.longitude || '';
// Afficher l'adresse si disponible
if (observationData.address) {
document.getElementById('address_result').textContent = observationData.address;
document.getElementById('id_address').value = observationData.address;
}
} else {
document.getElementById('id_latitude').value = '';
document.getElementById('id_longitude').value = '';
}
const map = new maplibregl.Map({
container: 'map',
style: window.MAP_STYLE_STANDARD,
center: [4.35, 50.85],
zoom: 14
});
// Marqueur de l’observation (rouge)
const marker = new maplibregl.Marker({ draggable: true, color: '#3949ab' })
.setLngLat([4.35, 50.85])
.addTo(map);
// Fonction pour mettre à jour les champs lat/lon
function updateFormFields(lngLat) {
document.getElementById('id_latitude').value = lngLat.lat;
document.getElementById('id_longitude').value = lngLat.lng;
document.getElementById('id_address').value = document.getElementById('address_result').textContent; // Mise à jour du champ caché avec l'adresse choisie
}
marker.on('dragend', () => {
lngLat = marker.getLngLat();
updateFormFields(lngLat);
getNearestAddress(lngLat.lng, lngLat.lat);
});
map.on('click', (e) => {
const interactiveLayers = ['existing-observations-circle', 'taxi-stands-circle', 'form-interventions-symbol', 'form-interventions-border'];
const hit = interactiveLayers.some(layer => {
try { return map.queryRenderedFeatures(e.point, { layers: [layer] }).length > 0; }
catch { return false; }
});
if (hit) return;
marker.setLngLat(e.lngLat);
updateFormFields(e.lngLat);
getNearestAddress(e.lngLat.lng, e.lngLat.lat);
});
// Initialisation d'une source vide pour la position utilisateur
map.on('load', () => {
map.addSource('user-location', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: []
}
});
map.addLayer({
id: 'user-location-point',
type: 'circle',
source: 'user-location',
paint: {
'circle-radius': 8,
'circle-color': '#007bff',
'circle-opacity': 0.8,
'circle-stroke-width': 2,
'circle-stroke-color': '#fff'
}
});
// Appliquer la position si elle est déjà connue
if (pendingPosition) {
applyPositionToMap(pendingPosition);
pendingPosition = null;
}
// En mode édition, centrer la carte sur la position existante
if (isEditMode && observationData && observationData.latitude && observationData.longitude) {
const editCoords = [observationData.longitude, observationData.latitude];
map.setCenter(editCoords);
map.setZoom(17);
marker.setLngLat(editCoords);
// Charger l'adresse pour affichage
getNearestAddress(observationData.longitude, observationData.latitude);
}
map.addSource('regional_roads', {
type: 'geojson',
data: '{% url "common:get_regional_roads_geojson" %}'
});
map.addLayer(
{
'id': 'regional_roads',
type: 'line',
source: 'regional_roads',
paint: {
'line-color': '#FF5733',
'line-width': 1.5,
'line-opacity': 0.8
},
}
);
// Add source and layer for existing observations
map.addSource('existing-observations', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: []
}
});
// Define colors for each status
const statusColors = {
'in_preparation': '#6c757d', // gray
'to_process': '#dc3545', // warning/red
'in_progress': '#ffc107', // warning/yellow
'processed': '#28a745', // success/green
'closed': '#343a40', // dark
'archived': '#000000' // black (shouldn't appear since we exclude them)
};
map.addLayer({
id: 'existing-observations-circle',
type: 'circle',
source: 'existing-observations',
minzoom: 13,
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
10, 6,
15, 10,
20, 14
],
'circle-color': [
'match',
['get', 'status'],
'in_preparation', statusColors.in_preparation,
'to_process', statusColors.to_process,
'in_progress', statusColors.in_progress,
'processed', statusColors.processed,
'closed', statusColors.closed,
'archived', statusColors.archived,
'#999999' // default color
],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
'circle-opacity': 0.7
}
});
// Add source and layer for taxi stands (shown when category is SI_TAXI)
map.addSource('taxi-stands', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
map.addLayer({
id: 'taxi-stands-circle',
type: 'circle',
source: 'taxi-stands',
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
10, 6,
15, 10,
20, 14
],
'circle-color': '#f59e0b',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
'circle-opacity': 0.9
}
});
map.on('click', 'taxi-stands-circle', function(e) {
e.preventDefault();
const feature = e.features[0];
const props = feature.properties;
const capacityField = props.capacity ? `
<div class="taxi-popup-field">
<span class="taxi-popup-label">{% translate "Capacité" %}</span>
<span class="taxi-popup-value">${props.capacity}</span>
</div>` : '';
const entityField = props.entity ? `
<div class="taxi-popup-field">
<span class="taxi-popup-label">{% translate "Entité" %}</span>
<span class="taxi-popup-value">${props.entity}</span>
</div>` : '';
new maplibregl.Popup({ offset: 12 })
.setLngLat(feature.geometry.coordinates)
.setHTML(`
<div class="taxi-popup-header">
<i class="bi bi-taxi-front-fill"></i>
{% translate "Station de taxis" %}
</div>
<div class="taxi-popup-body">
<div class="taxi-popup-field">
<span class="taxi-popup-label">{% translate "Code" %}</span>
<span class="taxi-popup-value">${props.code || '—'}</span>
</div>
${capacityField}
${entityField}
</div>
`)
.addTo(map);
});
map.on('mouseenter', 'taxi-stands-circle', function() {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'taxi-stands-circle', function() {
map.getCanvas().style.cursor = '';
});
function loadTaxiStands() {
const bounds = map.getBounds();
const buffer = 0.005;
const params = new URLSearchParams({
west: bounds.getWest() - buffer,
south: bounds.getSouth() - buffer,
east: bounds.getEast() + buffer,
north: bounds.getNorth() + buffer
});
fetch('{% url "observations:taxi_stands_geojson" %}?' + params.toString())
.then(response => response.json())
.then(data => {
if (map.getSource('taxi-stands')) {
map.getSource('taxi-stands').setData(data);
}
})
.catch(error => {
console.error('Error loading taxi stands:', error);
});
}
window.loadTaxiStands = loadTaxiStands;
// Detect if we're on mobile route (width <= 768px or mobile path)
const isMobileView = window.innerWidth <= 768 || window.location.pathname.includes('/mobile/');
// Add popup on click
map.on('click', 'existing-observations-circle', function (e) {
e.preventDefault();
const feature = e.features[0];
const props = feature.properties;
// Use mobile or desktop route based on detection
const detailUrl = isMobileView
? `/mobile/observations/${props.id}/`
: `/observations/${props.id}/`;
const statusColor = statusColors[props.status] || '#6c757d';
const descBlock = props.description
? `<div class="obs-popup-desc">${props.description}</div>`
: '';
const popupContent = `
<div class="obs-popup-header" style="background:${statusColor};">
${props.status_label}
</div>
<div class="obs-popup-body">
<div class="obs-popup-type">
<i class="bi bi-tag-fill" style="opacity:.6;"></i>
${props.observation_type}
</div>
${descBlock}
<a href="${detailUrl}" class="obs-popup-btn">
<i class="bi bi-arrow-right-circle"></i> {% translate "Voir le détail" %}
</a>
</div>
`;
new maplibregl.Popup({ offset: 12 })
.setLngLat(feature.geometry.coordinates)
.setHTML(popupContent)
.addTo(map);
});
// Change cursor on hover
map.on('mouseenter', 'existing-observations-circle', function () {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'existing-observations-circle', function () {
map.getCanvas().style.cursor = '';
});
// Load observations when filters change
function loadNearbyObservations() {
const thematicId = document.getElementById('id_thematic').value;
const categoryId = document.getElementById('id_category').value;
// Only load if we have at least a thematic and category
if (!thematicId || !categoryId) {
// Clear observations if filters are not set
if (map.getSource('existing-observations')) {
map.getSource('existing-observations').setData({
type: 'FeatureCollection',
features: []
});
}
return;
}
// Get map bounds with buffer
const bounds = map.getBounds();
const buffer = 0.005;
const observationType = document.getElementById('id_observation_type')?.value || '';
const params = new URLSearchParams({
thematic: thematicId,
category: categoryId,
observation_type: observationType,
west: bounds.getWest() - buffer,
south: bounds.getSouth() - buffer,
east: bounds.getEast() + buffer,
north: bounds.getNorth() + buffer
});
fetch('{% url "observations:observations_nearby_geojson" %}?' + params.toString())
.then(response => response.json())
.then(data => {
if (map.getSource('existing-observations')) {
map.getSource('existing-observations').setData(data);
}
})
.catch(error => {
console.error('Error loading nearby observations:', error);
});
}
// Reload observations on map move (with debounce)
let moveTimeout;
map.on('moveend', function() {
clearTimeout(moveTimeout);
moveTimeout = setTimeout(function() {
loadNearbyObservations();
const catEl = document.getElementById('id_category');
if (catEl && catEl.value) {
const selectedOpt = catEl.options[catEl.selectedIndex];
if (selectedOpt && (selectedOpt.dataset.code || '').toUpperCase() === 'SI_TAXI') {
loadTaxiStands();
}
loadFormInterventions(catEl.value);
}
}, 300);
});
// Expose loadNearbyObservations globally
window.loadNearbyObservations = loadNearbyObservations;
// Listen to category changes to load observations
const categorySelectEl = document.getElementById('id_category');
if (categorySelectEl) {
categorySelectEl.addEventListener('change', function() {
if (this.value) {
loadNearbyObservations();
loadFormInterventions(this.value);
// Show taxi stands when SI_TAXI category is selected
const selectedOpt = this.options[this.selectedIndex];
if (selectedOpt && (selectedOpt.dataset.code || '').toUpperCase() === 'SI_TAXI') {
loadTaxiStands();
} else {
if (map.getSource('taxi-stands')) {
map.getSource('taxi-stands').setData({ type: 'FeatureCollection', features: [] });
}
}
} else {
// Clear observations when no category selected
if (map.getSource('existing-observations')) {
map.getSource('existing-observations').setData({
type: 'FeatureCollection',
features: []
});
}
if (map.getSource('taxi-stands')) {
map.getSource('taxi-stands').setData({ type: 'FeatureCollection', features: [] });
}
loadFormInterventions(null);
}
});
}
// Listen to observation_type changes to reload observations
const obsTypeEl = document.getElementById('id_observation_type');
if (obsTypeEl) {
obsTypeEl.addEventListener('change', function() {
loadNearbyObservations();
});
}
// Listen to thematic changes to clear observations
const thematicInputEl = document.getElementById('id_thematic');
if (thematicInputEl) {
// Use MutationObserver to detect value changes on hidden input
const observer = new MutationObserver(function() {
// Clear observations when thematic changes (category will be reset)
if (map.getSource('existing-observations')) {
map.getSource('existing-observations').setData({
type: 'FeatureCollection',
features: []
});
}
if (map.getSource('taxi-stands')) {
map.getSource('taxi-stands').setData({ type: 'FeatureCollection', features: [] });
}
loadFormInterventions(null);
});
observer.observe(thematicInputEl, { attributes: true, attributeFilter: ['value'] });
}
// --- Interventions récentes (même catégorie, 3 derniers mois) ---
(function() {
const sz = 36;
const pad = 5;
// Image de remplissage
const canvas = document.createElement('canvas');
canvas.width = sz; canvas.height = sz;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, sz, sz);
ctx.fillStyle = '#000000';
ctx.beginPath();
ctx.moveTo(sz / 2, pad);
ctx.lineTo(sz - pad, sz - pad);
ctx.lineTo(pad, sz - pad);
ctx.closePath();
ctx.fill();
map.addImage('triangle-sdf', { width: sz, height: sz, data: ctx.getImageData(0, 0, sz, sz).data }, { sdf: true });
// Image de bord (contour strokeé, épaisseur uniforme)
const bc = document.createElement('canvas');
bc.width = sz; bc.height = sz;
const bctx = bc.getContext('2d');
bctx.clearRect(0, 0, sz, sz);
bctx.strokeStyle = '#000000';
bctx.lineWidth = 6;
bctx.lineJoin = 'round';
bctx.beginPath();
bctx.moveTo(sz / 2, pad);
bctx.lineTo(sz - pad, sz - pad);
bctx.lineTo(pad, sz - pad);
bctx.closePath();
bctx.stroke();
map.addImage('triangle-sdf-border', { width: sz, height: sz, data: bctx.getImageData(0, 0, sz, sz).data }, { sdf: true });
})();
map.addSource('form-interventions', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
map.addLayer({
id: 'form-interventions-border',
type: 'symbol',
source: 'form-interventions',
layout: {
'icon-image': 'triangle-sdf-border',
'icon-size': ['interpolate', ['linear'], ['zoom'], 10, 0.35, 14, 0.65, 18, 1.0],
'icon-allow-overlap': true,
'icon-ignore-placement': true,
},
paint: {
'icon-color': '#ffffff',
}
});
map.addLayer({
id: 'form-interventions-symbol',
type: 'symbol',
source: 'form-interventions',
layout: {
'icon-image': 'triangle-sdf',
'icon-size': ['interpolate', ['linear'], ['zoom'], 10, 0.35, 14, 0.65, 18, 1.0],
'icon-allow-overlap': true,
'icon-ignore-placement': true,
},
paint: {
'icon-color': INTERVENTION_STATUS_COLOR_EXPR,
}
});
map.on('mouseenter', 'form-interventions-symbol', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'form-interventions-symbol', () => { map.getCanvas().style.cursor = ''; });
map.on('click', 'form-interventions-symbol', function(e) {
e.preventDefault();
const lngLat = e.features[0].geometry.coordinates.slice();
const cards = e.features.map(function(f) {
const p = f.properties;
const color = p.status_color || '#6c757d';
const titleBlock = p.title
? `<div style="font-size:.82rem;color:#333;margin-bottom:6px;">${p.title}</div>` : '';
return `
<div style="margin-bottom:6px;border:1px solid #dee2e6;border-radius:6px;overflow:hidden;">
<div style="padding:6px 10px;color:#fff;font-weight:600;font-size:.85rem;background:${color};">
${p.status_label}
</div>
<div style="padding:8px 10px;font-size:.82rem;">
<div style="color:#555;margin-bottom:4px;font-weight:600;">
<i class="bi bi-tools" style="opacity:.7;"></i> ${p.code}
</div>
${titleBlock}
<a href="/interventions/${p.id}/" target="_blank" style="font-size:.8rem;color:#0d6efd;">
<i class="bi bi-arrow-right-circle"></i> {% translate "Voir l'intervention" %}
</a>
</div>
</div>`;
}).join('');
new maplibregl.Popup({ offset: 14, maxWidth: '260px' })
.setLngLat(lngLat)
.setHTML(`<div style="max-height:320px;overflow-y:auto;">${cards}</div>`)
.addTo(map);
});
function loadFormInterventions(categoryId) {
if (!categoryId) {
if (map.getSource('form-interventions')) {
map.getSource('form-interventions').setData({ type: 'FeatureCollection', features: [] });
}
document.getElementById('form-map-layers-bar').style.display = 'none';
return;
}
const bounds = map.getBounds();
const buffer = 0.005;
const params = new URLSearchParams({
category: categoryId,
west: bounds.getWest() - buffer,
south: bounds.getSouth() - buffer,
east: bounds.getEast() + buffer,
north: bounds.getNorth() + buffer,
});
fetch('{% url "observations:observations_nearby_interventions_geojson" %}?' + params.toString())
.then(r => r.json())
.then(data => {
if (map.getSource('form-interventions')) {
map.getSource('form-interventions').setData(data);
}
const count = (data.features || []).length;
const countEl = document.getElementById('form-interv-count');
if (countEl) {
countEl.textContent = count > 0
? `${count} {% translate "intervention(s) proche(s) dans les 3 derniers mois" %}`
: '{% translate "Aucune intervention récente" %}';
}
document.getElementById('form-map-layers-bar').style.display = '';
})
.catch(() => {});
}
window.loadFormInterventions = loadFormInterventions;
document.getElementById('toggle-form-interventions')?.addEventListener('click', function() {
const vis = map.getLayoutProperty('form-interventions-symbol', 'visibility') !== 'none';
const newVis = vis ? 'none' : 'visible';
map.setLayoutProperty('form-interventions-symbol', 'visibility', newVis);
map.setLayoutProperty('form-interventions-border', 'visibility', newVis);
this.classList.toggle('btn-secondary', !vis);
this.classList.toggle('btn-outline-secondary', vis);
});
// Load on initial category (edit mode)
const _initCatEl = document.getElementById('id_category');
if (_initCatEl && _initCatEl.value) {
loadFormInterventions(_initCatEl.value);
}
// --- Fin interventions récentes ---
});
function applyPositionToMap(position) {
const coords = [position.coords.longitude, position.coords.latitude];
const geojson = {
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: coords
}
}]
};
if (map.isStyleLoaded() && map.getSource('user-location')) {
map.getSource('user-location').setData(geojson);
}
// Mise à jour initiale : centrage + placement du marqueur si formulaire vide
if (!document.getElementById('id_latitude').value) {
map.flyTo({ center: coords, zoom: 18 });
marker.setLngLat(coords);
updateFormFields(marker.getLngLat());
getNearestAddress(coords[0], coords[1]);
}
}
function updateLivePosition(position) {
if (map.isStyleLoaded() && map.getSource('user-location')) {
applyPositionToMap(position);
} else {
pendingPosition = position;
}
}
function handleError(error) {
console.warn("Erreur de géolocalisation :", error);
}
if ("geolocation" in navigator) {
navigator.geolocation.getCurrentPosition(updateLivePosition, handleError, {
enableHighAccuracy: true,
timeout: 10000
});
navigator.geolocation.watchPosition(updateLivePosition, handleError, {
enableHighAccuracy: true,
maximumAge: 5000,
timeout: 10000
});
}
// Mémoriser le bouton de soumission cliqué (save ou send)
let submittedAction = null;
const saveBtnEl = document.getElementById('save-btn');
const sendBtnEl = document.getElementById('send-btn');
if (saveBtnEl) saveBtnEl.addEventListener('mousedown', function() { submittedAction = 'save'; });
if (sendBtnEl) sendBtnEl.addEventListener('mousedown', function() { submittedAction = 'send'; });
document.querySelector('form').addEventListener('submit', function (e) {
e.preventDefault();
const form = this;
// Vérification qu'il existe au moins une photo (nouvelles ou existantes)
const photoInputs = document.querySelectorAll('input[name="photos"]');
let newPhotos = 0;
photoInputs.forEach(input => {
if (input.files && input.files.length > 0) newPhotos++;
});
const existingPhotoElements = document.querySelectorAll('.preview-wrapper[data-existing-photo]');
const totalPhotos = newPhotos + existingPhotoElements.length;
if (totalPhotos === 0) {
alert("{% translate 'Veuillez sélectionner au moins une photo.' %}");
return;
}
// Collecter les données de la checklist
if (typeof window.getChecklistData === 'function') {
document.getElementById('checklist_data').value = JSON.stringify(window.getChecklistData());
}
// Construire le FormData et ajouter le bouton d'action
const formData = new FormData(form);
if (submittedAction) formData.append(submittedAction, '1');
// Calculer la taille totale des fichiers
let totalBytes = 0;
for (const [, value] of formData.entries()) {
if (value instanceof File) totalBytes += value.size;
}
const hasFiles = totalBytes > 0;
// Références aux éléments de l'overlay
const loadingOverlay = document.getElementById('loading-overlay');
const uploadProgressSection = document.getElementById('upload-progress-section');
const processingSection = document.getElementById('processing-section');
const progressBar = document.getElementById('upload-progress-bar');
const progressText = document.getElementById('upload-progress-text');
const sizeText = document.getElementById('upload-size-text');
// Afficher l'overlay
loadingOverlay.style.display = 'block';
if (hasFiles) {
const totalMb = (totalBytes / (1024 * 1024)).toFixed(2);
uploadProgressSection.style.display = 'block';
processingSection.style.display = 'none';
sizeText.textContent = `0,00 Mo / ${totalMb} Mo`;
} else {
processingSection.style.display = 'block';
}
// Envoi via XHR pour suivre la progression
const xhr = new XMLHttpRequest();
const formAction = form.getAttribute('action') || window.location.href;
xhr.open('POST', formAction);
if (hasFiles) {
xhr.upload.addEventListener('progress', function(event) {
if (!event.lengthComputable) return;
const percent = Math.round((event.loaded / event.total) * 100);
const loadedMb = (event.loaded / (1024 * 1024)).toFixed(2);
const totalMbDisp = (event.total / (1024 * 1024)).toFixed(2);
progressBar.style.width = percent + '%';
progressBar.setAttribute('aria-valuenow', percent);
progressBar.textContent = percent + ' %';
progressText.textContent = `{% translate "Envoi des fichiers…" %} ${percent} %`;
sizeText.textContent = `${loadedMb} Mo / ${totalMbDisp} Mo`;
});
xhr.upload.addEventListener('load', function() {
// Transfert terminé, le serveur traite la requête
uploadProgressSection.style.display = 'none';
processingSection.style.display = 'block';
});
}
xhr.addEventListener('load', function() {
const responseUrl = new URL(xhr.responseURL || formAction, window.location.href);
const actionUrl = new URL(formAction, window.location.href);
if (responseUrl.pathname !== actionUrl.pathname) {
// Redirection (succès) — naviguer vers la nouvelle URL
window.location.href = xhr.responseURL;
} else {
// Même URL — le formulaire contient probablement des erreurs
document.open('text/html', 'replace');
document.write(xhr.responseText);
document.close();
window.history.pushState({}, '', xhr.responseURL || formAction);
}
});
xhr.addEventListener('error', function() {
loadingOverlay.style.display = 'none';
alert("{% translate \"Une erreur est survenue lors de l'envoi. Veuillez réessayer.\" %}");
});
xhr.addEventListener('abort', function() {
loadingOverlay.style.display = 'none';
});
xhr.send(formData);
});
let photoInputIndex = 0;
const inputContainer = document.getElementById('photo-inputs');
const previewContainer = document.getElementById('preview-container');
const selectFilesBtn = document.getElementById('select-files-btn');
const filesInput = document.getElementById('mobile-files-input');
// Fonction pour ajouter un fichier à l'aperçu
function addFileToPreview(file) {
const currentIndex = photoInputIndex++;
const singleInput = document.createElement('input');
singleInput.type = 'file';
singleInput.name = 'photos';
singleInput.className = 'd-none';
singleInput.dataset.index = currentIndex;
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
singleInput.files = dataTransfer.files;
inputContainer.appendChild(singleInput);
const wrapper = document.createElement('div');
wrapper.className = 'col-auto preview-wrapper';
wrapper.dataset.inputIndex = currentIndex;
const reader = new FileReader();
reader.onload = function(e) {
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'preview-image';
const btn = document.createElement('button');
btn.type = 'button';
btn.innerHTML = '×';
btn.className = 'remove-btn';
btn.onclick = function() {
singleInput.remove();
wrapper.remove();
};
wrapper.appendChild(img);
wrapper.appendChild(btn);
previewContainer.appendChild(wrapper);
};
reader.readAsDataURL(file);
}
// Fonction pour afficher les photos existantes (mode édition)
function displayExistingPhotos(photos) {
if (!photos || photos.length === 0) return;
photos.forEach((photo, index) => {
const wrapper = document.createElement('div');
wrapper.className = 'col-auto preview-wrapper';
wrapper.dataset.existingPhoto = photo.id;
const img = document.createElement('img');
img.src = photo.thumb_url || photo.url;
img.className = 'preview-image';
img.alt = '{% translate "Photo existante" %}';
const btn = document.createElement('button');
btn.type = 'button';
btn.innerHTML = '×';
btn.className = 'remove-btn';
btn.onclick = function() {
// Ajouter l'ID de la photo à supprimer dans un champ caché
const deleteInput = document.createElement('input');
deleteInput.type = 'hidden';
deleteInput.name = 'delete_photos';
deleteInput.value = photo.id;
document.getElementById('observation-form').appendChild(deleteInput);
wrapper.remove();
};
wrapper.appendChild(img);
wrapper.appendChild(btn);
previewContainer.appendChild(wrapper);
});
}
// Afficher les photos existantes en mode édition
if (isEditMode && existingPhotos && existingPhotos.length > 0) {
displayExistingPhotos(existingPhotos);
}
// ========================================
// Compression des photos volumineuses
// ========================================
const COMPRESSION_THRESHOLD_MB = 3;
const COMPRESSION_THRESHOLD = COMPRESSION_THRESHOLD_MB * 1024 * 1024;
const COMPRESS_MAX_DIMENSION = 1920;
const COMPRESS_JPEG_QUALITY = 0.75;
let pendingFilesForPreview = [];
function formatFileMB(bytes) {
return (bytes / (1024 * 1024)).toFixed(1);
}
function compressImage(file) {
return new Promise(function(resolve) {
const reader = new FileReader();
reader.onerror = function() { resolve(file); };
reader.onload = function(e) {
const img = new Image();
img.onerror = function() { resolve(file); };
img.onload = function() {
let w = img.width;
let h = img.height;
if (w > COMPRESS_MAX_DIMENSION || h > COMPRESS_MAX_DIMENSION) {
if (w >= h) {
h = Math.round(h * COMPRESS_MAX_DIMENSION / w);
w = COMPRESS_MAX_DIMENSION;
} else {
w = Math.round(w * COMPRESS_MAX_DIMENSION / h);
h = COMPRESS_MAX_DIMENSION;
}
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
canvas.toBlob(function(blob) {
if (!blob) { resolve(file); return; }
const name = file.name.replace(/\.[^.]+$/, '') + '.jpg';
resolve(new File([blob], name, { type: 'image/jpeg' }));
}, 'image/jpeg', COMPRESS_JPEG_QUALITY);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
function processFiles(files) {
const largeFiles = files.filter(function(f) { return f.type.startsWith('image/') && f.size > COMPRESSION_THRESHOLD; });
if (largeFiles.length > 0) {
pendingFilesForPreview = files;
const totalLargeSize = largeFiles.reduce(function(sum, f) { return sum + f.size; }, 0);
const textPhotosExceed = "{% translate 'photo(s) dépassent' %}";
const textCumulativeSize = "{% translate 'taille cumulée' %}";
const textCompressionHelp1 = "{% translate 'La compression réduira la résolution à max' %}";
const textCompressionHelp2 = "{% translate 'et la qualité JPEG à' %}";
document.getElementById('compress-modal-info').innerHTML =
'<strong>' + largeFiles.length + '</strong> ' + textPhotosExceed + ' ' + COMPRESSION_THRESHOLD_MB + '\u00a0Mo ' +
'(' + textCumulativeSize + '\u00a0: <strong>' + formatFileMB(totalLargeSize) + '\u00a0Mo</strong>).<br>' +
'<span class="text-muted small">' + textCompressionHelp1 + '\u00a0' + COMPRESS_MAX_DIMENSION + '\u00a0px ' +
textCompressionHelp2 + '\u00a0' + Math.round(COMPRESS_JPEG_QUALITY * 100) + '\u00a0%.</span>';
const compressionModalEl = document.getElementById('compressionModal');
if (compressionModalEl && typeof bootstrap !== 'undefined') {
bootstrap.Modal.getOrCreateInstance(compressionModalEl).show();
}
} else {
files.forEach(function(f) { addFileToPreview(f); });
}
}
// Gestion du bouton de sélection de fichiers
if (selectFilesBtn && filesInput) {
selectFilesBtn.addEventListener('click', function() {
filesInput.click();
});
filesInput.addEventListener('change', function() {
const files = Array.from(this.files);
this.value = '';
processFiles(files);
});
}
// ========================================
// Gestion du modal caméra (mode continu)
// ========================================
const openCameraModalBtn = document.getElementById('open-camera-modal-btn');
const cameraModalEl = document.getElementById('cameraModal');
const cameraPreview = document.getElementById('cameraPreview');
const cameraCanvas = document.getElementById('cameraCanvas');
const cameraFlashOverlay = document.getElementById('cameraFlashOverlay');
const cameraStatus = document.getElementById('cameraStatus');
const cameraCaptureBtn = document.getElementById('cameraCaptureBtn');
const cameraStopBtn = document.getElementById('cameraStopBtn');
let cameraModal = null;
let cameraStream = null;
const isCameraSupported = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
if (cameraModalEl && typeof bootstrap !== 'undefined' && bootstrap.Modal) {
cameraModal = bootstrap.Modal.getOrCreateInstance(cameraModalEl);
}
function triggerCameraFlash() {
if (!cameraFlashOverlay) return;
cameraFlashOverlay.style.opacity = '1';
setTimeout(() => {
cameraFlashOverlay.style.opacity = '0';
}, 150);
}
function updateCameraStatus(message, type = 'info') {
if (!cameraStatus) return;
cameraStatus.textContent = message || '';
cameraStatus.className = 'small text-center mb-2';
if (type === 'error') {
cameraStatus.classList.add('text-danger');
} else if (type === 'success') {
cameraStatus.classList.add('text-success');
} else {
cameraStatus.classList.add('text-muted');
}
}
// Détecte si l'application tourne en mode PWA (standalone / fullscreen)
function isPWAStandalone() {
return (
window.matchMedia('(display-mode: standalone)').matches ||
window.matchMedia('(display-mode: fullscreen)').matches ||
window.navigator.standalone === true
);
}
// Affiche une modale d'aide à la permission caméra adaptée au mode PWA sur Android
function showCameraPermissionHelp(isDenied) {
const pwa = isPWAStandalone();
let msg;
if (isDenied && pwa) {
msg = [
'{% translate "La permission caméra a été refusée pour cette application." %}',
"",
'{% translate "Pour la réactiver sur Android (Samsung One UI) :" %}',
' 1. {% translate "Ouvrez les Paramètres Android" %}',
' 2. {% translate "Applications → Chrome (ou Internet)" %}',
' 3. {% translate "Autorisations → Appareil photo → Autoriser" %}',
' 4. {% translate "Revenez dans l\'application et réessayez." %}',
].join('\n');
} else if (isDenied) {
msg = '{% translate "La permission caméra a été refusée. Veuillez l\'autoriser dans les paramètres de votre navigateur, puis rechargez la page." %}';
} else {
msg = '{% translate "Impossible d\'accéder à la caméra. Veuillez vérifier les autorisations." %}';
}
alert(msg);
}
async function startCameraStream() {
if (!isCameraSupported) {
alert('{% translate "La caméra avancée n\'est pas supportée sur ce navigateur." %}');
return false;
}
// Vérification proactive via l'API Permissions (disponible sur Chrome/Android)
if (navigator.permissions && navigator.permissions.query) {
try {
const permStatus = await navigator.permissions.query({ name: 'camera' });
if (permStatus.state === 'denied') {
showCameraPermissionHelp(true);
updateCameraStatus('{% translate "Permission caméra refusée." %}', 'error');
return false;
}
// Si 'prompt' : on continue, getUserMedia déclenchera la boîte de dialogue système
} catch (permErr) {
// L'API Permissions n'est pas disponible ou l'appareil photo n'est pas requêtable → on continue
console.warn('Permissions API non disponible pour la caméra:', permErr);
}
}
try {
updateCameraStatus('{% translate "Initialisation de la caméra…" %}', 'info');
cameraStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false,
});
if ('srcObject' in cameraPreview) {
cameraPreview.srcObject = cameraStream;
} else {
cameraPreview.src = window.URL.createObjectURL(cameraStream);
}
cameraPreview.play();
updateCameraStatus('{% translate "Caméra prête. Appuyez sur Capturer pour prendre une photo." %}', 'info');
return true;
} catch (err) {
console.error('Erreur getUserMedia:', err);
// NotAllowedError = refus utilisateur ou permission système bloquée
const isDenied = (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError');
showCameraPermissionHelp(isDenied);
updateCameraStatus('{% translate "Impossible d\'accéder à la caméra." %}', 'error');
return false;
}
}
function stopCameraStream() {
if (cameraStream) {
cameraStream.getTracks().forEach(track => track.stop());
cameraStream = null;
}
if (cameraPreview) {
cameraPreview.srcObject = null;
}
updateCameraStatus('', 'info');
}
function captureFrame() {
if (!cameraPreview || !cameraCanvas) return;
const videoWidth = cameraPreview.videoWidth;
const videoHeight = cameraPreview.videoHeight;
if (!videoWidth || !videoHeight) {
updateCameraStatus('{% translate "La vidéo n\'est pas encore prête." %}', 'error');
return;
}
cameraCanvas.width = videoWidth;
cameraCanvas.height = videoHeight;
const ctx = cameraCanvas.getContext('2d');
ctx.drawImage(cameraPreview, 0, 0, videoWidth, videoHeight);
cameraCanvas.toBlob(function(blob) {
if (!blob) {
updateCameraStatus("{% translate 'Erreur lors de la capture.' %}", 'error');
return;
}
const filename = `observation_photo_${Date.now()}.jpg`;
const file = new File([blob], filename, { type: 'image/jpeg' });
addFileToPreview(file);
updateCameraStatus('{% translate "Photo capturée avec succès." %}', 'success');
}, 'image/jpeg', 0.9);
}
if (openCameraModalBtn && cameraModal) {
openCameraModalBtn.addEventListener('click', async function() {
const ok = await startCameraStream();
if (!ok) return;
cameraModal.show();
});
}
if (cameraModalEl) {
cameraModalEl.addEventListener('hidden.bs.modal', function() {
stopCameraStream();
});
}
if (cameraStopBtn) {
cameraStopBtn.addEventListener('click', function() {
if (cameraModal) {
cameraModal.hide();
}
});
}
if (cameraCaptureBtn) {
cameraCaptureBtn.addEventListener('click', function() {
triggerCameraFlash();
captureFrame();
});
}
// ========================================
// Gestion de la modale de compression
// ========================================
const compressSkipBtn = document.getElementById('compress-skip-btn');
const compressConfirmBtn = document.getElementById('compress-confirm-btn');
const compressCancelBtn = document.getElementById('compress-cancel-btn');
function dismissCompressionModal() {
const el = document.getElementById('compressionModal');
if (el && typeof bootstrap !== 'undefined') {
bootstrap.Modal.getOrCreateInstance(el).hide();
}
}
if (compressCancelBtn) {
compressCancelBtn.addEventListener('click', function() {
pendingFilesForPreview = [];
});
}
if (compressSkipBtn) {
compressSkipBtn.addEventListener('click', function() {
dismissCompressionModal();
const files = pendingFilesForPreview.slice();
pendingFilesForPreview = [];
files.forEach(function(f) { addFileToPreview(f); });
});
}
if (compressConfirmBtn) {
compressConfirmBtn.addEventListener('click', async function() {
dismissCompressionModal();
const files = pendingFilesForPreview.slice();
pendingFilesForPreview = [];
const btn = this;
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>{% translate "Compression…" %}';
const processed = await Promise.all(
files.map(function(f) {
return (f.type.startsWith('image/') && f.size > COMPRESSION_THRESHOLD)
? compressImage(f)
: Promise.resolve(f);
})
);
processed.forEach(function(f) { addFileToPreview(f); });
btn.disabled = false;
btn.innerHTML = originalHtml;
});
}
// Autocomplete pour l'adresse
// const autocomplete_street_url = "{% url 'common:autocomplete_street' %}";
// $(function() {
// $("#address").autocomplete({
// minLength: 2,
// source: function(request, response) {
// $.getJSON(autocomplete_street_url, {
// q: request.term
// }, function(data) {
// if (data.length > 0) {
// response(data);
// } else {
// response([{ label: "Aucune adresse trouvée", value: "" }]);
// }
// });
// },
// select: function(event, ui) {
// $("#address").val(ui.item.label);
// $('#address_result').text(ui.item.label);
// map.flyTo({ center: [ui.item.lon, ui.item.lat], zoom: 15 });
// marker.setLngLat([ui.item.lon, ui.item.lat]);
// updateFormFields(marker.getLngLat());
// return false;
// }
// });
// });
// Fonction pour récupérer l'adresse la plus proche
function getNearestAddress(lon, lat) {
$.ajax({
url: "/common/address/get-nearest-address/",
data: { lon: lon, lat: lat },
success: function(response) {
if (response.label) {
document.getElementById('address_result').textContent = response.label;
} else {
console.warn("Aucune adresse trouvée pour ces coordonnées.");
document.getElementById('address_result').textContent = "";
}
updateFormFields({ lat: lat, lng: lon });
},
error: function(xhr) {
console.warn("Adresse non trouvée :", xhr.responseJSON?.error || "Erreur");
document.getElementById('address_result').textContent = "";
updateFormFields({ lat: lat, lng: lon });
}
});
}
// Bouton pour effacer l'adresse
document.getElementById('clear-btn').addEventListener('click', function () {
document.getElementById('address').value = '';
document.getElementById('address_result').textContent = '';
document.getElementById('id_address').value = '';
document.getElementById('autocomplete-list').classList.add('d-none');
});
// Autocomplete pour l'adresse
const autocompleteStreetUrl = "{% url 'common:autocomplete_street' %}";
document.addEventListener("DOMContentLoaded", function () {
const addressInput = document.getElementById("address");
const resultContainer = document.getElementById("autocomplete-list");
const resultDisplay = document.getElementById("address_result");
const autocompleteStreetUrl = "{% url 'common:autocomplete_street' %}";
let debounceTimer;
addressInput.addEventListener("input", function () {
const query = this.value.trim();
clearTimeout(debounceTimer);
if (query.length < 2) {
resultContainer.classList.add("d-none");
return;
}
debounceTimer = setTimeout(() => {
fetch(`${autocompleteStreetUrl}?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
resultContainer.innerHTML = "";
const noAddressText = "{% translate 'Aucune adresse trouvée' %}";
if (data.length === 0) {
addItem({ label: noAddressText, value: "" }, true);
} else {
data.forEach(item => addItem(item));
}
resultContainer.classList.remove("d-none");
});
}, 250); // léger délai pour éviter les requêtes en rafale
});
function handleSelection(item) {
addressInput.blur(); // masque le clavier mobile
resultDisplay.textContent = item.label;
addressInput.value = "";
resultContainer.classList.add("d-none");
map.flyTo({ center: [item.lon, item.lat], zoom: 18 });
marker.setLngLat([item.lon, item.lat]);
updateFormFields(marker.getLngLat());
}
function addItem(item) {
const li = document.createElement("li");
li.className = "list-group-item list-group-item-action";
li.textContent = item.label;
li.setAttribute("tabindex", "0");
// Cas spécial : suggestion de type "Aucune adresse trouvée"
if (item.label === "{% translate 'Aucune adresse trouvée' %}") {
li.classList.add("disabled");
resultContainer.appendChild(li);
return;
}
// --- Gestion desktop : pointerdown ---
if (!("ontouchstart" in window)) {
li.addEventListener("pointerdown", (e) => {
e.preventDefault(); // Évite les interférences visuelles
handleSelection(item);
});
}
// --- Gestion mobile : tap sans scroll ---
let touchStartY = 0;
li.addEventListener("touchstart", (e) => {
touchStartY = e.touches[0].clientY;
});
li.addEventListener("touchend", (e) => {
const touchEndY = e.changedTouches[0].clientY;
const deltaY = Math.abs(touchEndY - touchStartY);
if (deltaY < 10) {
e.preventDefault();
e.stopPropagation();
handleSelection(item); // Tap réel
}
});
resultContainer.appendChild(li);
}
// Hide suggestions on outside click
document.addEventListener("click", (e) => {
if (!resultContainer.contains(e.target) && e.target !== addressInput) {
resultContainer.classList.add("d-none");
}
});
});
// Boutons zoom carte
document.getElementById('zoom-in-btn').addEventListener('click', function() { map.zoomIn(); });
document.getElementById('zoom-out-btn').addEventListener('click', function() { map.zoomOut(); });
})();
</script>
</body>
</html>