loko/streetup/controls/templates/controls/controls_detail_mobile.html

1222 lines
61 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% extends 'mobile/mobile_base.html' %}
{% load static %}
{% load tz %}
{% load controls_custom_filters %}
{% load i18n %}
{% block extra_head %}
<!-- MapLibre GL 5.7.0 (local) -->
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet" />
<link rel="stylesheet" href="{% static 'controls/controls_detail.css' %}">
<style>
.preview-image {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #dee2e6;
}
.preview-wrapper {
position: relative;
display: inline-block;
}
.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;
}
.camera-flash-active {
opacity: 1 !important;
}
</style>
{% endblock extra_head %}
{% block upleft %}
<a href="javascript:history.back()" class="nav-link navbar-upleft" style="color: white;">
<i class="bi bi-arrow-left fs-3" title="Retour"></i>
</a>
{% endblock upleft %}
{% block title %}
{{ control.control_code }}
{% endblock title %}
{% block content %}
<div class="container py-2">
<!-- Affichage des messages de succès ou d'échec -->
{% if messages %}
{% for message in messages %}
<div class="alert {% if message.tags == 'success' %}alert-success{% else %}alert-danger{% endif %} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% translate 'Fermer' %}"></button>
</div>
{% endfor %}
{% endif %}
<!-- Informations principales -->
<div class="card-body position-relative">
<a id="control-edit-btn"
href="{% url 'controls:change_control' control.id %}?v=mobile"
class="btn btn-sm btn-light border position-absolute top-0 end-0 m-2"
title="{% translate 'Modifier le template' %}"
onclick="showControlEditSpinner(event)">
<i id="control-edit-icon" class="bi bi-pencil"></i>
<div id="control-edit-spinner" class="spinner-border spinner-border-sm text-primary d-none" role="status"></div>
</a>
<p class="mb-1"><strong>{% translate 'Statut' %} :</strong>
<span class="badge bg-info">{{ control.get_status_display }}</span>
</p>
<p class="mb-1"><strong>{% translate 'Type' %} :</strong>
<span class="badge bg-secondary">{{ control.get_control_type_display }}</span>
</p>
<br>
{% if control.project %}
<p class="mb-1"><strong>{% translate 'Projet' %} :</strong>
<a href="{{ control.project.get_absolute_url }}?v=mobile">
{{ control.project.name }}
</a>
</p>
{% endif %}
{% if control.phase %}
<p class="mb-1"><strong>{% translate 'Phase' %} :</strong>
{{ control.phase.name }}
</p>
{% endif %}
{% if control.intervention %}
<p class="mb-1"><strong>{% translate 'Intervention' %} :</strong>
{{ control.intervention }}
</p>
{% endif %}
<p class="mb-1"><strong>{% translate 'Lieu' %} :</strong> {{ control.location }}</p>
<p class="text-muted small mb-0">
{% translate 'Créé le' %} {{ control.created_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}
| {% translate 'Mis à jour le' %} {{ control.updated_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}
</p>
<p class="text-muted small mb-0">
{% translate 'Auteur' %} : {{ control.control_author }}
</p>
{% if control.description %}
<p class="mb-1"><strong>{% translate 'Description' %} :</strong>
{{ control.description }}
</p>
{% endif %}
</div>
<!-- Formulaire en plein écran -->
<div id="remark-form-container">
<button class="close-btn btn btn-primary" onclick="hideRemarkForm()" title="{% translate 'Fermer' %}"><i class="bi bi-x-square"></i></button>
<!-- Carte MapLibre -->
<div id="map" class="border-bottom"></div>
<div id="layer-controls">
{% for plan in plans %}
<button class="btn btn-light" onclick="showPlan({{ plan.id }})">{{ plan.name }}</button>
{% endfor %}
{% if plans %}
<button class="btn btn-light" onclick="resetView()">{% translate 'Carte' %}</button>
{% endif %}
</div>
<!-- Formulaire d'ajout de remarque -->
<div class="form-content">
<h2 class="mt-2 text-center" style="color: #007bff;">{% translate 'Ajouter une remarque' %}</h2>
<div class="card shadow p-4">
<form id="remark-form" method="POST" enctype="multipart/form-data" class="needs-validation d-flex flex-column" novalidate data-no-overlay>
{% csrf_token %}
<!-- ✅ Management form du formset (obligatoire) -->
{{ qty_formset.management_form }}
<input type="hidden" id="control_id" name="control" value="{{ control.id }}">
<div id="status-badges" class="d-flex justify-content-center gap-2">
<span class="badge bg-secondary status-badge me-1" data-value="information">{% translate 'Info' %}</span>
<span class="badge bg-secondary status-badge me-1" data-value="validated">{% translate 'OK' %}</span>
<span class="badge bg-danger status-badge" data-value="to_be_processed">{% translate 'NOK' %}</span>
<input type="hidden" name="status" id="selectedStatusInput" value="to_be_processed">
</div>
<hr>
<div class="mb-3">
<label for="{{ remark_form.description.id_for_label }}" class="form-label">{% translate 'Description' %}:</label>
{{ remark_form.description }}
<div id="descriptionError" class="invalid-feedback" style="display:none;">
{% translate 'Veuillez remplir la description.' %}
</div>
</div>
<hr>
<div class="mb-3">
<label class="form-label">{% translate 'Images' %}:</label>
<div class="d-grid gap-2">
<button type="button" id="open-camera-modal-btn" class="btn btn-outline-primary">
<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>
<input type="file" id="id_images" name="images" multiple class="d-none">
<div id="photo-inputs" class="d-none"></div>
<div id="imagesError" class="invalid-feedback" style="display:none;">
{% translate 'Veuillez ajouter au moins une image.' %}
</div>
</div>
<div id="preview" class="d-flex flex-wrap gap-2"></div>
<hr>
<div class="mb-3">
<label class="form-label">{% translate 'Tags' %} :</label>
<div id="category-badges">
{% for category in categories %}
<span class="badge bg-secondary category-badge me-1" data-id="{{ category.id }}">
{{ category.get_name }}
</span>
{% endfor %}
</div>
<input type="hidden" name="categories" id="selectedCategoriesInput">
</div>
<!-- ✅ Coordonnées / plan -->
<input type="hidden" id="latitude" name="lat">
<input type="hidden" id="longitude" name="lon">
<input type="hidden" id="coord_x" name="x">
<input type="hidden" id="coord_y" name="y">
<input type="hidden" name="reference_plan" id="reference_plan" value="map">
<!-- ✅ Récapitulatif des quantités (lisible, pas des inputs) -->
<div class="mt-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="m-0">{% translate 'Quantités liées à la remarque' %}</h6>
<button type="button" class="btn btn-sm btn-outline-primary" id="open-qty-editor">
<i class="bi bi-plus-square"></i> {% translate 'Ajouter des quantités' %}
</button>
</div>
<div id="qty-summary" class="mt-2">
<!-- rempli par JS à partir des lignes du formset -->
<div class="text-muted small" id="qty-summary-empty">
{% translate "Aucune quantité pour l'instant." %}
</div>
<div class="list-group" id="qty-summary-list"></div>
</div>
</div>
<!-- ✅ Les formulaires réels du formset (inputs) sont *cachés* ici mais restent dans le <form> -->
<div id="qty-hidden-forms" class="d-none">
{% for form in qty_formset.forms %}
<div class="qty-form" data-form-index="{{ forloop.counter0 }}">
{{ form.as_p }}
</div>
{% endfor %}
<!-- gabarit pour création dynamique -->
<template id="qty-empty-form-template">
<div class="qty-form" data-form-index="__prefix__">
{{ qty_formset.empty_form.as_p }}
</div>
</template>
</div>
<!-- Footer actions du formulaire remarque -->
<button id="submit-remark-button" type="submit" name="submit_remark" class="btn btn-primary w-100 mt-3">
<span id="submit-remark-button-text">{% translate 'Ajouter' %}</span>
<span id="spinner-remark" class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
</button>
</form>
</div>
</div>
</div>
<!-- ✅ Bottom sheet plein écran: éditeur des quantités (formset UI) -->
<div id="qty-editor" class="position-fixed top-0 start-0 w-100 h-100 bg-body" style="z-index:1060; display:none;">
<div class="d-flex align-items-center justify-content-between p-3 border-bottom">
<h6 class="m-0">{% translate 'Quantités de la remarque' %}</h6>
<button type="button" class="btn btn-outline-secondary btn-sm" id="close-qty-editor">
<i class="bi bi-x-lg"></i>
</button>
</div>
<!-- ⚠️ Pas un formulaire séparé : ce sont des *clones visibles* des inputs déjà dans qty-hidden-forms -->
<div class="d-flex flex-column h-100">
<div class="flex-grow-1 overflow-auto p-3 content-scroll">
<div id="qty-editor-rows" class="vstack gap-3">
<!-- lignes injectées par JS en clonant les champs du formset -->
</div>
<template id="qty-editor-row-template">
<div class="card shadow-sm p-3" data-form-index="__idx__">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>{% translate 'Ligne' %} <span class="row-number">__num__</span></strong>
<button type="button" class="btn btn-outline-danger btn-sm editor-remove-row">
<i class="bi bi-trash"></i> {% translate 'Supprimer' %}
</button>
</div>
<!-- Marché -->
<div class="mb-2">
<label class="form-label">{% translate 'Marché (optionnel)' %}</label>
<div class="field-contract"></div>
</div>
<!-- Poste (compat. avec ton autocomplete : label visible + hidden id si tu les as dans le form) -->
<div class="mb-2">
<label class="form-label">{% translate 'Poste (autocomplete si un marché est choisi)' %}</label>
<div class="field-contract_post_label"></div>
<div class="field-contract_post_id"></div>
<div class="list-group mt-1 d-none position-relative" id="qty-post-suggestions-__idx__"></div>
</div>
<!-- Description -->
<div class="mb-2">
<label class="form-label">{% translate 'Description (si pas de marché/poste)' %}</label>
<div class="field-description"></div>
</div>
<!-- q1 q2 q3 -->
<div class="row g-2">
<div class="col-4">
<label class="form-label">{% translate 'Quantité (1)' %}</label>
<div class="field-q1"></div>
</div>
<div class="col-4">
<label class="form-label">{% translate 'Quantité (2)' %}</label>
<div class="field-q2"></div>
</div>
<div class="col-4">
<label class="form-label">{% translate 'Quantité (3)' %}</label>
<div class="field-q3"></div>
</div>
</div>
<div class="row g-2 mt-1">
<div class="col-6">
<label class="form-label">{% translate 'Résultat' %}</label>
<input class="form-control qty-preview" type="text" value="" readonly>
</div>
</div>
<!-- Champ DELETE (si présent dans formset) -->
<div class="field-DELETE d-none"></div>
</div>
</template>
</div>
<!-- Barre d’actions fixe -->
<div class="action-bar fixed-bottom border-top bg-body">
<div class="container-fluid p-3">
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary btn-lg" id="add-qty-row">
<i class="bi bi-plus-square"></i> {% translate 'Ajouter une ligne' %}
</button>
<button type="button" class="btn btn-primary btn-lg" id="apply-qty-and-close">
{% translate 'Terminer et revenir' %}
</button>
</div>
</div>
</div>
</div>
</div>
<hr class="border border-primary">
<!-- Remarks -->
<button id="add-remark-btn" onclick="showRemarkForm()" class="btn btn-primary w-100 mt-3">
<span id="button-text-pdf">
<span class="bg-white text-primary rounded-circle d-inline-flex align-items-center justify-content-center me-2"
style="width: 24px; height: 24px;">
<i class="bi bi-plus"></i>
</span>
{% translate 'Ajouter une remarque' %}
</span>
</button>
<div class="card shadow-sm mt-4" id="remarks-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{% translate 'Remarques' %}</h5>
</div>
<div class="card-body">
<!-- Remarques en attente (mode offline) -->
<div id="pending-remarks-list"></div>
<!-- Remarques existantes -->
<div id="remarks-list">
{% for remark in remarks %}
<div id="remark-{{ remark.id }}" class="list-group-item position-relative">
<!-- ✅ Bloc des boutons en haut à droite -->
<div id="buttons-{{ remark.id }}" class="d-flex flex-column justify-content-between position-absolute top-0 end-0 p-2 mb-1 h-100">
<div>
{% if remark.status == "information" %}
<button class="btn btn-sm btn-primary mb-1" onclick="markOk({{ remark.id }})" title="{% translate 'Valider' %}">
<i class="bi bi-hand-thumbs-up"></i>
</button>
<br>
<button class="btn btn-sm btn-primary mb-1" onclick="markNok({{ remark.id }})" title="{% translate 'Marquer NOK' %}">
<i class="bi bi-hand-thumbs-down"></i>
</button>
{% elif remark.status == "to_be_processed" or remark.status == "processed" %}
<button class="btn btn-sm btn-primary" onclick="markOk({{ remark.id }})" title="{% translate 'Valider' %}">
<i class="bi bi-hand-thumbs-up"></i>
</button>
{% elif remark.status == "validated" %}
<button class="btn btn-sm btn-primary" onclick="markNok({{ remark.id }})" title="{% translate 'Marquer NOK' %}">
<i class="bi bi-hand-thumbs-down"></i>
</button>
{% endif %}
</div>
<div class="d-flex flex-column">
<button class="btn btn-sm btn-secondary stick-to-bottom mb-1"
onclick="window.location.href='{% url 'mobile:change_remark' remark.id %}'" title="{% translate 'Éditer la remarque' %}">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-secondary stick-to-bottom" onclick="archive_remark({{ remark.id }})" title="{% translate 'Archiver la remarque' %}">
<i class="bi bi-archive"></i>
</button>
</div>
</div>
<!-- ✅ Première Ligne : Numéro, Statut -->
<div class="d-flex justify-content-between align-items-center pe-5">
<div class="d-flex align-items-center gap-2">
<div class="remark-number">
{{ remark.remark_number }}
</div>
{% if remark.images.exists %}
<button type="button" class="btn btn-sm btn-outline-primary rounded-pill px-2 py-0" onclick="openRemarkPhotoCarouselByRemarkId({{ remark.id }})" title="{% translate 'Visualiser les photos dans le carrousel' %}">
<i class="bi bi-images me-1"></i>{{ remark.images.count }}
</button>
{% endif %}
</div>
<span id="status-{{ remark.id }}" class="badge {{ remark.status|status_badge }}">
<i class="bi {{ remark.status|status_icon }}"></i>
{{ remark.get_status_display }}
</span>
<div style="width: 56px;"></div>
</div>
<!-- ✅ Infos sur l’utilisateur et la date (dernière mise à jour) -->
<p class="small text-muted mb-2">
{% load short_name %}
{% translate 'Mise à jour par' %} <span id="updated-by-{{ remark.id }}">{{ remark.updated_by|short_name|default:remark.updated_by.username }}</span>
{% translate 'le' %} <span id="updated-at-{{ remark.id }}">{{ remark.updated_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}</span>
</p>
<!-- ✅ Historique des suivis -->
<div class="remark-history mt-2 mb-3">
<div class="small text-muted mb-2 fw-semibold">{% translate "Suivis de la remarque" %} :</div>
<div class="vstack gap-3 ps-2 border-start border-2 border-primary-subtle ms-1">
{% for entry in remark.history.all %}
<div class="remark-history-entry">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="small text-muted fw-semibold">📅 {{ entry.created_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}</span>
<span class="badge {{ entry.status|status_badge }}">
<i class="bi {{ entry.status|status_icon }}"></i>
{{ entry.get_status_display }}
</span>
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:entry.created_by.username }})</span>
</div>
{% if entry.description %}
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ entry.description }}</div>
{% endif %}
<!-- Images associées à cette étape -->
<div class="remark-images mt-2">
{% for image in entry.images.all %}
<div class="remark-image-wrapper">
{% private_file_url image 'file' as image_file_url %}
{% private_file_url image 'thumbnail' as image_thumb_url %}
<img src="{{ image_thumb_url }}"
alt="{% translate 'Image de la remarque' %}"
class="remark-photo-thumb shadow-sm"
style="cursor: pointer;"
data-remark-id="{{ remark.id }}"
data-full-url="{{ image_file_url }}"
data-thumb-url="{{ image_thumb_url }}"
data-image-id="{{ image.id }}"
data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }}{% if entry.created_at %} — {{ entry.created_at|timezone:'Europe/Paris'|date:'d/m/Y H:i' }}{% endif %}"
onclick="openRemarkPhotoCarousel(this)">
{% if can_change_control %}
<button type="button"
class="btn btn-sm btn-danger remark-image-delete"
data-image-id="{{ image.id }}"
data-remark-id="{{ remark.id }}"
title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% empty %}
<!-- Fallback si pas encore d'historique -->
<div class="remark-history-entry">
<div class="d-flex align-items-center gap-2">
<span class="small text-muted fw-semibold">📅 {{ remark.created_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}</span>
<span class="badge {{ remark.status|status_badge }}">
<i class="bi {{ remark.status|status_icon }}"></i>
{{ remark.get_status_display }}
</span>
<span class="small text-muted">({% translate 'par' %} {{ remark.updated_by|short_name|default:remark.updated_by.username }})</span>
</div>
{% if remark.description %}
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ remark.description }}</div>
{% endif %}
<div class="remark-images mt-2">
{% for image in remark.images.all %}
{% if not image.history_entry %}
<div class="remark-image-wrapper">
{% private_file_url image 'file' as image_file_url %}
{% private_file_url image 'thumbnail' as image_thumb_url %}
<img src="{{ image_thumb_url }}"
alt="{% translate 'Image de la remarque' %}"
class="remark-photo-thumb shadow-sm"
style="cursor: pointer;"
data-remark-id="{{ remark.id }}"
data-full-url="{{ image_file_url }}"
data-thumb-url="{{ image_thumb_url }}"
data-image-id="{{ image.id }}"
data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }}"
onclick="openRemarkPhotoCarousel(this)">
{% if can_change_control %}
<button type="button"
class="btn btn-sm btn-danger remark-image-delete"
data-image-id="{{ image.id }}"
data-remark-id="{{ remark.id }}"
title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<!-- ✅ Catégories associées -->
<div class="remark-categories">
{% for tag in remark.remark_tags.all %}
<span class="badge me-1" style="background-color: rgb(169, 179, 191);">
{{ tag.category.get_name }}
</span>
{% endfor %}
</div>
<!-- ✅ Quantités associées -->
{% get_current_language as LANGUAGE_CODE %}
{% with qty_list=remark.quantities.all %}
{% if qty_list %}
<div class="remark-quantities mt-4" style="margin-right: 4rem !important;">
<div class="small text-muted mb-1">{% translate 'Quantités' %}</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-2">
<thead class="table-light">
<tr>
<th style="min-width: 180px;">{% translate 'Poste / Description' %}</th>
<th class="text-end">{% translate 'Total' %}</th>
</tr>
</thead>
<tbody>
{% for q in qty_list %}
<tr>
<td>
{% if q.contract_post %}
<div class="fw-semibold">
{{ q.contract_post.code }}
{% if q.contract_post.unit %}<span class="text-muted">({{ q.contract_post.unit }})</span>{% endif %}
</div>
<div class="text-muted small">
{% if LANGUAGE_CODE|slice:":2" == "nl" %}
{{ q.contract_post.description_nl|default:q.contract_post.description_fr|truncatechars:120 }}
{% else %}
{{ q.contract_post.description_fr|default:q.contract_post.description_nl|truncatechars:120 }}
{% endif %}
</div>
{% if q.contract %}
<div class="small text-muted">{{ q.contract.contract_number }}</div>
{% endif %}
{% else %}
<div class="fw-semibold">{{ q.description|default:"—" }}</div>
{% endif %}
</td>
<td class="text-end fw-semibold">{{ q.computed_qty|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endwith %}
{% if not forloop.last %}
<div class="shadow border-3 border-bottom mt-3 mb-4"></div>
{% endif %}
</div>
{% empty %}
<p class="text-muted">{% translate 'Aucune remarque enregistrée.' %}</p>
{% endfor %}
</div>
</div>
</div>
<hr class="border border-primary">
<!-- Rapports -->
<button id="generate-report-btn" data-url="{% url 'controls:export_pdf' control.id %}" class="btn btn-primary w-100 mt-3">
<span id="button-text-pdf"
class="bg-white text-primary rounded-circle d-inline-flex align-items-center justify-content-center me-2"
style="width: 24px; height: 24px;">
<i class="bi bi-plus"></i>
</span>
{% translate 'Produire un rapport PDF' %}
<span id="spinner-pdf" class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
</button>
<!-- Message de statut pour le rapport pdf -->
<div id="status-message-pdf" class="mt-3"></div>
<div class="card shadow-sm mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{% translate 'Rapports' %}</h5>
</div>
<div class="card-body" id="card-reports">
{% for report in reports %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
<small class="text-muted">
📅 {% translate 'Généré le' %} : {{ report.created_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }} <br>
🖊 {% translate 'Auteur' %} : {{ report.created_by }}
</small>
</div>
<div>
{% private_file_url report 'document' as report_document_url %}
<a href="{{ report_document_url }}" class="btn btn-sm btn-light mb-1" download>
📥 {% translate 'Télécharger' %}
</a>
<br>
<button id="share-button-{{ report.id }}" class="btn btn-sm btn-light share-button"><i class="bi bi-share-fill text-primary me-1"></i>{% translate 'Partager' %}</button>
</div>
</li>
{% if not forloop.last %}
<div class="shadow border-3 border-bottom mt-3 mb-4"></div>
{% endif %}
{% empty %}
<p class="text-muted">{% translate 'Aucun rapport sauvegardé.' %}</p>
{% endfor %}
</div>
</div>
{% if report_template %}
<div class="mt-3 m-2 small">
{% translate 'Template' %}
<button class="btn btn-sm btn-light stick-to-bottom mx-2"
onclick="window.location.href='{% url 'controls:change_template' report_template.id %}?v=mobile'" title="{% translate 'Éditer le template' %}">
<i class="bi bi-pencil"></i>
</button>
</div>
{% endif %}
<hr class="border border-primary">
<div class="card shadow-sm mt-4" id="archives-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{% translate 'Archives' %}</h5>
</div>
<div class="card-body">
<div id="archives-list">
{% for remark in archives %}
<div id="remark-{{ remark.id }}" class="list-group-item position-relative bg-light text-muted">
<!-- ✅ Bloc des boutons en haut à droite -->
<div id="buttons-{{ remark.id }}" class="d-flex flex-column gap-2 position-absolute top-0 end-0 p-2 mb-1">
<button class="btn btn-sm btn-primary" style="background-color:rgb(120, 163, 215)" onclick="restore_remark({{ remark.id }})" title="{% translate 'Restaurer la remarque' %}">
<i class="bi bi-arrow-90deg-up"></i>
</button>
</div>
<!-- ✅ Première Ligne : Numéro, Statut -->
<div class="d-flex justify-content-between align-items-center pe-5">
<div class="remark-number">
{{ remark.remark_number }}
</div>
<span id="status-{{ remark.id }}" class="badge {{ remark.status|status_badge }}">
<i class="bi {{ remark.status|status_icon }}"></i>
{{ remark.get_status_display }}
</span>
<div style="width: 56px;"></div>
</div>
<!-- ✅ Description -->
<h6 class="mt-2 mb-1" style="min-height: 40px;">{{ remark.description }}</h6>
<!-- ✅ Infos sur l’utilisateur et la date -->
<p class="small text-muted">
{% translate 'Mise à jour par' %} <span id="updated-by-{{ remark.id }}">{{ remark.updated_by.username }}</span>
{% translate 'le' %} <span id="updated-at-{{ remark.id }}">{{ remark.updated_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}</span>
</p>
<!-- ✅ Images associées -->
<div class="remark-images">
{% for image in remark.images.all %}
{% private_file_url image 'file' as image_file_url %}
{% private_file_url image 'thumbnail' as image_thumb_url %}
<div class="remark-image-wrapper">
<img src="{{ image_thumb_url }}"
alt="{% translate 'Image de la remarque' %}"
class="remark-photo-thumb shadow-sm"
style="cursor: pointer;"
data-remark-id="{{ remark.id }}"
data-full-url="{{ image_file_url }}"
data-thumb-url="{{ image_thumb_url }}"
data-image-id="{{ image.id }}"
data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }} ({% translate 'Archivée' %})"
onclick="openRemarkPhotoCarousel(this)">
{% if can_change_control %}
<button type="button"
class="btn btn-sm btn-danger remark-image-delete"
data-image-id="{{ image.id }}"
data-remark-id="{{ remark.id }}"
title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
{% endfor %}
</div>
<!-- ✅ Catégories associées -->
<div class="remark-categories">
{% for tag in remark.remark_tags.all %}
<span class="badge me-1" style="background-color: rgb(169, 179, 191);">
{{ tag.category.get_name }}
</span>
{% endfor %}
</div>
{% if not forloop.last %}
<div class="shadow border-3 border-bottom mt-3 mb-4"></div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
<hr class="border border-primary">
<!-- Bouton pour afficher le formulaire d'upload des plans de référence -->
<button onclick="showUploadPlanForm()" class="btn btn-light w-100 mt-3">
<i class="bi bi-plus-circle px-3 text-primary"></i>{% translate 'Ajouter un plan de référence' %}
</button>
<br><br>
<!-- Formulaire pour uploader les plans de référence -->
<div id="upload-plan-form-container">
<button class="close-btn btn btn-danger" onclick="hideUploadPlanForm()" title="{% translate 'Fermer' %}"><i class="bi bi-x-square"></i></button>
<div class="form-content">
<h3 class="text-center">{% translate 'Ajouter un Plan de Référence' %}</h3>
<div class="card shadow p-4 mt-4">
<form id="upload-plan-form" method="POST" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-3">
<label class="form-label">{% translate 'Nom du Plan' %}</label>
{{ upload_plan_form.name }}
</div>
<div class="mb-3">
<label class="form-label">{% translate 'Sélectionner un Fichier (PDF ou Image)' %}</label>
{{ upload_plan_form.plan }}
</div>
<button type="submit" name="submit_plan" class="btn btn-primary w-100">{% translate 'Uploader' %}</button>
</form>
</div>
</div>
</div>
<div class="modal fade" id="deleteImageModal" tabindex="-1" aria-labelledby="deleteImageModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteImageModalLabel">{% translate 'Confirmer la suppression' %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
{% translate 'Voulez-vous vraiment supprimer cette image ?' %}
</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="confirmDeleteImage">{% translate 'Supprimer' %}</button>
</div>
</div>
</div>
</div>
<!-- Modal caméra Canvas (mode continu) -->
<div class="modal fade" id="cameraModal" tabindex="-1" aria-labelledby="cameraModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen-sm-down d-flex m-0 h-100 mx-auto">
<div class="modal-content bg-dark text-white d-flex flex-column">
<div class="modal-header border-0 flex-shrink-0 py-2">
<h5 class="modal-title" id="cameraModalLabel">
<i class="bi bi-camera-video"></i> {% translate "Caméra" %}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}" id="cameraModalCloseBtn"></button>
</div>
<div class="modal-body d-flex flex-column p-2 flex-grow-1" style="overflow: hidden; min-height: 0;">
<div class="flex-grow-1 d-flex align-items-center justify-content-center" style="min-height: 0; overflow: hidden;">
<div class="position-relative w-100 h-100 d-flex align-items-center justify-content-center" style="border-radius: 0.5rem; overflow: hidden;">
<video id="cameraPreview" playsinline autoplay style="max-width: 100%; max-height: 100%; object-fit: contain;"></video>
<div id="cameraFlashOverlay" style="position: absolute; inset: 0; background: rgba(255, 255, 255, 0.9); opacity: 0; pointer-events: none; transition: opacity 120ms ease-out; z-index: 2;"></div>
<canvas id="cameraCanvas" class="d-none"></canvas>
</div>
</div>
<div id="cameraStatus" class="small text-center text-muted my-2 flex-shrink-0"></div>
<div class="d-flex justify-content-between align-items-center gap-2 mb-2 flex-shrink-0">
<button type="button" class="btn btn-outline-light flex-fill" id="cameraStopBtn">
<i class="bi bi-x-circle"></i> {% translate "Terminer" %}
</button>
<button type="button" class="btn btn-primary flex-fill" id="cameraCaptureBtn">
<i class="bi bi-camera-fill"></i> {% translate "Capturer" %}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Badge d'indicateur de synchronisation offline -->
<div id="sync-status" class="position-fixed end-0 m-3 d-none" style="bottom: 40px; z-index: 1000;">
<div class="card shadow-sm">
<div class="card-body p-2 d-flex align-items-center gap-2">
<span id="online-indicator" class="badge bg-danger">
<i class="bi bi-wifi-off"></i> {% translate 'Hors ligne' %}
</span>
<span id="pending-count" class="badge bg-warning d-none">
<i class="bi bi-cloud-upload"></i> <span id="pending-number">0</span> {% translate 'en attente' %}
</span>
<button id="force-sync-btn" class="btn btn-sm btn-primary d-none" title="{% translate 'Forcer la synchronisation' %}">
<i class="bi bi-arrow-repeat"></i>
</button>
</div>
</div>
</div>
</div>
<script>
// Mettre à jour le badge du nombre de remarques en attente
async function updatePendingBadge() {
if (!offlineManager) return;
try {
const count = await offlineManager.getPendingCount();
const pendingBadge = document.getElementById('pending-count');
const pendingNumber = document.getElementById('pending-number');
const forceSyncBtn = document.getElementById('force-sync-btn');
const syncStatus = document.getElementById('sync-status');
if (count > 0) {
pendingBadge.classList.remove('d-none');
pendingNumber.textContent = count;
forceSyncBtn.classList.remove('d-none');
// Afficher le badge s'il y a des remarques en attente
syncStatus.classList.remove('d-none');
} else {
pendingBadge.classList.add('d-none');
forceSyncBtn.classList.add('d-none');
// Masquer le badge s'il n'y a plus de remarques et qu'on est en ligne
if (navigator.onLine) {
syncStatus.classList.add('d-none');
}
}
} catch (error) {
console.error('Erreur lors de la mise à jour du badge:', error);
}
}
// Mettre à jour l'indicateur de connexion
function updateOnlineIndicator() {
const indicator = document.getElementById('online-indicator');
const syncStatus = document.getElementById('sync-status');
if (!indicator || !syncStatus) return;
const _om = (typeof offlineManager !== 'undefined') ? offlineManager : null;
const isEffectivelyOnline = _om ? _om.isOnline : navigator.onLine;
if (isEffectivelyOnline) {
indicator.className = 'badge bg-success';
indicator.innerHTML = '<i class="bi bi-wifi"></i> {% translate "En ligne" %}';
// Masquer le badge quand on revient en ligne (sauf si synchro en cours)
setTimeout(async () => {
if (offlineManager) {
const count = await offlineManager.getPendingCount();
if (count === 0) {
syncStatus.classList.add('d-none');
}
}
}, 2000);
} else if (navigator.onLine) {
// Connexion présente mais qualité insuffisante
indicator.className = 'badge bg-warning text-dark';
indicator.innerHTML = '<i class="bi bi-wifi-1"></i> {% translate "Connexion faible" %}';
syncStatus.classList.remove('d-none');
} else {
indicator.className = 'badge bg-danger';
indicator.innerHTML = '<i class="bi bi-wifi-off"></i> {% translate "Hors ligne" %}';
syncStatus.classList.remove('d-none');
}
}
// Écouter les changements de connectivité
window.addEventListener('online', () => {
updateOnlineIndicator();
updatePendingBadge();
});
window.addEventListener('offline', () => {
updateOnlineIndicator();
});
// Changements de qualité réseau (Network Information API)
window.addEventListener('effectiveOffline', () => {
updateOnlineIndicator();
updateExistingRemarksButtons();
});
window.addEventListener('effectiveOnline', () => {
updateOnlineIndicator();
updatePendingBadge();
updateExistingRemarksButtons();
});
// Initialiser au chargement de la page
document.addEventListener('DOMContentLoaded', () => {
// Mémoriser ce contrôle comme dernier visité (utilisé par la page offline)
try {
localStorage.setItem('streetup.lastControlUrl', window.location.href);
} catch (e) { /* localStorage non disponible */ }
// Afficher le badge si hors ligne ou connexion trop faible
const syncStatus = document.getElementById('sync-status');
const isEffectivelyOnline = (typeof offlineManager !== 'undefined' && offlineManager) ? offlineManager.isOnline : navigator.onLine;
if (!isEffectivelyOnline) {
syncStatus.classList.remove('d-none');
}
updateOnlineIndicator();
// Attendre que l'offline manager soit prêt
setTimeout(() => {
updatePendingBadge();
displayPendingRemarks();
updateExistingRemarksButtons();
}, 500);
});
// Afficher les remarques en attente
async function displayPendingRemarks() {
if (!offlineManager || !offlineManagerReady) return;
const controlId = document.getElementById('control_id').value;
const pendingRemarks = await offlineManager.getPendingRemarks(controlId);
const container = document.getElementById('pending-remarks-list');
if (!container) return;
container.innerHTML = '';
if (pendingRemarks.length === 0) {
return;
}
// Afficher chaque remarque en attente
pendingRemarks.forEach((remark, index) => {
const remarkDiv = document.createElement('div');
remarkDiv.className = 'list-group-item position-relative border-warning bg-warning-subtle';
remarkDiv.id = `pending-remark-${remark.id}`;
const statusBadgeClass = remark.status === 'validated' ? 'bg-success' :
remark.status === 'information' ? 'bg-secondary' : 'bg-danger';
const statusText = remark.status === 'validated' ? '{% translate "OK" %}' :
remark.status === 'information' ? '{% translate "Info" %}' : '{% translate "NOK" %}';
remarkDiv.innerHTML = `
<!-- Badge "En attente de synchronisation" -->
<div class="badge bg-warning text-dark mb-2">
<i class="bi bi-cloud-upload"></i> {% translate 'En attente de synchronisation' %}
</div>
<!-- Boutons d'action -->
<div class="d-flex flex-column position-absolute top-0 end-0 p-2 mb-1 gap-2">
<button class="btn btn-sm btn-outline-primary" onclick="editPendingRemark(${remark.id})" title="{% translate 'Modifier' %}">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deletePendingRemark(${remark.id})" title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</button>
</div>
<!-- En-tête -->
<div class="d-flex justify-content-between align-items-center pe-5">
<div class="remark-number">
<strong>#${pendingRemarks.length - index}</strong>
</div>
<span class="badge ${statusBadgeClass}">${statusText}</span>
<div style="width: 56px;"></div>
</div>
<!-- Description -->
<h6 class="mt-2 mb-1" style="min-height: 50px; max-width: 90%;">${remark.description || ''}</h6>
<!-- Infos -->
<p class="small text-muted">
{% translate 'Créé localement le' %} ${new Date(remark.timestamp).toLocaleString('fr-FR')}
</p>
<!-- Images -->
${remark.photos && remark.photos.length > 0 ? `
<div class="remark-images mt-2">
<div class="d-flex flex-wrap gap-2">
${remark.photos.map(photo => `
<div class="position-relative">
<img src="${photo.url}" class="img-thumbnail" style="width: 80px; height: 80px; object-fit: cover;" onclick="showImageModal('${photo.url}')">
<button class="btn btn-sm btn-danger position-absolute top-0 end-0" style="padding: 0.1rem 0.3rem;" onclick="deletePendingPhoto(${remark.id}, ${photo.id})" title="{% translate 'Supprimer' %}">
<i class="bi bi-x"></i>
</button>
</div>
`).join('')}
</div>
</div>
` : ''}
<div class="shadow border-3 border-bottom mt-3 mb-4"></div>
`;
container.appendChild(remarkDiv);
});
}
// Éditer une remarque en attente
async function editPendingRemark(remarkId) {
if (!offlineManager) return;
const controlId = document.getElementById('control_id').value;
const pendingRemarks = await offlineManager.getPendingRemarks(controlId);
const remark = pendingRemarks.find(r => r.id === remarkId);
if (!remark) return;
// Stocker l'ID de la remarque en édition AVANT d'afficher le formulaire
const form = document.getElementById('remark-form');
form.dataset.editingRemarkId = remarkId;
// Afficher le formulaire en mode édition
showRemarkForm();
// Pré-remplir le formulaire APRÈS l'avoir affiché
setTimeout(() => {
document.getElementById('id_description').value = remark.description || '';
document.getElementById('selectedStatusInput').value = remark.status;
// Mettre à jour les badges de statut
document.querySelectorAll('.status-badge').forEach(badge => {
if (badge.dataset.value === remark.status) {
badge.classList.remove('bg-secondary');
badge.classList.add('bg-primary');
} else {
badge.classList.remove('bg-primary');
badge.classList.add('bg-secondary');
}
});
// Changer le texte du bouton
document.getElementById('submit-remark-button-text').textContent = '{% translate "Mettre à jour" %}';
}, 100);
}
// Supprimer une remarque en attente
async function deletePendingRemark(remarkId) {
if (!offlineManager) return;
if (!confirm('{% translate "Voulez-vous vraiment supprimer cette remarque ?" %}')) {
return;
}
try {
await offlineManager.deleteRemarkOffline(remarkId);
await displayPendingRemarks();
await updatePendingBadge();
} catch (error) {
console.error('Erreur lors de la suppression:', error);
alert('{% translate "Erreur lors de la suppression de la remarque" %}');
}
}
// Supprimer une photo d'une remarque en attente
async function deletePendingPhoto(remarkId, photoId) {
if (!offlineManager) return;
if (!confirm('{% translate "Voulez-vous vraiment supprimer cette image ?" %}')) {
return;
}
try {
await offlineManager.deletePhotoOffline(remarkId, photoId);
await displayPendingRemarks();
} catch (error) {
console.error('Erreur lors de la suppression:', error);
alert("{% translate "Erreur lors de la suppression de l\'image" %}");
}
}
// Mettre à jour les boutons des remarques existantes selon le mode online/offline
function updateExistingRemarksButtons() {
const isOnline = (typeof offlineManager !== 'undefined' && offlineManager) ? offlineManager.isOnline : navigator.onLine;
// Sélectionner tous les boutons d'action des remarques existantes (pas les pending)
document.querySelectorAll('#remarks-list [id^="buttons-"]').forEach(buttonContainer => {
const buttons = buttonContainer.querySelectorAll('button, a');
buttons.forEach(button => {
if (!isOnline) {
button.disabled = true;
button.classList.add('opacity-50');
button.style.pointerEvents = 'none';
button.title = '{% translate "Action non disponible hors ligne" %}';
} else {
button.disabled = false;
button.classList.remove('opacity-50');
button.style.pointerEvents = '';
}
});
});
}
// Écouter les changements de connectivité pour mettre à jour les boutons
window.addEventListener('online', () => {
updateExistingRemarksButtons();
});
window.addEventListener('offline', () => {
updateExistingRemarksButtons();
});
// Bouton de synchronisation forcée
document.getElementById('force-sync-btn')?.addEventListener('click', async () => {
if (offlineManager) {
await offlineManager.syncPendingData();
await updatePendingBadge();
await displayPendingRemarks();
}
});
<!-- Modal Carrousel pour les photos d'une remarque -->
<div class="modal fade" id="remarkPhotoCarouselModal" tabindex="-1" aria-labelledby="remarkPhotoCarouselModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-dark text-white shadow-lg border-0">
<div class="modal-header border-bottom border-secondary py-2 px-3">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-images text-primary fs-5"></i>
<h5 class="modal-title fs-6 mb-0 text-white" id="remarkPhotoCarouselModalLabel">{% translate "Photos de la remarque" %}</h5>
<span class="badge bg-primary rounded-pill px-2 py-1 fs-7" id="remark-carousel-counter">1 / 1</span>
</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body p-0 position-relative bg-black d-flex align-items-center justify-content-center overflow-hidden" style="min-height: 300px; max-height: 80vh;">
<div id="remarkPhotoCarousel" class="carousel slide w-100 h-100 d-flex flex-column justify-content-center" data-bs-ride="false">
<div class="carousel-indicators mb-2" id="remark-carousel-indicators"></div>
<div class="carousel-inner h-100" id="remark-carousel-inner"></div>
<button class="carousel-control-prev" type="button" data-bs-target="#remarkPhotoCarousel" data-bs-slide="prev" style="z-index: 10;">
<span class="carousel-control-prev-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
<span class="visually-hidden">{% translate "Précédent" %}</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#remarkPhotoCarousel" data-bs-slide="next" style="z-index: 10;">
<span class="carousel-control-next-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
<span class="visually-hidden">{% translate "Suivant" %}</span>
</button>
</div>
</div>
<div class="modal-footer border-top border-secondary py-2 px-3 d-flex justify-content-between align-items-center bg-dark">
<div class="small text-muted text-truncate me-2" id="remark-carousel-caption"></div>
<div class="d-flex align-items-center gap-2">
<a id="remark-carousel-hd-btn" href="#" target="_blank" class="btn btn-sm btn-outline-light d-inline-flex align-items-center gap-1" title="{% translate 'Ouvrir en plein écran HD' %}">
<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">{% translate "Plein écran" %}</span>
</a>
{% if can_change_control %}
<button type="button" id="remark-carousel-delete-btn" class="btn btn-sm btn-outline-danger d-inline-flex align-items-center gap-1" title="{% translate 'Supprimer cette photo' %}">
<i class="bi bi-trash"></i> <span class="d-none d-sm-inline">{% translate "Supprimer" %}</span>
</button>
{% endif %}
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
</div>
</script>
{% endblock content %}
{% block extra_scripts %}
<!-- Convertir plans_data en JSON et le rendre accessible en JavaScript -->
{{ plans_data|json_script:"plans-data" }}
{{ remarks_data|json_script:"remarks-data" }}
<script>
const plansData = JSON.parse(document.getElementById('plans-data').textContent);
var remarksData = JSON.parse(document.getElementById('remarks-data').textContent);
const autocomplete_contract_posts_url = "{% url 'contracts:autocomplete-posts' %}";
</script>
<!-- jQuery 3.7.1 (local) -->
<script src="{% static 'common/jquery.min.js' %}"></script>
<script src="{% static 'common/maplibre-gl.js' %}"></script>
<script src="{% static 'controls/offline-manager.js' %}"></script>
<script src="{% static 'controls/controls_detail.js' %}"></script>
<script src="{% static 'common/clearable_inputs.js' %}"></script>
{% endblock extra_scripts %}