loko/streetup/observations/templates/observations/observation_detail_mobile.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 = '&times;';
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 %}