3369 lines
167 KiB
HTML
3369 lines
167 KiB
HTML
{% 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' %}">−</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>
|