loko/streetup/interventions/templates/interventions/intervention_detail_mobile.html

6521 lines
No EOL
308 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% extends "mobile/mobile_base.html" %}
{% load static %}
{% load i18n %}
{% load tz %}
{% load short_name %}
{% load interventions_tags %}
{% load mention_tags %}
{% get_current_language as LANGUAGE_CODE %}
{% block extra_head %}
{% if not is_technician %}
<link rel="stylesheet" href="{% static 'interventions/timeline.css' %}">
{% endif %}
<style>
.badge {
font-size: 0.75rem;
}
.card-title {
font-size: 1rem;
}
.form-select, .form-control {
font-size: 0.9rem;
}
.status-buttons .btn {
flex: 1;
font-size: 0.9rem;
max-width: 12rem;
}
.status-buttons .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(80%);
}
.status-buttons .btn:not(:disabled) {
border: 2px solid var(--bs-black) !important;
}
.section-title {
font-weight: 600;
margin-top: 1rem;
}
.save-button {
position: fixed;
top: 10px;
right: 10px;
z-index: 1050;
}
.code {
color: rgb(47, 62, 139);
}
#loading-overlay {
position: fixed;
inset: 0;
z-index: 1050;
}
.overlay-background {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.85);
}
.spinner-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.preview-image {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #dee2e6;
}
.preview-file-chip {
width: 100px;
height: 100px;
border-radius: 6px;
border: 1px solid #dee2e6;
background: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
padding: 0.35rem;
font-size: 0.75rem;
text-align: center;
color: #495057;
word-break: break-word;
}
.preview-wrapper {
position: relative;
display: inline-block;
margin-right: 10px;
}
.remove-btn {
position: absolute;
top: -6px;
right: -6px;
background: rgba(255, 0, 0, 0.7);
border: none;
color: white;
border-radius: 50%;
width: 22px;
height: 22px;
font-size: 14px;
line-height: 18px;
text-align: center;
cursor: pointer;
}
/* Empêche le zoom automatique iOS sur les inputs trop petits */
textarea {
font-size: 16px !important;
}
.camera-modal-content {
position: relative;
overflow: hidden;
}
/* Styles pour la modale caméra fullscreen sur mobile */
@media (max-width: 575.98px) {
#cameraModal .modal-dialog {
margin: 0;
max-height: 100%;
}
#cameraModal .modal-content {
min-height: 100dvh; /* dvh = dynamic viewport height, gère mieux les barres de navigation mobile */
max-height: 100dvh;
display: flex;
flex-direction: column;
}
#cameraModal .modal-body {
flex: 1;
min-height: 0; /* Important pour permettre le scroll si nécessaire */
overflow: auto;
}
#cameraModal .camera-buttons-footer {
/* Marge inférieure pour éviter la barre de navigation du navigateur */
padding-bottom: calc(env(safe-area-inset-bottom, 20px) + 1rem) !important;
}
}
.camera-preview-wrapper {
position: relative;
display: inline-block;
width: 100%;
border-radius: 0.5rem;
overflow: hidden;
}
.camera-flash-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.9);
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease-out;
z-index: 2;
}
.camera-flash-overlay.active {
opacity: 1;
}
.mobile-assets-helper {
font-size: 0.85rem;
}
.mobile-assets-result {
max-height: 360px;
overflow-y: auto;
}
.mobile-assets-result .list-group-item {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.mobile-assets-location-badge {
background-color: #f1f3f5;
color: #495057;
font-size: 0.7rem;
}
.mobile-assets-selection-bar {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
gap: 0.5rem;
}
.mobile-assets-empty-state {
font-size: 0.9rem;
color: #6c757d;
}
/* Alternance de fond pour les groupes d'opérations */
.operation-group-even {
background-color: #fcfbf8;
}
.operation-group-odd {
background-color: #ffffff;
}
/* Styles pour les groupes d'opérations collapsibles (mobile) */
.operation-collapse-header-mobile {
cursor: pointer;
user-select: none;
padding: 0.5rem;
margin: -0.5rem;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
transition: background-color 0.2s ease;
}
.operation-collapse-header-mobile:active {
background-color: rgba(0, 0, 0, 0.05);
}
.operation-collapse-header-mobile .collapse-icon {
transition: transform 0.3s ease;
}
.operation-collapse-header-mobile.collapsed .collapse-icon {
transform: rotate(-90deg);
}
.operations-toolbar-mobile {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.operations-toolbar-mobile .btn {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
/* === CARTE DÉTAIL === */
#map-overlay {
position: fixed;
top: 10.5rem;
bottom: 0;
left: 0.75rem;
right: 0;
z-index: 2000;
background: #fff;
border-radius: 14px 14px 0 0;
box-shadow: 0 -4px 24px rgba(0,0,0,.18);
overflow: hidden;
transform: translateY(100%);
opacity: 0;
pointer-events: none;
transition: transform .28s cubic-bezier(.4,0,.2,1), opacity .2s ease;
display: flex;
flex-direction: column;
}
#map-overlay.open {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
#intervention-map {
width: 100%;
flex: 1;
}
#map-overlay-header {
background: #0d6efd;
color: #fff;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
#map-close-btn {
/* kept for JS compatibility */
}
#map-fab-btn {
position: fixed;
bottom: 4.5rem;
right: 1rem;
z-index: 1050;
border-radius: 50%;
width: 3.25rem;
height: 3.25rem;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
padding: 0;
}
#discussion-mob-fab {
position: fixed;
bottom: 8.5rem;
right: 1rem;
z-index: 1050;
border-radius: 50%;
width: 3.25rem;
height: 3.25rem;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
padding: 0;
background: #0d6efd;
color: #fff;
border: none;
cursor: pointer;
font-size: 1.3rem;
}
#discussion-mob-fab .mob-fab-badge {
position: absolute;
top: -3px;
right: -3px;
background: #6c757d;
color: #fff;
font-size: .6rem;
font-weight: 700;
min-width: 17px;
height: 17px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 3px;
border: 2px solid #fff;
line-height: 1;
}
#discussion-mob-panel {
position: fixed;
top: 10.5rem;
bottom: 0;
left: 0.75rem;
right: 0;
z-index: 1060;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 14px 14px 0 0;
box-shadow: 0 -4px 24px rgba(0,0,0,.18);
overflow: hidden;
transform-origin: bottom center;
transform: translateY(100%);
opacity: 0;
pointer-events: none;
transition: transform .28s cubic-bezier(.4,0,.2,1), opacity .2s ease;
}
#discussion-mob-panel.open {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
#discussion-mob-panel-header {
background: #0d6efd;
color: #fff;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
#discussion-mob-thread {
flex: 1;
overflow-y: auto;
padding: 12px 12px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
#discussion-mob-form-area {
flex-shrink: 0;
padding: 10px 12px;
border-top: 1px solid #e9ecef;
background: #f8f9fa;
}
.maplibregl-popup-content a {
color: #0d6efd;
font-weight: 600;
text-decoration: none;
}
.maplibregl-popup-content a:hover {
text-decoration: underline;
}
.maplibregl-popup-content {
font-size: 0.88rem;
padding: 10px 12px;
max-width: 300px;
}
.maplibregl-popup-close-button {
font-size: 1.1rem;
padding: 2px 6px;
}
#map-overlay .maplibregl-ctrl-group button {
width: 42px;
height: 42px;
display: flex !important;
align-items: center;
justify-content: center;
}
#map-overlay .maplibregl-ctrl-group button span {
display: block;
width: 32px;
height: 32px;
background-size: 32px 32px;
background-position: center;
background-repeat: no-repeat;
flex-shrink: 0;
}
</style>
{% if intervention.lat and intervention.lon %}
<link rel="stylesheet" href="{% static 'common/maplibre-gl.css' %}">
{% endif %}
{% endblock extra_head %}
{% block upleft %}
<a href="{{ back_url }}" class="nav-link d-flex align-items-center gap-1" style="color: white;" title="{{ back_label }}">
<i class="bi bi-arrow-left fs-4"></i>
<span class="small d-none d-sm-inline">{{ back_label }}</span>
</a>
{% endblock upleft %}
{% block title %}
<span class="badge bg-{{ intervention.get_status_color }} status-{{ intervention.status }}">
{{ intervention.get_status_display }}
</span>
{% if intervention.prep_state == "processed" %}
<span class="badge bg-success" title="{% translate 'Stock prêt' %}">
<i class="bi bi-box"></i>
</span>
{% elif intervention.prep_state == "not_processed" %}
<span class="badge bg-danger" title="{% translate 'Stock en attente' %}">
<i class="bi bi-box"></i>
</span>
{% endif %}
{% endblock title %}
{% block upright %}
<button type="button" id="manualSaveBtn" class="btn btn-sm btn-light border shadow-sm" title="{% translate 'Enregistrer' %}" onclick="saveOperationsManually()">
<i class="bi bi-floppy" id="saveIcon"></i>
<span class="spinner-border spinner-border-sm d-none" id="saveSpinner"></span>
</button>
{% endblock upright %}
{% block content %}
{% if messages %}
<div class="container mt-3">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% translate "Fermer" %}"></button>
</div>
{% endfor %}
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<div class="code d-flex align-items-center gap-2">
<span><i class="bi bi-tools"></i> {{ intervention.code }}</span>
<button id="subscription-btn"
class="btn btn-sm {% if is_subscribed %}btn-secondary{% else %}btn-outline-secondary{% endif %} py-0 px-2"
onclick="toggleSubscription({{ intervention.id }})"
style="font-size: 0.75rem; border-radius: 4px; display: inline-flex; align-items: center;"
type="button"
title="{% if is_subscribed %}{% translate 'Ne plus suivre' %}{% else %}{% translate 'Suivre cette intervention' %}{% endif %}">
<i id="subscription-icon" class="bi {% if is_subscribed %}bi-bell-fill{% else %}bi-bell{% endif %} me-1"></i>
<span id="subscription-text">{% if is_subscribed %}{% translate "Suivi" %}{% else %}{% translate "Suivre" %}{% endif %}</span>
</button>
</div>
<div class="text-end d-flex align-items-center gap-2">
{% if intervention.lat and intervention.lon %}
<div class="btn-group shadow-sm rounded-pill" role="group" aria-label="{% translate 'Navigation GPS' %}">
<button type="button"
class="btn btn-outline-primary btn-sm rounded-start-pill fw-semibold px-2.5 py-1 d-inline-flex align-items-center gap-1.5"
onclick="openGpsNavigation('{{ intervention.lat|stringformat:'f' }}', '{{ intervention.lon|stringformat:'f' }}', '{{ intervention.code|escapejs }}', false, event)">
<i class="bi bi-sign-turn-right-fill text-primary"></i>
<span>{% translate "Itinéraire GPS" %}</span>
</button>
<button type="button"
class="btn btn-outline-primary btn-sm rounded-end-pill px-2 py-1"
onclick="openGpsNavigation('{{ intervention.lat|stringformat:'f' }}', '{{ intervention.lon|stringformat:'f' }}', '{{ intervention.code|escapejs }}', true, event)"
title="{% translate "Changer d'application GPS" %}">
<i class="bi bi-gear-fill small"></i>
</button>
</div>
{% endif %}
<div>
{% if intervention.contract and intervention.contract.uses_intervention_manager %}
<div class="text-muted small mb-1">
<i class="bi bi-person-badge me-1"></i>{% translate "Gestionnaire :" %}
{% if intervention.intervention_manager %}
{{ intervention.intervention_manager.get_full_name|default:intervention.intervention_manager.username }}
{% else %}
{% translate "Aucun" %}
{% endif %}
</div>
{% endif %}
{% if intervention.assigned_member %}
<div class="text-muted small"><i class="bi bi-person-fill me-1"></i>{{ intervention.assigned_member.name }}</div>
{% endif %}
</div>
</div>
</div>
<div class="code mb-3">{{ intervention.title }}</div>
<hr class="border-secondary">
<p class="mb-1 text-muted">
<i class="bi bi-calendar-event"></i> {{ intervention.planned_begin_time|date:"d/m/Y H:i"|default:"/" }}
</p>
<p class="mb-3 text-muted">
<i class="bi bi-gear
{% if intervention.maintain_type == 'corrective' %}text-danger
{% elif intervention.maintain_type == 'preventive' %}text-warning
{% elif intervention.maintain_type == 'ameliorative' %}text-success
{% endif %}"
></i> {{ intervention.get_maintain_type_display }}
</p>
{% include "interventions/includes/_operation_comment_modal.html" %}
{% if intervention.status == 'on_pause' and intervention.pause_reason %}
<div class="mb-3 p-3 bg-light border rounded">
<small class="text-muted">
<label>{% translate "En pause - Motif :" %}</label>
{{ intervention.get_pause_reason_display }}
{% if intervention.pause_reason_other %} - {{ intervention.pause_reason_other }} {% endif %}
</small>
</div>
{% endif %}
<hr class="border-secondary mb-0">
{% if can_edit_intervention %}
<form id="activityForm" method="post" enctype="multipart/form-data" action="{% url 'mobile:intervention_detail_mobile' intervention.id %}{% if back_history %}?back={{ back_history }}{% endif %}">
{% csrf_token %}
{% if back_history %}<input type="hidden" name="back" value="{{ back_history }}">{% endif %}
{% endif %}
<!-- Notes -->
<div class="card mt-4 mb-4 bg-white">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">{% translate "Notes" %}</span>
</div>
<div class="card-body">
<div id="notesContainer">
<div id="notesAlertContainer" class="m-1"></div>
{% if notes %}
{% for note in notes %}
<div id="note-container-{{ note.id }}"
class="note-item py-3 {% if not forloop.last %}border-bottom border-primary{% endif %}"
data-note-id="{{ note.id }}"
data-note-content="{{ note.content|urlencode }}"
data-note-author-id="{{ note.note_author_id }}"
data-note-author-display="{{ note.note_author|short_name|escapejs }}"
data-note-type="{{ note.note_type }}"
data-note-priority="{{ note.type_priority_order|default:note.type_priority_value }}"
data-note-time="{{ note.note_time|date:'c' }}"
data-note-type-display="{{ note.get_note_type_display }}"
data-note-badge-class="{{ note.type_badge_class }}"
data-note-icon-class="{{ note.type_icon_class }}">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="note-content flex-grow-1">
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
<span class="badge note-type-badge d-inline-flex align-items-center gap-1 {{ note.type_badge_class }}">
<i class="bi {{ note.type_icon_class }}"></i>
{{ note.get_note_type_display }}
</span>
<span class="text-muted small">
{{ note.note_time|timezone:"Europe/Paris"|date:"d-m-Y H:i" }} - {{ note.note_author|short_name }}
</span>
</div>
<p class="mb-0">{{ note.content|safe|linebreaksbr }}</p>
</div>
{% if note.id in editable_note_ids and can_edit_notes %}
<div class="note-actions d-flex flex-column align-items-center gap-2 ms-auto">
<button type="button" class="btn btn-link text-primary p-0 edit-note-btn"
data-note-id="{{ note.id }}" aria-label="{% translate "Modifier la note" %}">
<i class="bi bi-pencil-square fs-6"></i>
</button>
<button type="button" class="btn btn-link text-danger p-0 delete-note-btn"
data-note-id="{{ note.id }}" aria-label="{% translate "Supprimer la note" %}">
<i class="bi bi-trash fs-6"></i>
</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted mb-0 small">{% translate "Aucune note pour le moment." %}</p>
{% endif %}
</div>
</div>
</div>
<!-- Documents -->
<div class="card mb-4 bg-white">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">{% translate "Documents" %}</span>
</div>
<div class="card-body">
{% if 'documents' in allowed_update_fields %}
<div id="documentsAlertContainer" class="mb-2"></div>
{% endif %}
<div class="row" id="documentsContainer">
{% for document in intervention.documents.all %}
<div id="document-card-{{ document.id }}" class="col-6 col-md-2 mb-3" data-document-id="{{ document.id }}" data-upload-date="{{ document.upload_date|date:'c' }}">
<div class="position-relative">
{% if 'documents' in allowed_update_fields and document.id in deletable_document_ids %}
<button type="button"
class="btn btn-link text-danger p-0 position-absolute top-0 end-0 delete-document-btn bg-white"
data-document-id="{{ document.id }}"
data-document-name="{{ document.filename|default:''|escape }}"
aria-label="{% translate "Supprimer le document" %}"
title="{% translate "Supprimer le document" %}">
<i class="bi bi-trash fs-6"></i>
</button>
{% endif %}
{% private_file_url document 'file' as document_file_url %}
<a href="{{ document_file_url }}" target="_blank">
<div class="img-thumbnail" style="width: 100%; height: 100px; overflow: hidden;">
{% if document.thumbnail %}
{% private_file_url document 'thumbnail' as document_thumbnail_url %}
<img src="{{ document_thumbnail_url }}" alt="{{ document.filename }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
{% else %}
<img src="{{ document_file_url }}" alt="{{ document.filename }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
{% endif %}
</div>
</a>
</div>
<div class="text-center text-muted mt-1" style="font-size: 0.85em;">
{{ document.upload_date|date:"d/m/Y H:i" }}<br>
{{ document.uploaded_by|short_name }}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Opérations -->
<div class="card mt-4 mb-4 bg-white border-primary">
<div class="card-header d-flex justify-content-between align-items-center bg-primary text-white">
<span class="fw-semibold">{% translate "Compléter les opérations" %}</span>
</div>
<div class="card-body">
{% if operations_general_group or operations_asset_groups or guided_template %}
{% if operations_general_group or operations_asset_groups %}
<!-- Toolbar Expand/Collapse All (Mobile) -->
<div class="operations-toolbar-mobile mb-3" id="operationsToolbarMobile">
{% if can_edit_operation_comments %}
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#addCustomOperationModal">
<i class="bi bi-plus-circle"></i> {% translate "Ajouter opération" %}
</button>
{% endif %}
<button type="button" class="btn btn-outline-secondary btn-sm" id="expandAllOperationsMobile">
<i class="bi bi-chevron-expand"></i> {% translate "Tout déplier" %}
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="collapseAllOperationsMobile">
<i class="bi bi-chevron-contract"></i> {% translate "Tout replier" %}
</button>
</div>
{% endif %}
{% with general_group_count=operations_general_group.template_groups|length|default:0 %}
{% if operations_general_group %}
<div class="mt-3">
<p class="text-primary text-uppercase small mb-2 d-flex align-items-center gap-2">
<i class="bi bi-diagram-3"></i>
{% translate "Opérations générales" %}
</p>
{% for template_group in operations_general_group.template_groups %}
<div class="p-2 rounded mb-3 {% if forloop.counter|divisibleby:2 %}operation-group-even{% else %}operation-group-odd{% endif %}">
{% if template_group.name %}
<div class="operation-collapse-header-mobile collapsed d-flex align-items-center justify-content-between"
data-bs-toggle="collapse"
data-bs-target="#mobileGeneralGroup{{ forloop.counter }}"
aria-expanded="false"
aria-controls="mobileGeneralGroup{{ forloop.counter }}">
<p class="text-muted small mb-0 d-flex align-items-center gap-1">
<i class="bi bi-chevron-down collapse-icon"></i>
<i class="bi bi-list-check me-1"></i>{{ template_group.name }}
</p>
<div class="d-flex gap-1">
{% if template_group.success_count %}<span class="badge bg-success" title="{% translate 'Statut success' %}">{{ template_group.success_count }}</span>{% endif %}
{% if template_group.warning_count %}<span class="badge bg-warning text-dark" title="{% translate 'Statut warning' %}">{{ template_group.warning_count }}</span>{% endif %}
{% if template_group.danger_count %}<span class="badge bg-danger" title="{% translate 'Statut danger' %}">{{ template_group.danger_count }}</span>{% endif %}
{% if template_group.other_count %}<span class="badge bg-secondary" title="{% translate 'Autres / Sans valeur' %}">{{ template_group.other_count }}</span>{% endif %}
</div>
</div>
{% endif %}
<div class="collapse operation-collapse-content" id="mobileGeneralGroup{{ forloop.counter }}">
{% for op in template_group.operations %}
{% include "interventions/includes/_mobile_operation_card.html" with op=op %}
{% empty %}
<p class="text-muted small">{% translate "Aucune opération générale." %}</p>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if operations_asset_groups %}
<p class="text-primary text-uppercase small mb-2 mt-4 d-flex align-items-center gap-2">
<i class="bi bi-hdd-network"></i>
{% translate "Opérations par équipement" %}
</p>
<!-- Equipment search -->
<div class="mb-3">
<div class="input-group">
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
<input type="text"
id="equipmentSearchInput"
class="form-control"
placeholder="{% translate 'Rechercher un équipement...' %}"
autocomplete="off">
<button type="button" class="btn btn-outline-secondary" id="clearEquipmentSearch" style="display: none;">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div id="equipmentSearchResults" class="small text-muted mt-1"></div>
</div>
{% for asset_group in operations_asset_groups %}
{% with group_index=forloop.counter|add:general_group_count %}
<div class="mt-4 p-2 rounded {% if group_index|divisibleby:2 %}operation-group-even{% else %}operation-group-odd{% endif %}">
<!-- Header collapsible pour l'équipement (mobile) -->
<div class="operation-collapse-header-mobile collapsed d-flex align-items-center justify-content-between"
data-bs-toggle="collapse"
data-bs-target="#mobileAssetGroup{{ forloop.counter }}"
aria-expanded="false"
aria-controls="mobileAssetGroup{{ forloop.counter }}">
<div class="d-flex align-items-start gap-2">
<i class="bi bi-chevron-down collapse-icon"></i>
<i class="bi bi-hdd-network text-primary"></i>
<div>
{% if asset_group.asset %}
<div class="fw-semibold">
{% if asset_group.asset %}
<a href="{% url 'mobile:mobile_asset_detail' intervention.thematic.code asset_group.asset_model_name asset_group.asset.id %}"
class="text-decoration-none text-primary"
onclick="event.stopPropagation();">
{{ asset_group.asset.code|default:asset_group.asset }}
<i class="bi bi-box-arrow-up-right ms-1 small"></i>
</a>
{% else %}
{{ asset_group.asset.code|default:asset_group.asset }}
{% endif %}
</div>
{% if asset_group.asset.get_name %}
<div class="text-muted small">{{ asset_group.asset.get_name }}</div>
{% endif %}
{% else %}
<div class="text-muted small">{% translate "Équipement lié introuvable" %}</div>
{% endif %}
</div>
</div>
<div class="d-flex gap-1">
{% if asset_group.success_count %}<span class="badge bg-success" title="{% translate 'Statut success' %}">{{ asset_group.success_count }}</span>{% endif %}
{% if asset_group.warning_count %}<span class="badge bg-warning text-dark" title="{% translate 'Statut warning' %}">{{ asset_group.warning_count }}</span>{% endif %}
{% if asset_group.danger_count %}<span class="badge bg-danger" title="{% translate 'Statut danger' %}">{{ asset_group.danger_count }}</span>{% endif %}
{% if asset_group.other_count %}<span class="badge bg-secondary" title="{% translate 'Autres / Sans valeur' %}">{{ asset_group.other_count }}</span>{% endif %}
</div>
</div>
<!-- Contenu collapsible -->
<div class="collapse operation-collapse-content mt-2" id="mobileAssetGroup{{ forloop.counter }}">
{% for template_group in asset_group.template_groups %}
{% if template_group.name %}
<p class="text-muted small mb-1 mt-3">
<i class="bi bi-list-check me-1"></i>{{ template_group.name }}
</p>
{% endif %}
{% for op in template_group.operations %}
{% include "interventions/includes/_mobile_operation_card.html" with op=op %}
{% empty %}
<p class="text-muted small">{% translate "Aucune opération pour cet équipement." %}</p>
{% endfor %}
{% endfor %}
</div>
</div>
{% endwith %}
{% endfor %}
{% endif %}
{% endwith %}
{% if guided_template %}
{# ── Bloc opérations guidées (intégré dans la carte Opérations) ── #}
<div id="guidedOperationBlock"
data-save-url="{{ save_guided_url }}"
data-upload-media-url="{{ upload_guided_media_url }}">
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 text-primary"><i class="bi bi-ui-checks me-2"></i>{{ guided_template.getName }}</h5>
</div>
{% include "interventions/guided_operations/"|add:guided_template.slug|add:".html" %}
</div>
{% endif %}
{% else %}
<p class="text-muted small mb-0">{% translate "Aucune opération à compléter pour le moment." %}</p>
{% endif %}
</div>
</div>
{% if can_edit_intervention %}
<!-- Zone de note -->
<div class="card mt-4 mb-4 bg-white border-primary">
<div class="card-header d-flex justify-content-between align-items-center bg-primary text-white">
<span class="fw-semibold">{% translate "Ajouter une note" %}</span>
</div>
<div class="card-body">
<div class="mb-3">
<label for="noteType" class="form-label section-title text-primary mt-0">{% translate "Type de note" %}</label>
<select id="noteType" name="note_type" class="form-select border border-primary mb-3">
{% for value, label in note_type_choices %}
<option value="{{ value }}"{% if value == default_note_type %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<label for="note" class="form-label section-title text-primary">{% translate "Contenu" %}</label>
<textarea id="note" name="content"
class="form-control border border-primary"
rows="3"
placeholder="…"
spellcheck="true"
autocapitalize="sentences"
autocorrect="on"
autocomplete="off"
lang="{{ LANGUAGE_CODE }}"></textarea>
</div>
</div>
</div>
<!-- Upload de fichiers -->
<div class="card mt-4 mb-4 bg-white border-primary">
<div class="card-header d-flex justify-content-between align-items-center bg-primary text-white">
<span class="fw-semibold">{% translate "Ajouter des documents" %}</span>
{% if photos_required %}
<span class="badge bg-warning text-dark ms-2"><i class="bi bi-camera-fill me-1"></i>{% translate "Photos obligatoires" %}</span>
{% endif %}
</div>
<div class="card-body">
<div class="border rounded p-2">
<div class="d-grid gap-2">
<button type="button" id="open-camera-modal-btn" class="btn btn-outline-primary mt-1">
<i class="bi bi-camera-fill me-2"></i>{% translate "Prendre des photos (mode continu)" %}
</button>
<button type="button" id="select-files-btn" class="btn btn-outline-primary">
<i class="bi bi-folder2-open me-2"></i>{% translate "Photos, vidéos, fichiers" %}
</button>
</div>
<!-- Hidden file input -->
<input type="file" id="mobile-files-input" name="photos" multiple class="d-none">
<div id="photo-inputs" class="d-none"></div>
<div id="preview-container" class="row g-2 mb-3 mt-1"></div>
</div>
</div>
</div>
{% endif %}
<!-- Champ caché pour le statut demandé -->
<input type="hidden" name="status_action" id="status_action" value="">
<input type="hidden" name="pause_reason" id="pause_reason" value="">
<input type="hidden" name="pause_reason_other" id="pause_reason_other" value="">
<input type="hidden" name="note_id" id="activityFormNoteId" value="">
{% if can_edit_intervention %}
</form>
<hr class="border-secondary mt-4">
{% endif %}
{% if show_occupations %}
<!-- Occupations -->
<div class="card mt-4 mb-4 bg-white">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">
{% translate "Occupations" %}
{% if occupations_required and not occupations %}
<span class="badge bg-warning text-dark ms-1" title="{% translate "Obligatoire pour terminer cette intervention corrective" %}">
<i class="bi bi-exclamation-triangle-fill"></i> {% translate "Obligatoire" %}
</span>
{% endif %}
</span>
{% if can_manage_occupations %}
<button class="btn btn-sm btn-outline-primary open-occupation-modal" type="button"
data-bs-toggle="modal" data-bs-target="#occupationModal">
<i class="bi bi-plus-circle"></i>
</button>
{% endif %}
</div>
<div class="card-body">
<div id="occupationsAlertContainer" class="m-1"></div>
<div id="occupationsContainer">
{% for occupation in occupations %}
<div id="occupation-{{ occupation.id }}"
class="occupation-item py-3 {% if not forloop.last %}border-bottom{% endif %}"
data-occupation-id="{{ occupation.id }}"
data-member-id="{{ occupation.member_id }}"
data-member-display="{{ occupation.member.name|escapejs }}"
data-minutes="{{ occupation.minutes }}"
data-created-at="{% if occupation.created_at %}{{ occupation.created_at|timezone:'Europe/Paris'|date:'Y-m-d\\TH:i:s' }}{% endif %}"
data-updated-at="{% if occupation.updated_at %}{{ occupation.updated_at|timezone:'Europe/Paris'|date:'Y-m-d\\TH:i:s' }}{% endif %}"
data-created-by-display="{{ occupation.created_by|short_name|default_if_none:''|escapejs }}"
data-updated-by-display="{{ occupation.updated_by|short_name|default_if_none:''|escapejs }}">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1">
<span class="fw-semibold">{{ occupation.member.name }}</span>
<div class="text-muted small">
{{ occupation.created_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}
{% if occupation.created_by %}
— {{ occupation.created_by|short_name }}
{% endif %}
</div>
{% if occupation.updated_by %}
<div class="text-muted small">
{% translate "Mis à jour par" %} {{ occupation.updated_by|short_name }}
({{ occupation.updated_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }})
</div>
{% endif %}
</div>
<div class="text-end">
<span class="badge bg-primary">{{ occupation.minutes|minutes_to_hours_minutes }}</span>
{% if can_manage_occupations %}
<div class="mt-2">
<button type="button" class="btn btn-link text-primary p-0 edit-occupation-btn"
data-occupation-id="{{ occupation.id }}"
aria-label="{% translate "Modifier l'occupation" %}">
<i class="bi bi-pencil-square"></i>
</button>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
<p id="occupationsEmptyState" class="text-muted mb-0 {% if occupations|length %}d-none{% endif %}">
{% translate "Aucune occupation enregistrée pour le moment." %}
</p>
</div>
<div class="card-footer text-end">
<strong>{% translate "Total" %}:</strong>
<span id="occupationsTotal" data-minutes="{{ total_occupation_minutes }}">{{ total_occupation_minutes|minutes_to_hours_minutes }}</span>
</div>
</div>
{% if can_manage_occupations and occupation_form %}
<div class="modal fade" id="occupationModal" tabindex="-1" aria-labelledby="occupationModalTitle" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="occupationModalTitle">{% translate "Ajouter une occupation" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate "Fermer" %}"></button>
</div>
<form id="occupationForm" data-intervention-id="{{ intervention.id }}" data-no-overlay>
<div class="modal-body">
{% csrf_token %}
<input type="hidden" name="occupation_id" id="occupationIdField">
<input type="hidden" name="minutes" id="id_minutes">
<div class="mb-3">
<label for="{{ occupation_form.member.id_for_label }}" class="form-label">{% translate "Technicien" %}</label>
{{ occupation_form.member }}
</div>
<div class="mb-3">
<label class="form-label">{% translate "Durée" %}</label>
<div class="row g-2">
<div class="col-6">
<div class="input-group">
<input type="number" class="form-control" id="occupationHours" name="hours" min="0" step="1" value="0">
<span class="input-group-text">{% translate "h" %}</span>
</div>
</div>
<div class="col-6">
<div class="input-group">
<input type="number" class="form-control" id="occupationMinutes" name="mins" min="0" max="59" step="1" value="0">
<span class="input-group-text">{% translate "min" %}</span>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary" id="occupationSubmitButton">{% translate "Ajouter" %}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endif %}
<!-- Contrôles / Rapports de suivi -->
{% if intervention.status != 'in_preparation' %}
<!-- Modal mobile : Choisir le type de contrôle -->
{% if 'follow_up_reports' in allowed_update_fields %}
<div class="modal fade" id="mobileModalChooseControlType" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">
<i class="bi bi-clipboard-check me-2 text-primary"></i>
{% translate "Quel type de contrôle ?" %}
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<div class="d-grid gap-2">
<a href="{% url 'controls:control_quick_create_from_intervention' %}?intervention_id={{ intervention.id }}&control_type=control&v=mobile"
class="btn btn-outline-primary text-start d-flex align-items-center gap-2">
<i class="bi bi-clipboard2-check fs-5"></i>
<div>
<div class="fw-semibold">{% translate "Contrôle" %}</div>
<small class="text-muted">{% translate "Contrôle standard de chantier" %}</small>
</div>
</a>
<a href="{% url 'controls:control_quick_create_from_intervention' %}?intervention_id={{ intervention.id }}&control_type=site_report&v=mobile"
class="btn btn-outline-secondary text-start d-flex align-items-center gap-2">
<i class="bi bi-file-earmark-text fs-5"></i>
<div>
<div class="fw-semibold">{% translate "Rapport de chantier" %}</div>
<small class="text-muted">{% translate "Rapport de suivi de chantier" %}</small>
</div>
</a>
<a href="{% url 'controls:control_quick_create_from_intervention' %}?intervention_id={{ intervention.id }}&control_type=statement&v=mobile"
class="btn btn-outline-warning text-start d-flex align-items-center gap-2">
<i class="bi bi-camera fs-5"></i>
<div>
<div class="fw-semibold">{% translate "Constat" %}</div>
<small class="text-muted">{% translate "Constat de l'état des lieux" %}</small>
</div>
</a>
<a href="{% url 'controls:control_quick_create_from_intervention' %}?intervention_id={{ intervention.id }}&control_type=meeting_report&v=mobile"
class="btn btn-outline-info text-start d-flex align-items-center gap-2">
<i class="bi bi-people fs-5"></i>
<div>
<div class="fw-semibold">{% translate "Rapport de réunion" %}</div>
<small class="text-muted">{% translate "Compte-rendu de réunion de chantier" %}</small>
</div>
</a>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light w-100" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
</div>
</div>
</div>
</div>
{% endif %}
<div class="card mt-4 mb-4 bg-white">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">{% translate "Contrôles / Rapports de suivi" %}</span>
{% if 'follow_up_reports' in allowed_update_fields %}
<button type="button" class="btn btn-light border-0 p-1" title="{% translate 'Ajouter un contrôle' %}"
data-bs-toggle="modal" data-bs-target="#mobileModalChooseControlType">
<i class="bi bi-plus-circle text-primary fs-5"></i>
</button>
{% endif %}
</div>
<div class="card-body">
{% if is_user_intern %}
<div class="mb-3 p-2 rounded">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-clipboard2-check text-primary me-2 fs-5"></i>
<span class="fw-semibold">{% translate "Résultat du contrôle" %}</span>
</div>
{% if 'follow_up_reports' in allowed_update_fields %}
<div class="d-flex gap-2" id="mobile-follow-up-performed-radios">
<input type="radio" class="btn-check" name="mobile_follow_up_performed" id="mobile-fup-ok" value="ok" autocomplete="off"
{% if intervention.follow_up_performed == 'ok' %}checked{% endif %}>
<label class="btn btn-outline-success btn-sm flex-fill" for="mobile-fup-ok"
onclick="handleFollowUpClick(event, {{ intervention.id }}, 'ok', 'mobile-fup-ok', 'mobile-follow-up-performed-radios')">
<i class="bi bi-check-circle"></i> OK
</label>
<input type="radio" class="btn-check" name="mobile_follow_up_performed" id="mobile-fup-attention" value="attention" autocomplete="off"
{% if intervention.follow_up_performed == 'attention' %}checked{% endif %}>
<label class="btn btn-outline-warning btn-sm flex-fill" for="mobile-fup-attention"
onclick="handleFollowUpClick(event, {{ intervention.id }}, 'attention', 'mobile-fup-attention', 'mobile-follow-up-performed-radios')">
<i class="bi bi-exclamation-triangle"></i> {% translate "Attention" %}
</label>
<input type="radio" class="btn-check" name="mobile_follow_up_performed" id="mobile-fup-not-ok" value="not_ok" autocomplete="off"
{% if intervention.follow_up_performed == 'not_ok' %}checked{% endif %}>
<label class="btn btn-outline-danger btn-sm flex-fill" for="mobile-fup-not-ok"
onclick="handleFollowUpClick(event, {{ intervention.id }}, 'not_ok', 'mobile-fup-not-ok', 'mobile-follow-up-performed-radios')">
<i class="bi bi-x-circle"></i> Not OK
</label>
</div>
{% else %}
<span class="badge {% if intervention.follow_up_performed == 'ok' %}bg-success{% elif intervention.follow_up_performed == 'attention' %}bg-warning text-dark{% elif intervention.follow_up_performed == 'not_ok' %}bg-danger{% else %}bg-secondary{% endif %}">
{% if intervention.follow_up_performed == 'ok' %}OK{% elif intervention.follow_up_performed == 'attention' %}{% translate "Points d’attention" %}{% elif intervention.follow_up_performed == 'not_ok' %}Not OK{% else %}–{% endif %}
</span>
{% endif %}
</div>
{% endif %}
{% if visible_controls %}
<table class="table table-sm">
<thead>
<tr>
<th>{% translate "Date" %}</th>
<th>{% translate "Type" %}</th>
<th>{% translate "Statut" %}</th>
</tr>
</thead>
<tbody>
{% for control in visible_controls %}
<tr>
<td><a href="{% url 'controls:controls_detail' control.id %}">{{ control.updated_at|date:"d-m-Y" }}</a></td>
<td>{{ control.get_control_type_display }}</td>
<td>{{ control.get_status_display }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted mb-0 small">{% translate "Aucun contrôle pour le moment." %}</p>
{% endif %}
</div>
</div>
{% endif %}
<!-- Modal form add/edit note -->
<div class="modal fade" id="addNoteModal" tabindex="-1" aria-labelledby="addNoteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="noteModalTitle">{% translate "Ajouter une note" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<form id="addNoteForm" method="post" data-intervention-id="{{ intervention.id }}" data-can-edit-notes="{{ can_edit_notes|yesno:'true,false' }}" data-no-overlay>
{% csrf_token %}
<input type="hidden" name="note_id" id="noteIdField">
{{ add_note_form.as_p }}
<div class="modal-footer px-0">
<button type="submit" class="btn btn-primary" id="noteSubmitButton">{% translate "Ajouter" %}</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Modal confirm delete note -->
<div class="modal fade" id="deleteNoteModal" tabindex="-1" aria-labelledby="deleteNoteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteNoteModalLabel">{% translate "Supprimer la note" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<p class="mb-0">{% translate "Voulez-vous supprimer cette note ?" %}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="button" class="btn btn-danger" id="confirmDeleteNoteButton" data-note-id="">{% translate "Supprimer" %}</button>
</div>
</div>
</div>
</div>
{% if 'documents' in allowed_update_fields %}
<div class="modal fade" id="deleteDocumentModal" tabindex="-1" aria-labelledby="deleteDocumentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteDocumentModalLabel">{% translate "Supprimer le document" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate "Fermer" %}"></button>
</div>
<div class="modal-body">
<p class="mb-1">{% translate "Voulez-vous supprimer ce document ?" %}</p>
<p class="fw-semibold mb-0" id="documentNameToDelete"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="button" class="btn btn-danger" id="confirmDeleteDocument">{% translate "Supprimer" %}</button>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Fiche d'intervention PDF -->
{% if intervention.status in 'in_preparation,to_be_approved' %}
<div class="card mb-4 bg-white">
<div class="card-header fw-semibold small">
<i class="bi bi-file-earmark-pdf me-1"></i>{% translate "Fiche d'intervention" %}
</div>
<div class="card-body d-flex gap-2">
<a class="btn btn-outline-secondary flex-fill" href="{% url 'interventions:intervention_pdf_preview' intervention.id %}" target="_blank" data-no-overlay>
<i class="bi bi-eye me-1"></i>{% translate "Visualiser" %}
</a>
<button type="button" class="btn btn-outline-secondary flex-fill fiche-share-btn"
data-pdf-url="{% url 'interventions:intervention_pdf_preview' intervention.id %}"
data-pdf-name="fiche_{{ intervention.code }}.pdf">
<i class="bi bi-share-fill me-1"></i>{% translate "Partager" %}
</button>
</div>
</div>
{% elif intervention.intervention_sheet %}
<div class="card mb-4 bg-white">
<div class="card-header fw-semibold small">
<i class="bi bi-file-earmark-pdf me-1"></i>{% translate "Fiche d'intervention" %}
</div>
<div class="card-body d-flex gap-2">
{% private_file_url intervention 'intervention_sheet' as intervention_sheet_url %}
<a class="btn btn-outline-secondary flex-fill" href="{{ intervention_sheet_url }}" target="_blank" data-no-overlay>
<i class="bi bi-eye me-1"></i>{% translate "Visualiser" %}
</a>
<button type="button" class="btn btn-outline-secondary flex-fill fiche-share-btn"
data-pdf-url="{{ intervention_sheet_url }}"
data-pdf-name="{{ intervention.intervention_sheet_filename }}">
<i class="bi bi-share-fill me-1"></i>{% translate "Partager" %}
</button>
</div>
</div>
{% endif %}
<!-- Assets -->
{% if assets or mobile_assets_enabled %}
<div class="card mb-4 bg-white">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">{% translate "Équipements liés" %}</span>
{% if mobile_assets_enabled %}
<div class="btn-group">
<button type="button" class="btn btn-light border-0" data-bs-toggle="modal" data-bs-target="#addAssetMobileModal" title="{% translate 'Créer un nouvel équipement' %}">
<i class="bi bi-plus-circle text-success"></i> {% translate "Créer" %}
</button>
<button type="button" class="btn btn-light border-0" data-bs-toggle="modal" data-bs-target="#mobileAssetsModal" title="{% translate 'Lier un équipement existant' %}">
<i class="bi bi-link-45deg text-primary"></i> {% translate "Lier existant" %}
</button>
</div>
{% endif %}
</div>
<div class="card-body">
{% if assets %}
<ul class="list-group list-group-flush mb-3">
{% for link in assets %}
<li class="list-group-item bg-light d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1">
<div class="fw-semibold text-primary d-flex align-items-center gap-1 flex-wrap">
<i class="bi bi-nut small"></i>
{% if link.asset %}
<a href="{% url 'mobile:mobile_asset_detail' intervention.thematic.code link.asset_model_name link.asset.id %}"
class="text-primary text-decoration-none">
{{ link.asset }}
<i class="bi bi-box-arrow-up-right ms-1 small"></i>
</a>
{% else %}
<span>{{ link.asset }}</span>
{% endif %}
{% if link.asset and link.asset.status %}
<span class="badge {% if link.asset.status == 'active' %}bg-success{% elif link.asset.status == 'damaged' %}bg-warning text-dark{% elif link.asset.status == 'to_replace' %}bg-danger{% elif link.asset.status == 'removed' %}bg-secondary{% elif link.asset.status == 'to_be_approved' %}bg-info{% else %}bg-secondary{% endif %}">{{ link.asset.get_status_display }}</span>
{% endif %}
</div>
{% if link.asset and link.asset.category %}
<div class="text-muted small">{{ link.asset.category.get_name }}</div>
{% endif %}
</div>
{% if mobile_assets_enabled and link.content_type and link.content_type_id and link.object_id %}
<div class="d-flex align-items-center gap-2">
{% if link.can_replace_asset %}
<button type="button"
class="btn btn-sm btn-outline-warning mobile-asset-replace-button"
data-key="{{ link.content_type_id }}:{{ link.object_id }}"
data-label="{{ link.asset|default_if_none:''|escape }}"
title="{% translate "Remplacer l'équipement" %}"
aria-label="{% translate "Remplacer l'équipement" %}">
<i class="bi bi-arrow-repeat"></i>
</button>
{% endif %}
<button type="button"
class="btn btn-sm btn-outline-danger mobile-asset-remove-button"
data-key="{{ link.content_type_id }}:{{ link.object_id }}"
data-model="{{ link.content_type.model }}"
data-object-id="{{ link.object_id }}"
data-label="{{ link.asset|default_if_none:''|escape }}"
title="{% translate 'Supprimer le lien' %}"
aria-label="{% translate 'Supprimer le lien' %}">
<i class="bi bi-link-45deg"></i>
</button>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="mobile-assets-empty-state mb-0">
{% translate "Aucun équipement n'est encore lié à cette intervention." %}
</p>
{% endif %}
{% if mobile_assets_enabled and mobile_asset_location_labels %}
<div class="alert alert-info mobile-assets-helper mt-3 mb-0">
<strong>{% translate "Localisations de l'intervention" %}:</strong>
{{ mobile_asset_location_labels|join:', ' }}
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Modal de confirmation pour délier un équipement -->
<div class="modal fade" id="unlinkAssetModal" tabindex="-1" aria-labelledby="unlinkAssetModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="unlinkAssetModalLabel">{% translate "Supprimer le lien" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate "Fermer" %}"></button>
</div>
<div class="modal-body">
<p class="mb-3">
{% translate "Voulez-vous supprimer le lien avec" %}
<strong id="unlinkAssetLabel"></strong> ?
</p>
<div class="border-top pt-3">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="unlinkDeleteOperations">
<label class="form-check-label" for="unlinkDeleteOperations">
<i class="bi bi-list-check text-warning me-1"></i>
{% translate "Supprimer également les opérations liées à cet équipement" %}
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="unlinkMarkAsRemoved">
<label class="form-check-label" for="unlinkMarkAsRemoved">
<i class="bi bi-exclamation-triangle text-danger me-1"></i>
{% translate "Marquer l'équipement comme retiré ou n'existant plus" %}
</label>
<div class="form-text">
{% translate "Cette information sera conservée dans l'historique de l'équipement" %}
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-lg me-1"></i>{% translate "Annuler" %}
</button>
<button type="button" class="btn btn-danger" id="confirmUnlinkAsset">
<i class="bi bi-link-45deg me-1"></i>{% translate "Supprimer le lien" %}
</button>
</div>
</div>
</div>
</div>
{% if mobile_assets_enabled %}
<div class="modal fade" id="mobileAssetsModal" tabindex="-1" aria-labelledby="mobileAssetsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen-sm-down modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="mobileAssetsModalLabel">
<i class="bi bi-nut me-2"></i>{% translate "Ajouter des équipements" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<div id="mobileAssetsAlerts"></div>
<div id="mobileAssetsLocationSection" class="mb-3 border rounded p-2 bg-light">
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label mb-0 small fw-semibold">
<i class="bi bi-geo-alt me-1"></i>{% translate "Localisations" %}
</label>
</div>
<div id="mobileAssetsLocationChips" class="d-flex flex-wrap gap-1 mb-2">
{# Chips de localisation injectées par JS #}
</div>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-plus-circle"></i></span>
<input type="search" class="form-control" id="mobileAssetsLocationSearch"
placeholder="{% translate 'Ajouter une localisation…' %}" autocomplete="off">
</div>
<div class="list-group mt-1" id="mobileAssetsLocationResults" style="display:none; max-height:180px; overflow-y:auto;"></div>
</div>
<form id="mobileAssetsSearchForm" class="row g-3 mb-3">
<div class="col-12">
<label for="mobileAssetsSearchInput" class="form-label">{% translate "Rechercher" %}</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" id="mobileAssetsSearchInput" placeholder="{% translate 'Code, nom ou modèle' %}" autocomplete="off">
<button type="submit" class="btn btn-outline-primary">{% translate "Filtrer" %}</button>
</div>
</div>
<div class="col-12">
<label for="mobileAssetsCategorySelect" class="form-label">{% translate "Catégorie" %}</label>
<select id="mobileAssetsCategorySelect" class="form-select">
<option value="">{% translate "Toutes les catégories autorisées" %}</option>
</select>
</div>
<div class="col-12">
<label for="mobileAssetsModelSelect" class="form-label">{% translate "Modèle" %}</label>
<select id="mobileAssetsModelSelect" class="form-select" disabled>
<option value="">{% translate "Sélectionnez un modèle" %}</option>
</select>
</div>
</form>
<div class="d-flex justify-content-between align-items-center mb-2" id="mobileAssetsResultsMeta">
<span class="text-muted small" id="mobileAssetsResultsCount">{% translate "Aucun résultat pour l'instant." %}</span>
<span class="badge bg-light text-muted d-none" id="mobileAssetsLimitBadge"></span>
</div>
<div id="mobileAssetsResultsWrapper">
<div class="text-center py-4 text-muted" id="mobileAssetsResultsPlaceholder">
{% translate "Les équipements correspondants seront affichés ici." %}
</div>
<div class="text-center py-4 d-none" id="mobileAssetsLoading">
<div class="spinner-border text-primary" role="status"></div>
<p class="small mt-2 mb-0">{% translate "Chargement des équipements…" %}</p>
</div>
<div class="list-group mobile-assets-result d-none" id="mobileAssetsResultsList"></div>
</div>
</div>
<div class="modal-footer flex-column gap-2 align-items-stretch">
<div class="mobile-assets-selection-bar w-100">
<span>
<strong id="mobileAssetsSelectionCount">0</strong>
{% translate "sélection(s) en attente" %}
</span>
<div class="btn-group">
<button type="button" class="btn btn-outline-secondary btn-sm" id="mobileAssetsClearButton" disabled>
<i class="bi bi-eraser"></i> {% translate "Vider" %}
</button>
<button type="button" class="btn btn-primary" id="mobileAssetsAddButton" disabled>
<i class="bi bi-plus-circle"></i> {% translate "Ajouter les équipements" %}
</button>
</div>
</div>
<small class="text-muted text-center w-100">
{% blocktrans with limit=mobile_assets_max_results %}Affichage limité à {{ limit }} équipements par recherche.{% endblocktrans %}
</small>
</div>
</div>
</div>
</div>
{# Modal pour créer un nouvel équipement (Mobile - simplifié) #}
<div class="modal fade" id="addAssetMobileModal" tabindex="-1" aria-labelledby="addAssetMobileModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addAssetMobileModalLabel">
<i class="bi bi-plus-square me-2"></i>{% translate "Nouvel équipement" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<form id="addAssetMobileForm" class="add-asset-form" data-thematic="{{ intervention.thematic.code }}" data-intervention-id="{{ intervention.id }}">
{% csrf_token %}
<input type="hidden" name="intervention_id" value="{{ intervention.id }}">
<input type="hidden" name="thematic_code" value="{{ intervention.thematic.code }}">
<div class="modal-body">
<div id="addAssetMobileModalAlert" class="alert d-none" role="alert"></div>
{# Catégorie #}
<div class="mb-3">
<label for="addAssetMobileModalCategory" class="form-label">
{% translate "Catégorie" %} <span class="text-danger">*</span>
</label>
<select class="form-select asset-category-select" id="addAssetMobileModalCategory" name="category_id" required>
<option value="">{% translate "Sélectionner..." %}</option>
{% for cat in mobile_asset_categories %}
<option value="{{ cat.id }}">{{ cat.get_name }}</option>
{% endfor %}
</select>
</div>
{# Type d'asset (dynamique) #}
<div class="mb-3 d-none" id="addAssetMobileModalTypeContainer">
<label for="addAssetMobileModalType" class="form-label">
{% translate "Type d'équipement" %} <span class="text-danger">*</span>
</label>
<select class="form-select asset-type-select" id="addAssetMobileModalType" name="asset_type">
<option value="">{% translate "Sélectionner un type..." %}</option>
</select>
</div>
{# Modèle #}
<div class="mb-3">
<label for="addAssetMobileModalModel" class="form-label">
{% translate "Modèle" %} <span class="text-danger" id="addAssetMobileModalModelRequiredStar" style="display: none;">*</span>
</label>
<select class="form-select asset-model-select" id="addAssetMobileModalModel" name="model_id" required disabled>
<option value="">{% translate "Choisir une catégorie d'abord" %}</option>
</select>
</div>
{# Code #}
<div class="mb-3">
<label for="addAssetMobileModalCode" class="form-label">
{% translate "Code" %} <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="addAssetMobileModalCode" name="code" required
placeholder="{% translate 'Ex: STR-001' %}" maxlength="50">
</div>
{# Nom FR #}
<div class="mb-3">
<label for="addAssetMobileModalNameFr" class="form-label">
{% translate "Nom" %} (FR)
</label>
<input type="text" class="form-control" id="addAssetMobileModalNameFr" name="name_fr"
placeholder="{% translate 'Nom en français (optionnel)' %}" maxlength="200">
</div>
{# Nom NL #}
<div class="mb-3">
<label for="addAssetMobileModalNameNl" class="form-label">
{% translate "Nom" %} (NL)
</label>
<input type="text" class="form-control" id="addAssetMobileModalNameNl" name="name_nl"
placeholder="{% translate 'Naam in het Nederlands (optioneel)' %}" maxlength="200">
</div>
<div class="alert alert-info small">
<i class="bi bi-info-circle me-1"></i>
{% translate "Statut : À approuver" %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% translate "Annuler" %}
</button>
<button type="submit" class="btn btn-primary" id="addAssetMobileModalSubmit">
<i class="bi bi-plus-circle me-1"></i>{% translate "Créer" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% if mobile_assets_enabled and mobile_asset_replacement_data %}
<div class="modal fade" id="replaceAssetMobileModal" tabindex="-1" aria-labelledby="replaceAssetMobileModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="replaceAssetMobileModalLabel">
<i class="bi bi-arrow-repeat me-2"></i>{% translate "Remplacer l'équipement" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<form id="replaceAssetMobileForm" autocomplete="off">
<div class="modal-body">
<div id="replaceAssetModalAlert" class="alert d-none" role="alert"></div>
<input type="hidden" id="replaceAssetCurrentKey" value="">
<div class="mb-3">
<p class="small text-muted mb-1">{% translate "Équipement sélectionné" %}</p>
<div class="fw-semibold" id="replaceAssetCurrentLabel"></div>
</div>
<div class="mb-3">
<label class="form-label" for="replaceAssetCategorySelect">{% translate "Catégorie" %}</label>
<select class="form-select" id="replaceAssetCategorySelect">
<option value="">{% translate "Sélectionner une catégorie" %}</option>
</select>
</div>
<div class="mb-3">
<label class="form-label" for="replaceAssetModelSelect">{% translate "Modèle du nouvel équipement" %}</label>
<select class="form-select" id="replaceAssetModelSelect">
<option value="">{% translate "Conserver le modèle actuel" %}</option>
</select>
<small class="text-muted">{% translate "Par défaut, le modèle de l'équipement est présélectionné." %}</small>
</div>
<div class="mb-3">
<label class="form-label" for="replaceAssetSerialInput">{% translate "Numéro de série (optionnel)" %}</label>
<input type="text" class="form-control" id="replaceAssetSerialInput" maxlength="100" placeholder="{% translate 'Ex: SN-12345' %}">
</div>
<div class="mb-3">
<label class="form-label" for="replaceAssetBrandInput">{% translate "Marque (optionnel)" %}</label>
<input type="text" class="form-control" id="replaceAssetBrandInput" maxlength="100" placeholder="{% translate 'Ex: Marque' %}">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-warning" id="confirmReplaceAssetMobile">
<i class="bi bi-arrow-repeat"></i> {% translate "Créer le remplacement" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Modal pour proposer les actions sur une intervention "à traiter" -->
{% if intervention.status == 'to_be_processed' and can_take_over %}
<div class="modal fade" id="startInterventionModal" tabindex="-1" aria-labelledby="startInterventionModalLabel" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="startInterventionModalLabel">
<i class="bi bi-question-circle me-2"></i>{% translate "Que souhaitez-vous faire ?" %}
</h5>
</div>
<div class="modal-body text-center py-4">
<p class="mb-3">{% translate "Cette intervention est en attente de traitement." %}</p>
</div>
<div class="modal-footer flex-column gap-2">
{% if not is_restricted_team %}
<button type="button" class="btn btn-info w-100" id="takeOverBtn">
<i class="bi bi-person-check me-1"></i>{% translate "Prendre en charge" %}
</button>
{% endif %}
<button type="button" class="btn btn-primary w-100" id="startNowBtn">
<i class="bi bi-play-fill me-1"></i>{% translate "Démarrer l'intervention" %}
</button>
<button type="button" class="btn btn-outline-secondary w-100" id="consultBtn" data-bs-dismiss="modal">
<i class="bi bi-eye me-1"></i>{% translate "Consulter" %}
</button>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Modal pour proposer les actions sur une intervention "prise en charge" -->
{% if intervention.status == 'assigned' and can_take_over %}
<div class="modal fade" id="startInterventionModalAssigned" tabindex="-1" aria-labelledby="startInterventionModalAssignedLabel" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-info text-white">
<h5 class="modal-title" id="startInterventionModalAssignedLabel">
<i class="bi bi-question-circle me-2"></i>{% translate "Que souhaitez-vous faire ?" %}
</h5>
</div>
<div class="modal-body text-center py-4">
<p class="mb-3">{% translate "Cette intervention est prise en charge." %}</p>
</div>
<div class="modal-footer flex-column gap-2">
<button type="button" class="btn btn-primary w-100" id="startNowBtnAssigned">
<i class="bi bi-play-fill me-1"></i>{% translate "Démarrer l'intervention" %}
</button>
<button type="button" class="btn btn-outline-danger w-100" id="releaseBtnAssigned">
<i class="bi bi-box-arrow-left me-1"></i>{% translate "Libérer l'intervention" %}
</button>
<button type="button" class="btn btn-outline-secondary w-100" id="consultBtnAssigned" data-bs-dismiss="modal">
<i class="bi bi-eye me-1"></i>{% translate "Consulter" %}
</button>
</div>
</div>
</div>
</div>
{% endif %}
<div class="modal fade" id="pauseReasonModal" tabindex="-1" aria-labelledby="pauseReasonModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-light">
<h5 class="modal-title" id="pauseReasonModalLabel">{% translate "Raison de la mise en pause" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate "Fermer" %}"></button>
</div>
<div class="modal-body">
<label class="form-label" for="pauseReasonSelect">{% translate "Sélectionnez la raison" %}</label>
<select class="form-select" id="pauseReasonSelect">
<option value="" selected disabled>{% translate "Choisissez une raison" %}</option>
{% for value, label in pause_reason_choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
<div class="mt-3 d-none" id="pauseReasonOtherGroup">
<label class="form-label" for="pauseReasonOtherInput">{% translate "Précisez la raison" %}</label>
<textarea class="form-control" id="pauseReasonOtherInput" rows="3" maxlength="500"></textarea>
</div>
<div class="alert alert-danger d-none mt-3" id="pauseReasonError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="button" class="btn btn-primary" id="confirmPauseReason">{% translate "Confirmer" %}</button>
</div>
</div>
</div>
</div>
{% if can_create_repair %}
<form id="repairForm" method="post" action="{% url 'interventions:create_repair_intervention' intervention.id %}">
{% csrf_token %}
<input type="hidden" name="variant" value="mobile">
{% if assets %}
<input type="hidden" name="assets_filter_enabled" value="1">
{% endif %}
<button type="button"
class="btn btn-outline-secondary mb-3"
style="max-width: 400px;"
data-bs-toggle="modal"
data-bs-target="#confirmRepairModal">
<i class="bi bi-tools"></i>
{% translate "Créer une intervention de réparation" %}
</button>
</form>
<div class="modal fade" id="confirmRepairModal" tabindex="-1" aria-labelledby="confirmRepairModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirmRepairModalLabel">{% translate "Confirmation" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
{% translate "Créer une intervention de réparation à partir de cette intervention ?" %}
{% if assets %}
<hr class="my-3">
<p class="mb-2 fw-semibold small">{% translate "Équipements concernés" %} :</p>
<div id="repairAssetList">
{% for ia in assets %}
<div class="form-check">
<input class="form-check-input repair-asset-check" type="checkbox"
value="{{ ia.id }}"
id="repairAsset_{{ ia.id }}">
<label class="form-check-label" for="repairAsset_{{ ia.id }}">
<span class="fw-semibold">{{ ia.asset.code|default:ia.asset }}</span>{% if ia.asset.get_name %} <span class="text-muted">— {{ ia.asset.get_name }}</span>{% endif %}
</label>
</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% translate "Annuler" %}
</button>
<button type="button" class="btn btn-primary" id="confirmRepairBtn">
{% translate "Confirmer" %}
</button>
</div>
</div>
</div>
</div>
{% endif %}
{% if linked_groups %}
<div class="card mb-3">
<div class="card-body">
<h6 class="text-uppercase text-muted small mb-3">
<i class="bi bi-link-45deg"></i> {% translate "Interventions liées" %}
</h6>
{% for group in linked_groups %}
<p class="text-muted mb-1 fw-semibold">{{ group.label }}</p>
<ul class="list-unstyled {% if not forloop.last %}mb-3{% else %}mb-0{% endif %}">
{% for item in group.links %}
{% with related=item.intervention %}
<li class="mb-2">
<a href="{% url 'mobile:intervention_detail_mobile' related.id %}?back={{ child_history }}" class="text-decoration-none">
{{ related.code }} – {{ related.title }}
</a>
<span class="badge bg-{{ related.get_status_color }} status-{{ related.status }} ms-2">{{ related.get_status_display }}</span>
</li>
{% endwith %}
{% endfor %}
</ul>
{% endfor %}
</div>
</div>
{% endif %}
{% if linked_observations %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-eye me-1"></i>{% translate "Observations liées" %}</span>
<span class="badge bg-secondary">{{ linked_observations|length }}</span>
</div>
<div class="card-body p-0">
<ul class="list-group list-group-flush">
{% for obs in linked_observations %}
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1">
<a href="{% url 'mobile:observation_detail_mobile' obs.id %}?back={{ child_history }}" class="fw-semibold text-decoration-none">{{ obs.code }}</a>
{% if obs.description %}
<div class="text-muted small mt-1">{{ obs.description|truncatechars:60 }}</div>
{% endif %}
</div>
<span class="badge ms-2 flex-shrink-0 {% if obs.status == 'to_process' %}bg-danger{% elif obs.status == 'in_progress' %}bg-warning text-dark{% elif obs.status == 'processed' %}bg-success{% elif obs.status == 'not_relevant' %}bg-secondary{% elif obs.status == 'duplicate' %}bg-info text-dark{% elif obs.status == 'closed' %}bg-dark{% elif obs.status == 'archived' %}bg-dark{% else %}bg-secondary{% endif %}">
{{ obs.get_status_display }}
</span>
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<!-- Historique des interventions aux mêmes localisations (3 derniers mois) -->
{% if location_history_interventions %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold small">
<i class="bi bi-clock-history me-1"></i> {% translate "Historique des interventions (3 mois)" %}
</span>
<span class="badge bg-secondary">{{ location_history_interventions|length }}</span>
</div>
<div class="card-body p-0">
<p class="text-muted small px-3 pt-2 mb-2">
{% translate "Interventions récentes sur les mêmes localisations" %}
</p>
{% with total_items=location_history_interventions|length %}
{% if total_items > 10 %}
<ul class="list-group list-group-flush" id="locationHistoryPreviewMobile">
{% for item in location_history_interventions|slice:":10" %}
<li class="list-group-item {% if not item.can_view %}bg-light text-muted{% endif %}">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1 overflow-hidden">
{% if item.can_view %}
<a href="{% url 'mobile:intervention_detail_mobile' item.intervention.id %}?back={{ child_history }}" class="text-decoration-none d-block">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="fw-semibold text-primary">{{ item.intervention.code }}</span>
<span class="badge bg-{{ item.intervention.get_status_color }} status-{{ item.intervention.status }} ms-2">{{ item.intervention.get_status_display }}</span>
</div>
<small class="text-muted">{{ item.intervention.effective_end|date:"d/m/Y" }}</small>
</div>
<div class="small text-truncate mt-1">{{ item.intervention.title }}</div>
</a>
{% else %}
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="fw-semibold">{{ item.intervention.code }}</span>
<span class="badge bg-{{ item.intervention.get_status_color }} status-{{ item.intervention.status }} ms-2 opacity-50">{{ item.intervention.get_status_display }}</span>
<i class="bi bi-lock-fill ms-1 small" title="{% translate 'Accès restreint' %}"></i>
</div>
<small class="text-muted">{{ item.intervention.effective_end|date:"d/m/Y" }}</small>
</div>
<div class="small text-truncate mt-1 fst-italic">{{ item.intervention.title }}</div>
{% endif %}
</div>
{% if item.can_view_summary %}
<button type="button"
class="btn btn-sm btn-outline-secondary location-history-summary-btn"
data-bs-toggle="modal"
data-bs-target="#locationHistorySummaryModal"
data-summary-url="{% url 'interventions:intervention_summary_modal' item.intervention.id %}?variant=mobile"
data-summary-title="{{ item.intervention.code }}"
title="{% translate 'Voir le résumé' %}">
<i class="bi bi-eye"></i>
</button>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
<div class="px-3 py-2">
<button type="button" class="btn btn-link btn-sm p-0" data-bs-toggle="collapse" data-bs-target="#locationHistoryFullMobile" aria-expanded="false">
<i class="bi bi-chevron-down me-1"></i>{% translate "Voir tout" %} ({{ total_items }})
</button>
</div>
<div class="collapse" id="locationHistoryFullMobile">
<ul class="list-group list-group-flush">
{% for item in location_history_interventions|slice:"10:" %}
<li class="list-group-item {% if not item.can_view %}bg-light text-muted{% endif %}">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1 overflow-hidden">
{% if item.can_view %}
<a href="{% url 'mobile:intervention_detail_mobile' item.intervention.id %}?back={{ child_history }}" class="text-decoration-none d-block">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="fw-semibold text-primary">{{ item.intervention.code }}</span>
<span class="badge bg-{{ item.intervention.get_status_color }} status-{{ item.intervention.status }} ms-2">{{ item.intervention.get_status_display }}</span>
</div>
<small class="text-muted">{{ item.intervention.effective_end|date:"d/m/Y" }}</small>
</div>
<div class="small text-truncate mt-1">{{ item.intervention.title }}</div>
</a>
{% else %}
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="fw-semibold">{{ item.intervention.code }}</span>
<span class="badge bg-{{ item.intervention.get_status_color }} status-{{ item.intervention.status }} ms-2 opacity-50">{{ item.intervention.get_status_display }}</span>
<i class="bi bi-lock-fill ms-1 small" title="{% translate 'Accès restreint' %}"></i>
</div>
<small class="text-muted">{{ item.intervention.effective_end|date:"d/m/Y" }}</small>
</div>
<div class="small text-truncate mt-1 fst-italic">{{ item.intervention.title }}</div>
{% endif %}
</div>
{% if item.can_view_summary %}
<button type="button"
class="btn btn-sm btn-outline-secondary location-history-summary-btn"
data-bs-toggle="modal"
data-bs-target="#locationHistorySummaryModal"
data-summary-url="{% url 'interventions:intervention_summary_modal' item.intervention.id %}?variant=mobile"
data-summary-title="{{ item.intervention.code }}"
title="{% translate 'Voir le résumé' %}">
<i class="bi bi-eye"></i>
</button>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
<div class="px-3 py-2">
<button type="button" class="btn btn-link btn-sm p-0" data-bs-toggle="collapse" data-bs-target="#locationHistoryFullMobile" aria-expanded="true">
<i class="bi bi-chevron-up me-1"></i>{% translate "Réduire" %}
</button>
</div>
</div>
{% else %}
<ul class="list-group list-group-flush">
{% for item in location_history_interventions %}
<li class="list-group-item {% if not item.can_view %}bg-light text-muted{% endif %}">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1 overflow-hidden">
{% if item.can_view %}
<a href="{% url 'mobile:intervention_detail_mobile' item.intervention.id %}?back={{ child_history }}" class="text-decoration-none d-block">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="fw-semibold text-primary">{{ item.intervention.code }}</span>
<span class="badge bg-{{ item.intervention.get_status_color }} status-{{ item.intervention.status }} ms-2">{{ item.intervention.get_status_display }}</span>
</div>
<small class="text-muted">{{ item.intervention.effective_end|date:"d/m/Y" }}</small>
</div>
<div class="small text-truncate mt-1">{{ item.intervention.title }}</div>
</a>
{% else %}
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="fw-semibold">{{ item.intervention.code }}</span>
<span class="badge bg-{{ item.intervention.get_status_color }} status-{{ item.intervention.status }} ms-2 opacity-50">{{ item.intervention.get_status_display }}</span>
<i class="bi bi-lock-fill ms-1 small" title="{% translate 'Accès restreint' %}"></i>
</div>
<small class="text-muted">{{ item.intervention.effective_end|date:"d/m/Y" }}</small>
</div>
<div class="small text-truncate mt-1 fst-italic">{{ item.intervention.title }}</div>
{% endif %}
</div>
{% if item.can_view_summary %}
<button type="button"
class="btn btn-sm btn-outline-secondary location-history-summary-btn"
data-bs-toggle="modal"
data-bs-target="#locationHistorySummaryModal"
data-summary-url="{% url 'interventions:intervention_summary_modal' item.intervention.id %}?variant=mobile"
data-summary-title="{{ item.intervention.code }}"
title="{% translate 'Voir le résumé' %}">
<i class="bi bi-eye"></i>
</button>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
<div class="modal fade" id="locationHistorySummaryModal" tabindex="-1" aria-labelledby="locationHistorySummaryModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="locationHistorySummaryModalLabel" data-base-title="{% translate "Résumé de l'intervention" %}">
{% translate "Résumé de l'intervention" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body" id="locationHistorySummaryModalBody">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{% translate "Chargement..." %}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Modal pour voir une photo d'opération en grand -->
<div class="modal fade" id="operationPhotoModal" tabindex="-1" aria-labelledby="operationPhotoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="operationPhotoModalLabel">{% translate "Photo d'opération" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body text-center">
<img id="operationPhotoModalImage" src="" alt="" class="img-fluid" style="max-height: 70vh;">
<p id="operationPhotoModalDescription" class="mt-2 text-muted"></p>
</div>
</div>
</div>
</div>
<!-- Modal pour ajouter une photo à une opération -->
{% if can_edit_intervention %}
<div class="modal fade" id="addOperationPhotoModal" tabindex="-1" aria-labelledby="addOperationPhotoModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addOperationPhotoModalLabel">
<i class="bi bi-camera"></i> {% translate "Ajouter une photo" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<form id="operationPhotoForm" enctype="multipart/form-data">
<div class="modal-body">
{% csrf_token %}
<input type="hidden" id="operationPhotoOperationId" name="operation_id">
<div class="mb-3">
<label class="form-label fw-semibold" id="operationPhotoOperationName"></label>
</div>
<div class="mb-3">
<label for="operationPhotoFile" class="form-label">{% translate "Photo" %}</label>
<input type="file" class="form-control" id="operationPhotoFile" name="photo" accept="image/*" capture="environment" required>
</div>
<div class="mb-3">
<label for="operationPhotoDescription" class="form-label">{% translate "Description (optionnel)" %}</label>
<input type="text" class="form-control" id="operationPhotoDescription" name="description" maxlength="255">
</div>
<div id="operationPhotoAlert" class="alert d-none" role="alert"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-primary" id="submitOperationPhoto">{% translate "Ajouter" %}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Modal pour ajouter une opération personnalisée -->
{% if can_edit_intervention %}
<style>
#addCustomOperationModal .form-label {
color: #0d6efd;
font-weight: 600;
font-size: 0.9rem;
}
#addCustomOperationModal .form-label .text-danger {
font-weight: normal;
}
#addCustomOperationModal .form-check-label {
color: #495057;
}
#addCustomOperationModal .section-divider {
border-top: 1px solid #dee2e6;
margin: 1rem 0;
padding-top: 0.5rem;
}
#addCustomOperationModal .modal-header {
background-color: #f8f9fa;
border-bottom: 2px solid #0d6efd;
}
#addCustomOperationModal .modal-title {
color: #0d6efd;
}
</style>
<div class="modal fade" id="addCustomOperationModal" tabindex="-1" aria-labelledby="addCustomOperationModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addCustomOperationModalLabel">
<i class="bi bi-plus-circle"></i> {% translate "Ajouter une opération personnalisée" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<form id="customOperationForm">
<div class="modal-body">
{% csrf_token %}
<div class="mb-3">
<label class="form-label"><i class="bi bi-toggles me-1"></i>{% translate "Type d'opération" %}</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="operation_type" id="operationTypeStatus" value="status" checked>
<label class="form-check-label" for="operationTypeStatus">
<i class="bi bi-list-check me-1"></i>{% translate "Choix de statut" %}
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="operation_type" id="operationTypeMeasurement" value="measurement">
<label class="form-check-label" for="operationTypeMeasurement">
<i class="bi bi-speedometer2 me-1"></i>{% translate "Mesure" %}
</label>
</div>
</div>
<div class="section-divider"></div>
<div class="mb-3">
<label for="customOpNameFr" class="form-label">
<i class="bi bi-translate me-1"></i>{% translate "Nom (FR)" %} <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="customOpNameFr" name="name_fr" placeholder="{% translate 'Nom en français' %}">
<div class="form-text text-muted small">{% translate "Au moins un nom (FR ou NL) est obligatoire" %}</div>
</div>
<div class="mb-3">
<label for="customOpNameNl" class="form-label">
<i class="bi bi-translate me-1"></i>{% translate "Nom (NL)" %} <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="customOpNameNl" name="name_nl" placeholder="{% translate 'Nom en néerlandais' %}">
</div>
<div class="section-divider"></div>
<div class="mb-3" id="statusSetGroup">
<label for="customOpStatusSet" class="form-label">
<i class="bi bi-list-check me-1"></i>{% translate "Ensemble de statuts" %}
</label>
<select class="form-select" id="customOpStatusSet" name="status_set">
<option value="">{% translate "Sélectionner..." %}</option>
{% for status_set in operation_status_sets %}
<option value="{{ status_set.id }}">{{ status_set.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3 d-none" id="measurementDefGroup">
<label for="customOpMeasurementDef" class="form-label">
<i class="bi bi-speedometer2 me-1"></i>{% translate "Définition de mesure" %}
</label>
<select class="form-select" id="customOpMeasurementDef" name="measurement_definition">
<option value="">{% translate "Sélectionner..." %}</option>
{% for measurement_def in measurement_definitions %}
<option value="{{ measurement_def.id }}">{{ measurement_def.name }} ({{ measurement_def.unit }})</option>
{% endfor %}
</select>
</div>
<div class="section-divider"></div>
<div class="mb-3">
<label for="customOpGroup" class="form-label">
<i class="bi bi-folder me-1"></i>{% translate "Groupe (optionnel)" %}
</label>
<input type="text" class="form-control" id="customOpGroup" name="template_group" placeholder="{% translate 'Ex: Opérations personnalisées' %}">
</div>
<div class="mb-3" id="assetSelectGroup" style="display: none;">
<label class="form-label">
<i class="bi bi-box-seam me-1"></i>{% translate "Lier à des équipements" %}
</label>
<!-- Quick selection buttons -->
<div class="btn-group btn-group-sm w-100 mb-2" role="group">
<button type="button" class="btn btn-outline-secondary" id="selectNoAssets">
<i class="bi bi-square me-1"></i>{% translate "Aucun" %}
</button>
<button type="button" class="btn btn-outline-primary" id="selectAllAssets">
<i class="bi bi-check-all me-1"></i>{% translate "Tous" %}
</button>
</div>
<!-- Checkboxes for each asset -->
<div class="border rounded p-2" style="max-height: 200px; overflow-y: auto;">
<div class="form-check">
<input class="form-check-input asset-checkbox" type="checkbox" value="" id="assetNone" name="assets" checked>
<label class="form-check-label text-muted" for="assetNone">
<i class="bi bi-dash-circle me-1"></i>{% translate "Opération générale (sans équipement)" %}
</label>
</div>
{% for asset_group in operations_asset_groups %}
{% if asset_group.asset %}
<div class="form-check">
<input class="form-check-input asset-checkbox asset-item" type="checkbox"
value="{{ asset_group.asset_content_type_id }}:{{ asset_group.asset_object_id }}"
id="asset_{{ asset_group.asset_content_type_id }}_{{ asset_group.asset_object_id }}"
name="assets">
<label class="form-check-label" for="asset_{{ asset_group.asset_content_type_id }}_{{ asset_group.asset_object_id }}">
<strong>{{ asset_group.asset.code }}</strong>{% if asset_group.asset.get_name %} - {{ asset_group.asset.get_name }}{% endif %}
</label>
</div>
{% endif %}
{% endfor %}
</div>
<div class="form-text text-muted small mt-1">
<i class="bi bi-info-circle me-1"></i>{% translate "Sélectionnez un ou plusieurs équipements. L'opération sera créée pour chaque équipement sélectionné." %}
</div>
</div>
<div id="customOpAlert" class="alert d-none" role="alert"></div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-lg me-1"></i>{% translate "Annuler" %}
</button>
<button type="submit" class="btn btn-primary" id="submitCustomOperation">
<i class="bi bi-plus-lg me-1"></i>{% translate "Ajouter" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Modal caméra Canvas (mode continu) -->
<div class="modal fade" id="cameraModal" tabindex="-1" aria-labelledby="cameraModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen-sm-down modal-dialog-centered">
<div class="modal-content bg-dark text-white camera-modal-content">
<div class="modal-header border-0 flex-shrink-0">
<h5 class="modal-title" id="cameraModalLabel">
<i class="bi bi-camera-video"></i> {% translate "Caméra" %}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}" id="cameraModalCloseBtn"></button>
</div>
<div class="modal-body d-flex flex-column p-2 overflow-auto">
<div class="text-center flex-grow-1 d-flex align-items-center justify-content-center">
<div class="camera-preview-wrapper">
<video id="cameraPreview" class="w-100" playsinline autoplay></video>
<div id="cameraFlashOverlay" class="camera-flash-overlay"></div>
<canvas id="cameraCanvas" class="d-none"></canvas>
</div>
</div>
<div id="cameraStatus" class="small text-center text-muted mt-2"></div>
</div>
<div class="modal-footer border-0 flex-shrink-0 bg-dark camera-buttons-footer">
<div class="d-flex justify-content-between align-items-center gap-2 w-100">
<button type="button" class="btn btn-outline-light flex-fill" id="cameraStopBtn">
<i class="bi bi-x-circle"></i> {% translate "Terminer" %}
</button>
<button type="button" class="btn btn-primary flex-fill" id="cameraCaptureBtn">
<i class="bi bi-camera-fill"></i> {% translate "Capturer" %}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modal de transfert d'intervention (placée en dehors du footer pour éviter les problèmes de z-index) -->
<div class="modal fade" id="transferModal" tabindex="-1" aria-labelledby="transferModalLabel" aria-hidden="true" data-bs-backdrop="true" data-bs-keyboard="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="transferModalLabel">
<i class="bi bi-arrow-left-right me-2"></i>{% translate "Transférer l'intervention" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<div id="transferLoading" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{% translate "Chargement..." %}</span>
</div>
<p class="mt-2 text-muted">{% translate "Chargement des membres disponibles..." %}</p>
</div>
<div id="transferContent" class="d-none">
<p class="text-muted mb-3">{% translate "Sélectionnez le membre vers lequel transférer cette intervention :" %}</p>
<div id="transferMembersList" class="list-group" style="max-height: 50vh; overflow-y: auto;">
<!-- Members will be populated here -->
</div>
<div id="transferNoMembers" class="alert alert-info d-none mt-3">
<i class="bi bi-info-circle me-2"></i>{% translate "Aucun membre disponible pour le transfert." %}
</div>
<div id="transferError" class="alert alert-danger d-none mt-3">
<i class="bi bi-exclamation-triangle me-2"></i><span id="transferErrorText"></span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% translate "Annuler" %}
</button>
<button type="button" class="btn btn-primary" id="confirmTransferBtn" disabled onclick="confirmTransfer()">
<i class="bi bi-check-lg me-1"></i>{% translate "Transférer" %}
</button>
</div>
</div>
</div>
</div>
<!-- Traitement via une autre intervention -->
{% if 'processed_via_other_intervention' in allowed_update_fields %}
<div class="card mt-4 mb-4">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="bi bi-diagram-2 me-2"></i>
{% translate "Traitement via une autre intervention" %}
</h5>
</div>
<div class="card-body">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="processedViaOtherCheckbox"
data-intervention-id="{{ intervention.id }}"
{% if intervention.processed_via_other_intervention %}checked{% endif %} />
<label class="form-check-label" for="processedViaOtherCheckbox">
{% translate "Le traitement est fait via une autre intervention" %}
</label>
</div>
</div>
</div>
{% elif intervention.processed_via_other_intervention %}
<div class="card mt-4 mb-4 bg-light">
<div class="card-body">
<i class="bi bi-diagram-2 me-2 text-success"></i>
<span class="text-success fw-semibold">{% translate "Le traitement est fait via une autre intervention" %}</span>
</div>
</div>
{% endif %}
{% if is_technician and not can_edit_intervention %}
{# Formulaire minimal pour la prise en charge / démarrage sans droit d'édition #}
<form id="activityForm" method="post" action="{% url 'mobile:intervention_detail_mobile' intervention.id %}{% if back_history %}?back={{ back_history }}{% endif %}">
{% csrf_token %}
{% if back_history %}<input type="hidden" name="back" value="{{ back_history }}">{% endif %}
<input type="hidden" name="status_action" id="status_action" value="">
<input type="hidden" name="pause_reason" id="pause_reason" value="">
<input type="hidden" name="pause_reason_other" id="pause_reason_other" value="">
</form>
{% endif %}
{% if intervention.lat and intervention.lon %}
<!-- Bouton flottant carte (au-dessus du footer) -->
<button id="map-fab-btn" class="btn btn-primary" title="{% translate 'Voir sur la carte' %}" aria-label="{% translate 'Voir sur la carte' %}">
<i class="bi bi-map fs-5"></i>
</button>
<!-- Overlay pleine page : carte -->
<div id="map-overlay" role="dialog" aria-modal="true" aria-label="{% translate 'Localisation de l\'intervention' %}">
<div id="map-overlay-header">
<span class="fw-semibold d-flex align-items-center gap-2 small">
<i class="bi bi-map-fill"></i> {% translate "Carte" %}
</span>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm btn-light py-1 px-2.5 text-primary fw-semibold rounded-pill d-inline-flex align-items-center gap-1 shadow-sm"
onclick="openGpsNavigation('{{ intervention.lat|stringformat:'f' }}', '{{ intervention.lon|stringformat:'f' }}', '{{ intervention.code|escapejs }}', false, event)">
<i class="bi bi-sign-turn-right-fill"></i>{% translate "GPS" %}
</button>
<button id="map-close-btn" type="button" class="btn-close btn-close-white btn-sm" aria-label="{% translate 'Fermer la carte' %}"></button>
</div>
</div>
<div id="intervention-map"></div>
</div>
{% include "interventions/includes/_gps_navigation_modal.html" %}
<script src="{% static 'interventions/mobile/gps_navigation.js' %}"></script>
{% endif %}
<!-- ===== Bouton flottant Discussion (au-dessus du bouton carte) ===== -->
<button id="discussion-mob-fab" onclick="toggleMobDiscussionPanel()"
title="{% translate 'Discussion' %}" aria-label="{% translate 'Discussion' %}">
<i class="bi bi-chat-dots-fill"></i>
{% if discussion_messages %}
<span class="mob-fab-badge" id="discussion-mob-badge">{{ discussion_messages|length }}</span>
{% endif %}
</button>
<div id="discussion-mob-panel" role="dialog" aria-label="{% translate 'Discussion' %}">
<div id="discussion-mob-panel-header">
<span class="fw-semibold d-flex align-items-center gap-2 small">
<i class="bi bi-chat-dots-fill"></i> {% translate "Discussion" %}
</span>
<button type="button" class="btn-close btn-close-white btn-sm"
onclick="toggleMobDiscussionPanel()" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div id="discussion-mob-thread">
{% if discussion_messages %}
{% 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:28px;height:28px;font-size:.7rem;">
{{ 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 }}</strong>
&middot; {{ msg.created_at|date:"d/m H:i" }}
</div>
{% else %}
<div class="small text-muted mb-1 text-end">{{ msg.created_at|date:"d/m H:i" }}</div>
{% endif %}
<div class="p-2 rounded {% if msg.author != request.user %}bg-light{% endif %}"
style="{% if msg.author == request.user %}background:#e7ebff;{% endif %}border-radius:12px;word-break:break-word;display:inline-block;text-align:left;font-size:.85rem;">
{{ msg.content|render_mentions }}
{% if msg.attachments.exists %}
<div class="mt-1 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:44px;height:44px;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 py-0 px-1" style="font-size:.75rem;">
<i class="bi bi-paperclip"></i>{{ att.name|truncatechars:16 }}
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted small text-center py-3 mb-0">
<i class="bi bi-chat-square me-1"></i>{% translate "Aucun message." %}
</p>
{% endif %}
</div>
{% if can_post_discussion_message %}
<div id="discussion-mob-form-area">
<form method="post"
action="{% url 'interventions:intervention_add_message' intervention.id %}"
enctype="multipart/form-data"
id="mob-msg-form">
{% csrf_token %}
<input type="hidden" name="content" id="mob-msg-content-real">
<div class="d-flex gap-2 align-items-end">
<div class="flex-grow-1 position-relative">
<textarea id="mob-msg-content" class="form-control form-control-sm"
rows="2" style="resize:none;font-size:.85rem;"
placeholder="{% translate 'Écrire… (@ pour mentionner)' %}"></textarea>
<div id="mob-mention-dropdown"
class="list-group shadow"
style="display:none;position:absolute;bottom:100%;left:0;right:0;z-index:1080;max-height:160px;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' %}" style="padding:5px 8px;">
<i class="bi bi-send-fill"></i>
</button>
<label class="btn btn-outline-secondary btn-sm mb-0" title="{% translate 'Joindre un fichier' %}" style="padding:5px 8px;">
<i class="bi bi-paperclip"></i>
<input type="file" name="attachments" multiple class="d-none" id="mob-msg-file-input">
</label>
</div>
</div>
<div id="mob-msg-file-preview" class="d-flex flex-wrap gap-2 mt-2"></div>
</form>
</div>
{% endif %}
</div>
<!-- ===== fin widget Discussion mobile ===== -->
{% endblock content %}
<!-- Boutons d'action -->
{% block footer %}
{% if is_technician %}
{# ── Techniciens : boutons d'action spécifiques ── #}
{% if photos_required and intervention.status in 'in_progress,on_pause' and can_edit_intervention %}
<div class="container mt-2">
<div id="photosRequiredAlert" class="alert alert-warning d-none" role="alert">
<i class="bi bi-camera-fill me-2"></i><span id="photosRequiredAlertMsg">{% translate "Au moins une photo est obligatoire pour terminer cette intervention." %}</span>
</div>
</div>
{% endif %}
<div class="container d-flex gap-2 justify-content-center text-center status-buttons mb-3 mt-3">
{% if intervention.status == 'to_be_processed' and can_take_over %}
<button class="btn btn-info"
onclick="submitWithStatus('take_over')">
<i class="bi bi-person-fill-check"></i> {% translate "Prendre" %}
</button>
{% endif %}
{% if intervention.status == 'assigned' and can_edit_intervention %}
<button class="btn btn-outline-secondary"
onclick="submitWithStatus('release')">
<i class="bi bi-box-arrow-left"></i> {% translate "Libérer" %}
</button>
{% endif %}
{% if intervention.status in 'to_be_processed,assigned' and can_take_over %}
<button class="btn btn-primary"
onclick="submitWithStatus('start')">
<i class="bi bi-play-circle-fill"></i> {% translate "Démarrer" %}
</button>
{% endif %}
{% if intervention.status in 'on_pause' and can_edit_intervention %}
<button class="btn btn-primary"
onclick="submitWithStatus('start')">
<i class="bi bi-play-circle-fill"></i> {% translate "Démarrer" %}
</button>
{% endif %}
{% if intervention.status == 'in_progress' and can_edit_intervention %}
<button class="btn btn-warning"
onclick="submitWithStatus('pause')">
<i class="bi bi-pause-circle-fill"></i> {% translate "Pause" %}
</button>
{% endif %}
{% if intervention.status in 'in_progress,on_pause' and can_edit_intervention %}
<button class="btn btn-success"
onclick="submitFinish()">
<i class="bi bi-stop-circle-fill"></i> {% translate "Terminer" %}
</button>
{% endif %}
{% if intervention.status in 'to_be_processed,assigned,in_progress,on_pause' and can_edit_intervention %}
<button class="btn btn-outline-primary"
onclick="openTransferModal()">
<i class="bi bi-arrow-left-right"></i> {% translate "Transférer" %}
</button>
{% endif %}
</div>
{% else %}
{# ── Autres rôles : timeline de changement de statut ── #}
<div class="container">
<div class="timeline" id="statusTimeline"></div>
</div>
{% endif %}
{% endblock footer %}
{% block extra_scripts %}
{% if not is_technician %}
{# ── Modals timeline (rendus hors du footer position:fixed pour éviter le contexte d'empilement) ── #}
<!-- Modal de confirmation d'annulation -->
<div class="modal fade" id="cancelConfirmModal" tabindex="-1" aria-labelledby="cancelConfirmLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-light">
<h5 class="modal-title" id="cancelConfirmLabel">{% translate "Confirmer l'annulation" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<p>{% translate "Êtes-vous sûr de vouloir annuler cette intervention ?" %}</p>
<div class="mt-3">
<label class="form-label" for="cancellationTypeSelect">{% translate "Type d'annulation" %} <span class="text-danger">*</span></label>
<select class="form-select" id="cancellationTypeSelect" required>
<option value="" selected disabled>{% translate "Choisissez un type" %}</option>
<option value="duplicate">{% translate "Doublon" %}</option>
<option value="to_redirect">{% translate "A rediriger" %}</option>
<option value="not_our_assets">{% translate "Pas sous notre gestion" %}</option>
<option value="non_relevant">{% translate "Non-relevant" %}</option>
<option value="other">{% translate "Autre" %}</option>
</select>
</div>
<div class="mt-3">
<label class="form-label" for="cancellationReasonInput">{% translate "Commentaire" %} <span class="text-danger d-none" id="cancellationReasonRequired">*</span></label>
<textarea class="form-control" id="cancellationReasonInput" rows="3" maxlength="1000" placeholder="{% translate 'Commentaire libre...' %}"></textarea>
<small class="form-text text-muted" id="cancellationReasonHint">{% translate "Obligatoire pour le motif \"Autre\". Sinon, le motif choisi sera enregistré comme note." %}</small>
</div>
<div class="alert alert-danger d-none mt-3" id="cancellationReasonError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Non" %}</button>
<button type="button" class="btn btn-danger" id="confirmCancelBtn">{% translate "Oui, annuler" %}</button>
</div>
</div>
</div>
</div>
<!-- Modal de confirmation pour la validation par périodes -->
<div class="modal fade" id="periodValidationModal" tabindex="-1" aria-labelledby="periodValidationLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-light">
<h5 class="modal-title" id="periodValidationLabel">{% translate "Confirmer la validation" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<div id="periodValidationMessage" class="mb-2"></div>
<div class="alert alert-info" role="alert">
<small>{% translate "Cette action est liée à la validation par périodes." %}</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="button" class="btn btn-primary" id="confirmPeriodValidationBtn">{% translate "Confirmer" %}</button>
</div>
</div>
</div>
</div>
<!-- Modal pour la raison de la correction -->
<div class="modal fade" id="correctionReasonModal" tabindex="-1" aria-labelledby="correctionReasonLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-light">
<h5 class="modal-title" id="correctionReasonLabel">{% translate "Raison de la correction" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<label class="form-label" for="reasonForCorrectionInput">{% translate "Indiquez la raison pour laquelle une correction est demandée" %}</label>
<textarea class="form-control" id="reasonForCorrectionInput" rows="4" maxlength="1000"></textarea>
<small class="text-muted">{% translate "Maximum 1000 caractères" %}</small>
<div class="alert alert-danger d-none mt-3" id="correctionReasonError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="button" class="btn btn-primary" id="confirmCorrectionReason">{% translate "Confirmer" %}</button>
</div>
</div>
</div>
</div>
<!-- Modal pour les détails de la correction -->
<div class="modal fade" id="correctionDetailsModal" tabindex="-1" aria-labelledby="correctionDetailsLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-light">
<h5 class="modal-title" id="correctionDetailsLabel">{% translate "Détails de la correction" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<label class="form-label" for="correctionDetailsInput">{% translate "Décrivez ce qui a été corrigé" %}</label>
<textarea class="form-control" id="correctionDetailsInput" rows="4" maxlength="1000"></textarea>
<small class="text-muted">{% translate "Maximum 1000 caractères" %}</small>
<div class="alert alert-danger d-none mt-3" id="correctionDetailsError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="button" class="btn btn-primary" id="confirmCorrectionDetails">{% translate "Confirmer" %}</button>
</div>
</div>
</div>
</div>
<!-- Modal de confirmation de changement de statut (mobile) -->
<div class="modal fade" id="mobileStatusConfirmModal" tabindex="-1" aria-labelledby="mobileStatusConfirmLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content">
<div class="modal-header bg-light py-2">
<h6 class="modal-title" id="mobileStatusConfirmLabel">{% translate "Confirmer" %}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body py-3">
<p class="mb-0" id="mobileStatusConfirmMessage"></p>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="button" class="btn btn-sm btn-primary" id="confirmMobileStatusBtn">{% translate "Confirmer" %}</button>
</div>
</div>
</div>
</div>
{% endif %}
{# ── Globals partagés (timeline + photo upload + suppression documents) ── #}
<script>
const interventionId = '{{ intervention.id }}';
const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || '{{ csrf_token }}';
</script>
{% if not is_technician %}
{# ── jQuery + globals requis par timeline.js ── #}
<script src="{% static 'common/jquery.min.js' %}"></script>
{{ status_choices|json_script:"status_choices" }}
<script>
const STATUS_CHOICES = JSON.parse(document.getElementById("status_choices").textContent);
const currentStatus = '{{ intervention.status }}';
const recommendedNextStatus = '{{ recommended_next_status }}';
const allowed_transitions = {{ allowed_transitions|safe }};
const userRoles = {{ user_roles|safe }};
const canEditIntervention = {{ can_edit_intervention|yesno:"true,false" }};
const canTakeOver = {{ can_take_over|yesno:"true,false" }};
const periodValidationEnabled = false;
const hasPeriodQuantities = false;
const periodNumber = 1;
const periodIsFinal = false;
const isMobileTimeline = true;
</script>
<script src="{% static 'interventions/status_colors.js' %}"></script>
<script src="{% static 'interventions/timeline.js' %}"></script>
{% endif %}
<script src="{% static 'interventions/detail/intervention_notes.js' %}"></script>
<script src="{% static 'interventions/detail/intervention_occupations.js' %}"></script>
<script src="{% static 'interventions/detail/operation_comments.js' %}"></script>
<script src="{% static 'assets/asset_add_modal.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const summaryModal = document.getElementById('locationHistorySummaryModal');
const summaryModalBody = document.getElementById('locationHistorySummaryModalBody');
const summaryModalLabel = document.getElementById('locationHistorySummaryModalLabel');
if (!summaryModal || !summaryModalBody) {
return;
}
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="text-muted">{% translate "Impossible de charger le résumé." %}</div>`;
return;
}
fetch(summaryUrl, {
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (!response.ok) {
throw new Error('Request failed');
}
return response.text();
})
.then(html => {
summaryModalBody.innerHTML = html;
})
.catch(() => {
summaryModalBody.innerHTML = `<div class="text-muted">{% translate "Impossible de charger le résumé." %}</div>`;
});
});
summaryModal.addEventListener('hidden.bs.modal', function() {
if (summaryModalLabel && summaryModalLabel.dataset.baseTitle) {
summaryModalLabel.textContent = summaryModalLabel.dataset.baseTitle;
}
});
});
</script>
<!-- Auto-save indicator toast -->
<div id="autoSaveIndicator" class="position-fixed bottom-0 start-50 translate-middle-x mb-5 p-2 px-3 rounded-pill shadow-sm d-none"
style="z-index: 1060; background-color: rgba(255,255,255,0.95); border: 1px solid #dee2e6;">
<span class="spinner-border spinner-border-sm me-1 d-none" id="autoSaveSpinner"></span>
<i class="bi bi-check-circle-fill text-success me-1 d-none" id="autoSaveSuccess"></i>
<i class="bi bi-exclamation-circle-fill text-danger me-1 d-none" id="autoSaveError"></i>
<small id="autoSaveText"></small>
</div>
<!-- Auto-save and AJAX save functionality -->
<script>
(function initAutoSave() {
const AUTOSAVE_INTERVAL = 10000; // 10 seconds
const SAVE_URL = '{% url "interventions:save_intervention_mobile_ajax" intervention.id %}';
const SAVE_OPERATIONS_URL = '{% url "interventions:save_operations_ajax" intervention.id %}';
let autoSaveTimer = null;
let isDirty = false;
let lastSavedOperationsData = null;
// Show auto-save indicator
window.showAutoSaveIndicator = function(message, type = 'info') {
const indicator = document.getElementById('autoSaveIndicator');
const spinner = document.getElementById('autoSaveSpinner');
const success = document.getElementById('autoSaveSuccess');
const error = document.getElementById('autoSaveError');
const text = document.getElementById('autoSaveText');
if (!indicator) return;
spinner.classList.add('d-none');
success.classList.add('d-none');
error.classList.add('d-none');
if (type === 'saving') {
spinner.classList.remove('d-none');
} else if (type === 'success') {
success.classList.remove('d-none');
} else if (type === 'error') {
error.classList.remove('d-none');
}
text.textContent = message;
indicator.classList.remove('d-none');
// Auto-hide after 3 seconds (except for saving state)
if (type !== 'saving') {
setTimeout(() => {
indicator.classList.add('d-none');
}, 3000);
}
};
// Collect current operations data (only operations with values)
function collectOperationsData() {
const operations = [];
const naOperations = new Set();
// First, collect N/A operations
document.querySelectorAll('input[name^="operation_na_"]:checked').forEach(input => {
const match = input.name.match(/operation_na_(\d+)/);
if (match) {
const opId = parseInt(match[1]);
naOperations.add(opId);
operations.push({
id: opId,
is_not_applicable: true
});
}
});
// Collect measurement values (always include to allow clearing, unless N/A)
document.querySelectorAll('input[name^="operation_measure_"]').forEach(input => {
const match = input.name.match(/operation_measure_(\d+)/);
if (match) {
const opId = parseInt(match[1]);
if (!naOperations.has(opId)) {
operations.push({
id: opId,
measured_value: input.value.trim(),
is_not_applicable: false
});
}
}
});
// Collect duration values (always include to allow clearing, unless N/A)
document.querySelectorAll('input[name^="operation_duration_"]').forEach(input => {
const match = input.name.match(/operation_duration_(\d+)/);
if (match) {
const opId = parseInt(match[1]);
if (!naOperations.has(opId)) {
operations.push({
id: opId,
measured_duration: input.value.trim(),
is_not_applicable: false
});
}
}
});
// Collect datetime values (always include to allow clearing, unless N/A)
document.querySelectorAll('input[name^="operation_datetime_"]').forEach(input => {
const match = input.name.match(/operation_datetime_(\d+)/);
if (match) {
const opId = parseInt(match[1]);
if (!naOperations.has(opId)) {
operations.push({
id: opId,
measured_datetime: input.value.trim(),
is_not_applicable: false
});
}
}
});
// Collect status values (only checked ones and not N/A)
document.querySelectorAll('input[name^="operation_status_"]:checked').forEach(input => {
const match = input.name.match(/operation_status_(\d+)/);
if (match) {
const opId = parseInt(match[1]);
if (!naOperations.has(opId)) {
operations.push({
id: opId,
status_id: parseInt(input.value),
is_not_applicable: false
});
}
}
});
return operations;
}
// Collect ALL operations data for change detection (including empty ones)
function collectAllOperationsData() {
const data = {};
// Collect all N/A checkboxes
document.querySelectorAll('input[name^="operation_na_"]').forEach(input => {
data[input.name] = input.checked ? '1' : '0';
});
// Collect all measurement values
document.querySelectorAll('input[name^="operation_measure_"]').forEach(input => {
data[input.name] = input.value || '';
});
// Collect all duration values
document.querySelectorAll('input[name^="operation_duration_"]').forEach(input => {
data[input.name] = input.value || '';
});
// Collect all datetime values
document.querySelectorAll('input[name^="operation_datetime_"]').forEach(input => {
data[input.name] = input.value || '';
});
// Collect all status values
document.querySelectorAll('input[name^="operation_status_"]:checked').forEach(input => {
data[input.name] = input.value;
});
return data;
}
// Check if operations data has changed
function hasOperationsDataChanged() {
const currentData = JSON.stringify(collectAllOperationsData());
if (lastSavedOperationsData === null) {
lastSavedOperationsData = currentData;
return false;
}
return currentData !== lastSavedOperationsData;
}
// Check if there's note content to save
function hasNoteContent() {
const noteInput = document.getElementById('note');
return noteInput && noteInput.value.trim() !== '';
}
// Check if there are pending files to upload
function hasPendingFiles() {
// Check for files in the dynamically created inputs in photo-inputs container
const inputContainer = document.getElementById('photo-inputs');
if (inputContainer) {
const fileInputs = inputContainer.querySelectorAll('input[type="file"][name="photos"]');
return fileInputs.length > 0;
}
return false;
}
// Check if anything needs to be saved
function hasAnythingToSave() {
return hasOperationsDataChanged() || hasNoteContent() || hasPendingFiles();
}
// Save operations only via AJAX (for auto-save)
async function saveOperationsOnly(showIndicator = true) {
const operations = collectOperationsData();
if (operations.length === 0) {
return { success: true, saved_count: 0 };
}
try {
const response = await fetch(SAVE_OPERATIONS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken'),
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ operations: operations })
});
const data = await response.json();
if (response.ok && data.success) {
lastSavedOperationsData = JSON.stringify(collectAllOperationsData());
return { success: true, saved_count: data.saved_count };
}
return { success: false, error: data.error };
} catch (error) {
console.error('Save operations error:', error);
return { success: false, error: 'Network error' };
}
}
// Save everything (note + files + operations) via AJAX with FormData
async function saveAll(showIndicator = true) {
if (showIndicator) {
showAutoSaveIndicator(gettext('Sauvegarde en cours...'), 'saving');
}
const formData = new FormData();
// Add note content
const noteInput = document.getElementById('note');
const noteTypeSelect = document.getElementById('noteType');
if (noteInput && noteInput.value.trim()) {
formData.append('content', noteInput.value.trim());
if (noteTypeSelect) {
formData.append('note_type', noteTypeSelect.value);
}
// If we have a note ID, include it to update the same note
if (window.getDraftNoteId && window.getDraftNoteId()) {
formData.append('note_id', window.getDraftNoteId());
}
}
// Add files from dynamically created inputs in photo-inputs container
const inputContainer = document.getElementById('photo-inputs');
if (inputContainer) {
const fileInputs = inputContainer.querySelectorAll('input[type="file"][name="photos"]');
fileInputs.forEach(input => {
if (input.files && input.files.length > 0) {
for (let i = 0; i < input.files.length; i++) {
formData.append('photos', input.files[i]);
}
}
});
}
// Add operations as JSON
const operations = collectOperationsData();
formData.append('operations_json', JSON.stringify(operations));
// Trigger guided operation save if active
const guidedSavePromises = [];
const saveEvent = new CustomEvent('cms-save-before', {
detail: {
addSavePromise: (promise) => guidedSavePromises.push(promise)
}
});
document.dispatchEvent(saveEvent);
if (guidedSavePromises.length > 0) {
try {
await Promise.all(guidedSavePromises);
} catch (error) {
console.error('Error saving guided operations:', error);
if (showIndicator) {
showAutoSaveIndicator(gettext('Erreur de sauvegarde des opérations guidées'), 'error');
}
return { success: false, error: 'Guided operations save error' };
}
}
try {
const response = await fetch(SAVE_URL, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
});
const data = await response.json();
if (response.ok && data.success) {
lastSavedOperationsData = JSON.stringify(collectAllOperationsData());
isDirty = false;
// Clear note field after successful save
if (data.note_saved && noteInput) {
noteInput.value = '';
}
// Clear files inputs and preview after successful upload
if (data.documents_count > 0) {
// Clear dynamically created inputs in photo-inputs container
const photoInputsContainer = document.getElementById('photo-inputs');
if (photoInputsContainer) {
photoInputsContainer.innerHTML = '';
}
// Also clear preview container
const previewContainer = document.getElementById('preview-container');
if (previewContainer) {
previewContainer.innerHTML = '';
}
}
if (showIndicator) {
showAutoSaveIndicator(data.message || gettext('Sauvegardé') + ' ✓', 'success');
}
return { success: true, message: data.message, data: data };
} else {
if (showIndicator) {
const errorMsg = data.errors && data.errors.length > 0
? data.errors.join(', ')
: gettext('Erreur de sauvegarde');
showAutoSaveIndicator(errorMsg, 'error');
}
return { success: false, error: data.errors };
}
} catch (error) {
console.error('Save all error:', error);
if (showIndicator) {
showAutoSaveIndicator(gettext('Erreur réseau'), 'error');
}
return { success: false, error: 'Network error' };
}
}
// Expose save function globally for manual save button
window.saveOperationsManually = async function() {
const btn = document.getElementById('manualSaveBtn');
const icon = document.getElementById('saveIcon');
const spinner = document.getElementById('saveSpinner');
const noteInput = document.getElementById('note');
if (btn) {
btn.disabled = true;
if (icon) icon.classList.add('d-none');
if (spinner) spinner.classList.remove('d-none');
}
// Use saveAll to save note + files + operations
const result = await saveAll(true);
if (btn) {
btn.disabled = false;
if (icon) icon.classList.remove('d-none');
if (spinner) spinner.classList.add('d-none');
// Flash the button green on success
if (result.success) {
btn.classList.remove('btn-light');
btn.classList.add('btn-success');
// If a note was saved, update it in DOM and clear input for new note
if (result.data && result.data.note_saved && result.data.note) {
// Update note in DOM with manual save flag (adds to bottom with border-top)
window.addOrUpdateNoteInDOM(result.data.note, true);
// Show success message
window.showNoteAlert(gettext('Note sauvegardée'), 'success');
// Clear the input and reset note ID to allow creating a new note
if (noteInput) {
noteInput.value = '';
}
// Reset current note ID
if (window.resetDraftNoteId) {
window.resetDraftNoteId();
}
}
setTimeout(() => {
btn.classList.remove('btn-success');
btn.classList.add('btn-light');
}, 1500);
}
}
return result;
};
// Auto-save function (only saves operations, not note/files)
function autoSave() {
if (hasOperationsDataChanged()) {
console.log('[AutoSave] Changements détectés, sauvegarde en cours...');
saveOperationsOnly(true).then(result => {
if (result.success) {
const msg = result.saved_count > 0
? gettext('Sauvegarde automatique') + ' ✓'
: gettext('Aucune modification');
showAutoSaveIndicator(msg, 'success');
} else {
showAutoSaveIndicator(result.error || gettext('Erreur de sauvegarde'), 'error');
}
});
} else {
//console.log('[AutoSave] Aucun changement détecté');
}
}
// Start auto-save timer
function startAutoSave() {
if (autoSaveTimer) {
clearInterval(autoSaveTimer);
}
autoSaveTimer = setInterval(autoSave, AUTOSAVE_INTERVAL);
}
// Mark as dirty when operations change
function markDirty() {
isDirty = true;
}
// Initialize event listeners for operations
document.addEventListener('DOMContentLoaded', function() {
// Initialize lastSavedOperationsData with ALL operations data for change detection
lastSavedOperationsData = JSON.stringify(collectAllOperationsData());
// Listen for changes on operation inputs
document.querySelectorAll('input[name^="operation_measure_"], input[name^="operation_duration_"], input[name^="operation_datetime_"], input[name^="operation_status_"], input[name^="operation_na_"]').forEach(input => {
input.addEventListener('change', markDirty);
input.addEventListener('input', markDirty);
});
// Handle status radio button changes - auto-open comment modal if requires_comment
document.querySelectorAll('.operation-status-radio').forEach(radio => {
radio.addEventListener('change', function() {
if (this.checked && this.dataset.requiresComment === 'true') {
const opId = this.dataset.operationId;
const card = this.closest('[data-operation-card]');
if (card) {
const commentBtn = card.querySelector('.operation-comment-trigger');
if (commentBtn) {
// Small delay to let the UI update first
setTimeout(() => {
commentBtn.click();
}, 100);
}
}
}
});
});
// Handle N/A toggle button click
document.querySelectorAll('.operation-na-toggle').forEach(toggleBtn => {
toggleBtn.addEventListener('click', function() {
const opId = this.dataset.operationId;
const opType = this.dataset.operationType;
const card = this.closest('[data-operation-card]');
const checkbox = card.querySelector(`#op_na_${opId}`);
// Toggle the hidden checkbox
const isNA = !checkbox.checked;
checkbox.checked = isNA;
// Update toggle button appearance
this.classList.toggle('text-warning', isNA);
this.classList.toggle('text-secondary', !isNA);
this.classList.toggle('opacity-50', !isNA);
this.title = isNA ? gettext('Marquer comme applicable') : gettext('Marquer comme non applicable');
if (opType === 'measurement') {
// Disable/enable measurement input
const inputGroup = card.querySelector(`[data-input-group="${opId}"]`);
const measureInput = card.querySelector(`input[name="operation_measure_${opId}"]`);
if (measureInput) {
measureInput.disabled = isNA;
if (isNA) {
measureInput.value = '';
}
}
if (inputGroup) {
inputGroup.classList.toggle('opacity-50', isNA);
}
} else if (opType === 'status') {
// Disable/enable status radio buttons
const statusContainer = card.querySelector(`[data-status-group="${opId}"]`);
const statusRadios = card.querySelectorAll(`input[name="operation_status_${opId}"]`);
statusRadios.forEach(radio => {
radio.disabled = isNA;
if (isNA) {
radio.checked = false;
}
});
if (statusContainer) {
statusContainer.classList.toggle('opacity-50', isNA);
}
}
// Update the card styling
card.classList.toggle('bg-light', isNA);
const title = card.querySelector('p.fw-medium');
if (title) {
title.classList.toggle('text-muted', isNA);
title.classList.toggle('text-secondary', !isNA);
}
markDirty();
});
});
// Start auto-save
startAutoSave();
console.log('[AutoSave] Démarré avec intervalle de', AUTOSAVE_INTERVAL, 'ms');
// Auto-save notes with debounce
const noteInput = document.getElementById('note');
const noteTypeSelect = document.getElementById('noteType');
const notesContainer = document.getElementById('notesContainer');
let noteDebounceTimer = null;
let currentDraftNoteId = null; // Track the draft note being edited
const NOTE_DEBOUNCE_DELAY = 3000; // 3 seconds after typing stops
let isSavingNote = false; // Guard against concurrent note saves
let pendingNoteSave = false; // A new save was requested while one was in-flight
// Function to show alert in notes container
window.showNoteAlert = function(message, type = 'success') {
if (!notesContainer) return;
const alertContainer = document.getElementById('notesAlertContainer');
if (!alertContainer) return;
// Remove existing alerts
alertContainer.innerHTML = '';
const alertBox = document.createElement('div');
alertBox.className = `alert alert-${type} alert-dismissible fade show`;
alertBox.role = 'alert';
alertBox.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
alertContainer.appendChild(alertBox);
};
// Function to add or update a note in the DOM
window.addOrUpdateNoteInDOM = function(noteData, isManualSave = false) {
if (!notesContainer) return;
const noteId = noteData.id || noteData.note_id;
let noteContainer = document.getElementById(`note-container-${noteId}`);
// Remove "no notes" message if present
const noNotesMsg = notesContainer.querySelector('.text-muted.mb-0.small');
if (noNotesMsg && noNotesMsg.textContent.includes('Aucune note')) {
noNotesMsg.remove();
}
// Check if user can edit notes
const addNoteForm = document.getElementById('addNoteForm');
const canEditNotes = addNoteForm ? addNoteForm.dataset.canEditNotes === 'true' : false;
// Build action buttons if user can edit
let actionsHTML = '';
if (canEditNotes) {
actionsHTML = `
<div class="note-actions d-flex flex-column align-items-center gap-2 ms-auto">
<button type="button" class="btn btn-link text-primary p-0 edit-note-btn"
data-note-id="${noteId}" aria-label="${gettext('Modifier la note')}">
<i class="bi bi-pencil-square fs-6"></i>
</button>
<button type="button" class="btn btn-link text-danger p-0 delete-note-btn"
data-note-id="${noteId}" aria-label="${gettext('Supprimer la note')}">
<i class="bi bi-trash fs-6"></i>
</button>
</div>
`;
}
// Build note HTML
const noteHTML = `
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="note-content flex-grow-1">
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
<span class="badge note-type-badge d-inline-flex align-items-center gap-1 ${noteData.badge_class || 'bg-secondary'}">
<i class="bi ${noteData.icon_class || 'bi-chat-left-text'}"></i>
${noteData.note_type_display || ''}
</span>
<span class="text-muted small">
${noteData.note_time_display || ''} - ${noteData.author_display || ''}
</span>
</div>
<p class="mb-0">${(noteData.content || '').replace(/\n/g, '<br>')}</p>
</div>
${actionsHTML}
</div>
`;
// Set all data-attributes needed for edit modal
const setDataAttributes = (container) => {
container.dataset.noteId = noteId;
container.dataset.noteContent = encodeURIComponent(noteData.content || '');
container.dataset.noteAuthorId = noteData.author_id || '';
container.dataset.noteAuthorDisplay = noteData.author_display || '';
container.dataset.noteType = noteData.note_type || '';
container.dataset.notePriority = noteData.note_type_priority != null ? noteData.note_type_priority : 99;
container.dataset.noteTime = noteData.note_time_iso || noteData.note_time || '';
container.dataset.noteTypeDisplay = noteData.note_type_display || '';
container.dataset.noteBadgeClass = noteData.badge_class || '';
container.dataset.noteIconClass = noteData.icon_class || '';
};
if (noteContainer) {
// Update existing note
noteContainer.innerHTML = noteHTML;
setDataAttributes(noteContainer);
// If manual save, add border-top primary and move to bottom
if (isManualSave) {
noteContainer.style.borderTop = '3px solid var(--bs-primary)';
// Move note to the end (bottom of the list)
notesContainer.appendChild(noteContainer);
}
} else {
// Create new note element
noteContainer = document.createElement('div');
noteContainer.id = `note-container-${noteId}`;
noteContainer.className = 'note-item py-3';
setDataAttributes(noteContainer);
noteContainer.innerHTML = noteHTML;
// If manual save, add border-top primary and insert at the end
if (isManualSave) {
noteContainer.style.borderTop = '3px solid var(--bs-primary)';
// Insert at the end (bottom of the list)
notesContainer.appendChild(noteContainer);
} else {
// Auto-save: Insert at the beginning (after alert container)
const alertContainer = notesContainer.querySelector('#notesAlertContainer');
const firstNote = notesContainer.querySelector('.note-item');
if (alertContainer && firstNote) {
notesContainer.insertBefore(noteContainer, firstNote);
} else if (alertContainer) {
alertContainer.insertAdjacentElement('afterend', noteContainer);
} else if (firstNote) {
notesContainer.insertBefore(noteContainer, firstNote);
} else {
notesContainer.appendChild(noteContainer);
}
}
}
// Update borders
const allNotes = notesContainer.querySelectorAll('.note-item');
allNotes.forEach((note, index) => {
if (index === allNotes.length - 1) {
note.classList.remove('border-bottom');
} else {
note.classList.add('border-bottom');
}
});
};
// Function to reset draft note ID
window.resetDraftNoteId = function() {
currentDraftNoteId = null;
console.log('[AutoSave Notes] Draft note ID réinitialisé');
// Also clear the hidden field in activityForm
const activityFormNoteId = document.getElementById('activityFormNoteId');
if (activityFormNoteId) {
activityFormNoteId.value = '';
}
};
// Function to get current draft note ID
window.getDraftNoteId = function() {
return currentDraftNoteId;
};
if (noteInput) {
const autoSaveNote = async function() {
const content = noteInput.value.trim();
if (!content) {
return;
}
// Guard: if a save is already in progress, mark pending and return
if (isSavingNote) {
pendingNoteSave = true;
return;
}
isSavingNote = true;
pendingNoteSave = false;
// Silent auto-save - no indicator shown
try {
// Get CSRF token from the form
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || '';
const formData = new FormData();
formData.append('content', content);
if (noteTypeSelect) {
formData.append('note_type', noteTypeSelect.value);
//console.log('[AutoSave Notes] Type de note:', noteTypeSelect.value);
}
if (currentDraftNoteId) {
formData.append('note_id', currentDraftNoteId); // Update existing note
//console.log('[AutoSave Notes] Mise à jour de la note ID:', currentDraftNoteId);
}
// Add empty operations array to satisfy the endpoint
formData.append('operations_json', JSON.stringify([]));
//console.log('[AutoSave Notes] Envoi de la requête vers:', SAVE_URL);
const response = await fetch(SAVE_URL, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
});
//console.log('[AutoSave Notes] Statut de la réponse:', response.status, response.statusText);
const data = await response.json();
//console.log('[AutoSave Notes] Réponse du serveur:', data);
if (response.ok && data.success) {
// DON'T clear note field - keep it for continued editing
if (data.note_saved) {
if (data.note) {
const newNoteId = data.note.id || data.note.note_id;
//console.log('[AutoSave Notes] Note ID reçue:', newNoteId, 'Mise à jour:', data.note_updated);
// Store the note ID
currentDraftNoteId = newNoteId;
// Update hidden field in activityForm
const activityFormNoteId = document.getElementById('activityFormNoteId');
if (activityFormNoteId) {
activityFormNoteId.value = newNoteId;
}
// Update note in DOM
window.addOrUpdateNoteInDOM(data.note);
//console.log('[AutoSave Notes] Note sauvegardée avec succès, ID:', currentDraftNoteId);
} else {
//console.log('[AutoSave Notes] Note sauvegardée mais données de note manquantes');
}
// Don't show success indicator - silent auto-save
} else {
//console.log('[AutoSave Notes] Réponse success mais note_saved=false');
// Don't show indicator - silent auto-save
}
} else {
console.error('[AutoSave Notes] Échec de la sauvegarde:', data);
throw new Error(data.errors ? data.errors.join(', ') : 'Save failed');
}
} catch (error) {
console.error('[AutoSave Notes] Exception attrapée:', error);
console.error('[AutoSave Notes] Stack trace:', error.stack);
showAutoSaveIndicator(gettext('Erreur lors de la sauvegarde'), 'error');
} finally {
isSavingNote = false;
// If a new save was requested while we were saving, re-trigger
if (pendingNoteSave) {
pendingNoteSave = false;
noteDebounceTimer = setTimeout(autoSaveNote, 500);
}
}
};
// Listen for input changes with debounce
noteInput.addEventListener('input', function() {
// Clear previous timer
if (noteDebounceTimer) {
clearTimeout(noteDebounceTimer);
}
// Only set timer if there's content
const content = this.value.trim();
if (content) {
console.log('[AutoSave Notes] Changement détecté, timer de', NOTE_DEBOUNCE_DELAY, 'ms démarré');
noteDebounceTimer = setTimeout(autoSaveNote, NOTE_DEBOUNCE_DELAY);
}
});
// Also save when note type changes (if there's content)
if (noteTypeSelect) {
noteTypeSelect.addEventListener('change', function() {
if (noteInput.value.trim()) {
console.log('[AutoSave Notes] Type de note changé, sauvegarde immédiate');
if (noteDebounceTimer) {
clearTimeout(noteDebounceTimer);
}
autoSaveNote();
}
});
}
}
// Save before page unload
window.addEventListener('beforeunload', function(e) {
if (hasOperationsDataChanged()) {
// Try to save synchronously before leaving
navigator.sendBeacon(SAVE_OPERATIONS_URL, JSON.stringify({
operations: collectOperationsData()
}));
}
});
});
// Override form submission to save first and preserve scroll
const activityForm = document.getElementById('activityForm');
if (activityForm) {
activityForm.addEventListener('submit', function(e) {
// Store scroll position before submit
sessionStorage.setItem('scrollPosition_{{ intervention.id }}', window.scrollY.toString());
// The form submission will handle everything normally
});
}
// Restore scroll position after page load (for form submissions)
document.addEventListener('DOMContentLoaded', function() {
const savedScroll = sessionStorage.getItem('scrollPosition_{{ intervention.id }}');
if (savedScroll) {
window.scrollTo(0, parseInt(savedScroll));
sessionStorage.removeItem('scrollPosition_{{ intervention.id }}');
}
});
})();
</script>
<!-- Operations collapse/expand functionality (mobile) -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const expandAllBtn = document.getElementById('expandAllOperationsMobile');
const collapseAllBtn = document.getElementById('collapseAllOperationsMobile');
// Get all collapse elements for operations
const collapseElements = document.querySelectorAll('.operation-collapse-content');
// Update collapse icon state when collapse events occur
collapseElements.forEach(collapseEl => {
collapseEl.addEventListener('show.bs.collapse', function() {
const header = document.querySelector(`[data-bs-target="#${this.id}"]`);
if (header) header.classList.remove('collapsed');
});
collapseEl.addEventListener('hide.bs.collapse', function() {
const header = document.querySelector(`[data-bs-target="#${this.id}"]`);
if (header) header.classList.add('collapsed');
});
});
// Expand all operations
if (expandAllBtn) {
expandAllBtn.addEventListener('click', function() {
collapseElements.forEach(collapseEl => {
const bsCollapse = bootstrap.Collapse.getOrCreateInstance(collapseEl, {toggle: false});
bsCollapse.show();
});
});
}
// Collapse all operations
if (collapseAllBtn) {
collapseAllBtn.addEventListener('click', function() {
collapseElements.forEach(collapseEl => {
const bsCollapse = bootstrap.Collapse.getOrCreateInstance(collapseEl, {toggle: false});
bsCollapse.hide();
});
});
}
});
</script>
{% if mobile_assets_enabled and mobile_assets_config %}
{{ mobile_assets_config|json_script:"mobile-assets-config" }}
<script>
window.mobileAssetsI18n = {
noResults: "{{ _('Aucun équipement ne correspond aux filtres sélectionnés.')|escapejs }}",
requiresCategory: "{{ _('Veuillez sélectionner une catégorie pour lancer une recherche.')|escapejs }}",
alreadyLinked: "{{ _('Déjà lié')|escapejs }}",
selectionSingular: "{{ _('équipement sélectionné')|escapejs }}",
selectionPlural: "{{ _('équipements sélectionnés')|escapejs }}",
resultSingular: "{{ _('résultat')|escapejs }}",
resultPlural: "{{ _('résultats')|escapejs }}",
applyIdle: "{{ _('Ajouter les équipements')|escapejs }}",
applyBusy: "{{ _('Synchronisation en cours…')|escapejs }}",
errorFilters: "{{ _('Impossible de charger les filtres disponibles.')|escapejs }}",
errorSearch: "{{ _('Impossible de charger les équipements.')|escapejs }}",
errorSync: "{{ _('Impossible de synchroniser les équipements sélectionnés.')|escapejs }}",
limitMessage: "{{ _('Résultats limités à {count} équipements.')|escapejs }}",
unsavedWarning: "{{ _('Vous avez des sélections non appliquées. Quitter malgré tout ?')|escapejs }}",
keepValuesWarning: "{{ _('Certaines opérations n\'ont pas été supprimées car des valeurs ont déjà été encodées.')|escapejs }}",
modelPlaceholder: "{{ _('Sélectionnez un modèle')|escapejs }}",
removeConfirm: "{{ _('Supprimer le lien avec {label} ?')|escapejs }}",
removeError: "{{ _('Impossible de supprimer ce lien.')|escapejs }}",
removeBusy: "{{ _('Suppression en cours…')|escapejs }}",
removeIdle: "{{ _('Supprimer')|escapejs }}",
noLocationChips: "{{ _('Aucune localisation — sélectionnez une catégorie pour rechercher.')|escapejs }}",
locationNoResults: "{{ _('Aucune localisation trouvée.')|escapejs }}",
locationRemove: "{{ _('Retirer cette localisation du filtre')|escapejs }}"
};
</script>
<script src="{% static 'interventions/mobile/mobile_assets.js' %}"></script>
{% endif %}
{% if mobile_asset_replacement_data %}
{{ mobile_asset_replacement_data|json_script:"mobile-asset-replacement-data" }}
<script>
(function () {
const dataScript = document.getElementById('mobile-asset-replacement-data');
const modalEl = document.getElementById('replaceAssetMobileModal');
const formEl = document.getElementById('replaceAssetMobileForm');
const categorySelect = document.getElementById('replaceAssetCategorySelect');
const modelSelect = document.getElementById('replaceAssetModelSelect');
const serialInput = document.getElementById('replaceAssetSerialInput');
const brandInput = document.getElementById('replaceAssetBrandInput');
const alertBox = document.getElementById('replaceAssetModalAlert');
const keyInput = document.getElementById('replaceAssetCurrentKey');
const labelContainer = document.getElementById('replaceAssetCurrentLabel');
const submitBtn = document.getElementById('confirmReplaceAssetMobile');
if (!dataScript || !modalEl || !formEl || !categorySelect || !modelSelect || !serialInput || !brandInput || !keyInput || !labelContainer || !submitBtn) {
return;
}
const replacementData = JSON.parse(dataScript.textContent || '{}') || {};
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
const i18n = {
missing: "{% translate 'Configuration de remplacement introuvable.' %}",
success: "{% translate 'Remplacement créé, l&#39;ancien équipement est archivé.' %}",
error: "{% translate 'Impossible de créer l&#39;action de remplacement.' %}",
placeholder: "{% translate 'Conserver le modèle actuel' %}",
selectCategory: "{% translate 'Sélectionner une catégorie' %}",
};
function toggleOverlay(show) {
const overlay = document.getElementById('loading-overlay');
if (overlay) {
overlay.style.display = show ? 'block' : 'none';
}
}
function resetAlert() {
if (alertBox) {
alertBox.classList.add('d-none');
alertBox.textContent = '';
alertBox.classList.remove('alert-danger', 'alert-success');
}
}
function showAlert(message, type = 'danger') {
if (!alertBox) {
return;
}
alertBox.textContent = message;
alertBox.classList.remove('d-none', 'alert-danger', 'alert-success');
alertBox.classList.add(type === 'success' ? 'alert-success' : 'alert-danger');
}
function getCsrfToken() {
if (typeof getCookie === 'function') {
return getCookie('csrftoken');
}
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.substring(0, 'csrftoken'.length + 1) === 'csrftoken=') {
cookieValue = decodeURIComponent(cookie.substring('csrftoken'.length + 1));
break;
}
}
}
return cookieValue;
}
function fillCategoryOptions(categories, defaultId) {
categorySelect.innerHTML = '';
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.textContent = i18n.selectCategory;
categorySelect.appendChild(defaultOption);
(categories || []).forEach(category => {
const option = document.createElement('option');
option.value = category.id;
option.textContent = category.name;
categorySelect.appendChild(option);
});
if (defaultId) {
categorySelect.value = String(defaultId);
}
}
function fillModelOptions(models, defaultId) {
modelSelect.innerHTML = '';
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.textContent = i18n.placeholder;
modelSelect.appendChild(defaultOption);
(models || []).forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.label;
modelSelect.appendChild(option);
});
if (defaultId) {
modelSelect.value = String(defaultId);
}
}
async function loadModelsByCategory(cfg, categoryId) {
try {
const params = new URLSearchParams({
asset_model: cfg.asset_model,
asset_id: cfg.asset_id,
});
if (categoryId && categoryId !== '') {
params.append('category_id', categoryId);
}
const response = await fetch(`{% url 'interventions:ajax_get_replacement_models_by_category' %}?${params.toString()}`);
const result = await response.json();
if (!response.ok || result.error) {
console.error('Error loading models:', result.error);
fillModelOptions([], null);
return;
}
// If the category is the same as default, preselect the default model
const defaultModelId = (categoryId && categoryId === String(cfg.default_category_id)) ? cfg.default_model_id : null;
fillModelOptions(result.models, defaultModelId);
} catch (error) {
console.error('Error loading models:', error);
fillModelOptions([], null);
}
}
function handleOpen(button) {
const key = button.dataset.key;
const cfg = replacementData[key];
if (!cfg) {
showAlert(i18n.missing);
return;
}
keyInput.value = key;
fillCategoryOptions(cfg.categories, cfg.default_category_id);
fillModelOptions(cfg.models, cfg.default_model_id);
serialInput.value = cfg.serial_number || '';
brandInput.value = cfg.brand || '';
labelContainer.textContent = cfg.label || button.dataset.label || '';
resetAlert();
modal.show();
}
function setSubmitting(isSubmitting) {
submitBtn.disabled = isSubmitting;
categorySelect.disabled = isSubmitting;
modelSelect.disabled = isSubmitting;
serialInput.disabled = isSubmitting;
brandInput.disabled = isSubmitting;
}
// Event listener for category change
categorySelect.addEventListener('change', async function() {
const key = keyInput.value;
const cfg = replacementData[key];
if (!cfg) {
return;
}
const categoryId = categorySelect.value;
await loadModelsByCategory(cfg, categoryId);
});
document.querySelectorAll('.mobile-asset-replace-button').forEach(button => {
button.addEventListener('click', () => handleOpen(button));
});
formEl.addEventListener('submit', async (event) => {
event.preventDefault();
const key = keyInput.value;
const cfg = replacementData[key];
if (!cfg) {
showAlert(i18n.missing);
return;
}
const payload = {
copy_attributes: true,
new_model_id: modelSelect.value || null,
new_data: {},
intervention_id: {{ intervention.id }},
};
const serial = serialInput.value.trim();
const brand = brandInput.value.trim();
if (serial) {
payload.new_data.serial_number = serial;
}
if (brand) {
payload.new_data.brand = brand;
}
setSubmitting(true);
toggleOverlay(true);
try {
const response = await fetch(`/assets/${cfg.asset_model}/${cfg.asset_id}/replace/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok || !result.success) {
const detail = (result && result.error) ? result.error : i18n.error;
throw new Error(detail);
}
showAlert(i18n.success, 'success');
setTimeout(() => window.location.reload(), 1200);
} catch (error) {
showAlert(error.message || i18n.error);
} finally {
toggleOverlay(false);
setSubmitting(false);
}
});
modalEl.addEventListener('hidden.bs.modal', () => {
keyInput.value = '';
categorySelect.selectedIndex = 0;
serialInput.value = '';
brandInput.value = '';
modelSelect.selectedIndex = 0;
labelContainer.textContent = '';
resetAlert();
});
})();
</script>
{% endif %}
<script>
{% if is_technician %}
const statusActionInput = document.getElementById('status_action');
const pauseReasonHiddenInput = document.getElementById('pause_reason');
const pauseReasonOtherHiddenInput = document.getElementById('pause_reason_other');
const pauseReasonModalEl = document.getElementById('pauseReasonModal');
const pauseReasonSelect = document.getElementById('pauseReasonSelect');
const pauseReasonOtherGroup = document.getElementById('pauseReasonOtherGroup');
const pauseReasonOtherInput = document.getElementById('pauseReasonOtherInput');
const pauseReasonError = document.getElementById('pauseReasonError');
const confirmPauseReasonBtn = document.getElementById('confirmPauseReason');
const pauseReasonModal = pauseReasonModalEl ? bootstrap.Modal.getOrCreateInstance(pauseReasonModalEl) : null;
function hidePauseReasonError() {
if (pauseReasonError) {
pauseReasonError.classList.add('d-none');
pauseReasonError.textContent = '';
}
}
function showPauseReasonError(message) {
if (!pauseReasonError) {
return;
}
pauseReasonError.textContent = message;
pauseReasonError.classList.remove('d-none');
}
function resetPauseReasonModal() {
if (pauseReasonSelect) {
pauseReasonSelect.value = '';
}
if (pauseReasonOtherGroup) {
pauseReasonOtherGroup.classList.add('d-none');
}
if (pauseReasonOtherInput) {
pauseReasonOtherInput.value = '';
}
hidePauseReasonError();
}
if (pauseReasonSelect) {
pauseReasonSelect.addEventListener('change', () => {
hidePauseReasonError();
if (!pauseReasonOtherGroup || !pauseReasonOtherInput) {
return;
}
if (pauseReasonSelect.value === 'other') {
pauseReasonOtherGroup.classList.remove('d-none');
} else {
pauseReasonOtherGroup.classList.add('d-none');
pauseReasonOtherInput.value = '';
}
});
}
if (pauseReasonModalEl) {
pauseReasonModalEl.addEventListener('hidden.bs.modal', resetPauseReasonModal);
}
if (confirmPauseReasonBtn) {
confirmPauseReasonBtn.addEventListener('click', () => {
if (!pauseReasonSelect) {
return;
}
const reason = pauseReasonSelect.value;
const otherText = pauseReasonOtherInput ? pauseReasonOtherInput.value.trim() : '';
if (!reason) {
showPauseReasonError(gettext('Veuillez sélectionner une raison.'));
return;
}
if (reason === 'other' && !otherText) {
showPauseReasonError(gettext('Veuillez préciser la raison de la mise en pause.'));
return;
}
if (pauseReasonHiddenInput) {
pauseReasonHiddenInput.value = reason;
}
if (pauseReasonOtherHiddenInput) {
pauseReasonOtherHiddenInput.value = otherText;
}
if (statusActionInput) {
statusActionInput.value = 'pause';
}
hidePauseReasonError();
if (pauseReasonModal) {
pauseReasonModal.hide();
}
submitForm();
});
}
function submitForm() {
document.getElementById('loading-overlay').style.display = 'block';
document.getElementById('activityForm').submit();
}
function submitWithStatus(status) {
if (!statusActionInput) {
return;
}
if (status === 'pause' && pauseReasonModal) {
resetPauseReasonModal();
pauseReasonModal.show();
return;
}
statusActionInput.value = status;
if (pauseReasonHiddenInput) {
pauseReasonHiddenInput.value = '';
}
if (pauseReasonOtherHiddenInput) {
pauseReasonOtherHiddenInput.value = '';
}
submitForm();
}
const photosRequired = {{ photos_required|yesno:'true,false' }};
const photosRequiredSince = "{{ photos_required_since }}";
function hasPhotos() {
const since = photosRequiredSince ? new Date(photosRequiredSince).getTime() : null;
// Documents déjà enregistrés dans la DB
const existingCards = Array.from(document.querySelectorAll('#documentsContainer [id^="document-card-"], #documentsContainer [id^="doc-thumb-"]'));
if (existingCards.length > 0) {
if (since) {
const hasRecent = existingCards.some(card => {
const uploadDate = card.dataset.uploadDate;
return uploadDate && new Date(uploadDate).getTime() >= since;
});
if (hasRecent) return true;
} else {
return true;
}
}
// Photos uploadées en cours de session (camera mode continu) : toujours considérées comme récentes
const uploadedPreviews = document.querySelectorAll('#preview-container [data-document-id]');
if (uploadedPreviews.length > 0) return true;
return false;
}
function submitFinish() {
if (photosRequired && !hasPhotos()) {
const alertContainer = document.getElementById('photosRequiredAlert');
if (alertContainer) {
const msgSpan = document.getElementById('photosRequiredAlertMsg');
if (msgSpan) {
msgSpan.textContent = photosRequiredSince
? gettext("Au moins une photo ajoutée après le passage au statut \u00AB\u00A0À traiter\u00A0\u00BB est obligatoire pour terminer cette intervention.")
: gettext("Au moins une photo est obligatoire pour terminer cette intervention.");
}
alertContainer.classList.remove('d-none');
alertContainer.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return;
}
submitWithStatus('finish');
}
{% endif %}
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
(function initDocumentDeletion() {
const documentsContainer = document.getElementById('documentsContainer');
const deleteDocumentModalEl = document.getElementById('deleteDocumentModal');
const confirmDeleteBtn = document.getElementById('confirmDeleteDocument');
if (!documentsContainer || !deleteDocumentModalEl || !confirmDeleteBtn) {
return;
}
const modal = bootstrap.Modal.getOrCreateInstance(deleteDocumentModalEl);
const documentNameContainer = document.getElementById('documentNameToDelete');
const documentsAlertContainer = document.getElementById('documentsAlertContainer');
const interventionId = '{{ intervention.id }}';
const csrftoken = getCookie('csrftoken');
const successDeleteDocumentMsg = '{{ _("Document supprimé !")|escapejs }}';
const errorDeleteDocumentMsg = '{{ _("Une erreur est survenue lors de la suppression du document.")|escapejs }}';
const closeLabel = '{{ _("Fermer")|escapejs }}';
let documentIdToDelete = null;
let documentCardToDelete = null;
function showDocumentAlert(message, type = 'success') {
if (!documentsAlertContainer) {
return;
}
documentsAlertContainer.innerHTML = '';
const alertElement = document.createElement('div');
alertElement.className = `alert alert-${type} alert-dismissible fade show`;
alertElement.setAttribute('role', 'alert');
alertElement.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="${closeLabel}"></button>
`;
documentsAlertContainer.appendChild(alertElement);
}
documentsContainer.addEventListener('click', function (event) {
const deleteBtn = event.target.closest('.delete-document-btn');
if (!deleteBtn) {
return;
}
documentIdToDelete = deleteBtn.dataset.documentId || null;
documentCardToDelete = documentIdToDelete ? document.getElementById(`document-card-${documentIdToDelete}`) : null;
if (documentNameContainer) {
documentNameContainer.textContent = deleteBtn.dataset.documentName || '';
}
modal.show();
});
deleteDocumentModalEl.addEventListener('hidden.bs.modal', function () {
documentIdToDelete = null;
documentCardToDelete = null;
confirmDeleteBtn.disabled = false;
if (documentNameContainer) {
documentNameContainer.textContent = '';
}
});
confirmDeleteBtn.addEventListener('click', function () {
if (!documentIdToDelete) {
return;
}
confirmDeleteBtn.disabled = true;
fetch(`/interventions/${interventionId}/documents/${documentIdToDelete}/delete/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'X-Requested-With': 'XMLHttpRequest',
},
})
.then(response => response.json().then(data => ({ ok: response.ok, data })))
.then(({ ok, data }) => {
if (ok && data.success) {
if (documentCardToDelete) {
documentCardToDelete.remove();
}
showDocumentAlert(`✅ ${successDeleteDocumentMsg}`, 'success');
} else {
const errorMsg = data && data.error ? data.error : errorDeleteDocumentMsg;
showDocumentAlert(`❌ ${errorMsg}`, 'danger');
}
})
.catch(() => {
showDocumentAlert(`❌ ${errorDeleteDocumentMsg}`, 'danger');
})
.finally(() => {
confirmDeleteBtn.disabled = false;
modal.hide();
});
});
})();
const activityFormEl = document.getElementById('activityForm');
if (activityFormEl) {
activityFormEl.addEventListener('submit', function () {
// Afficher l'overlay de chargement avec le spinner uniquement pour le formulaire principal
const overlay = document.getElementById('loading-overlay');
if (overlay) {
overlay.style.display = 'block';
}
});
}
// *** Upload avec trois boutons séparés ***
let photoInputIndex = 0;
const inputContainer = document.getElementById('photo-inputs');
const previewContainer = document.getElementById('preview-container');
// Boutons / éléments pour les deux systèmes
const openCameraModalBtn = document.getElementById('open-camera-modal-btn');
const selectFilesBtn = document.getElementById('select-files-btn');
const filesInput = document.getElementById('mobile-files-input');
// Éléments de la modale caméra Canvas
const cameraModalEl = document.getElementById('cameraModal');
const cameraPreview = document.getElementById('cameraPreview');
const cameraCanvas = document.getElementById('cameraCanvas');
const cameraCaptureBtn = document.getElementById('cameraCaptureBtn');
const cameraStopBtn = document.getElementById('cameraStopBtn');
const cameraStatus = document.getElementById('cameraStatus');
const cameraModalCloseBtn = document.getElementById('cameraModalCloseBtn');
const cameraFlashOverlay = document.getElementById('cameraFlashOverlay');
const cameraModal = cameraModalEl ? bootstrap.Modal.getOrCreateInstance(cameraModalEl) : null;
console.log('[Photo Upload] Initialisation des boutons de photos');
console.log('[Photo Upload] Détection navigateur:', navigator.userAgent);
console.log('[Photo Upload] Platform:', navigator.platform);
// Fonction pour afficher les erreurs de permission
function showPermissionError(message) {
console.error('[Photo Upload] Permission error:', message);
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-warning alert-dismissible fade show mt-3';
alertDiv.setAttribute('role', 'alert');
alertDiv.innerHTML = `
<i class="bi bi-exclamation-triangle-fill me-2"></i>${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="${gettext('Fermer')}"></button>
`;
if (previewContainer && previewContainer.parentElement) {
previewContainer.parentElement.insertBefore(alertDiv, previewContainer);
}
// Auto-dismiss après 8 secondes
setTimeout(() => {
alertDiv.remove();
}, 8000);
}
// Vérifier les permissions média
async function checkMediaPermissions(type = 'camera') {
console.log('[Photo Upload] Vérification des permissions (méthode simplifiée):', type);
// Sur la plupart des navigateurs mobiles, l'API Permissions n'est pas disponible ou ne fonctionne pas pour la caméra/photos
// On laisse l'input s'ouvrir normalement, et on gère les erreurs lors de l'utilisation réelle
if (navigator.permissions && navigator.permissions.query) {
try {
// On tente la requête, mais on ne bloque jamais l'accès
const permissionName = type === 'camera' ? 'camera' : 'camera';
const result = await navigator.permissions.query({ name: permissionName });
if (result.state === 'denied') {
console.warn('[Photo Upload] Permission refusée (API Permissions):', type);
// On affiche un message mais on ne bloque pas
showPermissionError(gettext("Permission refusée par le navigateur. Veuillez vérifier les paramètres."));
}
else {
console.log('[Photo Upload] Permission accordée:', type, result.state);
}
} catch (err) {
console.log('[Photo Upload] API Permissions non supportée ou erreur:', err);
}
} else {
console.log('[Photo Upload] API Permissions non disponible sur ce navigateur.');
}
// Toujours retourner true pour laisser l'input s'ouvrir
return true;
}
// Gérer l'ajout de fichiers
function handleFileInput(input, errorContext) {
console.log('[Photo Upload] handleFileInput appelé pour:', errorContext);
try {
if (!input.files || input.files.length === 0) {
console.warn('[Photo Upload] Aucun fichier sélectionné');
return;
}
const files = Array.from(input.files);
console.log(`[Photo Upload] ${files.length} fichier(s) sélectionné(s)`);
files.forEach((file, idx) => {
console.log(`[Photo Upload] Fichier ${idx + 1}:`, {
name: file.name,
type: file.type,
size: file.size,
lastModified: new Date(file.lastModified)
});
});
const isImageFile = (file) => {
const mimeCheck = file.type && file.type.startsWith('image/');
const extensionCheck = /\.(jpe?g|png|gif|webp|bmp|heic|heif)$/i.test(file.name || '');
return mimeCheck || extensionCheck;
};
const createFileChip = (file) => {
const chip = document.createElement('div');
chip.className = 'preview-file-chip';
const label = file.name || gettext('Fichier');
chip.textContent = label;
chip.title = label;
return chip;
};
files.forEach(file => {
// Crée un nouvel input isolé pour ce fichier
const singleInput = document.createElement('input');
singleInput.type = 'file';
singleInput.name = 'photos';
singleInput.className = 'd-none';
const currentIndex = photoInputIndex++;
singleInput.dataset.index = currentIndex;
// Injecte le fichier dans l'input via DataTransfer
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
singleInput.files = dataTransfer.files;
inputContainer.appendChild(singleInput);
// Crée l'aperçu visuel
const wrapper = document.createElement('div');
wrapper.className = 'col-auto preview-wrapper';
wrapper.dataset.inputIndex = currentIndex;
const btn = document.createElement('button');
btn.type = 'button';
btn.innerHTML = '×';
btn.className = 'remove-btn';
btn.onclick = function () {
singleInput.remove();
wrapper.remove();
};
const finalizeWrapper = (contentEl) => {
wrapper.appendChild(contentEl);
wrapper.appendChild(btn);
previewContainer.appendChild(wrapper);
};
if (isImageFile(file)) {
const reader = new FileReader();
reader.onload = function (e) {
console.log('[Photo Upload] Fichier image prêt:', file.name);
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'preview-image';
finalizeWrapper(img);
};
reader.onerror = function (err) {
console.error('[Photo Upload] Erreur lecture fichier image:', file.name, err);
finalizeWrapper(createFileChip(file));
};
reader.readAsDataURL(file);
} else {
finalizeWrapper(createFileChip(file));
}
});
// Réinitialiser l'input pour permettre de resélectionner
input.value = '';
} catch (err) {
console.error("Error handling files:", err);
console.error('[Photo Upload] Stack trace:', err.stack);
let errorMsg = gettext("Une erreur est survenue lors de l'ajout des fichiers. Veuillez réessayer.");
if (err.name === 'NotAllowedError' || err.name === 'SecurityError') {
errorMsg = errorContext === 'camera'
? gettext("L'accès à l'appareil photo est refusé. Veuillez autoriser l'accès dans les paramètres de votre navigateur.")
: gettext("L'accès aux fichiers est refusé. Veuillez autoriser l'accès dans les paramètres de votre navigateur.");
}
showPermissionError(errorMsg);
}
}
// --- Caméra avancée avec getUserMedia + Canvas + upload API ---
let cameraStream = null;
let isCameraSupported = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
console.log('[Photo Upload] Support getUserMedia:', isCameraSupported);
function triggerCameraFlash() {
if (!cameraFlashOverlay) {
return;
}
cameraFlashOverlay.classList.add('active');
setTimeout(() => {
cameraFlashOverlay.classList.remove('active');
}, 150);
}
function updateCameraStatus(message, type = 'info') {
if (!cameraStatus) return;
cameraStatus.textContent = message || '';
cameraStatus.className = 'small text-center mb-2';
if (type === 'error') {
cameraStatus.classList.add('text-danger');
} else if (type === 'success') {
cameraStatus.classList.add('text-success');
} else {
cameraStatus.classList.add('text-muted');
}
}
async function startCameraStream() {
if (!isCameraSupported) {
console.warn('[Photo Upload] getUserMedia non supporté, fallback vers input classique');
showPermissionError(gettext("La caméra avancée n'est pas supportée sur ce navigateur. Utilisez le mode classique."));
return false;
}
try {
updateCameraStatus(gettext('Initialisation de la caméra…'), 'info');
console.log('[Photo Upload] Demande d\'accès caméra via getUserMedia');
cameraStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false,
});
if ('srcObject' in cameraPreview) {
cameraPreview.srcObject = cameraStream;
} else {
cameraPreview.src = window.URL.createObjectURL(cameraStream);
}
cameraPreview.play();
updateCameraStatus(gettext('Caméra prête. Appuyez sur Capturer pour prendre une photo.'), 'info');
console.log('[Photo Upload] Flux caméra démarré');
return true;
} catch (err) {
console.error('[Photo Upload] Erreur getUserMedia:', err);
showPermissionError(gettext("Impossible d'accéder à la caméra. Veuillez vérifier les autorisations dans les paramètres de votre navigateur."));
updateCameraStatus(gettext("Impossible d'accéder à la caméra."), 'error');
return false;
}
}
function stopCameraStream() {
if (cameraStream) {
console.log('[Photo Upload] Arrêt du flux caméra');
cameraStream.getTracks().forEach(track => track.stop());
cameraStream = null;
}
if (cameraPreview) {
cameraPreview.srcObject = null;
}
updateCameraStatus('', 'info');
}
async function captureAndUploadFrame() {
if (!cameraPreview || !cameraCanvas) {
console.warn('[Photo Upload] captureAndUploadFrame: éléments manquants');
return;
}
const videoWidth = cameraPreview.videoWidth;
const videoHeight = cameraPreview.videoHeight;
if (!videoWidth || !videoHeight) {
console.warn('[Photo Upload] Dimensions vidéo indisponibles');
updateCameraStatus(gettext("La vidéo n'est pas encore prête. Réessayez dans un instant."), 'error');
return;
}
cameraCanvas.width = videoWidth;
cameraCanvas.height = videoHeight;
const ctx = cameraCanvas.getContext('2d');
ctx.drawImage(cameraPreview, 0, 0, videoWidth, videoHeight);
updateCameraStatus(gettext('Capture en cours…'), 'info');
cameraCanvas.toBlob(async function(blob) {
if (!blob) {
console.error('[Photo Upload] toBlob a retourné null');
updateCameraStatus(gettext("Erreur lors de la capture."), 'error');
return;
}
console.log('[Photo Upload] Blob capturé, taille:', blob.size);
const isGuided = cameraModalEl && cameraModalEl.dataset.isGuided === "true";
const guidedFieldKey = cameraModalEl ? (cameraModalEl.dataset.guidedFieldKey || "") : "";
const guidedUploadUrl = cameraModalEl ? (cameraModalEl.dataset.guidedUploadUrl || "") : "";
const filename = `intervention_${interventionId}_mobile_${Date.now()}.jpg`;
const file = new File([blob], filename, { type: 'image/jpeg' });
if (isGuided) {
updateCameraStatus(gettext('Envoi en cours…'), 'info');
try {
const formData = new FormData();
formData.append('file', file);
formData.append('field_key', guidedFieldKey);
formData.append('media_type', 'photo');
formData.append('csrfmiddlewaretoken', csrftoken);
const resp = await fetch(guidedUploadUrl, {
method: 'POST',
body: formData,
});
const data = await resp.json();
if (data.success) {
if (window.insertGuidedMediaThumbMobile) {
window.insertGuidedMediaThumbMobile(data, guidedFieldKey);
}
updateCameraStatus(gettext('Photo envoyée avec succès.'), 'success');
} else {
const msg = data.error || gettext("Erreur lors de l'envoi de la photo.");
updateCameraStatus(msg, 'error');
showPermissionError(msg);
}
} catch (err) {
console.error('[Photo Upload] Erreur lors de l\'upload guidé API:', err);
updateCameraStatus(gettext("Erreur réseau lors de l'envoi de la photo."), 'error');
showPermissionError(gettext("Erreur réseau lors de l'envoi de la photo."));
}
return;
}
// Aperçu local immédiat dans la grille (sans encore connaître l'id du document côté serveur)
const reader = new FileReader();
const tempWrapper = document.createElement('div');
tempWrapper.className = 'col-auto preview-wrapper pending-upload';
reader.onload = function(e) {
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'preview-image';
const btn = document.createElement('button');
btn.type = 'button';
btn.innerHTML = '×';
btn.className = 'remove-btn';
// Pour une vignette en attente, on peut simplement la retirer localement
btn.onclick = function () {
tempWrapper.remove();
};
tempWrapper.appendChild(img);
tempWrapper.appendChild(btn);
previewContainer.appendChild(tempWrapper);
};
reader.readAsDataURL(file);
// Upload vers l'API
try {
const formData = new FormData();
formData.append('image', file);
console.log('[Photo Upload] Envoi de la photo à l\'API mobile_upload_photo_api');
const resp = await fetch(`/interventions/${interventionId}/mobile/photos/upload/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
const data = await resp.json();
console.log('[Photo Upload] Réponse upload API:', resp.status, data);
if (!resp.ok || !data.success) {
const msg = data && data.error ? data.error : gettext("Erreur lors de l'envoi de la photo.");
updateCameraStatus(msg, 'error');
showPermissionError(msg);
return;
}
// On marque la vignette comme liée au document créé, pour permettre une suppression via l'API
if (data.document_id && tempWrapper) {
tempWrapper.dataset.documentId = data.document_id;
tempWrapper.classList.remove('pending-upload');
console.log('[Photo Upload] Vignette associée au document_id:', data.document_id);
// Remplacer le handler du bouton de suppression pour appeler l'API de delete
const deleteBtn = tempWrapper.querySelector('.remove-btn');
if (deleteBtn) {
deleteBtn.onclick = async function () {
const docId = tempWrapper.dataset.documentId;
console.log('[Photo Upload] Demande de suppression pour document_id:', docId);
try {
const respDel = await fetch(`/interventions/${interventionId}/mobile/photos/${docId}/delete/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'X-Requested-With': 'XMLHttpRequest',
},
});
const dataDel = await respDel.json();
console.log('[Photo Upload] Réponse delete API:', respDel.status, dataDel);
if (!respDel.ok || !dataDel.success) {
const msgDel = dataDel && dataDel.error ? dataDel.error : gettext("Erreur lors de la suppression de la photo.");
showPermissionError(msgDel);
return;
}
tempWrapper.remove();
} catch (errDel) {
console.error('[Photo Upload] Erreur lors de l\'appel delete API:', errDel);
showPermissionError(gettext("Erreur réseau lors de la suppression de la photo."));
}
};
}
}
updateCameraStatus(gettext('Photo envoyée avec succès.'), 'success');
} catch (err) {
console.error('[Photo Upload] Erreur lors de l\'upload API:', err);
updateCameraStatus(gettext("Erreur réseau lors de l'envoi de la photo."), 'error');
showPermissionError(gettext("Erreur réseau lors de l'envoi de la photo."));
}
}, 'image/jpeg', 0.9);
}
if (openCameraModalBtn && cameraModal) {
openCameraModalBtn.addEventListener('click', async function() {
console.log('[Photo Upload] Bouton "Caméra (mode continu)" cliqué');
const ok = await startCameraStream();
if (!ok) {
return;
}
cameraModal.show();
});
}
if (cameraModalEl) {
cameraModalEl.addEventListener('hidden.bs.modal', function () {
console.log('[Photo Upload] Fermeture de la modale caméra');
stopCameraStream();
if (cameraModalEl.dataset) {
delete cameraModalEl.dataset.isGuided;
delete cameraModalEl.dataset.guidedFieldKey;
delete cameraModalEl.dataset.guidedUploadUrl;
}
});
}
if (cameraStopBtn) {
cameraStopBtn.addEventListener('click', function() {
console.log('[Photo Upload] Bouton "Terminer" caméra avancée cliqué');
if (cameraModal) {
cameraModal.hide();
}
});
}
if (cameraCaptureBtn) {
cameraCaptureBtn.addEventListener('click', function() {
console.log('[Photo Upload] Bouton "Capturer" (caméra avancée) cliqué');
triggerCameraFlash();
captureAndUploadFrame();
});
}
// Event listener pour le bouton de sélection de fichiers
if (selectFilesBtn && filesInput) {
selectFilesBtn.addEventListener('click', function () {
console.log('[Photo Upload] Bouton "Sélectionner des fichiers" cliqué');
console.log('[Photo Upload] Ouverture de l\'input fichiers');
filesInput.click();
});
filesInput.addEventListener('change', async function () {
console.log('[Photo Upload] Event "change" déclenché sur filesInput');
console.log('[Photo Upload] Nombre de fichiers:', this.files ? this.files.length : 0);
// Auto-upload des fichiers sélectionnés
if (this.files && this.files.length > 0) {
const files = Array.from(this.files);
console.log(`[Photo Upload] Auto-upload de ${files.length} fichier(s)`);
for (const file of files) {
console.log(`[Photo Upload] Upload de: ${file.name}`);
// Créer un aperçu local immédiat
const tempWrapper = document.createElement('div');
tempWrapper.className = 'col-auto preview-wrapper pending-upload';
const isImageFile = file.type && file.type.startsWith('image/');
if (isImageFile) {
const reader = new FileReader();
reader.onload = function(e) {
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'preview-image';
tempWrapper.appendChild(img);
};
reader.readAsDataURL(file);
} else {
const chip = document.createElement('div');
chip.className = 'preview-file-chip';
chip.textContent = file.name;
chip.title = file.name;
tempWrapper.appendChild(chip);
}
// Bouton de suppression temporaire
const btnTemp = document.createElement('button');
btnTemp.type = 'button';
btnTemp.innerHTML = '×';
btnTemp.className = 'remove-btn';
btnTemp.onclick = function () {
tempWrapper.remove();
};
tempWrapper.appendChild(btnTemp);
previewContainer.appendChild(tempWrapper);
// Upload vers l'API
try {
const formData = new FormData();
formData.append('image', file);
const resp = await fetch(`/interventions/${interventionId}/mobile/photos/upload/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
const data = await resp.json();
console.log('[Photo Upload] Réponse upload API:', resp.status, data);
if (resp.ok && data.success && data.document_id) {
// Marquer comme uploadé
tempWrapper.dataset.documentId = data.document_id;
tempWrapper.classList.remove('pending-upload');
// Remplacer le handler du bouton de suppression
const deleteBtn = tempWrapper.querySelector('.remove-btn');
if (deleteBtn) {
deleteBtn.onclick = async function () {
const docId = tempWrapper.dataset.documentId;
try {
const respDel = await fetch(`/interventions/${interventionId}/mobile/photos/${docId}/delete/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'X-Requested-With': 'XMLHttpRequest',
},
});
const dataDel = await respDel.json();
if (respDel.ok && dataDel.success) {
tempWrapper.remove();
} else {
showPermissionError(dataDel.error || gettext("Erreur lors de la suppression."));
}
} catch (errDel) {
console.error('[Photo Upload] Erreur delete:', errDel);
showPermissionError(gettext("Erreur réseau lors de la suppression."));
}
};
}
showAutoSaveIndicator(gettext('Photo enregistrée') + ' ✓', 'success');
} else {
throw new Error(data.error || 'Upload failed');
}
} catch (err) {
console.error('[Photo Upload] Erreur upload:', err);
showPermissionError(gettext("Erreur lors de l'upload de") + ` ${file.name}`);
tempWrapper.remove();
}
}
// Réinitialiser l'input
this.value = '';
}
});
filesInput.addEventListener('error', function () {
console.error('[Photo Upload] Erreur sur filesInput');
showPermissionError(gettext("Impossible d'accéder aux fichiers. Veuillez vérifier les autorisations dans les paramètres de votre navigateur."));
});
filesInput.addEventListener('cancel', function () {
console.log('[Photo Upload] Sélection de fichiers annulée par l\'utilisateur');
});
// Log pour le debugging
console.log('[Photo Upload] Configuration de l\'input fichiers:', filesInput ? 'OK' : 'MANQUANT');
}
if (document.getElementById('confirmRepairBtn')) {
document.getElementById('confirmRepairBtn').addEventListener('click', function() {
const form = document.getElementById('repairForm');
// Remove previously injected hidden inputs
form.querySelectorAll('input[type="hidden"][name="selected_assets"][data-dynamic="1"]').forEach(function(el) {
el.remove();
});
// Inject checked asset values as hidden inputs
document.querySelectorAll('.repair-asset-check:checked').forEach(function(cb) {
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'selected_assets';
hidden.value = cb.value;
hidden.dataset.dynamic = '1';
form.appendChild(hidden);
});
form.submit();
});
}
// Modal pour proposer de démarrer l'intervention "à traiter"
(function initStartInterventionModal() {
const startModalEl = document.getElementById('startInterventionModal');
if (!startModalEl) {
return;
}
const startModal = bootstrap.Modal.getOrCreateInstance(startModalEl);
const startNowBtn = document.getElementById('startNowBtn');
const takeOverBtn = document.getElementById('takeOverBtn');
const consultBtn = document.getElementById('consultBtn');
// Références locales au formulaire (compatibles technicien et admin)
const _statusInput = document.getElementById('status_action');
const _pauseInput = document.getElementById('pause_reason');
const _pauseOtherInput = document.getElementById('pause_reason_other');
function _submit() {
const overlay = document.getElementById('loading-overlay');
if (overlay) overlay.style.display = 'block';
document.getElementById('activityForm').submit();
}
// Afficher la modale automatiquement au chargement de la page
startModal.show();
// Bouton "Prendre en charge" (to_be_processed -> assigned)
if (takeOverBtn) {
takeOverBtn.addEventListener('click', function() {
// Marquer dans sessionStorage que l'utilisateur vient de prendre en charge
// pour éviter d'afficher la modale 'assigned' après rechargement
sessionStorage.setItem('just_took_over_{{ intervention.id }}', 'true');
// Désactiver le bouton pour éviter les clics multiples
takeOverBtn.disabled = true;
takeOverBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>' + gettext('Prise en charge...');
// Fermer la modale
startModal.hide();
// Soumettre le formulaire avec l'action "take_over"
if (_statusInput) _statusInput.value = 'take_over';
if (_pauseInput) _pauseInput.value = '';
if (_pauseOtherInput) _pauseOtherInput.value = '';
_submit();
});
}
// Bouton "Démarrer l'intervention"
if (startNowBtn) {
startNowBtn.addEventListener('click', function() {
// Désactiver le bouton pour éviter les clics multiples
startNowBtn.disabled = true;
startNowBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>' + gettext('Démarrage...');
// Fermer la modale
startModal.hide();
// Soumettre le formulaire avec l'action "start"
if (_statusInput) _statusInput.value = 'start';
if (_pauseInput) _pauseInput.value = '';
if (_pauseOtherInput) _pauseOtherInput.value = '';
_submit();
});
}
// Bouton "Consulter" - ferme simplement la modale (géré par data-bs-dismiss="modal")
})();
// Modal pour proposer de démarrer l'intervention "prise en charge" (assigned)
(function initStartInterventionModalAssigned() {
const startModalEl = document.getElementById('startInterventionModalAssigned');
if (!startModalEl) {
return;
}
const startModal = bootstrap.Modal.getOrCreateInstance(startModalEl);
const startNowBtn = document.getElementById('startNowBtnAssigned');
const releaseBtn = document.getElementById('releaseBtnAssigned');
// Références locales au formulaire (compatibles technicien et admin)
const _statusInput = document.getElementById('status_action');
const _pauseInput = document.getElementById('pause_reason');
const _pauseOtherInput = document.getElementById('pause_reason_other');
function _submit() {
const overlay = document.getElementById('loading-overlay');
if (overlay) overlay.style.display = 'block';
document.getElementById('activityForm').submit();
}
// Vérifier si l'utilisateur vient de prendre en charge l'intervention
const justTookOver = sessionStorage.getItem('just_took_over_{{ intervention.id }}');
if (justTookOver) {
// Supprimer le marqueur et ne pas afficher la modale
sessionStorage.removeItem('just_took_over_{{ intervention.id }}');
return;
}
// Afficher la modale automatiquement au chargement de la page
startModal.show();
// Bouton "Démarrer l'intervention"
if (startNowBtn) {
startNowBtn.addEventListener('click', function() {
// Désactiver le bouton pour éviter les clics multiples
startNowBtn.disabled = true;
startNowBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>' + gettext('Démarrage...');
// Fermer la modale
startModal.hide();
// Soumettre le formulaire avec l'action "start"
if (_statusInput) _statusInput.value = 'start';
if (_pauseInput) _pauseInput.value = '';
if (_pauseOtherInput) _pauseOtherInput.value = '';
_submit();
});
}
// Bouton "Libérer l'intervention"
if (releaseBtn) {
releaseBtn.addEventListener('click', function() {
// Désactiver le bouton pour éviter les clics multiples
releaseBtn.disabled = true;
releaseBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>' + gettext('Libération...');
// Fermer la modale
startModal.hide();
// Soumettre le formulaire avec l'action "release"
if (_statusInput) _statusInput.value = 'release';
if (_pauseInput) _pauseInput.value = '';
if (_pauseOtherInput) _pauseOtherInput.value = '';
_submit();
});
}
})();
// Operation photo functions
window.showOperationPhotoModal = function(photoUrl, description) {
const modal = document.getElementById('operationPhotoModal');
const img = document.getElementById('operationPhotoModalImage');
const descEl = document.getElementById('operationPhotoModalDescription');
if (modal && img) {
img.src = photoUrl;
img.alt = description || '';
if (descEl) {
descEl.textContent = description || '';
}
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
bsModal.show();
}
};
window.deleteOperationPhoto = async function(photoId, operationId) {
if (!confirm(gettext('Voulez-vous supprimer cette photo ?'))) {
return;
}
try {
const response = await fetch(`/interventions/operations/photos/${photoId}/delete/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'X-Requested-With': 'XMLHttpRequest',
}
});
const data = await response.json();
if (response.ok && data.success) {
// Remove the photo element from the DOM
const photoEl = document.querySelector(`[data-photo-id="${photoId}"]`);
if (photoEl) {
photoEl.remove();
}
// Check if there are no more photos and hide the container
const photosContainer = document.getElementById(`operation-photos-${operationId}`);
if (photosContainer && photosContainer.children.length === 0) {
photosContainer.closest('.mt-2').style.display = 'none';
}
} else {
alert(data.error || gettext('Erreur lors de la suppression'));
}
} catch (error) {
console.error('Error deleting photo:', error);
alert(gettext('Erreur réseau lors de la suppression'));
}
};
// Handle add photo button clicks
document.addEventListener('click', function(e) {
const btn = e.target.closest('.operation-add-photo-btn');
if (!btn) return;
const operationId = btn.dataset.operationId;
const operationName = btn.dataset.operationName;
const modal = document.getElementById('addOperationPhotoModal');
if (modal) {
document.getElementById('operationPhotoOperationId').value = operationId;
document.getElementById('operationPhotoOperationName').textContent = operationName;
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
bsModal.show();
}
});
// Handle operation photo form submission
const operationPhotoForm = document.getElementById('operationPhotoForm');
if (operationPhotoForm) {
operationPhotoForm.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('submitOperationPhoto');
const alertDiv = document.getElementById('operationPhotoAlert');
const operationId = document.getElementById('operationPhotoOperationId').value;
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> ' + gettext('Envoi en cours...');
alertDiv.classList.add('d-none');
const formData = new FormData(operationPhotoForm);
try {
const response = await fetch(`/interventions/operations/${operationId}/add-photo/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'X-Requested-With': 'XMLHttpRequest',
},
body: formData
});
const data = await response.json();
if (response.ok && data.success) {
// Add photo to the DOM without reloading
let photosContainer = document.getElementById(`operation-photos-${operationId}`);
// If container doesn't exist, create it
if (!photosContainer && data.photo_url) {
// Find the add photo button for this operation
const addPhotoBtn = document.querySelector(`button.operation-add-photo-btn[data-operation-id="${operationId}"]`);
if (addPhotoBtn) {
const btnContainer = addPhotoBtn.closest('.mt-2');
if (btnContainer) {
// Create new photos container before the button
const newPhotoSection = document.createElement('div');
newPhotoSection.className = 'mt-2';
newPhotoSection.innerHTML = `<div class="d-flex flex-wrap gap-2" id="operation-photos-${operationId}"></div>`;
btnContainer.parentNode.insertBefore(newPhotoSection, btnContainer);
photosContainer = document.getElementById(`operation-photos-${operationId}`);
}
}
}
// Now add the photo to the container
if (photosContainer && data.photo_url) {
const photoDiv = document.createElement('div');
photoDiv.className = 'position-relative';
photoDiv.dataset.photoId = data.photo_id;
photoDiv.innerHTML = `
<img src="${data.photo_url}" alt=""
class="img-thumbnail" style="width: 60px; height: 60px; object-fit: cover; cursor: pointer;"
onclick="showOperationPhotoModal('${data.photo_url}', '')">
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 p-0"
style="width: 18px; height: 18px; font-size: 10px; line-height: 1;"
onclick="deleteOperationPhoto(${data.photo_id}, ${operationId})"
title="${gettext('Supprimer')}">
<i class="bi bi-x"></i>
</button>
`;
photosContainer.appendChild(photoDiv);
}
// Reset button state BEFORE closing modal
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('addOperationPhotoModal'));
if (modal) modal.hide();
// Reset form
operationPhotoForm.reset();
// Scroll back to the operation
setTimeout(() => {
const operationBtn = document.querySelector(`button.operation-add-photo-btn[data-operation-id="${operationId}"]`);
if (operationBtn) {
operationBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 300);
// Show success toast
showAutoSaveIndicator(data.message || gettext('Photo ajoutée avec succès'), 'success');
} else {
alertDiv.textContent = data.error || gettext('Une erreur est survenue');
alertDiv.className = 'alert alert-danger';
alertDiv.classList.remove('d-none');
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
} catch (error) {
console.error('Error uploading photo:', error);
alertDiv.textContent = gettext('Erreur réseau lors de l\'envoi');
alertDiv.className = 'alert alert-danger';
alertDiv.classList.remove('d-none');
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
});
// Reset form when modal is closed
const modal = document.getElementById('addOperationPhotoModal');
if (modal) {
modal.addEventListener('hidden.bs.modal', function() {
operationPhotoForm.reset();
const alertDiv = document.getElementById('operationPhotoAlert');
if (alertDiv) alertDiv.classList.add('d-none');
const submitBtn = document.getElementById('submitOperationPhoto');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-upload"></i> ' + gettext('Ajouter');
}
});
}
}
// Custom operation form handling
(function initCustomOperationForm() {
const form = document.getElementById('customOperationForm');
const modal = document.getElementById('addCustomOperationModal');
const alertDiv = document.getElementById('customOpAlert');
const statusSetGroup = document.getElementById('statusSetGroup');
const measurementDefGroup = document.getElementById('measurementDefGroup');
const assetSelectGroup = document.getElementById('assetSelectGroup');
const operationTypeInputs = document.querySelectorAll('input[name="operation_type"]');
if (!form || !modal) {
return;
}
// Toggle fields based on operation type
operationTypeInputs.forEach(input => {
input.addEventListener('change', function() {
if (this.value === 'status') {
statusSetGroup.classList.remove('d-none');
measurementDefGroup.classList.add('d-none');
document.getElementById('customOpStatusSet').required = true;
document.getElementById('customOpMeasurementDef').required = false;
} else {
statusSetGroup.classList.add('d-none');
measurementDefGroup.classList.remove('d-none');
document.getElementById('customOpStatusSet').required = false;
document.getElementById('customOpMeasurementDef').required = true;
}
});
});
// Show asset selector if there are asset checkboxes
const assetCheckboxes = document.querySelectorAll('.asset-item');
if (assetSelectGroup && assetCheckboxes.length > 0) {
assetSelectGroup.style.display = 'block';
}
// Handle "Select All" button
const selectAllBtn = document.getElementById('selectAllAssets');
const selectNoneBtn = document.getElementById('selectNoAssets');
const assetNoneCheckbox = document.getElementById('assetNone');
if (selectAllBtn) {
selectAllBtn.addEventListener('click', function() {
assetCheckboxes.forEach(cb => cb.checked = true);
if (assetNoneCheckbox) assetNoneCheckbox.checked = false;
});
}
if (selectNoneBtn) {
selectNoneBtn.addEventListener('click', function() {
assetCheckboxes.forEach(cb => cb.checked = false);
if (assetNoneCheckbox) assetNoneCheckbox.checked = true;
});
}
// When "None" is checked, uncheck all assets; when an asset is checked, uncheck "None"
if (assetNoneCheckbox) {
assetNoneCheckbox.addEventListener('change', function() {
if (this.checked) {
assetCheckboxes.forEach(cb => cb.checked = false);
}
});
}
assetCheckboxes.forEach(cb => {
cb.addEventListener('change', function() {
if (this.checked && assetNoneCheckbox) {
assetNoneCheckbox.checked = false;
}
// If no asset is checked, check "None"
const anyAssetChecked = Array.from(assetCheckboxes).some(c => c.checked);
if (!anyAssetChecked && assetNoneCheckbox) {
assetNoneCheckbox.checked = true;
}
});
});
form.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('submitCustomOperation');
const originalHTML = submitBtn.innerHTML;
alertDiv.classList.add('d-none');
// Client-side validation: at least one name is required
const nameFr = document.getElementById('customOpNameFr').value.trim();
const nameNl = document.getElementById('customOpNameNl').value.trim();
if (!nameFr && !nameNl) {
alertDiv.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>' + gettext('Vous devez fournir au moins un nom (FR ou NL).');
alertDiv.className = 'alert alert-warning';
alertDiv.classList.remove('d-none');
// Highlight the name fields
document.getElementById('customOpNameFr').classList.add('is-invalid');
document.getElementById('customOpNameNl').classList.add('is-invalid');
return;
} else {
document.getElementById('customOpNameFr').classList.remove('is-invalid');
document.getElementById('customOpNameNl').classList.remove('is-invalid');
}
// Collect selected assets
const selectedAssets = [];
const noneChecked = assetNoneCheckbox && assetNoneCheckbox.checked;
if (noneChecked) {
selectedAssets.push(''); // Empty string means general operation
} else {
assetCheckboxes.forEach(cb => {
if (cb.checked) {
selectedAssets.push(cb.value);
}
});
}
if (selectedAssets.length === 0) {
alertDiv.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>' + gettext('Veuillez sélectionner au moins un équipement ou "Opération générale".');
alertDiv.className = 'alert alert-warning';
alertDiv.classList.remove('d-none');
return;
}
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> ' + gettext('Ajout en cours...');
const formData = new FormData(form);
// Remove old assets entries and add selected ones
formData.delete('assets');
selectedAssets.forEach(asset => {
formData.append('assets', asset);
});
try {
const response = await fetch('{% url "interventions:add_custom_operation" intervention.id %}', {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'X-Requested-With': 'XMLHttpRequest',
},
body: formData
});
const data = await response.json();
if (response.ok && data.success) {
alertDiv.innerHTML = '<i class="bi bi-check-circle me-1"></i>' + (data.message || gettext('Opération ajoutée avec succès'));
alertDiv.className = 'alert alert-success';
alertDiv.classList.remove('d-none');
// Reload page after 1 second to show the new operation
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
let errorMsg = data.error || gettext('Une erreur est survenue');
if (data.errors && Array.isArray(data.errors)) {
errorMsg = data.errors.join('<br>');
}
alertDiv.innerHTML = '<i class="bi bi-exclamation-circle me-1"></i>' + errorMsg;
alertDiv.className = 'alert alert-danger';
alertDiv.classList.remove('d-none');
submitBtn.disabled = false;
submitBtn.innerHTML = originalHTML;
}
} catch (error) {
console.error('Error adding custom operation:', error);
alertDiv.innerHTML = '<i class="bi bi-wifi-off me-1"></i>' + gettext('Erreur réseau lors de l\'ajout de l\'opération');
alertDiv.className = 'alert alert-danger';
alertDiv.classList.remove('d-none');
submitBtn.disabled = false;
submitBtn.innerHTML = originalHTML;
}
});
// Reset form when modal is closed
modal.addEventListener('hidden.bs.modal', function() {
form.reset();
alertDiv.classList.add('d-none');
document.getElementById('submitCustomOperation').disabled = false;
document.getElementById('submitCustomOperation').innerHTML = '<i class="bi bi-plus-lg me-1"></i>' + gettext('Ajouter');
// Reset to default state
statusSetGroup.classList.remove('d-none');
measurementDefGroup.classList.add('d-none');
// Reset asset checkboxes
if (assetNoneCheckbox) assetNoneCheckbox.checked = true;
assetCheckboxes.forEach(cb => cb.checked = false);
// Remove invalid state from name fields
document.getElementById('customOpNameFr').classList.remove('is-invalid');
document.getElementById('customOpNameNl').classList.remove('is-invalid');
});
})();
// Equipment search functionality
(function initEquipmentSearch() {
const searchInput = document.getElementById('equipmentSearchInput');
const clearBtn = document.getElementById('clearEquipmentSearch');
const resultsDiv = document.getElementById('equipmentSearchResults');
if (!searchInput) {
return;
}
// Get all equipment groups
const equipmentGroups = document.querySelectorAll('[id^="mobileAssetGroup"]');
const equipmentHeaders = document.querySelectorAll('[data-bs-target^="#mobileAssetGroup"]');
let totalEquipment = equipmentGroups.length;
function filterEquipment() {
const searchTerm = searchInput.value.toLowerCase().trim();
if (searchTerm === '') {
// Show all equipment
equipmentGroups.forEach(group => {
group.closest('.mt-4').style.display = '';
});
resultsDiv.textContent = '';
if (clearBtn) clearBtn.style.display = 'none';
return;
}
if (clearBtn) clearBtn.style.display = 'block';
let visibleCount = 0;
equipmentGroups.forEach((group, index) => {
const container = group.closest('.mt-4');
const header = equipmentHeaders[index];
if (header) {
// Get equipment code and name from the header
const text = header.textContent.toLowerCase();
if (text.includes(searchTerm)) {
container.style.display = '';
visibleCount++;
} else {
container.style.display = 'none';
}
}
});
// Update results text
if (visibleCount === 0) {
resultsDiv.innerHTML = '<i class="bi bi-info-circle text-warning"></i> ' + gettext('Aucun équipement trouvé');
} else if (visibleCount === totalEquipment) {
resultsDiv.textContent = '';
} else {
resultsDiv.textContent = visibleCount + ' / ' + totalEquipment + ' ' + gettext('équipement(s)');
}
}
// Search on input
searchInput.addEventListener('input', filterEquipment);
// Clear search
if (clearBtn) {
clearBtn.addEventListener('click', function() {
searchInput.value = '';
filterEquipment();
searchInput.focus();
});
}
})();
// Transfer intervention functionality
(function initTransfer() {
const TRANSFER_MEMBERS_URL = '{% url "interventions:get_transferable_members" intervention.id %}';
const TRANSFER_URL = '{% url "interventions:transfer_intervention" intervention.id %}';
let selectedMemberId = null;
let transferModal = null;
// Initialize modal
const transferModalEl = document.getElementById('transferModal');
if (transferModalEl) {
transferModal = new bootstrap.Modal(transferModalEl);
// Clean up modal backdrop on hide
transferModalEl.addEventListener('hidden.bs.modal', function () {
document.body.classList.remove('modal-open');
const backdrop = document.querySelector('.modal-backdrop');
if (backdrop) backdrop.remove();
});
}
window.openTransferModal = function() {
if (!transferModal) return;
// Reset state
selectedMemberId = null;
document.getElementById('confirmTransferBtn').disabled = true;
document.getElementById('transferLoading').classList.remove('d-none');
document.getElementById('transferContent').classList.add('d-none');
document.getElementById('transferError').classList.add('d-none');
document.getElementById('transferNoMembers').classList.add('d-none');
document.getElementById('transferMembersList').innerHTML = '';
transferModal.show();
// Fetch available members
fetch(TRANSFER_MEMBERS_URL, {
method: 'GET',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Accept': 'application/json',
}
})
.then(response => response.json())
.then(data => {
document.getElementById('transferLoading').classList.add('d-none');
document.getElementById('transferContent').classList.remove('d-none');
if (!data.success) {
document.getElementById('transferError').classList.remove('d-none');
document.getElementById('transferErrorText').textContent = data.error || '{% translate "Une erreur est survenue." %}';
return;
}
const membersList = document.getElementById('transferMembersList');
if (!data.members || data.members.length === 0) {
document.getElementById('transferNoMembers').classList.remove('d-none');
return;
}
// Sort members alphabetically by name
const sortedMembers = data.members.sort((a, b) =>
a.name.localeCompare(b.name, 'fr', { sensitivity: 'base' })
);
// Build the list (simple alphabetical list)
let html = '';
sortedMembers.forEach(member => {
const teamInfo = member.team_name ? ` <small class="text-muted">(${member.team_name})</small>` : '';
html += `<button type="button"
class="list-group-item list-group-item-action transfer-member-item d-flex align-items-center"
data-member-id="${member.id}"
data-member-name="${member.name}">
<i class="bi bi-person me-2"></i>
<span>${member.name}${teamInfo}</span>
</button>`;
});
membersList.innerHTML = html;
// Add click handlers
membersList.querySelectorAll('.transfer-member-item').forEach(item => {
item.addEventListener('click', function() {
// Remove active class from all items
membersList.querySelectorAll('.transfer-member-item').forEach(i => {
i.classList.remove('active');
});
// Add active class to selected item
this.classList.add('active');
selectedMemberId = this.dataset.memberId;
document.getElementById('confirmTransferBtn').disabled = false;
});
});
})
.catch(error => {
console.error('Error fetching transferable members:', error);
document.getElementById('transferLoading').classList.add('d-none');
document.getElementById('transferContent').classList.remove('d-none');
document.getElementById('transferError').classList.remove('d-none');
document.getElementById('transferErrorText').textContent = '{% translate "Erreur de connexion." %}';
});
};
window.confirmTransfer = async function() {
if (!selectedMemberId) return;
const confirmBtn = document.getElementById('confirmTransferBtn');
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>{% translate "Sauvegarde..." %}';
// First, save any unsaved notes, documents, and operations
try {
const saveResult = await saveAll(false);
if (!saveResult.success) {
console.warn('Save before transfer had issues:', saveResult.error);
// Continue with transfer even if save fails (user may have nothing to save)
}
} catch (error) {
console.error('Error saving before transfer:', error);
// Continue with transfer even if save fails
}
// Now proceed with the transfer
confirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>{% translate "Transfert..." %}';
fetch(TRANSFER_URL, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ member_id: selectedMemberId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Show success message and redirect
if (transferModal) transferModal.hide();
showAutoSaveIndicator(data.message, 'success');
// Redirect to the interventions list after a short delay
setTimeout(() => {
window.location.href = '{% url "mobile:interventions_index_mobile" %}';
}, 1500);
} else {
document.getElementById('transferError').classList.remove('d-none');
document.getElementById('transferErrorText').textContent = data.error || '{% translate "Une erreur est survenue." %}';
confirmBtn.disabled = false;
confirmBtn.innerHTML = '<i class="bi bi-check-lg me-1"></i>{% translate "Transférer" %}';
}
})
.catch(error => {
console.error('Error transferring intervention:', error);
document.getElementById('transferError').classList.remove('d-none');
document.getElementById('transferErrorText').textContent = '{% translate "Erreur de connexion." %}';
confirmBtn.disabled = false;
confirmBtn.innerHTML = '<i class="bi bi-check-lg me-1"></i>{% translate "Transférer" %}';
});
};
})();
// Handle processed_via_other_intervention checkbox on mobile
(function initProcessedViaOther() {
const checkbox = document.getElementById('processedViaOtherCheckbox');
if (!checkbox || !checkbox.dataset.interventionId) return;
checkbox.addEventListener('change', function() {
const interventionId = this.dataset.interventionId;
const value = this.checked ? 'true' : 'false';
fetch(`/interventions/${interventionId}/update/`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": getCookie("csrftoken")
},
body: new URLSearchParams({
field: "processed_via_other_intervention",
value: value
})
})
.then(res => res.json())
.then(data => {
if (data.success) {
showAutoSaveIndicator(gettext('Enregistré avec succès'), 'success');
} else {
alert(gettext("Erreur : ") + (data.error || gettext("Une erreur est survenue.")));
// Revert checkbox
checkbox.checked = !checkbox.checked;
}
})
.catch(error => {
console.error("Error saving processed_via_other_intervention:", error);
alert(gettext("Une erreur est survenue."));
// Revert checkbox
checkbox.checked = !checkbox.checked;
});
});
})();
// Handle processed_via_other_intervention form
(function initProcessedViaOther() {
const form = document.getElementById('editProcessedViaOtherForm');
if (!form) return;
form.addEventListener('submit', function(event) {
event.preventDefault();
const interventionId = form.querySelector('input[name="intervention_id"]').value;
const checkbox = form.querySelector('input[name="processed_via_other_intervention"]');
const value = checkbox.checked ? 'true' : 'false';
fetch(`/interventions/${interventionId}/update/`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": getCookie("csrftoken")
},
body: new URLSearchParams({
field: "processed_via_other_intervention",
value: value
})
})
.then(res => res.json())
.then(data => {
if (data.success) {
// Close the modal
const modal = bootstrap.Modal.getInstance(document.getElementById("editProcessedViaOtherModal"));
if (modal) modal.hide();
// Show success message
showAutoSaveIndicator(gettext('Enregistré avec succès'), 'success');
} else {
alert(gettext("Erreur : ") + (data.error || gettext("Une erreur est survenue.")));
}
})
.catch(error => {
console.error("Error saving processed_via_other_intervention:", error);
alert(gettext("Une erreur est survenue."));
});
});
})();
// Check for newly linked assets and show template selection modal
(function() {
document.addEventListener('DOMContentLoaded', function() {
// Attendre que les modales de démarrage d'intervention soient fermées avant de vérifier les assets liés
const startModalEl = document.getElementById('startInterventionModal');
const startModalAssignedEl = document.getElementById('startInterventionModalAssigned');
// Fonction pour vérifier les assets après fermeture de la modale
const checkAfterModalClose = () => {
// Petit délai pour s'assurer que la modale est complètement fermée
setTimeout(() => {
checkForNewlyLinkedAssets();
}, 300);
};
// Si une des modales de démarrage existe et est visible, attendre qu'elle soit fermée
if (startModalEl) {
startModalEl.addEventListener('hidden.bs.modal', checkAfterModalClose, { once: true });
} else if (startModalAssignedEl) {
startModalAssignedEl.addEventListener('hidden.bs.modal', checkAfterModalClose, { once: true });
} else {
// Aucune modale de démarrage, vérifier immédiatement
checkForNewlyLinkedAssets();
}
});
async function checkForNewlyLinkedAssets() {
try {
const response = await fetch(`/interventions/{{ intervention.id }}/get-newly-linked-assets/`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
});
if (!response.ok) {
return;
}
const data = await response.json();
if (data.success && data.has_new_assets && data.assets && data.assets.length > 0) {
showLinkedAssetsTemplateModal(data.assets, data.templates);
}
} catch (error) {
console.error('Error checking for newly linked assets:', error);
}
}
function showLinkedAssetsTemplateModal(assets, templates) {
if (!templates || templates.length === 0) {
showAutoSaveIndicator("{% trans "Aucun template d'opérations disponible pour cette intervention" %}", 'warning');
return;
}
// Create table header with templates as columns
const tableHeaderCells = templates.map(template => `
<th class="text-center" style="min-width: 100px; font-size: 0.85rem;">
<div class="mb-1">${template.name}</div>
${template.description ? `<small class="text-muted d-block" style="font-size: 0.7rem;">${template.description}</small>` : ''}
<button type="button" class="btn btn-sm btn-outline-primary mt-1 select-column-btn" data-template-id="${template.id}" style="font-size: 0.7rem;">
<i class="bi bi-check-square"></i> {% trans "Tout" %}
</button>
</th>
`).join('');
// Create table rows with assets
const tableRows = assets.map(asset => {
const templateCells = templates.map(template => `
<td class="text-center">
<input type="checkbox" class="form-check-input template-checkbox"
data-asset-ct-id="${asset.content_type_id}"
data-asset-obj-id="${asset.object_id}"
data-template-id="${template.id}">
</td>
`).join('');
return `
<tr>
<td style="font-size: 0.85rem;">
<strong>${asset.display}</strong>
<div class="mt-1">
<button type="button" class="btn btn-sm btn-outline-primary select-row-btn"
data-asset-ct-id="${asset.content_type_id}"
data-asset-obj-id="${asset.object_id}"
style="font-size: 0.7rem;">
<i class="bi bi-check-square"></i> {% trans "Tout" %}
</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-row-btn"
data-asset-ct-id="${asset.content_type_id}"
data-asset-obj-id="${asset.object_id}"
style="font-size: 0.7rem;">
<i class="bi bi-square"></i> {% trans "Aucun" %}
</button>
</div>
</td>
${templateCells}
</tr>
`;
}).join('');
// Create modal HTML with table
const modalHTML = `
<div class="modal fade" id="linkedAssetsTemplateModal" tabindex="-1" aria-labelledby="linkedAssetsTemplateModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="linkedAssetsTemplateModalLabel">
{% trans "Sélection des templates d'opérations" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="mb-3">
{% trans "Vous avez lié" %} <strong>${assets.length}</strong>
${assets.length > 1 ? "{% trans 'nouveaux équipements' %}" : "{% trans 'nouvel équipement' %}"}
{% trans "à cette intervention. Sélectionnez les templates d'opérations à appliquer." %}
</p>
<div class="table-responsive">
<table class="table table-bordered table-hover table-sm" id="assetsTemplatesTable">
<thead class="table-light">
<tr>
<th style="font-size: 0.85rem;">{% trans "Équipement" %}</th>
${tableHeaderCells}
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Annuler" %}</button>
<button type="button" class="btn btn-primary" id="createLinkedAssetsOperations">
{% trans "Créer les opérations" %}
</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if any
const existingModal = document.getElementById('linkedAssetsTemplateModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to body
document.body.insertAdjacentHTML('beforeend', modalHTML);
// Add event listeners for column selection
document.querySelectorAll('.select-column-btn').forEach(btn => {
btn.addEventListener('click', function() {
const templateId = this.dataset.templateId;
document.querySelectorAll(`input[data-template-id="${templateId}"]`).forEach(cb => {
cb.checked = true;
});
});
});
// Add event listeners for row selection
document.querySelectorAll('.select-row-btn').forEach(btn => {
btn.addEventListener('click', function() {
const ctId = this.dataset.assetCtId;
const objId = this.dataset.assetObjId;
document.querySelectorAll(`input[data-asset-ct-id="${ctId}"][data-asset-obj-id="${objId}"]`).forEach(cb => {
cb.checked = true;
});
});
});
// Add event listeners for row deselection
document.querySelectorAll('.deselect-row-btn').forEach(btn => {
btn.addEventListener('click', function() {
const ctId = this.dataset.assetCtId;
const objId = this.dataset.assetObjId;
document.querySelectorAll(`input[data-asset-ct-id="${ctId}"][data-asset-obj-id="${objId}"]`).forEach(cb => {
cb.checked = false;
});
});
});
// Show modal
const modal = new bootstrap.Modal(document.getElementById('linkedAssetsTemplateModal'));
modal.show();
// Handle create button
document.getElementById('createLinkedAssetsOperations').addEventListener('click', async function() {
await createOperationsForLinkedAssetsTable(assets, templates, modal);
});
}
async function createOperationsForLinkedAssetsTable(assets, templates, modal) {
const assetsWithTemplates = [];
// Collect selected templates for each asset from the table
assets.forEach(asset => {
const templateIds = [];
templates.forEach(template => {
const checkbox = document.querySelector(
`input[data-asset-ct-id="${asset.content_type_id}"][data-asset-obj-id="${asset.object_id}"][data-template-id="${template.id}"]`
);
if (checkbox && checkbox.checked) {
templateIds.push(template.id);
}
});
if (templateIds.length > 0) {
assetsWithTemplates.push({
content_type_id: asset.content_type_id,
object_id: asset.object_id,
template_ids: templateIds
});
}
});
if (assetsWithTemplates.length === 0) {
showAutoSaveIndicator("{% trans 'Veuillez sélectionner au moins un template pour au moins un équipement' %}", 'warning');
return;
}
try {
const response = await fetch(`/interventions/{{ intervention.id }}/create-operations-for-linked-assets/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({
assets: assetsWithTemplates
})
});
const data = await response.json();
if (data.success) {
modal.hide();
showAutoSaveIndicator(data.message, 'success');
// Reload page after a brief delay to show the message
setTimeout(() => window.location.reload(), 1500);
} else {
showAutoSaveIndicator("{% trans 'Erreur' %}: " + (data.error || "{% trans 'Erreur inconnue' %}"), 'danger');
}
} catch (error) {
console.error('Error creating operations:', error);
showAutoSaveIndicator("{% trans 'Erreur lors de la création des opérations' %}", 'danger');
}
}
})();
</script>
<script>
// Partage de la fiche d'intervention PDF
(function initFicheShare() {
async function sharePDF(pdfUrl, filename) {
try {
const response = await fetch(pdfUrl);
const blob = await response.blob();
const file = new File([blob], filename, { type: 'application/pdf' });
if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({
files: [file],
title: filename,
});
} else if (navigator.share) {
await navigator.share({
title: filename,
url: pdfUrl,
});
} else {
// Fallback : ouverture dans un nouvel onglet
window.open(pdfUrl, '_blank');
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Erreur lors du partage du PDF:', error);
}
}
}
document.querySelectorAll('.fiche-share-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const pdfUrl = btn.dataset.pdfUrl;
const pdfName = btn.dataset.pdfName || 'fiche.pdf';
if (pdfUrl) sharePDF(pdfUrl, pdfName);
});
});
})();
</script>
{% if intervention.lat and intervention.lon %}
<script src="{% static 'common/maplibre-gl.js' %}"></script>
<script>
(function () {
const STATUS_COLORS = {
'secondary': '#6c757d',
'danger': '#dc3545',
'warning': '#ffc107',
'primary': '#0d6efd',
'info': '#0dcaf0',
'success': '#198754',
'dark': '#212529',
};
const MARKER = {
lat: {{ intervention.lat|stringformat:"f" }},
lon: {{ intervention.lon|stringformat:"f" }},
code: '{{ intervention.code|escapejs }}',
title: '{{ intervention.title|escapejs }}',
status_label: '{{ intervention.get_status_display|escapejs }}',
status_color: '{{ intervention.status_color_hex }}',
url: '{% url "mobile:intervention_detail_mobile" intervention.id %}',
};
{% if tl_intersection_ids %}
const TL_INTERSECTION_IDS = {{ tl_intersection_ids|safe }};
const TL_ASSETS_URL = '{% url "assets:trafficlights_assets_geojson" %}';
const TL_ICON_POLE = '{% static "assets/traffic_light_icon.svg" %}';
const TL_ICON_SIMPLE_POLE = '{% static "assets/simple_pole_icon.svg" %}';
const TL_ICON_CABINET = '{% static "assets/electrical-panel.png" %}';
function loadNormalisedIcon(url, size) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
const scale = Math.min(size / img.width, size / img.height);
const w = img.width * scale, h = img.height * scale;
ctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h);
resolve(ctx.getImageData(0, 0, size, size));
};
img.onerror = () => reject(new Error('Icon load error: ' + url));
img.src = url;
});
}
async function loadTLIcons() {
if (!map.hasImage('traffic-light-icon')) {
try {
const img = await loadNormalisedIcon(TL_ICON_POLE, 64);
if (!map.hasImage('traffic-light-icon')) map.addImage('traffic-light-icon', img);
} catch (e) { console.warn(e); }
}
if (!map.hasImage('simple-pole-icon')) {
try {
const img = await loadNormalisedIcon(TL_ICON_SIMPLE_POLE, 64);
if (!map.hasImage('simple-pole-icon')) map.addImage('simple-pole-icon', img);
} catch (e) { console.warn(e); }
}
if (!map.hasImage('electrical-panel-icon')) {
try {
const img = await loadNormalisedIcon(TL_ICON_CABINET, 32);
if (!map.hasImage('electrical-panel-icon')) map.addImage('electrical-panel-icon', img);
} catch (e) { console.warn(e); }
}
}
async function loadTLAssets() {
try {
const csrf = document.querySelector('[name=csrfmiddlewaretoken]')?.value || '{{ csrf_token }}';
const resp = await fetch(TL_ASSETS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf },
body: JSON.stringify({ locations_ids: TL_INTERSECTION_IDS }),
});
if (!resp.ok) return;
const geojson = await resp.json();
if (map.getSource('tl-assets')) {
map.getSource('tl-assets').setData(geojson);
} else {
map.addSource('tl-assets', { type: 'geojson', data: geojson });
// map.addLayer(
// {
// id: 'tl-assets-circles',
// type: 'circle',
// source: 'tl-assets',
// paint: {
// 'circle-radius': ['interpolate', ['linear'], ['zoom'], 8, 2, 22, 10],
// 'circle-color': '#0022ff',
// 'circle-opacity': 0.7,
// },
// minzoom: 10,
// }, 'intervention-circle'); /* under the intervention marker */
map.addLayer({
id: 'tl-assets-symbols',
type: 'symbol',
source: 'tl-assets',
layout: {
'icon-image': ['match', ['get', 'asset_type'],
'trafficlightelectricalcabinet', 'electrical-panel-icon',
'trafficlightradarpole', 'simple-pole-icon',
'trafficlightpole', 'traffic-light-icon',
'traffic-light-icon'
],
'icon-size': ['interpolate', ['linear'], ['zoom'], 8, 0.1, 14, 0.5, 22, 1.0],
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'text-field': ['get', 'name'],
'text-font': ['Noto Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 14, 8, 18, 11, 22, 14],
'text-anchor': 'top',
'text-offset': [0, 1.5],
'text-allow-overlap': false,
'text-optional': true,
},
paint: {
'icon-opacity': 0.85,
'text-color': '#333333',
'text-halo-color': 'rgba(255,255,255,0.85)',
'text-halo-width': 3,
'text-halo-blur': 1,
},
minzoom: 10,
});
/* Popup on asset click */
map.on('click', 'tl-assets-circles', onAssetClick);
map.on('click', 'tl-assets-symbols', onAssetClick);
map.on('mouseenter', 'tl-assets-circles', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'tl-assets-circles', () => { map.getCanvas().style.cursor = ''; });
}
} catch (e) { console.warn('TL assets load error:', e); }
}
function onAssetClick(e) {
const p = e.features[0].properties;
const name = p.name || p.code || '';
const cat = p.category || '';
const html = `<div style="font-size:0.85rem">
${p.detail_url ? `<a href="${p.detail_url}">${name}</a>` : `<strong>${name}</strong>`}
${cat ? `<div style="color:#555;margin-top:2px">${cat}</div>` : ''}
</div>`;
new maplibregl.Popup({ closeButton: true, maxWidth: '260px', offset: 10 })
.setLngLat(e.lngLat)
.setHTML(html)
.addTo(map);
}
{% endif %}
function hexColor(bsColor) {
if (bsColor && bsColor.startsWith('#')) return bsColor;
return STATUS_COLORS[bsColor] || '#6c757d';
}
let map = null;
function initMap() {
if (map) return;
map = new maplibregl.Map({
container: 'intervention-map',
style: window.MAP_STYLE_LIGHT,
center: [MARKER.lon, MARKER.lat],
zoom: 18,
touchPitch: false,
});
window.map = map;
map.addControl(new maplibregl.NavigationControl(), 'bottom-right');
map.addControl(new maplibregl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: false,
showUserHeading: true,
fitBoundsOptions: { maxZoom: 17 },
}), 'bottom-right');
map.on('load', async () => {
const fill = hexColor(MARKER.status_color);
map.addSource('intervention-point', {
type: 'geojson',
data: {
type: 'Feature',
geometry: { type: 'Point', coordinates: [MARKER.lon, MARKER.lat] },
properties: { fill: fill },
},
});
map.addLayer({
id: 'intervention-circle',
type: 'circle',
source: 'intervention-point',
paint: {
'circle-radius': 12,
'circle-color': ['get', 'fill'],
'circle-opacity': 0.92,
'circle-stroke-width': 2,
'circle-stroke-color': '#fff',
},
});
{% if tl_intersection_ids %}
await loadTLIcons();
await loadTLAssets();
{% endif %}
});
}
function openMap() {
const overlay = document.getElementById('map-overlay');
overlay.classList.add('open');
if (!map) {
initMap();
} else {
setTimeout(() => map.resize(), 80);
}
}
function closeMap() {
document.getElementById('map-overlay').classList.remove('open');
}
document.getElementById('map-fab-btn').addEventListener('click', openMap);
document.getElementById('map-close-btn').addEventListener('click', closeMap);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMap(); });
// Expose for guided operations
window.openMap = openMap;
window.closeMap = closeMap;
Object.defineProperty(window, 'map', {
get: function() { return map; },
set: function(val) { map = val; },
configurable: true
});
})();
</script>
{% endif %}
<script>
function handleFollowUpClick(event, interventionId, value, radioId, containerId) {
event.preventDefault();
const container = document.getElementById(containerId);
const radio = document.getElementById(radioId);
const allRadios = container.querySelectorAll('input[type=radio]');
if (radio.checked) {
radio.checked = false;
setFollowUpPerformed(interventionId, null, containerId);
} else {
allRadios.forEach(r => r.checked = false);
radio.checked = true;
setFollowUpPerformed(interventionId, value, containerId);
}
}
function setFollowUpPerformed(interventionId, value, containerId) {
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value
|| document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || '';
const radios = document.querySelectorAll('#' + containerId + ' input[type=radio]');
radios.forEach(r => r.disabled = true);
fetch(`/interventions/${interventionId}/toggle-follow-up-performed/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: value }),
})
.then(r => r.json())
.then(data => {
radios.forEach(r => r.disabled = false);
if (!data.success) {
radios.forEach(r => r.checked = false);
alert(data.message || '{% translate "Erreur lors de la mise à jour." %}');
}
})
.catch(() => {
radios.forEach(r => r.disabled = false);
radios.forEach(r => r.checked = false);
alert('{% translate "Erreur réseau." %}');
});
}
</script>
<script>
// =====================================================================
// Discussion mobile – widget flottant + @mention + file preview
// =====================================================================
function toggleMobDiscussionPanel() {
const panel = document.getElementById('discussion-mob-panel');
if (!panel) return;
const opening = !panel.classList.contains('open');
panel.classList.toggle('open', opening);
if (opening) {
const thread = document.getElementById('discussion-mob-thread');
if (thread) setTimeout(() => { thread.scrollTop = thread.scrollHeight; }, 50);
const badge = document.getElementById('discussion-mob-badge');
if (badge) badge.style.display = 'none';
}
}
document.addEventListener('click', function (e) {
const panel = document.getElementById('discussion-mob-panel');
const fab = document.getElementById('discussion-mob-fab');
if (!panel || !panel.classList.contains('open')) return;
if (panel.contains(e.target) || (fab && fab.contains(e.target))) return;
panel.classList.remove('open');
});
if (window.location.hash === '#discussion') {
toggleMobDiscussionPanel();
}
document.addEventListener('DOMContentLoaded', function () {
// ---- Aperçu pièces jointes ----
const fileInput = document.getElementById('mob-msg-file-input');
const filePreview = document.getElementById('mob-msg-file-preview');
if (fileInput && filePreview) {
fileInput.addEventListener('change', renderMobPreview);
function renderMobPreview() {
filePreview.innerHTML = '';
const files = fileInput.files;
if (!files || !files.length) { 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;';
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:120px;';
chip.innerHTML = `<i class="bi bi-paperclip text-secondary"></i><span class="text-truncate">${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.addEventListener('click', () => {
const dt = new DataTransfer();
Array.from(fileInput.files).forEach((f, i) => { if (i !== idx) dt.items.add(f); });
fileInput.files = dt.files;
renderMobPreview();
});
wrapper.appendChild(btn);
filePreview.appendChild(wrapper);
});
}
const msgForm = document.getElementById('mob-msg-form');
if (msgForm) {
msgForm.addEventListener('submit', () => { filePreview.innerHTML = ''; filePreview.style.display = 'none'; });
}
}
});
// ---- @mention autocomplete (mobile) ----
(function () {
const textarea = document.getElementById('mob-msg-content');
const realInput = document.getElementById('mob-msg-content-real');
const dropdown = document.getElementById('mob-mention-dropdown');
const form = document.getElementById('mob-msg-form');
if (!textarea || !dropdown || !realInput || !form) return;
let mentions = [];
let mentionStart = -1;
let mentionQuery = null;
let searchTimer = null;
function escHtml(s) {
return s.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 hideDropdown() {
dropdown.style.display = 'none';
dropdown.innerHTML = '';
mentionStart = -1;
mentionQuery = null;
}
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 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(r => r.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];
searchMentions(mentionQuery);
});
form.addEventListener('submit', function (e) {
syncReal();
if (!realInput.value.trim()) {
e.preventDefault();
textarea.setCustomValidity('{% translate "Le message ne peut pas être vide." %}');
textarea.reportValidity();
} else {
textarea.setCustomValidity('');
}
});
textarea.addEventListener('keydown', function (e) {
if (dropdown.style.display === 'none') return;
const items = Array.from(dropdown.querySelectorAll('.list-group-item'));
if (e.key === 'Escape') { e.stopPropagation(); hideDropdown(); }
else if (e.key === 'ArrowDown') { e.preventDefault(); (items[0])?.focus(); }
});
dropdown.addEventListener('keydown', function (e) {
const items = Array.from(dropdown.querySelectorAll('.list-group-item'));
const idx = items.indexOf(document.activeElement);
if (e.key === 'Enter' && idx >= 0) { e.preventDefault(); items[idx].dispatchEvent(new MouseEvent('mousedown')); }
else if (e.key === 'Escape') { hideDropdown(); textarea.focus(); }
});
document.addEventListener('click', function (e) {
if (!dropdown.contains(e.target) && e.target !== textarea) hideDropdown();
});
form.addEventListener('submit', function () {
setTimeout(() => { textarea.value = ''; realInput.value = ''; mentions = []; }, 0);
});
})();
</script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// If can_edit_intervention is true, enter edit mode for guided operations
{% if can_edit_intervention %}
document.dispatchEvent(new CustomEvent('cms-edit-mode-enter'));
{% endif %}
});
</script>
<script>
function toggleSubscription(interventionId) {
const btn = document.getElementById('subscription-btn');
const icon = document.getElementById('subscription-icon');
const text = document.getElementById('subscription-text');
if (!btn || !icon || !text) return;
fetch(`/interventions/${interventionId}/toggle-subscription/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (data.subscribed) {
btn.classList.replace('btn-outline-secondary', 'btn-secondary');
icon.classList.replace('bi-bell', 'bi-bell-fill');
text.textContent = "{% translate 'Suivi' %}";
btn.title = "{% translate 'Ne plus suivre' %}";
} else {
btn.classList.replace('btn-secondary', 'btn-outline-secondary');
icon.classList.replace('bi-bell-fill', 'bi-bell');
text.textContent = "{% translate 'Suivre' %}";
btn.title = "{% translate 'Suivre cette intervention' %}";
}
} else {
alert(data.message || "{% translate 'Une erreur est survenue.' %}");
}
})
.catch(error => {
console.error('Error:', error);
alert("{% translate 'Une erreur réseau est survenue.' %}");
});
}
</script>
{% endblock extra_scripts %}