1313 lines
62 KiB
HTML
1313 lines
62 KiB
HTML
{% extends "mobile/mobile_base.html" %}
|
|
{% load static %}
|
|
{% load i18n %}
|
|
{% load observation_tags %}
|
|
{% load short_name %}
|
|
{% load mention_tags %}
|
|
|
|
{% block extra_head %}
|
|
<style>
|
|
.photo-thumbnail {
|
|
width: 90px;
|
|
height: 90px;
|
|
object-fit: cover;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
}
|
|
|
|
.photo-thumbnail:hover, .photo-thumbnail:active {
|
|
transform: scale(1.04);
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
|
}
|
|
|
|
.info-card {
|
|
background-color: #f8f9fa;
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.badge {
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.choice-badge {
|
|
transition: all 0.2s ease;
|
|
opacity: 0.7;
|
|
border: 2px solid transparent;
|
|
}
|
|
|
|
.choice-badge.active {
|
|
opacity: 1;
|
|
border-color: rgba(0, 0, 0, 0.2);
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.choice-badge:hover {
|
|
opacity: 0.9;
|
|
transform: scale(1.02);
|
|
}
|
|
|
|
.actions-card .btn {
|
|
font-size: 0.8rem !important;
|
|
}
|
|
/* jQuery UI autocomplete au-dessus des modales Bootstrap */
|
|
.ui-autocomplete { z-index: 2000 !important; }
|
|
</style>
|
|
<link rel="stylesheet" href="{% static 'common/jquery-ui.min.css' %}">
|
|
{% endblock extra_head %}
|
|
|
|
{% block upleft %}
|
|
<a href="{% url 'mobile:observations_index_mobile' %}" class="nav-link" style="color: white;">
|
|
<i class="bi bi-arrow-left fs-3" title="{% translate 'Retour à la liste' %}"></i>
|
|
</a>
|
|
{% endblock upleft %}
|
|
|
|
{% block title %}
|
|
{% translate "Détail observation" %}
|
|
{% endblock title %}
|
|
|
|
{% block content %}
|
|
|
|
<!-- Bloc Actions -->
|
|
<div class="card shadow-sm mb-3 actions-card">
|
|
<div class="card-header py-2 d-flex align-items-center gap-2">
|
|
<i class="bi bi-lightning-charge-fill text-warning"></i>
|
|
<strong>{% translate "Actions" %}</strong>
|
|
</div>
|
|
<div class="card-body p-3 d-flex flex-column gap-2">
|
|
|
|
{% if observation.intervention and observation.intervention.status == 'canceled' %}
|
|
<div class="alert alert-warning d-flex align-items-center gap-2 mb-2" role="alert">
|
|
<i class="bi bi-exclamation-triangle-fill text-warning fs-5 flex-shrink-0"></i>
|
|
<div class="small">
|
|
<strong>{% translate "Attention :" %}</strong>
|
|
{% translate "Une intervention liée à cette observation a été annulée" %}
|
|
{% if observation.intervention.cancellation_type %}
|
|
({{ observation.intervention.get_cancellation_type_display }})
|
|
{% endif %}
|
|
:
|
|
{% if can_view_intervention_detail %}
|
|
<a href="{% url 'mobile:intervention_detail_mobile' observation.intervention.id %}" class="alert-link">
|
|
{{ observation.intervention.code }}
|
|
</a>
|
|
{% else %}
|
|
<strong>{{ observation.intervention.code }}</strong>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Deux colonnes : Intervention/Projet | Statut -->
|
|
{% if observation.status == 'to_process' %}
|
|
{% if can_take_action %}
|
|
<div class="d-flex gap-3 align-items-start">
|
|
<!-- Colonne 1 : Intervention / Projet -->
|
|
<div class="d-flex flex-column gap-1" style="flex:1 1 0;">
|
|
{% if observation.observation_type == 'inventory' or observation.observation_type == 'remark' %}
|
|
<button type="button" class="btn btn-outline-primary btn-sm w-100"
|
|
data-bs-toggle="modal" data-bs-target="#modalChoixTypeIntervention">
|
|
<i class="bi bi-tools"></i> {% translate "Créer intervention" %}
|
|
</button>
|
|
{% else %}
|
|
<form method="post" action="{% url 'observations:create_intervention_from_observation' observation.id %}">
|
|
{% csrf_token %}
|
|
<button type="submit" class="btn btn-outline-primary btn-sm w-100">
|
|
<i class="bi bi-tools"></i> {% translate "Créer intervention" %}
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
{% if not observation.intervention %}
|
|
<button type="button" class="btn btn-outline-primary btn-sm w-100"
|
|
data-bs-toggle="modal" data-bs-target="#modalLierIntervention">
|
|
<i class="bi bi-link-45deg"></i> {% translate "Lier à une intervention" %}
|
|
</button>
|
|
{% endif %}
|
|
{% if not observation.project %}
|
|
<button type="button" class="btn btn-outline-primary btn-sm w-100"
|
|
data-bs-toggle="modal" data-bs-target="#modalLierProjet">
|
|
<i class="bi bi-diagram-3"></i> {% translate "Lier à un projet" %}
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
<!-- Colonne 2 : Statut -->
|
|
<div class="d-flex flex-column gap-1" style="flex:1 1 0;">
|
|
<button type="button" class="btn btn-outline-success btn-sm w-100"
|
|
data-bs-toggle="modal" data-bs-target="#actionStatusModal"
|
|
data-status="processed"
|
|
data-label="{% translate 'Marquer comme traitée' %}"
|
|
data-icon="bi-check2-circle" data-btn-class="btn-success">
|
|
<i class="bi bi-check2-circle"></i> {% translate "Traitée" %}
|
|
</button>
|
|
<button type="button" class="btn btn-outline-info btn-sm w-100"
|
|
data-bs-toggle="modal" data-bs-target="#actionStatusModal"
|
|
data-status="duplicate"
|
|
data-label="{% translate 'Doublon' %}"
|
|
data-icon="bi-files" data-btn-class="btn-info">
|
|
<i class="bi bi-files"></i> {% translate "Doublon" %}
|
|
</button>
|
|
<button type="button" class="btn btn-outline-warning btn-sm w-100"
|
|
data-bs-toggle="modal" data-bs-target="#actionStatusModal"
|
|
data-status="to_redirect"
|
|
data-label="{% translate 'À rediriger' %}"
|
|
data-icon="bi-reply-all" data-btn-class="btn-warning">
|
|
<i class="bi bi-reply-all"></i> {% translate "À rediriger" %}
|
|
</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm w-100"
|
|
data-bs-toggle="modal" data-bs-target="#actionStatusModal"
|
|
data-status="not_relevant"
|
|
data-label="{% translate 'Non relevant' %}"
|
|
data-icon="bi-slash-circle" data-btn-class="btn-secondary">
|
|
<i class="bi bi-slash-circle"></i> {% translate "Non relevant" %}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
<!-- Envoyer (in_preparation → to_process) -->
|
|
{% if observation.status == 'in_preparation' or not observation.status %}
|
|
<form method="post" action="{% url 'observations:observation_change_status' observation.id %}">
|
|
{% csrf_token %}
|
|
<input type="hidden" name="new_status" value="to_process">
|
|
<button type="submit" class="btn btn-outline-primary btn-sm" style="min-width:120px">
|
|
<i class="bi bi-send"></i> {% translate "Envoyer" %}
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
|
|
<!-- Archiver -->
|
|
{% if observation.status == 'processed' or observation.status == 'not_relevant' or observation.status == 'to_redirect' or observation.status == 'duplicate' %}
|
|
{% if 'admin' in user_roles or 'operator' in user_roles or 'manager' in user_roles or 'controller' in user_roles %}
|
|
<form method="post" action="{% url 'observations:observation_change_status' observation.id %}">
|
|
{% csrf_token %}
|
|
<input type="hidden" name="new_status" value="archived">
|
|
<button type="submit" class="btn btn-outline-secondary btn-sm" style="min-width:120px">
|
|
<i class="bi bi-archive"></i> {% translate "Archiver" %}
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary badges -->
|
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
|
<div>
|
|
<span class="badge bg-light text-dark me-1">{{ observation.code }}</span>
|
|
{% if can_edit or can_delete %}
|
|
<span class="ms-1">
|
|
{% if can_edit %}
|
|
{% if observation.status == 'in_preparation' or observation.status == 'to_process' or observation.status == 'to_redirect' %}
|
|
<a href="{% url 'mobile:observation_edit_mobile' observation.id %}" class="btn btn-outline-secondary btn-sm" style="font-size:small">
|
|
<i class="bi bi-pencil"></i> {% translate "Modifier" %}
|
|
</a>
|
|
{% endif %}
|
|
{% endif %}
|
|
{% if can_delete %}
|
|
<button type="button" class="btn btn-outline-danger btn-sm" style="font-size:small"
|
|
data-bs-toggle="modal" data-bs-target="#deleteObservationModal">
|
|
<i class="bi bi-trash"></i> {% translate "Supprimer" %}
|
|
</button>
|
|
{% endif %}
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
<div class="d-flex flex-column align-items-end gap-1">
|
|
<span class="badge {% if observation.observation_type == 'incident' %}bg-danger{% else %}bg-info{% endif %}">
|
|
{% if observation.observation_type == 'incident' %}<i class="bi bi-exclamation-triangle me-1"></i>{% else %}<i class="bi bi-clipboard-check me-1"></i>{% endif %}
|
|
{{ observation.get_observation_type_display }}
|
|
</span>
|
|
{% if observation.status == 'in_preparation' %}
|
|
<span class="badge bg-secondary text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ observation.get_status_display }}</span>
|
|
{% elif observation.status == 'to_process' %}
|
|
<span class="badge bg-danger"><i class="bi bi-clock-history me-1"></i>{{ observation.get_status_display }}</span>
|
|
{% elif observation.status == 'in_progress' %}
|
|
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ observation.get_status_display }}</span>
|
|
{% elif observation.status == 'processed' %}
|
|
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>{{ observation.get_status_display }}</span>
|
|
{% elif observation.status == 'not_relevant' %}
|
|
<span class="badge bg-secondary"><i class="bi bi-slash-circle me-1"></i>{{ observation.get_status_display }}</span>
|
|
{% elif observation.status == 'to_redirect' %}
|
|
<span class="badge bg-warning text-dark"><i class="bi bi-reply-all me-1"></i>{{ observation.get_status_display }}</span>
|
|
{% elif observation.status == 'duplicate' %}
|
|
<span class="badge bg-info text-dark"><i class="bi bi-files me-1"></i>{{ observation.get_status_display }}</span>
|
|
{% elif observation.status == 'closed' %}
|
|
<span class="badge bg-dark"><i class="bi bi-check-circle-fill me-1"></i>{{ observation.get_status_display }}</span>
|
|
{% elif observation.status == 'archived' %}
|
|
<span class="badge bg-dark"><i class="bi bi-archive me-1"></i>{{ observation.get_status_display }}</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Thematic and Category -->
|
|
<div class="info-card">
|
|
<div class="row">
|
|
<div class="col-6">
|
|
<h6 class="text-muted mb-2">{% translate "Thématique" %}</h6>
|
|
{% if observation.thematic %}
|
|
<span class="badge bg-light text-dark">
|
|
<i class="bi {{ observation.thematic.icon }} me-1"></i>
|
|
{{ observation.thematic.name }}
|
|
</span>
|
|
{% else %}
|
|
<p class="mb-0">-</p>
|
|
{% endif %}
|
|
</div>
|
|
<div class="col-6">
|
|
<h6 class="text-muted mb-2">{% translate "Catégorie" %}</h6>
|
|
{% if observation.category %}
|
|
<span class="badge bg-light text-dark">
|
|
{% if observation.category.parent %}{{ observation.category.parent.get_name }} — {% endif %}{{ observation.category.get_name }}
|
|
</span>
|
|
{% else %}
|
|
<p class="mb-0">-</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div class="info-card">
|
|
<h6 class="text-muted mb-2">{% translate "Description" %}</h6>
|
|
<p class="mb-0">{{ observation.description|default:"-" }}</p>
|
|
</div>
|
|
|
|
<!-- Location -->
|
|
{% if observation.address %}
|
|
<div class="info-card">
|
|
<h6 class="text-muted mb-2"><i class="bi bi-geo-alt"></i> {% translate "Adresse" %}</h6>
|
|
<p class="mb-0">{{ observation.address }}</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Created info -->
|
|
<div class="info-card">
|
|
<div class="row">
|
|
<div class="col-6">
|
|
<h6 class="text-muted mb-2">{% translate "Créé le" %}</h6>
|
|
<p class="mb-0">{{ observation.created_at|date:"d/m/Y H:i" }}</p>
|
|
</div>
|
|
<div class="col-6">
|
|
<h6 class="text-muted mb-2">{% translate "Créé par" %}</h6>
|
|
<p class="mb-0">
|
|
{% if observation.created_by %}
|
|
{{ observation.created_by|short_name }}{% with comp=observation.created_by|user_company %}{% if comp %} ({{ comp }}){% endif %}{% endwith %}
|
|
{% else %}
|
|
-
|
|
{% endif %}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Symptom -->
|
|
{% if observation.symptom %}
|
|
<div class="info-card">
|
|
<h6 class="text-muted mb-2">{% translate "Symptôme" %}</h6>
|
|
<span class="badge text-danger">{{ observation.symptom.name_fr }}</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Checklist -->
|
|
{% if checklist_template and checklist_items_with_responses %}
|
|
<div class="info-card">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h6 class="text-muted mb-0">
|
|
<i class="bi bi-clipboard-check me-1"></i>
|
|
{{ checklist_template.get_name }}
|
|
</h6>
|
|
</div>
|
|
|
|
<!-- Display mode -->
|
|
<div id="checklistDisplay">
|
|
{% for item_data in checklist_items_with_responses %}
|
|
<div class="d-flex justify-content-between align-items-start py-2 {% if not forloop.last %}border-bottom{% endif %}">
|
|
<div class="flex-grow-1">
|
|
<div class="fw-medium">{{ item_data.item.get_name }}</div>
|
|
{% if item_data.item.get_description %}
|
|
<small class="text-muted">{{ item_data.item.get_description }}</small>
|
|
{% endif %}
|
|
</div>
|
|
<div class="ms-2 text-end">
|
|
{% if item_data.response %}
|
|
{% if item_data.item.item_type == 'boolean' %}
|
|
{% if item_data.response.value_boolean %}
|
|
<span class="badge bg-success"><i class="bi bi-check-lg"></i> {% translate "Oui" %}</span>
|
|
{% elif item_data.response.value_boolean == False %}
|
|
<span class="badge bg-danger"><i class="bi bi-x-lg"></i> {% translate "Non" %}</span>
|
|
{% else %}
|
|
<span class="badge bg-secondary">-</span>
|
|
{% endif %}
|
|
{% elif item_data.item.item_type == 'text' %}
|
|
<span class="text-dark">{{ item_data.response.value_text|default:"-" }}</span>
|
|
{% elif item_data.item.item_type == 'number' %}
|
|
<span class="text-dark">{{ item_data.response.value_number|default:"-" }}</span>
|
|
{% elif item_data.item.item_type == 'choice' %}
|
|
{% display_choice_value item_data.item item_data.response.value_choice %}
|
|
{% elif item_data.item.item_type == 'choice_multiple' %}
|
|
{% display_choice_value item_data.item item_data.response.value_choices %}
|
|
{% endif %}
|
|
{% if item_data.response.comment %}
|
|
<div class="small text-muted mt-1"><i class="bi bi-chat-dots"></i> {{ item_data.response.comment }}</div>
|
|
{% endif %}
|
|
{% else %}
|
|
<span class="badge bg-secondary">-</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Linked intervention -->
|
|
{% if observation.intervention %}
|
|
<div class="info-card">
|
|
<h6 class="text-muted mb-2">{% translate "Intervention liée" %}</h6>
|
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|
<span class="badge bg-{{ observation.intervention.get_status_color }} status-{{ observation.intervention.status }}">
|
|
{{ observation.intervention.get_status_display }}
|
|
</span>
|
|
{% if can_view_intervention_summary_modal %}
|
|
<button type="button"
|
|
class="btn btn-sm btn-outline-secondary"
|
|
data-bs-toggle="modal"
|
|
data-bs-target="#interventionSummaryModal"
|
|
data-summary-url="{% url 'interventions:intervention_summary_modal' observation.intervention.id %}?variant=mobile"
|
|
data-summary-title="{{ observation.intervention.code }}"
|
|
title="{% translate 'Voir le résumé' %}">
|
|
<i class="bi bi-eye"></i>
|
|
</button>
|
|
{% endif %}
|
|
{% if can_view_intervention_detail %}
|
|
<a class="btn btn-outline-primary btn-sm" href="{% url 'mobile:intervention_detail_mobile' observation.intervention.id %}">
|
|
<i class="bi bi-link-45deg"></i> {{ observation.intervention.code|default:observation.intervention.id }}
|
|
</a>
|
|
{% else %}
|
|
<span class="text-muted">{{ observation.intervention.code|default:observation.intervention.id }}</span>
|
|
{% endif %}
|
|
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Photos -->
|
|
{% if photos %}
|
|
<div class="info-card">
|
|
<h6 class="text-muted mb-3">{% translate "Photos" %} ({{ photos|length }})</h6>
|
|
<div class="d-flex flex-wrap gap-2">
|
|
{% for photo in photos %}
|
|
{% private_file_url photo 'photo' as photo_url %}
|
|
{% private_file_url photo 'thumbnail' as thumb_url %}
|
|
<img src="{{ thumb_url|default:photo_url }}"
|
|
alt="Photo"
|
|
class="photo-thumbnail"
|
|
data-bs-toggle="modal"
|
|
data-bs-target="#photoLightboxModalMobile"
|
|
onclick="openPhotoLightboxMobile({{ forloop.counter0 }})">
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Modal lightbox photo carrousel (mobile) -->
|
|
<div class="modal fade" id="photoLightboxModalMobile" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered modal-fullscreen-sm-down">
|
|
<div class="modal-content bg-dark border-0">
|
|
<div class="modal-header border-0 pb-1 px-3 d-flex justify-content-between align-items-center">
|
|
<span id="photoLightboxCounterMobile" class="text-white-50 small"></span>
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
|
</div>
|
|
<div class="modal-body text-center p-0 position-relative" id="lightboxBodyMobile" style="min-height: 200px; touch-action: pan-y;">
|
|
<button type="button" id="photoPrevBtnMobile"
|
|
class="btn btn-dark opacity-75 position-absolute start-0 top-50 translate-middle-y ms-2"
|
|
style="z-index:10; border-radius:50%; width:40px; height:40px; padding:0;"
|
|
onclick="lightboxNavMobile(-1)">
|
|
<i class="bi bi-chevron-left"></i>
|
|
</button>
|
|
<img id="photoLightboxImageMobile" src="" alt=""
|
|
class="img-fluid rounded px-5"
|
|
style="max-height: 75vh; object-fit: contain;">
|
|
<button type="button" id="photoNextBtnMobile"
|
|
class="btn btn-dark opacity-75 position-absolute end-0 top-50 translate-middle-y me-2"
|
|
style="z-index:10; border-radius:50%; width:40px; height:40px; padding:0;"
|
|
onclick="lightboxNavMobile(1)">
|
|
<i class="bi bi-chevron-right"></i>
|
|
</button>
|
|
</div>
|
|
<div class="modal-footer border-0 pt-1 justify-content-center">
|
|
<a id="photoLightboxLinkMobile" href="#" target="_blank" class="btn btn-sm btn-outline-light">
|
|
<i class="bi bi-box-arrow-up-right me-1"></i>{% translate "Ouvrir en plein écran" %}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Comments -->
|
|
<div class="info-card">
|
|
<h6 class="text-muted mb-2">{% translate "Commentaires" %}</h6>
|
|
{% if comments %}
|
|
<div class="list-group list-group-flush">
|
|
{% for c in comments %}
|
|
<div class="list-group-item px-0">
|
|
<div class="d-flex justify-content-between">
|
|
<div class="fw-semibold small">
|
|
{{ c.author|short_name }}{% with comp=c.author|user_company %}{% if comp %} ({{ comp }}){% endif %}{% endwith %}
|
|
</div>
|
|
<div class="text-muted small">{{ c.created_at|date:"d/m/Y H:i" }}</div>
|
|
</div>
|
|
<div class="mt-1">{{ c.comment|linebreaksbr }}</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<p class="text-muted mb-0">{% translate "Aucun commentaire." %}</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Discussions -->
|
|
<div class="info-card" id="discussion-mob">
|
|
<h6 class="text-muted mb-3"><i class="bi bi-chat-dots me-1 text-primary"></i>{% translate "Messages" %}</h6>
|
|
|
|
{% if discussion_messages %}
|
|
<div class="d-flex flex-column gap-3 mb-3" id="discussion-thread-mob"
|
|
style="max-height:340px; overflow-y:auto; padding-right:2px;">
|
|
{% for msg in discussion_messages %}
|
|
<div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}">
|
|
{% if msg.author != request.user %}
|
|
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0"
|
|
style="width:30px;height:30px;font-size:0.75rem;">
|
|
{{ msg.author|short_name|first|upper|default:msg.author.username|first|upper }}
|
|
</div>
|
|
{% endif %}
|
|
<div style="max-width:80%;">
|
|
{% if msg.author != request.user %}
|
|
<div class="small text-muted mb-1">
|
|
<strong>{{ msg.author|short_name }}{% with comp=msg.author|user_company %}{% if comp %} ({{ comp }}){% endif %}{% endwith %}</strong>
|
|
· {{ msg.created_at|date:"d/m/Y H:i" }}
|
|
</div>
|
|
{% else %}
|
|
<div class="small text-muted mb-1 text-end">{{ msg.created_at|date:"d/m/Y H:i" }}</div>
|
|
{% endif %}
|
|
<div class="p-2 rounded"
|
|
style="{% if msg.author == request.user %}background:#e7ebff;{% else %}background:#f1f3f5;{% endif %}
|
|
border-radius:12px; word-break:break-word; display:inline-block; text-align:left; font-size:0.9rem;">
|
|
{{ msg.content|render_mentions }}
|
|
{% if msg.attachments.exists %}
|
|
<div class="mt-2 d-flex flex-wrap gap-1">
|
|
{% for att in msg.attachments.all %}
|
|
{% if att.is_image %}
|
|
<a href="{{ att.file.url }}" target="_blank">
|
|
<img src="{{ att.file.url }}" style="width:52px;height:52px;object-fit:cover;border-radius:6px;" alt="{{ att.name }}">
|
|
</a>
|
|
{% else %}
|
|
<a href="{{ att.file.url }}" target="_blank"
|
|
class="btn btn-sm btn-outline-secondary">
|
|
<i class="bi bi-paperclip me-1"></i>{{ att.name|truncatechars:20 }}
|
|
</a>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<p class="text-muted small text-center py-2 mb-2">
|
|
<i class="bi bi-chat-square me-1"></i>{% translate "Aucun message." %}
|
|
</p>
|
|
{% endif %}
|
|
|
|
{% if can_post_discussion_message %}
|
|
<form method="post"
|
|
action="{% url 'observations:observation_add_message' observation.id %}"
|
|
enctype="multipart/form-data"
|
|
id="msg-form-mob">
|
|
{% csrf_token %}
|
|
<input type="hidden" name="content" id="msg-content-real-mob">
|
|
<div class="d-flex gap-2 align-items-stretch">
|
|
<div class="flex-grow-1 position-relative">
|
|
<textarea id="msg-content-mob" class="form-control form-control-sm"
|
|
style="height:72px;resize:none;" placeholder="{% translate 'Écrire un message… (@ pour mentionner)' %}"></textarea>
|
|
<div id="mention-dropdown-mob"
|
|
class="list-group shadow"
|
|
style="display:none; position:absolute; bottom:100%; left:0; right:0; z-index:1050; max-height:180px; overflow-y:auto; margin-bottom:2px;">
|
|
</div>
|
|
</div>
|
|
<div class="d-flex flex-column gap-1">
|
|
<button type="submit" class="btn btn-primary btn-sm" title="{% translate 'Envoyer' %}">
|
|
<i class="bi bi-send"></i>
|
|
</button>
|
|
<label class="btn btn-outline-secondary btn-sm mb-0" title="{% translate 'Joindre un fichier' %}">
|
|
<i class="bi bi-paperclip"></i>
|
|
<input type="file" name="attachments" multiple class="d-none" id="msg-file-input-mob">
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div id="msg-file-preview-mob" class="d-flex flex-wrap gap-2 mt-2" style="display:none !important;"></div>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Map -->
|
|
{% if geom_geojson %}
|
|
<div class="info-card">
|
|
<h6 class="text-muted mb-2">{% translate "Carte" %}</h6>
|
|
<div id="map" style="width: 100%; height: 300px; border-radius: 4px;"></div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Modal : confirmation changement de statut (avec commentaire optionnel) -->
|
|
<div class="modal fade" id="actionStatusModal" tabindex="-1" aria-labelledby="actionStatusModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<form method="post" action="{% url 'observations:observation_change_status' observation.id %}" id="actionStatusForm">
|
|
{% csrf_token %}
|
|
<input type="hidden" name="new_status" id="actionStatusValue">
|
|
<input type="hidden" name="symptom_id" id="actionStatusSymptomId">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="actionStatusModalLabel"></h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<!-- Section choix (uniquement pour "à rediriger") -->
|
|
<div id="redirectChoiceSection" style="display:none;" class="mb-3">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="redirectChoice" id="redirectChoiceAuthor" value="author" checked>
|
|
<label class="form-check-label" for="redirectChoiceAuthor">
|
|
{% translate "Demander une redirection à l'auteur de l'observation" %}
|
|
</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="redirectChoice" id="redirectChoiceSymptom" value="symptom">
|
|
<label class="form-check-label" for="redirectChoiceSymptom">
|
|
{% translate "Choisir un nouveau symptôme" %}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<!-- Section autocomplete symptôme (uniquement si choix "nouveau symptôme") -->
|
|
<div id="redirectSymptomSection" style="display:none;" class="mb-2">
|
|
<label for="redirectSymptomSearch" class="form-label small">{% translate "Symptôme" %} <span class="text-danger">*</span></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="redirectSymptomSearch" class="form-control"
|
|
style="padding-left:2rem;"
|
|
placeholder="{% translate 'Saisir le nom du symptôme...' %}" autocomplete="off">
|
|
</div>
|
|
<div id="redirectSymptomDisplay" class="mt-1 p-2 rounded bg-light small" style="display:none;"></div>
|
|
</div>
|
|
<!-- Section commentaire (par défaut pour tous les statuts) -->
|
|
<div id="redirectCommentSection" class="mb-2">
|
|
<label for="actionStatusComment" class="form-label small text-muted">{% translate "Commentaire" %} <span class="text-muted fst-italic">({% translate "optionnel" %})</span></label>
|
|
<textarea id="actionStatusComment" name="comment" class="form-control" rows="3" placeholder="{% translate 'Ajoutez un commentaire...' %}"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">
|
|
{% translate "Annuler" %}
|
|
</button>
|
|
<button type="submit" id="actionStatusSubmitBtn" class="btn btn-sm"></button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modale résumé intervention -->
|
|
{% if observation.intervention and can_view_intervention_summary_modal %}
|
|
<div class="modal fade" id="interventionSummaryModal" tabindex="-1" aria-labelledby="interventionSummaryModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg modal-dialog-scrollable modal-fullscreen-sm-down">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="interventionSummaryModalLabel" data-base-title="{% translate 'Résumé intervention' %}">{% translate 'Résumé intervention' %}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
|
</div>
|
|
<div class="modal-body" id="interventionSummaryModalBody"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Modal choix type intervention (correctif / amélioratif) — affiché pour les observations de type inventaire ou remarque -->
|
|
<div class="modal fade" id="modalChoixTypeIntervention" tabindex="-1" aria-labelledby="modalChoixTypeInterventionLabelMobile" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="modalChoixTypeInterventionLabelMobile">
|
|
<i class="bi bi-tools"></i> {% translate "Type d'intervention" %}
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="text-muted mb-4">{% translate "Quel type d'intervention souhaitez-vous créer à partir de cette observation ?" %}</p>
|
|
<div class="d-flex gap-3 justify-content-center align-items-stretch">
|
|
<form method="post" action="{% url 'observations:create_intervention_from_observation' observation.id %}">
|
|
{% csrf_token %}
|
|
<input type="hidden" name="maintain_type" value="corrective">
|
|
<button type="submit" class="btn btn-outline-danger btn-lg px-3 d-flex flex-column align-items-center justify-content-center h-100" style="width:160px;">
|
|
<i class="bi bi-wrench-adjustable fs-2 mb-2"></i>
|
|
<strong>{% translate "Correctif" %}</strong>
|
|
<div class="small text-muted mt-1" style="white-space:normal;word-wrap:break-word;text-align:center;">{% translate "Remédier à un dysfonctionnement" %}</div>
|
|
</button>
|
|
</form>
|
|
<form method="post" action="{% url 'observations:create_intervention_from_observation' observation.id %}">
|
|
{% csrf_token %}
|
|
<input type="hidden" name="maintain_type" value="ameliorative">
|
|
<button type="submit" class="btn btn-outline-success btn-lg px-3 d-flex flex-column align-items-center justify-content-center h-100" style="width:160px;">
|
|
<i class="bi bi-graph-up-arrow fs-2 mb-2"></i>
|
|
<strong>{% translate "Amélioratif" %}</strong>
|
|
<div class="small text-muted mt-1" style="white-space:normal;word-wrap:break-word;text-align:center;">{% translate "Améliorer l'existant" %}</div>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if can_delete %}
|
|
<div class="modal fade" id="deleteObservationModal" tabindex="-1" aria-labelledby="deleteObservationModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="deleteObservationModalLabel"><i class="bi bi-trash me-2 text-danger"></i>{% translate "Supprimer l'observation" %}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
{% blocktranslate with code=observation.code %}Voulez-vous vraiment supprimer l'observation <strong>{{ code }}</strong> ? Cette action est irréversible.{% endblocktranslate %}
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
|
|
<form method="post" action="{% url 'observations:observation_delete' observation.id %}" class="d-inline">
|
|
{% csrf_token %}
|
|
<button type="submit" class="btn btn-danger"><i class="bi bi-trash me-1"></i>{% translate "Supprimer" %}</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Modal : Lier à une intervention existante -->
|
|
{% if can_link %}
|
|
<div class="modal fade" id="modalLierIntervention" tabindex="-1" aria-labelledby="modalLierInterventionLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="modalLierInterventionLabel">
|
|
<i class="bi bi-link-45deg"></i> {% translate "Lier à une intervention" %}
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
|
</div>
|
|
<form method="post" action="{% url 'observations:link_observation_to_intervention' observation.id %}">
|
|
{% csrf_token %}
|
|
<div class="modal-body">
|
|
<p class="text-muted mb-3">{% translate "Recherchez une intervention par code ou titre." %}</p>
|
|
<input type="text" class="form-control mb-2" id="searchInterventionInput"
|
|
placeholder="{% translate 'Code ou titre…' %}" autocomplete="off">
|
|
<div id="searchInterventionResults" class="list-group mb-2" style="max-height:240px;overflow-y:auto;"></div>
|
|
<input type="hidden" name="intervention_id" id="selectedInterventionId">
|
|
<div id="selectedInterventionDisplay" class="d-none alert alert-info py-2 px-3 small mb-0"></div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
|
|
<button type="submit" class="btn btn-primary btn-sm" id="btnConfirmLierIntervention" disabled>
|
|
<i class="bi bi-link-45deg"></i> {% translate "Confirmer" %}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal : Lier à un projet existant -->
|
|
<div class="modal fade" id="modalLierProjet" tabindex="-1" aria-labelledby="modalLierProjetLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="modalLierProjetLabel">
|
|
<i class="bi bi-diagram-3"></i> {% translate "Lier à un projet" %}
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
|
</div>
|
|
<form method="post" action="{% url 'observations:link_observation_to_project' observation.id %}">
|
|
{% csrf_token %}
|
|
<div class="modal-body">
|
|
<p class="text-muted mb-3">{% translate "Recherchez un projet par code ou nom." %}</p>
|
|
<input type="text" class="form-control mb-2" id="searchProjetInput"
|
|
placeholder="{% translate 'Code ou nom…' %}" autocomplete="off">
|
|
<div id="searchProjetResults" class="list-group mb-2" style="max-height:240px;overflow-y:auto;"></div>
|
|
<input type="hidden" name="project_id" id="selectedProjetId">
|
|
<div id="selectedProjetDisplay" class="d-none alert alert-info py-2 px-3 small mb-0"></div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
|
|
<button type="submit" class="btn btn-primary btn-sm" id="btnConfirmLierProjet" disabled>
|
|
<i class="bi bi-diagram-3"></i> {% translate "Confirmer" %}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% endblock content %}
|
|
|
|
{% block extra_scripts %}
|
|
<!-- jQuery + jQuery UI (not in mobile_base.html, needed for symptom autocomplete) -->
|
|
<script src="{% static 'common/jquery.min.js' %}"></script>
|
|
<script src="{% static 'common/jquery-ui.min.js' %}"></script>
|
|
{{ detail_symptoms_autocomplete|json_script:"detail-symptoms-list" }}
|
|
<script>
|
|
// ---- Carrousel lightbox photos (mobile) ----
|
|
const _mobPhotoUrls = [
|
|
{% for photo in photos %}
|
|
{% private_file_url photo 'photo' as photo_url %}
|
|
"{{ photo_url|safe }}"{% if not forloop.last %},{% endif %}
|
|
{% endfor %}
|
|
];
|
|
let _mobPhotoIdx = 0;
|
|
|
|
function _mobLightboxShow(index) {
|
|
if (!_mobPhotoUrls.length) return;
|
|
_mobPhotoIdx = ((index % _mobPhotoUrls.length) + _mobPhotoUrls.length) % _mobPhotoUrls.length;
|
|
const url = _mobPhotoUrls[_mobPhotoIdx];
|
|
document.getElementById('photoLightboxImageMobile').src = url;
|
|
document.getElementById('photoLightboxLinkMobile').href = url;
|
|
document.getElementById('photoLightboxCounterMobile').textContent =
|
|
(_mobPhotoIdx + 1) + ' / ' + _mobPhotoUrls.length;
|
|
const showNav = _mobPhotoUrls.length > 1;
|
|
document.getElementById('photoPrevBtnMobile').style.display = showNav ? '' : 'none';
|
|
document.getElementById('photoNextBtnMobile').style.display = showNav ? '' : 'none';
|
|
}
|
|
|
|
function openPhotoLightboxMobile(index) { _mobLightboxShow(index); }
|
|
function lightboxNavMobile(dir) { _mobLightboxShow(_mobPhotoIdx + dir); }
|
|
|
|
// Swipe gauche/droite
|
|
(function() {
|
|
const body = document.getElementById('lightboxBodyMobile');
|
|
if (!body) return;
|
|
let startX = null;
|
|
body.addEventListener('touchstart', function(e) {
|
|
startX = e.touches[0].clientX;
|
|
}, { passive: true });
|
|
body.addEventListener('touchend', function(e) {
|
|
if (startX === null) return;
|
|
const dx = e.changedTouches[0].clientX - startX;
|
|
if (Math.abs(dx) > 50) lightboxNavMobile(dx < 0 ? 1 : -1);
|
|
startX = null;
|
|
}, { passive: true });
|
|
})();
|
|
// ---- fin carrousel ----
|
|
|
|
// Gestion de la modale de changement de statut
|
|
(function() {
|
|
const actionStatusModal = document.getElementById('actionStatusModal');
|
|
if (!actionStatusModal) return;
|
|
|
|
// Autocomplete symptôme pour la redirection
|
|
const DETAIL_SYMPTOM_DATA = JSON.parse(document.getElementById('detail-symptoms-list').textContent);
|
|
const _normalizeStr = s => (s || "").normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
|
|
|
|
$("#redirectSymptomSearch").autocomplete({
|
|
source: function(request, response) {
|
|
const term = _normalizeStr(request.term);
|
|
const items = DETAIL_SYMPTOM_DATA.filter(o => _normalizeStr(o.value).includes(term));
|
|
response(items.slice(0, 20));
|
|
},
|
|
minLength: 2,
|
|
delay: 100,
|
|
focus: function(event, ui) {
|
|
$("#redirectSymptomSearch").val(ui.item.value);
|
|
return false;
|
|
},
|
|
select: function(event, ui) {
|
|
event.preventDefault();
|
|
$("#redirectSymptomSearch").val(ui.item.value);
|
|
document.getElementById('actionStatusSymptomId').value = ui.item.id;
|
|
const display = document.getElementById('redirectSymptomDisplay');
|
|
display.textContent = ui.item.label;
|
|
display.style.display = '';
|
|
}
|
|
});
|
|
|
|
function _updateRedirectSections() {
|
|
const isSymptom = document.getElementById('redirectChoiceSymptom') &&
|
|
document.getElementById('redirectChoiceSymptom').checked;
|
|
document.getElementById('redirectSymptomSection').style.display = isSymptom ? '' : 'none';
|
|
document.getElementById('redirectCommentSection').style.display = isSymptom ? 'none' : '';
|
|
}
|
|
|
|
document.querySelectorAll('input[name="redirectChoice"]').forEach(function(radio) {
|
|
radio.addEventListener('change', _updateRedirectSections);
|
|
});
|
|
|
|
document.getElementById('actionStatusForm').addEventListener('submit', function(e) {
|
|
const status = document.getElementById('actionStatusValue').value;
|
|
if (status === 'to_redirect') {
|
|
const isSymptom = document.getElementById('redirectChoiceSymptom').checked;
|
|
if (isSymptom && !document.getElementById('actionStatusSymptomId').value) {
|
|
e.preventDefault();
|
|
document.getElementById('redirectSymptomSearch').classList.add('is-invalid');
|
|
document.getElementById('redirectSymptomSearch').focus();
|
|
return;
|
|
}
|
|
if (!isSymptom) {
|
|
document.getElementById('actionStatusSymptomId').value = '';
|
|
}
|
|
}
|
|
});
|
|
|
|
actionStatusModal.addEventListener('show.bs.modal', function(event) {
|
|
const button = event.relatedTarget;
|
|
const status = button.getAttribute('data-status');
|
|
const label = button.getAttribute('data-label');
|
|
const icon = button.getAttribute('data-icon');
|
|
const btnClass = button.getAttribute('data-btn-class') || 'btn-primary';
|
|
document.getElementById('actionStatusValue').value = status;
|
|
document.getElementById('actionStatusModalLabel').textContent = label;
|
|
const submitBtn = document.getElementById('actionStatusSubmitBtn');
|
|
submitBtn.className = 'btn btn-sm ' + btnClass;
|
|
submitBtn.innerHTML = '<i class="bi ' + icon + '"></i> {% translate "Confirmer" %}';
|
|
// Réinitialiser les champs
|
|
document.getElementById('actionStatusComment').value = '';
|
|
document.getElementById('actionStatusSymptomId').value = '';
|
|
document.getElementById('redirectSymptomSearch').value = '';
|
|
document.getElementById('redirectSymptomDisplay').style.display = 'none';
|
|
document.getElementById('redirectSymptomDisplay').textContent = '';
|
|
document.getElementById('redirectSymptomSearch').classList.remove('is-invalid');
|
|
// Afficher/masquer la section choix selon le statut
|
|
const isToRedirect = status === 'to_redirect';
|
|
document.getElementById('redirectChoiceSection').style.display = isToRedirect ? '' : 'none';
|
|
document.getElementById('redirectChoiceAuthor').checked = true;
|
|
document.getElementById('redirectSymptomSection').style.display = 'none';
|
|
document.getElementById('redirectCommentSection').style.display = '';
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
{% if geom_geojson %}
|
|
<!-- MapLibre GL 5.7.0 (local) -->
|
|
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet" />
|
|
<script src="{% static 'common/maplibre-gl.js' %}"></script>
|
|
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const geojson = {{ geom_geojson|safe }};
|
|
|
|
// Initialize map with MapLibre
|
|
const map = new maplibregl.Map({
|
|
container: 'map',
|
|
style: window.MAP_STYLE_LIGHT,
|
|
center: [4.3517, 50.8503], // Brussels
|
|
zoom: 12
|
|
});
|
|
|
|
map.on('load', function() {
|
|
map.addSource('observation', {
|
|
type: 'geojson',
|
|
data: geojson
|
|
});
|
|
|
|
// Add marker for point or polygon for area
|
|
const feature = geojson.features[0];
|
|
if (feature.geometry.type === 'Point') {
|
|
// Add a marker at the observation location
|
|
new maplibregl.Marker({ color: '#007cbf' })
|
|
.setLngLat(feature.geometry.coordinates)
|
|
.addTo(map);
|
|
|
|
// Center on point
|
|
map.flyTo({ center: feature.geometry.coordinates, zoom: 16 });
|
|
} else {
|
|
map.addLayer({
|
|
id: 'observation-fill',
|
|
type: 'fill',
|
|
source: 'observation',
|
|
paint: {
|
|
'fill-color': '#007cbf',
|
|
'fill-opacity': 0.3
|
|
}
|
|
});
|
|
|
|
map.addLayer({
|
|
id: 'observation-outline',
|
|
type: 'line',
|
|
source: 'observation',
|
|
paint: {
|
|
'line-color': '#007cbf',
|
|
'line-width': 2
|
|
}
|
|
});
|
|
|
|
// Fit to bounds
|
|
const bounds = new maplibregl.LngLatBounds();
|
|
feature.geometry.coordinates[0].forEach(coord => bounds.extend(coord));
|
|
map.fitBounds(bounds, { padding: 20 });
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
{% endif %}
|
|
|
|
<!-- Script pour la modale de résumé d'intervention -->
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const summaryModal = document.getElementById('interventionSummaryModal');
|
|
if (summaryModal) {
|
|
const summaryModalBody = document.getElementById('interventionSummaryModalBody');
|
|
const summaryModalLabel = document.getElementById('interventionSummaryModalLabel');
|
|
|
|
summaryModal.addEventListener('show.bs.modal', function(event) {
|
|
const trigger = event.relatedTarget;
|
|
const summaryUrl = trigger?.getAttribute('data-summary-url');
|
|
const summaryTitle = trigger?.getAttribute('data-summary-title');
|
|
|
|
if (summaryModalLabel && summaryTitle) {
|
|
summaryModalLabel.textContent = `${summaryModalLabel.dataset.baseTitle || summaryModalLabel.textContent} ${summaryTitle}`.trim();
|
|
}
|
|
|
|
summaryModalBody.innerHTML = `
|
|
<div class="text-center py-4">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">{% translate "Chargement..." %}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
if (!summaryUrl) {
|
|
summaryModalBody.innerHTML = '<div class="alert alert-danger">{% translate "URL de résumé manquante" %}</div>';
|
|
return;
|
|
}
|
|
|
|
fetch(summaryUrl)
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error('Network response was not ok');
|
|
}
|
|
return response.text();
|
|
})
|
|
.then(html => {
|
|
summaryModalBody.innerHTML = html;
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading intervention summary:', error);
|
|
summaryModalBody.innerHTML = `<div class="alert alert-danger">{% translate "Erreur lors du chargement du résumé" %}</div>`;
|
|
});
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<script>
|
|
// ---- Live-search modals : Lier à une intervention / un projet ----
|
|
(function () {
|
|
function makeLiveSearch({ inputId, resultsId, hiddenId, displayId, confirmBtnId, searchUrl }) {
|
|
const input = document.getElementById(inputId);
|
|
const results = document.getElementById(resultsId);
|
|
const hidden = document.getElementById(hiddenId);
|
|
const display = document.getElementById(displayId);
|
|
const confirm = document.getElementById(confirmBtnId);
|
|
if (!input) return;
|
|
|
|
let debounceTimer;
|
|
|
|
input.addEventListener('input', function () {
|
|
clearTimeout(debounceTimer);
|
|
const q = this.value.trim();
|
|
if (q.length < 2) { results.innerHTML = ''; return; }
|
|
debounceTimer = setTimeout(() => {
|
|
fetch(`${searchUrl}?q=${encodeURIComponent(q)}`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
results.innerHTML = '';
|
|
if (!data.results.length) {
|
|
results.innerHTML = '<span class="list-group-item text-muted small">{% translate "Aucun résultat" %}</span>';
|
|
return;
|
|
}
|
|
data.results.forEach(item => {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'list-group-item list-group-item-action small';
|
|
btn.textContent = item.text;
|
|
btn.addEventListener('click', () => {
|
|
hidden.value = item.id;
|
|
display.textContent = item.text;
|
|
display.classList.remove('d-none');
|
|
confirm.disabled = false;
|
|
results.innerHTML = '';
|
|
input.value = item.text;
|
|
});
|
|
results.appendChild(btn);
|
|
});
|
|
})
|
|
.catch(() => { results.innerHTML = ''; });
|
|
}, 300);
|
|
});
|
|
}
|
|
|
|
makeLiveSearch({
|
|
inputId: 'searchInterventionInput',
|
|
resultsId: 'searchInterventionResults',
|
|
hiddenId: 'selectedInterventionId',
|
|
displayId: 'selectedInterventionDisplay',
|
|
confirmBtnId: 'btnConfirmLierIntervention',
|
|
searchUrl: '{% url "observations:api_search_interventions" %}',
|
|
});
|
|
|
|
makeLiveSearch({
|
|
inputId: 'searchProjetInput',
|
|
resultsId: 'searchProjetResults',
|
|
hiddenId: 'selectedProjetId',
|
|
displayId: 'selectedProjetDisplay',
|
|
confirmBtnId: 'btnConfirmLierProjet',
|
|
searchUrl: '{% url "observations:api_search_projects" %}',
|
|
});
|
|
|
|
// Reset modals on close
|
|
['modalLierIntervention', 'modalLierProjet'].forEach(modalId => {
|
|
const el = document.getElementById(modalId);
|
|
if (!el) return;
|
|
el.addEventListener('hidden.bs.modal', function () {
|
|
el.querySelectorAll('input[type=text]').forEach(i => i.value = '');
|
|
el.querySelectorAll('.list-group').forEach(r => r.innerHTML = '');
|
|
el.querySelectorAll('input[type=hidden]').forEach(i => i.value = '');
|
|
el.querySelectorAll('.alert').forEach(d => d.classList.add('d-none'));
|
|
el.querySelectorAll('button[type=submit]').forEach(b => b.disabled = true);
|
|
});
|
|
});
|
|
})();
|
|
// ---- fin live-search modals ----
|
|
</script>
|
|
|
|
<script>
|
|
// ---- Discussion mobile : auto-scroll ----
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
const thread = document.getElementById('discussion-thread-mob');
|
|
if (thread) thread.scrollTop = thread.scrollHeight;
|
|
});
|
|
// ---- fin auto-scroll ----
|
|
|
|
// ---- Aperçu pièces jointes (discussion mobile) ----
|
|
(function () {
|
|
const fileInput = document.getElementById('msg-file-input-mob');
|
|
const filePreview = document.getElementById('msg-file-preview-mob');
|
|
if (!fileInput || !filePreview) return;
|
|
|
|
fileInput.addEventListener('change', renderPreview);
|
|
|
|
function renderPreview() {
|
|
filePreview.innerHTML = '';
|
|
const files = fileInput.files;
|
|
if (!files || files.length === 0) {
|
|
filePreview.style.display = 'none';
|
|
return;
|
|
}
|
|
filePreview.style.display = 'flex';
|
|
Array.from(files).forEach((file, idx) => {
|
|
const isImage = file.type.startsWith('image/');
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'position-relative';
|
|
wrapper.style.cssText = 'display:inline-block;';
|
|
if (isImage) {
|
|
const img = document.createElement('img');
|
|
img.style.cssText = 'width:56px;height:56px;object-fit:cover;border-radius:6px;border:1px solid #dee2e6;';
|
|
img.alt = file.name;
|
|
const reader = new FileReader();
|
|
reader.onload = e => { img.src = e.target.result; };
|
|
reader.readAsDataURL(file);
|
|
wrapper.appendChild(img);
|
|
} else {
|
|
const chip = document.createElement('div');
|
|
chip.className = 'd-flex align-items-center gap-1 bg-light border rounded px-2 py-1 small';
|
|
chip.style.cssText = 'max-width:130px;';
|
|
chip.innerHTML = `<i class="bi bi-paperclip text-secondary"></i>
|
|
<span class="text-truncate" title="${file.name}">${file.name}</span>`;
|
|
wrapper.appendChild(chip);
|
|
}
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'btn btn-danger btn-sm position-absolute top-0 end-0 p-0 d-flex align-items-center justify-content-center';
|
|
btn.style.cssText = 'width:18px;height:18px;font-size:10px;border-radius:50%;transform:translate(40%,-40%);line-height:1;';
|
|
btn.innerHTML = '×';
|
|
btn.title = '{% translate "Retirer" %}';
|
|
btn.addEventListener('click', () => {
|
|
const dt = new DataTransfer();
|
|
Array.from(fileInput.files).forEach((f, i) => { if (i !== idx) dt.items.add(f); });
|
|
fileInput.files = dt.files;
|
|
renderPreview();
|
|
});
|
|
wrapper.appendChild(btn);
|
|
filePreview.appendChild(wrapper);
|
|
});
|
|
}
|
|
|
|
const form = document.getElementById('msg-form-mob');
|
|
if (form) {
|
|
form.addEventListener('submit', () => {
|
|
filePreview.innerHTML = '';
|
|
filePreview.style.display = 'none';
|
|
});
|
|
}
|
|
})();
|
|
// ---- fin aperçu pièces jointes ----
|
|
|
|
// ---- @mention autocomplete (discussion mobile) ----
|
|
(function () {
|
|
const textarea = document.getElementById('msg-content-mob');
|
|
const realInput = document.getElementById('msg-content-real-mob');
|
|
const dropdown = document.getElementById('mention-dropdown-mob');
|
|
const form = document.getElementById('msg-form-mob');
|
|
if (!textarea || !dropdown || !realInput || !form) return;
|
|
|
|
let mentions = []; // [{start, end, marker}]
|
|
let mentionStart = -1;
|
|
let mentionQuery = null;
|
|
let searchTimer = null;
|
|
|
|
function escHtml(str) {
|
|
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
function syncReal() {
|
|
const dv = textarea.value;
|
|
if (!mentions.length) { realInput.value = dv; return; }
|
|
let real = '', pos = 0;
|
|
const sorted = [...mentions].sort((a, b) => a.start - b.start);
|
|
for (const m of sorted) {
|
|
if (m.start > pos) real += dv.slice(pos, m.start);
|
|
real += m.marker;
|
|
pos = m.end;
|
|
}
|
|
real += dv.slice(pos);
|
|
realInput.value = real;
|
|
}
|
|
|
|
function shiftMentions(fromPos, delta) {
|
|
for (const m of mentions) {
|
|
if (m.start >= fromPos) { m.start += delta; m.end += delta; }
|
|
}
|
|
}
|
|
|
|
function removeMentionsIn(start, end) {
|
|
mentions = mentions.filter(m => m.end <= start || m.start >= end);
|
|
}
|
|
|
|
function showDropdown(results) {
|
|
dropdown.innerHTML = '';
|
|
if (!results || !results.length) { hideDropdown(); return; }
|
|
results.forEach(r => {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2 small';
|
|
const icon = r.type === 'user' ? 'bi-person-fill' : 'bi-people-fill';
|
|
const badgeCls = r.type === 'user'
|
|
? 'badge bg-primary-subtle text-primary-emphasis border border-primary-subtle'
|
|
: 'badge bg-info-subtle text-info-emphasis border border-info-subtle';
|
|
btn.innerHTML = `<span class="${badgeCls}"><i class="bi ${icon}"></i></span><span>${escHtml(r.display)}</span>`;
|
|
btn.addEventListener('mousedown', function (e) { e.preventDefault(); insertMention(r); });
|
|
dropdown.appendChild(btn);
|
|
});
|
|
dropdown.style.display = 'block';
|
|
}
|
|
|
|
function hideDropdown() {
|
|
dropdown.style.display = 'none';
|
|
dropdown.innerHTML = '';
|
|
mentionStart = -1;
|
|
mentionQuery = null;
|
|
}
|
|
|
|
function insertMention(r) {
|
|
const shortTag = `@[${r.display}]`;
|
|
const marker = r.type === 'user'
|
|
? `@[${r.display}](u:${r.id})`
|
|
: `@[${r.display}](t:${r.id})`;
|
|
const dv = textarea.value;
|
|
const qLen = mentionQuery ? mentionQuery.length : 0;
|
|
const before = dv.slice(0, mentionStart);
|
|
const after = dv.slice(mentionStart + 1 + qLen);
|
|
textarea.value = before + shortTag + ' ' + after;
|
|
removeMentionsIn(mentionStart, mentionStart + 1 + qLen);
|
|
shiftMentions(mentionStart + 1 + qLen, shortTag.length + 1 - (1 + qLen));
|
|
mentions.push({ start: mentionStart, end: mentionStart + shortTag.length, marker });
|
|
syncReal();
|
|
const pos = mentionStart + shortTag.length + 1;
|
|
textarea.setSelectionRange(pos, pos);
|
|
textarea.focus();
|
|
hideDropdown();
|
|
}
|
|
|
|
function searchMentions(q) {
|
|
clearTimeout(searchTimer);
|
|
searchTimer = setTimeout(() => {
|
|
fetch(`{% url 'notifications:api_mention_search' %}?q=${encodeURIComponent(q)}`, {
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
})
|
|
.then(resp => resp.json())
|
|
.then(data => showDropdown(data.results))
|
|
.catch(() => hideDropdown());
|
|
}, 180);
|
|
}
|
|
|
|
textarea.addEventListener('input', function () {
|
|
syncReal();
|
|
const cur = this.selectionStart;
|
|
const before = this.value.slice(0, cur);
|
|
const match = before.match(/@([^\s@]*)$/);
|
|
if (!match) { hideDropdown(); return; }
|
|
mentionStart = match.index;
|
|
mentionQuery = match[1];
|
|
if (mentionQuery.length >= 1) searchMentions(mentionQuery);
|
|
else hideDropdown();
|
|
});
|
|
|
|
textarea.addEventListener('keydown', function (e) {
|
|
if (dropdown.style.display === 'none') return;
|
|
const items = dropdown.querySelectorAll('button');
|
|
let active = dropdown.querySelector('button.active');
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (!active) { items[0] && items[0].classList.add('active'); }
|
|
else {
|
|
active.classList.remove('active');
|
|
const next = active.nextElementSibling;
|
|
(next || items[0]).classList.add('active');
|
|
}
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (!active) { items[items.length - 1] && items[items.length - 1].classList.add('active'); }
|
|
else {
|
|
active.classList.remove('active');
|
|
const prev = active.previousElementSibling;
|
|
(prev || items[items.length - 1]).classList.add('active');
|
|
}
|
|
} else if (e.key === 'Enter' && active) {
|
|
e.preventDefault();
|
|
active.dispatchEvent(new MouseEvent('mousedown'));
|
|
} else if (e.key === 'Escape') {
|
|
hideDropdown();
|
|
}
|
|
});
|
|
|
|
textarea.addEventListener('blur', function () {
|
|
setTimeout(hideDropdown, 150);
|
|
});
|
|
|
|
form.addEventListener('submit', function () {
|
|
syncReal();
|
|
if (!realInput.value.trim()) { textarea.focus(); return false; }
|
|
});
|
|
})();
|
|
// ---- fin @mention autocomplete ----
|
|
</script>
|
|
{% endblock extra_scripts %}
|