6521 lines
No EOL
308 KiB
HTML
6521 lines
No EOL
308 KiB
HTML
{% 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>
|
||
· {{ 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'ancien équipement est archivé.' %}",
|
||
error: "{% translate 'Impossible de créer l'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 = '×';
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
function syncReal() {
|
||
const dv = textarea.value;
|
||
if (!mentions.length) { realInput.value = dv; return; }
|
||
let real = '', pos = 0;
|
||
const sorted = [...mentions].sort((a, b) => a.start - b.start);
|
||
for (const m of sorted) {
|
||
if (m.start > pos) real += dv.slice(pos, m.start);
|
||
real += m.marker;
|
||
pos = m.end;
|
||
}
|
||
real += dv.slice(pos);
|
||
realInput.value = real;
|
||
}
|
||
function shiftMentions(fromPos, delta) {
|
||
for (const m of mentions) { if (m.start >= fromPos) { m.start += delta; m.end += delta; } }
|
||
}
|
||
function removeMentionsIn(start, end) {
|
||
mentions = mentions.filter(m => m.end <= start || m.start >= end);
|
||
}
|
||
function 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 %} |