loko/loko/controls/static/controls/controls_detail.js

2270 lines
No EOL
83 KiB
JavaScript
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.

// Vérifier si offline-manager.js est chargé
let map, marker, geolocateControl;
var current_reference_plan = "map";
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
let deleteImageModalInstance = null;
let deleteImageModalElement = null;
let pendingDeleteImage = null;
let deleteImageConfirmButton = null;
// ✅ Définition des couleurs selon le statut
const statusColors = {
"information": "#0dcaf0 ", // Bleu (Information)
"to_be_processed": "#dc3545 ", // Jaune (À traiter)
"processed": "#0d6efd ", // Bleu (Traité)
"validated": "#198754 ", // Vert (Validé)
"canceled": "#6c757d" // Gris (Annulé)
};
map = new maplibregl.Map({
container: 'map', // ID de l'élément HTML
style: window.MAP_STYLE_LIGHT,
center: [4.3572, 50.8477],
zoom: 11,
minzoom: 4,
maxzoom: 24,
maxBounds: null
});
// Ajouter un marqueur au clic
map.on('click', function (e) {
let coords = e.lngLat;
if (marker) {
marker.setLngLat([coords.lng, coords.lat]);
} else {
marker = new maplibregl.Marker({ draggable: true })
.setLngLat([coords.lng, coords.lat])
.addTo(map);
}
// Mettre à jour les champs cachés avec la localisation
document.getElementById("reference_plan").value = current_reference_plan;
document.getElementById("coord_x").value = coords.lng;
document.getElementById("coord_y").value = coords.lat;
if (current_reference_plan === "map") {
document.getElementById("longitude").value = coords.lng;
document.getElementById("latitude").value = coords.lat;
}
});
// Rendre le marqueur draggable et mettre à jour les coordonnées
marker?.on('dragend', function () {
let newCoords = marker.getLngLat();
document.getElementById("reference_plan").value = current_reference_plan;
document.getElementById("coord_x").value = newCoords.lng;
document.getElementById("coord_y").value = newCoords.lat;
if (current_reference_plan === "map") {
document.getElementById("longitude").value = newCoords.lng;
document.getElementById("latitude").value = newCoords.lat;
}
});
map.on("load", function () {
map.addSource("white-image", {
type: "image",
url: '/static/common/solid_white.png',
coordinates: [
[-2, 2],
[2, 2],
[2, -2],
[-2, -2]
]
});
map.addLayer({
id: 'white-layer',
'type': 'raster',
'source': 'white-image',
'paint': {
'raster-fade-duration': 0
}
});
plansData.forEach((plan, index) => {
const sourceId = `plan-${plan.id}`; // ID unique pour la source
const layerId = `plan-${plan.id}-layer`; // ID unique pour la couche
// Ajouter la source de l'image
map.addSource(sourceId, {
type: 'image',
url: plan.url, // URL de l'image du plan
coordinates: [
[plan.min_x, plan.max_y], // Coin supérieur gauche
[plan.max_x, plan.max_y], // Coin supérieur droit
[plan.max_x, plan.min_y], // Coin inférieur droit
[plan.min_x, plan.min_y], // Coin inférieur gauche
],
});
// Ajouter la couche raster pour afficher l'image
map.addLayer({
id: layerId,
type: 'raster',
source: sourceId,
paint: {
'raster-fade-duration': 0,
},
layout: {
visibility: 'none', // Masquer la couche par défaut
}
});
});
// Ajouter une source GeoJSON pour les remarques
map.addSource('remarks', {
type: 'geojson',
data: remarksData // Utiliser les données GeoJSON des remarques
});
// Ajouter une couche pour afficher les remarques
map.addLayer({
id: 'remarks-layer',
type: 'circle',
source: 'remarks',
paint: {
'circle-radius': 10,
'circle-color': [
'match', ['get', 'status'],
'information', statusColors["information"],
'to_be_processed', statusColors["to_be_processed"],
'processed', statusColors["processed"],
'validated', statusColors["validated"],
'canceled', statusColors["canceled"],
"#000000" // Couleur par défaut (Bleu)
],
'circle-stroke-width': 2, // Bordure pour mieux distinguer les couleurs
'circle-stroke-color': '#ffffff' // Bordure blanche
},
layout: {
'circle-sort-key': ['get', 'remark_number'] // ✅ Trie les cercles du plus ancien au plus récent
}
});
// ✅ Ajout d'une couche `symbol` pour afficher le `remark_number`
map.addLayer({
id: 'remarks-labels',
type: 'symbol',
source: 'remarks',
layout: {
'text-field': ['get', 'remark_number'], // Texte affiché = numéro de la remarque
'text-size': 10, // Taille du texte
'text-font': ['Noto Sans Bold'], // Police du texte
//'text-allow-overlap': true, // Empêche le masquage du texte
//'text-ignore-placement': true, // Ignore le placement automatique pour forcer l'affichage
'symbol-sort-key': ['-', ['get', 'remark_number']] // ✅ Trie les numéros du plus ancien au plus récent
},
paint: {
'text-color': '#ffffff', // Texte blanc
'text-halo-color': '#000000', // Contour noir pour lisibilité
'text-halo-width': 1 // Épaisseur du contour
}
});
// Initialiser le contrôle Geolocate
geolocateControl = new maplibregl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true
},
trackUserLocation: true
});
// Ajouter le contrôle Geolocate si current_reference_plan est 'map'
if (current_reference_plan === 'map') {
map.addControl(geolocateControl, 'bottom-right');
}
// Si des plans existent, afficher le premier plan par défaut
if (plansData.length > 0) {
showPlan(plansData[0].id);
}
});
// Fonction pour afficher un plan et zoomer dessus
function showPlan(planId) {
const sourceId = `plan-${planId}`;
const layerId = `plan-${planId}-layer`;
// Masquer toutes les couches de plans
plansData.forEach((_, index) => {
const currentLayerId = `plan-${plansData[index].id}-layer`;
map.setLayoutProperty(currentLayerId, 'visibility', 'none');
});
// Afficher la couche sélectionnée
map.setLayoutProperty(layerId, 'visibility', 'visible');
// Zoomer sur les coordonnées du plan sélectionné
const source = map.getSource(sourceId);
if (source && source.coordinates) {
const bounds = new maplibregl.LngLatBounds();
source.coordinates.forEach(coord => bounds.extend(coord));
map.fitBounds(bounds, { padding: 0, duration: 1 });
}
// Filtrer les remarques en fonction du plan sélectionné
const filteredRemarks = {
type: 'FeatureCollection',
features: remarksData.features.filter(feature => feature.properties.reference_plan === planId)
};
map.getSource('remarks').setData(filteredRemarks);
current_reference_plan = planId;
// Désactiver le contrôle Geolocate si current_reference_plan n'est pas 'map'
if (current_reference_plan !== 'map' && map.hasControl(geolocateControl)) {
map.removeControl(geolocateControl);
}
}
function resetView() {
// Masquer toutes les couches
plansData.forEach((_, index) => {
const currentLayerId = `plan-${plansData[index].id}-layer`;
map.setLayoutProperty(currentLayerId, 'visibility', 'none');
});
// remove filters based on previous selected plan
map.getSource('remarks').setData(remarksData);
// Réinitialiser la vue de la carte
map.setCenter([4.3572, 50.8477]);
map.setZoom(11);
current_reference_plan = "map";
// Réactiver le contrôle Geolocate si current_reference_plan est 'map'
if (current_reference_plan === 'map' && !map.hasControl(geolocateControl)) {
map.addControl(geolocateControl, 'bottom-right');
}
}
function showRemarkForm() {
// Réinitialiser le formulaire si on n'est pas en mode édition
const form = document.getElementById("remark-form");
if (!form.dataset.editingRemarkId) {
form.reset();
// Réinitialiser les badges de statut
document.querySelectorAll(".status-badge").forEach(badge => {
badge.classList.remove("bg-primary", "bg-info", "bg-success", "bg-danger");
badge.classList.add("bg-secondary");
});
// Réinitialiser le texte du bouton
const buttonText = document.getElementById('submit-remark-button-text');
if (buttonText && buttonText.textContent.includes('Mettre à jour')) {
buttonText.textContent = buttonText.textContent.replace('Mettre à jour', 'Ajouter');
}
// Réinitialiser la prévisualisation des photos
const previewContainer = document.getElementById('preview');
if (previewContainer) {
previewContainer.innerHTML = '';
}
const photoInputsContainer = document.getElementById('photo-inputs');
if (photoInputsContainer) {
photoInputsContainer.innerHTML = '';
}
}
document.getElementById("remark-form-container").style.display = "flex";
map.resize();
}
function hideRemarkForm() {
const form = document.getElementById("remark-form");
// Supprimer l'attribut d'édition
if (form.dataset.editingRemarkId) {
delete form.dataset.editingRemarkId;
}
// Réinitialiser le texte du bouton
const buttonText = document.getElementById('submit-remark-button-text');
if (buttonText && buttonText.textContent.includes('Mettre à jour')) {
buttonText.textContent = buttonText.textContent.replace('Mettre à jour', 'Ajouter');
}
document.getElementById("remark-form-container").style.display = "none";
}
function showQuantityForm() {
document.getElementById("quantity-form-container").style.display = "flex";
map.resize();
}
function hideQuantityForm() {
document.getElementById("quantity-form-container").style.display = "none";
}
function showUploadPlanForm() {
document.getElementById("upload-plan-form-container").style.display = "flex";
}
function hideUploadPlanForm() {
document.getElementById("upload-plan-form-container").style.display = "none";
}
document.querySelector("#remark-form").addEventListener("submit", async (e) => {
const form = e.currentTarget;
const submitButton = e.submitter;
const maxWidth = 4096;
const maxHeight = 4096;
const fileInput = form.querySelector('input[type="file"][multiple]');
const files = fileInput.files;
e.preventDefault(); // on stoppe la soumission classique
const newFiles = await Promise.all(
Array.from(files).map(file => resizeImage(file, maxWidth, maxHeight))
);
// Remplacer les fichiers dans un nouvel input
const dataTransfer = new DataTransfer();
newFiles.forEach(file => dataTransfer.items.add(file));
// Créer un clone pour remplacer l’input original
const newInput = fileInput.cloneNode();
newInput.files = dataTransfer.files;
fileInput.replaceWith(newInput);
let valid = true; // Variable pour savoir si le formulaire est valide
let description = document.querySelector("#id_description");
let descriptionError = document.querySelector("#descriptionError");
if (!description.value.trim()) {
description.classList.add("is-invalid");
descriptionError.style.display = "block";
valid = false;
} else {
description.classList.remove("is-invalid");
descriptionError.style.display = "none";
}
if (valid) {
const button = document.getElementById("submit-remark-button");
const spinner = document.getElementById("spinner-remark");
const buttonText = document.getElementById("submit-remark-button-text");
spinner.classList.remove("d-none");
buttonText.style.display = "none";
// Vérifier si on est hors ligne (ou connexion trop faible) et si l'offline manager est disponible
const hasOfflineManager = typeof offlineManager !== 'undefined' &&
offlineManager !== null &&
offlineManager.isReady === true;
const isOffline = hasOfflineManager ? !offlineManager.isOnline : !navigator.onLine;
if (isOffline && hasOfflineManager) {
try {
// Vérifier si on est en mode édition d'une remarque offline
const editingRemarkId = form.dataset.editingRemarkId;
// Récupérer l'ID du contrôle depuis le champ caché ou l'URL
const hiddenControlId = document.getElementById('control_id')?.value;
const pathParts = window.location.pathname.split('/');
const controlsIndex = pathParts.indexOf('controls');
const urlControlId = controlsIndex !== -1 ? pathParts[controlsIndex + 1] : null;
const controlId = hiddenControlId || urlControlId;
// Collecter les données du formulaire
const formData = {
controlId: controlId,
description: document.querySelector("#id_description").value,
status: document.querySelector("#selectedStatusInput").value,
latitude: document.querySelector("#latitude")?.value || '',
longitude: document.querySelector("#longitude")?.value || '',
coord_x: document.querySelector("#coord_x")?.value || '',
coord_y: document.querySelector("#coord_y")?.value || '',
reference_plan: document.querySelector("#reference_plan")?.value || 'map'
};
// Collecter les photos depuis le nouvel input
const photos = [];
if (newInput.files && newInput.files.length > 0) {
photos.push(...Array.from(newInput.files));
}
// Collecter aussi les photos depuis les inputs cachés créés par la caméra
const photoInputs = document.querySelectorAll('#photo-inputs input[type="file"]');
photoInputs.forEach(input => {
if (input.files && input.files.length > 0) {
photos.push(...Array.from(input.files));
}
});
if (editingRemarkId) {
// Mode édition : mettre à jour la remarque existante
await offlineManager.updateRemarkOffline(parseInt(editingRemarkId), formData, photos);
delete form.dataset.editingRemarkId;
} else {
// Mode création : sauvegarder une nouvelle remarque
await offlineManager.saveRemarkOffline(formData, photos);
}
// Cacher l'overlay de chargement global (version mobile)
const loadingOverlay = document.getElementById('loading-overlay');
if (loadingOverlay) {
loadingOverlay.classList.remove('active');
loadingOverlay.setAttribute('aria-hidden', 'true');
}
// Réinitialiser le formulaire
form.reset();
// Réinitialiser le badge de statut
document.querySelectorAll(".status-badge").forEach(badge => {
badge.classList.remove("bg-info", "bg-success", "bg-danger", "bg-primary");
badge.classList.add("bg-secondary");
});
// Réinitialiser la prévisualisation des photos
const previewContainer = document.getElementById('preview');
if (previewContainer) {
previewContainer.innerHTML = '';
}
const photoInputsContainer = document.getElementById('photo-inputs');
if (photoInputsContainer) {
photoInputsContainer.innerHTML = '';
}
// Cacher le formulaire
if (typeof hideRemarkForm === 'function') {
hideRemarkForm();
}
// Réinitialiser le bouton
spinner.classList.add("d-none");
buttonText.style.display = "";
buttonText.textContent = buttonText.textContent.includes('Mettre à jour') ?
buttonText.textContent.replace('Mettre à jour', 'Ajouter') :
buttonText.textContent;
button.disabled = false;
// Mettre à jour le badge de synchronisation et l'affichage des remarques
if (typeof updatePendingBadge === 'function') {
await updatePendingBadge();
}
if (typeof displayPendingRemarks === 'function') {
await displayPendingRemarks();
}
} catch (error) {
// Message d'erreur plus détaillé
let errorMsg = "Erreur lors de la sauvegarde hors ligne.";
if (error.message) {
errorMsg += " Détails: " + error.message;
}
if (typeof gettext === 'function') {
alert(gettext(errorMsg));
} else {
alert(errorMsg);
}
// Réinitialiser le bouton en cas d'erreur
spinner.classList.add("d-none");
buttonText.style.display = "";
button.disabled = false;
}
} else if (isOffline && !hasOfflineManager) {
// Mode offline mais offline manager pas prêt
alert("Vous êtes hors ligne et le système de sauvegarde locale n'est pas encore prêt. Veuillez attendre quelques secondes et réessayer.");
// Réinitialiser le bouton
spinner.classList.add("d-none");
buttonText.style.display = "";
button.disabled = false;
} else {
// Ajoute un léger délai avant de désactiver le bouton pour permettre la soumission
setTimeout(() => {
button.disabled = true;
}, 50);
// ✅ Ajoute un input hidden pour simuler le bouton submit_remark
if (submitButton && submitButton.name) {
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = submitButton.name;
hidden.value = submitButton.value || '';
form.appendChild(hidden);
}
// Soumettre le formulaire normalement
form.submit();
}
}
});
function resizeImage(file, maxWidth, maxHeight) {
return new Promise((resolve) => {
const img = new Image();
const reader = new FileReader();
reader.onload = (e) => {
img.src = e.target.result;
};
img.onload = () => {
let { width, height } = img;
const scale = Math.min(maxWidth / width, maxHeight / height, 1);
const canvas = document.createElement('canvas');
canvas.width = width * scale;
canvas.height = height * scale;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
const newFile = new File([blob], file.name, { type: file.type });
resolve(newFile);
}, file.type, 0.8); // qualité compression
};
reader.readAsDataURL(file);
});
}
document.addEventListener("DOMContentLoaded", function() {
let selectedCategories = new Set();
let inputField = document.getElementById("selectedCategoriesInput");
document.querySelectorAll(".category-badge").forEach(badge => {
badge.addEventListener("click", function() {
let categoryId = this.getAttribute("data-id");
if (selectedCategories.has(categoryId)) {
selectedCategories.delete(categoryId);
this.classList.remove("bg-primary");
this.classList.add("bg-secondary");
} else {
selectedCategories.add(categoryId);
this.classList.remove("bg-secondary");
this.classList.add("bg-primary");
}
inputField.value = Array.from(selectedCategories).join(",");
});
});
let selectedStatusInput = document.getElementById("selectedStatusInput");
document.querySelectorAll(".status-badge").forEach(badge => {
badge.addEventListener("click", function() {
// Réinitialiser tous les badges en gris
document.querySelectorAll(".status-badge").forEach(b => {
b.classList.remove("bg-info", "bg-success", "bg-danger");
b.classList.add("bg-secondary");
});
// Appliquer la bonne couleur au badge sélectionné
let status = this.getAttribute("data-value");
if (status === "information") {
this.classList.add("bg-info");
} else if (status === "validated") {
this.classList.add("bg-success");
} else if (status === "to_be_processed") {
this.classList.add("bg-danger");
}
// Mettre à jour la valeur du champ caché
selectedStatusInput.value = status;
});
});
attachShareHandlers();
deleteImageModalElement = document.getElementById('deleteImageModal');
if (deleteImageModalElement) {
deleteImageConfirmButton = document.getElementById('confirmDeleteImage');
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
deleteImageModalInstance = bootstrap.Modal.getOrCreateInstance(deleteImageModalElement);
}
if (deleteImageConfirmButton) {
deleteImageConfirmButton.addEventListener('click', function() {
if (!pendingDeleteImage) {
return;
}
deleteImageConfirmButton.disabled = true;
const deletionPromise = deleteRemarkImage(pendingDeleteImage.imageId, pendingDeleteImage.triggerButton);
if (deletionPromise && typeof deletionPromise.then === 'function') {
deletionPromise
.then(success => {
if (success && deleteImageModalInstance) {
deleteImageModalInstance.hide();
}
})
.finally(() => {
deleteImageConfirmButton.disabled = false;
});
} else {
deleteImageConfirmButton.disabled = false;
}
});
}
deleteImageModalElement.addEventListener('hidden.bs.modal', function() {
pendingDeleteImage = null;
if (deleteImageConfirmButton) {
deleteImageConfirmButton.disabled = false;
}
});
}
});
document.addEventListener('click', function(event) {
const deleteButton = event.target.closest('.remark-image-delete');
if (!deleteButton) {
return;
}
event.preventDefault();
const imageId = deleteButton.dataset.imageId;
if (!imageId) {
return;
}
if (!deleteImageModalInstance && deleteImageConfirmButton && deleteImageModalElement && typeof bootstrap !== 'undefined' && bootstrap.Modal) {
deleteImageModalInstance = bootstrap.Modal.getOrCreateInstance(deleteImageModalElement);
}
if (deleteImageModalInstance && deleteImageConfirmButton) {
pendingDeleteImage = {
imageId: imageId,
triggerButton: deleteButton
};
deleteImageModalInstance.show();
} else {
let confirmationMessage = 'Voulez-vous vraiment supprimer cette image ?';
if (deleteImageModalElement) {
const modalBody = deleteImageModalElement.querySelector('.modal-body');
if (modalBody && modalBody.textContent.trim()) {
confirmationMessage = modalBody.textContent.trim();
}
}
if (window.confirm(confirmationMessage)) {
deleteRemarkImage(imageId, deleteButton);
}
}
});
function updateRemarkStatus(remarkId, newStatus) {
fetch(`/controls/update_remark_status/${remarkId}/${newStatus}/`, {
method: "POST",
headers: { "X-CSRFToken": csrftoken },
mode: 'same-origin'
})
.then(response => response.json())
.then(data => {
if (data.success) {
// ✅ Mise à jour du badge de statut
let statusBadge = document.getElementById(`status-${remarkId}`);
statusBadge.innerHTML = `<i class="bi ${data.new_status_icon}"></i> ${data.new_status_label}`;
statusBadge.className = `badge ${data.new_status_class}`;
// ✅ Mise à jour des boutons (Supprimer les anciens et ajouter les bons)
let buttonsContainer = document.getElementById(`buttons-${remarkId}`);
buttonsContainer.innerHTML = "";
if (data.new_status === "information") {
buttonsContainer.innerHTML = `
<button class="btn btn-sm btn-primary" onclick="markOk(${remarkId})">
<i class="bi bi-hand-thumbs-up"></i>
</button>
<button class="btn btn-sm btn-primary" onclick="markNok(${remarkId})">
<i class="bi bi-hand-thumbs-down"></i>
</button>`;
} else if (data.new_status === "to_be_processed" || data.new_status === "processed") {
buttonsContainer.innerHTML = `
<button class="btn btn-sm btn-primary" onclick="markOk(${remarkId})">
<i class="bi bi-hand-thumbs-up"></i>
</button>`;
} else if (data.new_status === "validated") {
buttonsContainer.innerHTML = `
<button class="btn btn-sm btn-primary" onclick="markNok(${remarkId})">
<i class="bi bi-hand-thumbs-down"></i>
</button>`;
}
// ✅ Mise à jour de la couleur sur la carte
updateRemarkColorOnMap(remarkId, data.new_status);
// ✅ Mise à jour de la date et de l’utilisateur
document.getElementById(`updated-by-${remarkId}`).innerText = data.updated_by;
document.getElementById(`updated-at-${remarkId}`).innerText = data.updated_at;
} else {
alert("Erreur: " + data.error);
}
})
.catch(error => console.error("Erreur AJAX:", error));
}
function markOk(remarkId) {
updateRemarkStatus(remarkId, "validated");
}
function markNok(remarkId) {
updateRemarkStatus(remarkId, "to_be_processed");
}
function updateRemarkColorOnMap(remarkId, newStatus) {
if (!map.getSource('remarks')) return;
remarksData.features.forEach(feature => {
if (feature.properties.id === remarkId) {
feature.properties.status = newStatus;
}
});
map.getSource('remarks').setData(remarksData);
}
if (document.getElementById("generate-report-btn")) {
document.getElementById("generate-report-btn").addEventListener("click", function () {
let button = this;
let url = button.getAttribute("data-url");
let spinner = document.getElementById("spinner-pdf");
let buttonText = document.getElementById("button-text-pdf");
let statusMessage = document.getElementById("status-message-pdf");
// Afficher le spinner et désactiver le bouton
spinner.classList.remove("d-none");
buttonText.style.display = "none";
button.disabled = true;
// Envoyer la requête AJAX
fetch(url, {
method: "GET",
headers: {
"X-Requested-With": "XMLHttpRequest"
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusMessage.innerHTML = `
<div class="alert alert-success">
📄 ${gettext('Rapport généré avec succès !')}
</div>`;
// Formate la date (si elle est dans data.created_at sous forme ISO)
const date = new Date(data.created_at);
const formattedDate = date.toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
// Crée le nouvel élément de rapport
const li = document.createElement('li');
li.className = "list-group-item d-flex justify-content-between align-items-start";
li.innerHTML = `
<div>
<small class="text-muted">
📅 ${gettext('Généré le')} : ${formattedDate} <br>
🖊 ${gettext('Auteur')} : ${data.created_by}
</small>
</div>
<div>
<a href="${data.pdf_url}" class="btn btn-sm btn-light mb-1" download>
📥 ${gettext('Télécharger')}
</a>
<br>
<button id="share-button-${data.id}" class="btn btn-sm btn-light share-button">
<i class="bi bi-share-fill text-primary me-1"></i>${gettext('Partager')}
</button>
</div>
`;
// Insère l'élément au début de la liste
const list = document.getElementById('card-reports');
const line = document.createElement('div');
line.innerHTML = '<div class="shadow border-3 border-bottom mt-3 mb-4"></div>';
list.prepend(line);
list.prepend(li);
// Réattache le listener de partage
attachShareHandlers();
}
else {
statusMessage.innerHTML = `<div class="alert alert-danger">❌ ${gettext('Erreur lors de la génération du rapport.')}</div>`;
}
})
.catch(error => {
console.error("Erreur:", error);
statusMessage.innerHTML = `<div class="alert alert-danger">⚠ ${gettext('Une erreur est survenue.')}</div>`;
})
.finally(() => {
// Cacher le spinner et réactiver le bouton
spinner.classList.add("d-none");
buttonText.style.display = "inline";
button.disabled = false;
});
});
}
function archive_remark(remarkId) {
// 1. Appel AJAX
fetch(`/controls/archive_remark/${remarkId}/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json'
},
body: JSON.stringify({ archived: true })
})
.then(response => {
if (!response.ok) {
throw new Error(gettext("Erreur lors de l'archivage."));
}
return response.json();
})
.then(data => {
// 2. Déplacer la remarque dans le DOM
const remarkElement = document.getElementById(`remark-${remarkId}`);
const archiveContainer = document.getElementById("archives-list");
if (remarkElement && archiveContainer) {
const separator = document.createElement('div');
separator.className = "shadow border-3 border-bottom mt-3 mb-4";
archiveContainer.appendChild(separator);
archiveContainer.appendChild(remarkElement);
const buttonsContainer = document.getElementById(`buttons-${remarkId}`);
if (buttonsContainer) {
buttonsContainer.className = "d-flex flex-column gap-2 position-absolute top-0 end-0 p-2 mb-1";
buttonsContainer.innerHTML = `
<button class="btn btn-sm btn-primary" style="background-color:rgb(120, 163, 215)" onclick="restore_remark(${remarkId})">
<i class="bi bi-arrow-90deg-up"></i>
</button>
`;
}
}
// (Optionnel) Modifier son apparence pour montrer qu'elle est archivée
remarkElement.classList.add("bg-light", "text-muted");
})
.catch(error => {
console.error(gettext("Erreur :"), error);
alert(gettext("Une erreur est survenue lors de l'archivage."));
});
}
function restore_remark(remarkId) {
// 1. Appel AJAX
fetch(`/controls/restore_remark/${remarkId}/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json'
},
body: JSON.stringify({ archived: true })
})
.then(response => {
if (!response.ok) {
throw new Error(gettext("Erreur lors de la restauration de la remarque."));
}
return response.json();
})
.then(data => {
// 2. Déplacer la remarque dans le DOM
const remarkElement = document.getElementById(`remark-${remarkId}`);
const archiveContainer = document.getElementById("remarks-list");
if (remarkElement && archiveContainer) {
const separator = document.createElement('div');
separator.className = "shadow border-3 border-bottom mt-3 mb-4";
archiveContainer.appendChild(separator);
archiveContainer.appendChild(remarkElement);
const buttonsContainer = document.getElementById(`buttons-${remarkId}`);
if (buttonsContainer) {
buttonsContainer.className = "d-flex flex-column gap-2 position-absolute top-0 end-0 p-2 mb-1";
let buttonsHTML = "";
if (data.status === "information") {
buttonsHTML = `
<button class="btn btn-sm btn-primary" onclick="markOk(${remarkId})">
<i class="bi bi-hand-thumbs-up"></i>
</button>
<button class="btn btn-sm btn-primary" onclick="markNok(${remarkId})">
<i class="bi bi-hand-thumbs-down"></i>
</button>
`;
} else if (["to_be_processed", "processed"].includes(data.status)) {
buttonsHTML = `
<button class="btn btn-sm btn-primary" onclick="markOk(${remarkId})">
<i class="bi bi-hand-thumbs-up"></i>
</button>
`;
} else if (data.status === "validated") {
buttonsHTML = `
<button class="btn btn-sm btn-primary" onclick="markNok(${remarkId})">
<i class="bi bi-hand-thumbs-down"></i>
</button>
`;
}
// Bouton archive (toujours présent)
buttonsHTML += `
<button class="btn btn-sm btn-primary" style="background-color:rgb(45, 111, 191)" onclick="archive_remark(${remarkId})">
<i class="bi bi-archive"></i>
</button>
`;
buttonsContainer.innerHTML = buttonsHTML;
}
}
remarkElement.classList.remove("bg-light", "text-muted");
})
.catch(error => {
console.error(gettext("Erreur :"), error);
alert(gettext("Une erreur est survenue lors de l'archivage."));
});
}
// Partage du rapport PDF
async function sharePDF(pdfUrl) {
try {
// Téléchargez le PDF en tant que fichier Blob
const response = await fetch(pdfUrl);
const blob = await response.blob();
// Créez un fichier à partir du Blob
const file = new File([blob], 'Rapport.pdf', { type: 'application/pdf' });
// Utilisez l'API Web Share pour partager le fichier
if (navigator.share) {
await navigator.share({
files: [file],
title: gettext('Partager ce PDF'),
text: gettext('Voici un rapport PDF que je souhaite partager avec vous.'),
});
} else {
alert(gettext("Le partage de fichiers n'est pas supporté sur ce navigateur."));
}
} catch (error) {
console.error(gettext('Erreur lors du partage du PDF:'), error);
}
}
function attachShareHandlers() {
document.querySelectorAll('.share-button').forEach(button => {
button.addEventListener('click', async () => {
const container = button.closest('div');
const downloadLink = container.querySelector('a[download]');
const pdfUrl = downloadLink ? downloadLink.href : null;
if (!pdfUrl) return;
sharePDF(pdfUrl);
});
});
}
function deleteRemarkImage(imageId, triggerButton) {
return fetch(`/controls/remark_images/${imageId}/delete/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken
}
})
.then(async response => {
const data = await response.json().catch(() => ({}));
return { ok: response.ok, data };
})
.then(({ ok, data }) => {
if (!ok || !data.success) {
if (data && data.error) {
alert(data.error);
} else {
alert(gettext('Une erreur est survenue.'));
}
return false;
}
if (triggerButton) {
const wrapper = triggerButton.closest('.remark-image-wrapper');
if (wrapper) {
wrapper.remove();
}
}
return true;
})
.catch(error => {
console.error(gettext('Erreur :'), error);
alert(gettext('Une erreur est survenue.'));
return false;
});
}
function showControlEditSpinner(event) {
document.getElementById("control-edit-icon").classList.add("d-none");
document.getElementById("control-edit-spinner").classList.remove("d-none");
// Optionnel : désactiver le lien temporairement pour éviter double clic
document.getElementById("control-edit-btn").classList.add("disabled");
// Laisser le lien faire sa redirection (ne pas faire event.preventDefault())
}
// Reset l’état du bouton au chargement de la page
window.addEventListener("pageshow", function(event) {
// Ce test est utile surtout pour les back/forward navigation
if (event.persisted) {
document.getElementById("control-edit-icon").classList.remove("d-none");
document.getElementById("control-edit-spinner").classList.add("d-none");
document.getElementById("control-edit-btn").classList.remove("disabled");
}
});
// (function() {
// const $contract = document.getElementById("quantity-contract-select");
// const $label = document.getElementById("id_contract_post_label");
// const $hiddenId = document.getElementById("id_contract_post_id");
// const $sugg = document.getElementById("qty-post-suggestions");
// // Live preview quantité
// function dec(v){ return v ? parseFloat(v.replace(",", ".")) : null; }
// function updatePreview(){
// const q1 = dec(document.getElementById("id_q1").value);
// const q2 = dec(document.getElementById("id_q2").value);
// const q3 = dec(document.getElementById("id_q3").value);
// let qty = 0;
// if (!isNaN(q1) && q1 !== null && !isNaN(q2) && q2 !== null && !isNaN(q3) && q3 !== null) {
// qty = q1 * q2 * q3;
// } else if (!isNaN(q1) && q1 !== null && !isNaN(q2) && q2 !== null) {
// qty = q1 * q2;
// } else if (!isNaN(q1) && q1 !== null) {
// qty = q1;
// }
// const shown = isFinite(qty) ? qty.toFixed(2) : "";
// document.getElementById("qty-preview").value = shown;
// }
// ["id_q1","id_q2","id_q3"].forEach(id=>{
// const el = document.getElementById(id);
// if (el) el.addEventListener("input", updatePreview);
// });
// updatePreview();
// // Autocomplete poste
// function clearSuggestions(){ $sugg.innerHTML = ""; $sugg.style.display = "none"; }
// function showSuggestions(items){
// $sugg.innerHTML = "";
// items.forEach(it=>{
// const a = document.createElement("a");
// const label = `${it.order_number} - ${it.code} - ${it.description}`;
// a.href = "#";
// a.className = "list-group-item list-group-item-action";
// a.textContent = label;
// a.addEventListener("click", (e)=>{
// e.preventDefault();
// $label.value = `${it.code} - ${it.description}`;
// $hiddenId.value = it.id;
// clearSuggestions();
// });
// $sugg.appendChild(a);
// });
// $sugg.style.display = items.length ? "block" : "none";
// }
// let debounceTimer=null;
// $label && $label.addEventListener("input", function(){
// $hiddenId.value = ""; // on reset l’ID tant que l’utilisateur modifie le libellé
// clearTimeout(debounceTimer);
// debounceTimer = setTimeout(()=>{
// const q = ($label.value || "").trim();
// const contractId = $contract ? $contract.value : "";
// if (!contractId || q.length < 1){ clearSuggestions(); return; }
// const url = autocomplete_contract_posts_url + "?contract_id=" + encodeURIComponent(contractId) + "&q=" + encodeURIComponent(q);
// fetch(url, {credentials: "same-origin"})
// .then(r=>r.json())
// .then(data=> showSuggestions(data || []))
// .catch(()=> clearSuggestions());
// }, 200);
// });
// // Si on change de marché, on vide le poste choisi
// $contract && $contract.addEventListener("change", function(){
// $label.value = "";
// $hiddenId.value = "";
// clearSuggestions();
// });
// // Fermer le menu si on clique dehors
// document.addEventListener("click", function(e){
// if (!$sugg.contains(e.target) && e.target !== $label) clearSuggestions();
// });
// })();
(function() {
const remarkForm = document.getElementById("remark-form");
function getTotalFormsInput() {
// on le (re)cherche à la demande pour éviter null
const el = remarkForm?.querySelector('input[name="qty-TOTAL_FORMS"]')
|| document.querySelector('input[name="qty-TOTAL_FORMS"]');
return el || null;
}
function ensureMgmtFormOrFail() {
const total = getTotalFormsInput();
if (!total) {
console.error('[qty] Management form manquant: input[name="qty-TOTAL_FORMS"] introuvable.');
alert("Erreur: management form du formset absent. Vérifie le prefix='qty' et l’inclusion de {{ qty_formset.management_form }} dans #remark-form.");
return false;
}
return true;
}
// --- Bottom sheet
const editor = document.getElementById("qty-editor");
const openBtn = document.getElementById("open-qty-editor");
const closeBtn = document.getElementById("close-qty-editor");
const applyBtn = document.getElementById("apply-qty-and-close");
// --- Hidden real formset
const hiddenWrap = document.getElementById("qty-hidden-forms");
const totalFormsInput = remarkForm.querySelector('input[name="qty-TOTAL_FORMS"]');
const emptyHiddenTpl = document.getElementById("qty-empty-form-template").innerHTML;
// --- Editor rows
const editorRows = document.getElementById("qty-editor-rows");
const editorRowTpl = document.getElementById("qty-editor-row-template").innerHTML;
// --- Summary
const summaryList = document.getElementById("qty-summary-list");
const summaryEmpty = document.getElementById("qty-summary-empty");
function showEditor() { editor.style.display = "block"; buildEditorFromHidden(); }
function hideEditor() { editor.style.display = "none"; }
openBtn.addEventListener("click", showEditor);
closeBtn.addEventListener("click", hideEditor);
applyBtn.addEventListener("click", () => {
// rien à sauver ici : les inputs sont déjà “les vrais”.
rebuildSummary();
hideEditor();
});
function buildEditorFromHidden() {
editorRows.innerHTML = "";
const hiddenForms = hiddenWrap.querySelectorAll(".qty-form");
hiddenForms.forEach((hf, idx) => {
const html = editorRowTpl.replaceAll("__idx__", idx).replaceAll("__num__", (idx + 1));
const temp = document.createElement("div");
temp.innerHTML = html.trim();
const card = temp.firstElementChild;
card.dataset.formIndex = idx;
mountMirrorField(hf, card, "contract");
mountMirrorField(hf, card, "contract_post_label");
mountMirrorField(hf, card, "contract_post_id");
mountMirrorField(hf, card, "description");
mountMirrorField(hf, card, "q1");
mountMirrorField(hf, card, "q2");
mountMirrorField(hf, card, "q3");
mountMirrorField(hf, card, "DELETE");
bindRow(card);
bindAutocompleteForRow(card);
editorRows.appendChild(card);
});
}
function mountMirrorField(hiddenFormEl, card, fieldName) {
const original = hiddenFormEl.querySelector(`[name$="-${fieldName}"]`);
const slot = card.querySelector(`.field-${fieldName}`);
if (!original || !slot) return;
// Cloner pour l'UI, sans "name" pour éviter d'être soumis 2x
const mirror = original.cloneNode(true);
mirror.removeAttribute("name");
mirror.id = (original.id || `${original.name}-mirror`) + "-mirror";
// Style mobile
if (/^(INPUT|TEXTAREA|SELECT)$/.test(mirror.tagName)) {
mirror.classList.add("form-control");
if (mirror.type === "number") mirror.setAttribute("inputmode","decimal");
}
if (/^(INPUT|TEXTAREA)$/.test(mirror.tagName)) {
mirror.setAttribute("autocomplete", "off");
mirror.setAttribute("autocorrect", "off"); // iOS
mirror.setAttribute("autocapitalize", "none");
mirror.setAttribute("spellcheck", "false");
}
slot.appendChild(mirror);
// Copier la valeur initiale
if (mirror.tagName === "SELECT") {
mirror.value = original.value;
} else if (mirror.type === "checkbox" || mirror.type === "radio") {
mirror.checked = original.checked;
} else {
mirror.value = original.value;
}
// Sync mirror -> original
const syncToOriginal = () => {
if (mirror.tagName === "SELECT") {
original.value = mirror.value;
} else if (mirror.type === "checkbox" || mirror.type === "radio") {
original.checked = mirror.checked;
} else {
original.value = mirror.value;
}
// Déclenche un event "change" au cas où
original.dispatchEvent(new Event("change", { bubbles: true }));
};
// Sync original -> mirror (utile si tu modifies côté serveur/re-rendu, ou autre JS)
const syncToMirror = () => {
if (mirror.tagName === "SELECT") {
mirror.value = original.value;
} else if (mirror.type === "checkbox" || mirror.type === "radio") {
mirror.checked = original.checked;
} else {
mirror.value = original.value;
}
};
mirror.addEventListener("input", syncToOriginal);
mirror.addEventListener("change", syncToOriginal);
original.addEventListener("change", syncToMirror);
// Pour l’autocomplete “poste”, branche tes handlers sur "mirror" (et dans syncToOriginal, la valeur est copié vers l’original)
}
function bindRow(card) {
const q1 = card.querySelector('[id$="-q1-mirror"]');
const q2 = card.querySelector('[id$="-q2-mirror"]');
const q3 = card.querySelector('[id$="-q3-mirror"]');
const preview = card.querySelector('.qty-preview');
function recalc() {
const val = computeQty(q1?.value, q2?.value, q3?.value);
if (preview) preview.value = fmtQty(val);
}
[q1, q2, q3].forEach(inp => inp && inp.addEventListener("input", recalc));
recalc();
const removeBtn = card.querySelector(".editor-remove-row");
removeBtn?.addEventListener("click", () => {
const delMirror = card.querySelector('[id$="-DELETE-mirror"]');
if (delMirror) {
delMirror.checked = true;
delMirror.dispatchEvent(new Event("change", { bubbles: true })); // propager vers l’original
}
card.style.display = "none";
renumberRows();
rebuildSummary(); // refresh la liste
});
}
function removeHiddenFormAt(idx) {
const forms = Array.from(hiddenWrap.querySelectorAll(".qty-form"));
const target = forms[idx];
if (target) {
target.remove();
// réindexer les names - crucial si on retire physiquement des lignes
reindexFormset();
}
}
function renumberRows() {
const cards = editorRows.querySelectorAll('.card[data-form-index]');
let n = 1;
cards.forEach(c => {
if (c.style.display !== "none") {
const rn = c.querySelector(".row-number");
if (rn) rn.textContent = n++;
}
});
}
// Ajout de ligne
document.getElementById("add-qty-row").addEventListener("click", () => {
if (!ensureMgmtFormOrFail()) return;
const totalEl = getTotalFormsInput();
const idx = parseInt(totalEl.value, 10);
// 1) créer HIDDEN original
const tmp = document.createElement("div");
tmp.innerHTML = emptyHiddenTpl.trim();
const newHidden = tmp.firstElementChild;
newHidden.innerHTML = newHidden.innerHTML.replaceAll(/__prefix__/g, idx);
newHidden.dataset.formIndex = idx;
newHidden.classList.add("qty-form"); // <-- important : la classe existe
hiddenWrap.appendChild(newHidden);
totalEl.value = idx + 1;
// 2) carte visible
const html = editorRowTpl.replaceAll("__idx__", idx).replaceAll("__num__", (idx + 1));
const t = document.createElement("div");
t.innerHTML = html.trim();
const card = t.firstElementChild;
editorRows.appendChild(card);
// 3) monter les mirrors (clones visibles synchronisés)
mountMirrorField(newHidden, card, "contract");
mountMirrorField(newHidden, card, "contract_post_label");
mountMirrorField(newHidden, card, "contract_post_id");
mountMirrorField(newHidden, card, "description");
mountMirrorField(newHidden, card, "q1");
mountMirrorField(newHidden, card, "q2");
mountMirrorField(newHidden, card, "q3");
mountMirrorField(newHidden, card, "DELETE");
bindRow(card);
bindAutocompleteForRow(card);
renumberRows();
});
// Recrée le récapitulatif à partir des hidden forms
function rebuildSummary() {
summaryList.innerHTML = "";
let count = 0;
const forms = hiddenWrap.querySelectorAll(".qty-form"); // originaux
forms.forEach(hf => {
const isDeleted = hf.querySelector('input[name$="-DELETE"]')?.checked;
if (isDeleted) return;
const q1 = hf.querySelector('[name$="-q1"]')?.value;
const q2 = hf.querySelector('[name$="-q2"]')?.value;
const q3 = hf.querySelector('[name$="-q3"]')?.value;
const total = computeQty(q1, q2, q3);
const desc = hf.querySelector('[name$="-description"]')?.value || "";
const postLabel = hf.querySelector('[name$="-contract_post_label"]')?.value || "";
const contractSel = hf.querySelector('[name$="-contract"]');
const contractTxt = contractSel && contractSel.options[contractSel.selectedIndex]?.text || "";
const dims = [
(q1 ? String(q1) : null),
(q2 ? String(q2) : null),
(q3 ? String(q3) : null),
].filter(Boolean).join(" × ");
const item = document.createElement("div");
item.className = "list-group-item d-flex flex-column";
const line1 = postLabel
? `<div class="fw-semibold">${postLabel}</div>`
: (desc ? `<div class="fw-semibold">${desc}</div>` : `<div class="fw-semibold">—</div>`);
const line2 = `<div class="small text-muted">${contractTxt || '—'}${dims ? ` · ${dims}` : ''}</div>`;
const line3 = `<div class="mt-1"><span class="badge bg-primary">${fmtQty(total)}</span></div>`;
item.innerHTML = `${line1}${line2}${line3}`;
summaryList.appendChild(item);
count++;
});
summaryEmpty.style.display = (count === 0) ? "" : "none";
}
// Si on retire physiquement des lignes, il faut réindexer tous les names qty-<i>-field
function reindexFormset() {
const forms = Array.from(hiddenWrap.querySelectorAll(".qty-form"));
forms.forEach((hf, i) => {
hf.dataset.formIndex = i;
hf.querySelectorAll("[name]").forEach(inp => {
inp.name = inp.name.replace(/qty-\d+-/,'qty-' + i + '-');
if (inp.id) inp.id = inp.id.replace(/qty-\d+-/,'qty-' + i + '-');
});
});
totalFormsInput.value = forms.length;
}
// Init: construire un récap si des lignes existent déjà (ex. erreurs de validation)
rebuildSummary();
})();
function toDec(v) {
if (v === undefined || v === null) return null;
const s = String(v).trim().replace(',', '.');
if (s === '') return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
function computeQty(q1, q2, q3) {
const a = toDec(q1), b = toDec(q2), c = toDec(q3);
let res = 0;
if (a !== null && b !== null && c !== null) res = a * b * c;
else if (a !== null && b !== null) res = a * b;
else res = a || 0;
// arrondi half-up à 2 décimales
return Math.round(res * 100) / 100;
}
function fmtQty(n) {
return (Number.isFinite(n) ? n : 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function bindAutocompleteForRow(card) {
// Champs de cette ligne (MIRRORS)
const $contract = card.querySelector('.field-contract select');
const $label = card.querySelector('[id$="-contract_post_label-mirror"]');
const $hiddenId = card.querySelector('[id$="-contract_post_id-mirror"]');
// Zone suggestions dédiée à la ligne
const idx = card.dataset.formIndex;
const $sugg = card.querySelector('#qty-post-suggestions-' + idx)
|| card.querySelector('.qty-post-suggestions'); // fallback si tu préfères une classe
if (!$label || !$hiddenId || !$sugg) return;
function clearSuggestions(){
$sugg.innerHTML = "";
$sugg.classList.add("d-none");
$sugg.style.display = "none";
}
function showSuggestions(items){
$sugg.innerHTML = "";
items.forEach(it => {
const a = document.createElement("a");
const label = `${it.order_number} - ${it.code} - ${it.description}`;
a.href = "#";
a.className = "list-group-item list-group-item-action";
a.textContent = label;
a.addEventListener("click", (e) => {
e.preventDefault();
// 1) Remplir les MIRRORS
$label.value = `${it.order_number} - ${it.code} - ${it.description}`;
$hiddenId.value = it.id;
// 2) Propager aux originaux via événements (mountMirrorField écoute input/change)
$label.dispatchEvent(new Event("input", { bubbles: true }));
$hiddenId.dispatchEvent(new Event("change", { bubbles: true }));
clearSuggestions();
});
$sugg.appendChild(a);
});
$sugg.classList.toggle("d-none", items.length === 0);
$sugg.style.display = items.length ? "block" : "none";
}
// Debounce
let debounceTimer=null;
$label.addEventListener("input", function(e){
// On reset l’ID tant que l’utilisateur édite le libellé
// Ne reset que sur frappe utilisateur
if (e.isTrusted) {
$hiddenId.value = "";
$hiddenId.dispatchEvent(new Event("change", { bubbles: true }));
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const q = ($label.value || "").trim();
const contractId = $contract ? $contract.value : "";
if (!contractId || q.length < 1){ clearSuggestions(); return; }
const url = autocomplete_contract_posts_url
+ "?contract_id=" + encodeURIComponent(contractId)
+ "&q=" + encodeURIComponent(q);
fetch(url, { credentials: "same-origin" })
.then(r => r.ok ? r.json() : [])
.then(data => showSuggestions(data || []))
.catch(() => clearSuggestions());
}, 200);
});
// Si on change de marché, on vide le poste choisi
$contract?.addEventListener("change", function(){
$label.value = "";
$label.dispatchEvent(new Event("input", { bubbles: true })); // effacera aussi l’ID via le handler ci-dessus
clearSuggestions();
});
// Fermer le menu si on clique en dehors
document.addEventListener("click", function(e){
if (!$sugg.contains(e.target) && e.target !== $label) clearSuggestions();
});
}
// ========================================
// Gestion du modal caméra (mode continu)
// ========================================
(function() {
// Éléments du DOM
const openCameraModalBtn = document.getElementById('open-camera-modal-btn');
const cameraModalEl = document.getElementById('cameraModal');
const cameraPreview = document.getElementById('cameraPreview');
const cameraCanvas = document.getElementById('cameraCanvas');
const cameraFlashOverlay = document.getElementById('cameraFlashOverlay');
const cameraStatus = document.getElementById('cameraStatus');
const cameraCaptureBtn = document.getElementById('cameraCaptureBtn');
const cameraRotateBtn = document.getElementById('cameraRotateBtn');
const cameraStopBtn = document.getElementById('cameraStopBtn');
const selectFilesBtn = document.getElementById('select-files-btn');
const filesInput = document.getElementById('id_images');
const previewContainer = document.getElementById('preview');
const photoInputsContainer = document.getElementById('photo-inputs');
if (!cameraModalEl) return; // Pas de modal caméra, on sort
let cameraModal = null;
let cameraStream = null;
let photoInputIndex = 0;
let currentCameraRotation = 0; // 0, 90, 180, 270
const isCameraSupported = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
// Initialiser le modal Bootstrap
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
cameraModal = bootstrap.Modal.getOrCreateInstance(cameraModalEl);
}
if (cameraRotateBtn) {
cameraRotateBtn.addEventListener('click', function() {
currentCameraRotation = (currentCameraRotation + 90) % 360;
if (cameraPreview) {
cameraPreview.style.transform = currentCameraRotation ? `rotate(${currentCameraRotation}deg)` : '';
}
});
}
function triggerCameraFlash() {
if (!cameraFlashOverlay) return;
cameraFlashOverlay.style.opacity = '1';
setTimeout(() => {
cameraFlashOverlay.style.opacity = '0';
}, 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) {
alert(gettext("La caméra avancée n'est pas supportée sur ce navigateur."));
return false;
}
try {
currentCameraRotation = 0;
if (cameraPreview) cameraPreview.style.transform = '';
updateCameraStatus(gettext('Initialisation de la caméra…'), 'info');
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');
return true;
} catch (err) {
console.error('Erreur getUserMedia:', err);
alert(gettext("Impossible d'accéder à la caméra. Veuillez vérifier les autorisations."));
updateCameraStatus(gettext("Impossible d'accéder à la caméra."), 'error');
return false;
}
}
function stopCameraStream() {
if (cameraStream) {
cameraStream.getTracks().forEach(track => track.stop());
cameraStream = null;
}
if (cameraPreview) {
cameraPreview.srcObject = null;
cameraPreview.style.transform = '';
}
currentCameraRotation = 0;
updateCameraStatus('', 'info');
}
function captureFrame() {
if (!cameraPreview || !cameraCanvas) return;
const videoWidth = cameraPreview.videoWidth;
const videoHeight = cameraPreview.videoHeight;
if (!videoWidth || !videoHeight) {
updateCameraStatus(gettext("La vidéo n'est pas encore prête."), 'error');
return;
}
const rotation = currentCameraRotation % 360;
if (rotation === 90 || rotation === 270) {
cameraCanvas.width = videoHeight;
cameraCanvas.height = videoWidth;
} else {
cameraCanvas.width = videoWidth;
cameraCanvas.height = videoHeight;
}
const ctx = cameraCanvas.getContext('2d');
ctx.clearRect(0, 0, cameraCanvas.width, cameraCanvas.height);
ctx.save();
if (rotation === 90) {
ctx.translate(videoHeight, 0);
ctx.rotate(90 * Math.PI / 180);
} else if (rotation === 180) {
ctx.translate(videoWidth, videoHeight);
ctx.rotate(180 * Math.PI / 180);
} else if (rotation === 270) {
ctx.translate(0, videoWidth);
ctx.rotate(270 * Math.PI / 180);
}
ctx.drawImage(cameraPreview, 0, 0, videoWidth, videoHeight);
ctx.restore();
cameraCanvas.toBlob(function(blob) {
if (!blob) {
updateCameraStatus(gettext("Erreur lors de la capture."), 'error');
return;
}
const filename = `remark_photo_${Date.now()}.jpg`;
const file = new File([blob], filename, { type: 'image/jpeg' });
// Ajouter au formulaire
addFileToPreview(file);
updateCameraStatus(gettext('Photo capturée avec succès.'), 'success');
}, 'image/jpeg', 0.92);
}
function addFileToPreview(file) {
const currentIndex = photoInputIndex++;
// Créer un input file caché pour ce fichier
const singleInput = document.createElement('input');
singleInput.type = 'file';
singleInput.name = 'images';
singleInput.className = 'd-none';
singleInput.dataset.index = currentIndex;
// Injecter le fichier via DataTransfer
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
singleInput.files = dataTransfer.files;
if (photoInputsContainer) {
photoInputsContainer.appendChild(singleInput);
}
// Créer l'aperçu visuel
const wrapper = document.createElement('div');
wrapper.className = 'preview-wrapper';
wrapper.dataset.inputIndex = currentIndex;
const reader = new FileReader();
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';
btn.onclick = function() {
singleInput.remove();
wrapper.remove();
};
wrapper.appendChild(img);
wrapper.appendChild(btn);
if (previewContainer) {
previewContainer.appendChild(wrapper);
}
};
reader.readAsDataURL(file);
}
// Event: Ouvrir le modal caméra
if (openCameraModalBtn && cameraModal) {
openCameraModalBtn.addEventListener('click', async function() {
const ok = await startCameraStream();
if (!ok) return;
cameraModal.show();
});
}
// Event: Fermeture du modal
if (cameraModalEl) {
cameraModalEl.addEventListener('hidden.bs.modal', function() {
stopCameraStream();
});
}
// Event: Bouton Terminer
if (cameraStopBtn) {
cameraStopBtn.addEventListener('click', function() {
if (cameraModal) {
cameraModal.hide();
}
});
}
// Event: Bouton Capturer
if (cameraCaptureBtn) {
cameraCaptureBtn.addEventListener('click', function() {
triggerCameraFlash();
captureFrame();
});
}
// Event: Sélection de fichiers classique
if (selectFilesBtn && filesInput) {
selectFilesBtn.addEventListener('click', function() {
filesInput.click();
});
filesInput.addEventListener('change', function() {
const files = Array.from(this.files);
files.forEach(file => addFileToPreview(file));
this.value = ''; // Reset pour permettre de resélectionner
});
}
})();
// Global variables for remark / control photo carousel
let currentRemarkCarouselPhotos = [];
let currentRemarkCarouselIndex = 0;
function setCarouselModalTitle(titleText) {
const titleEl = document.getElementById('remarkPhotoCarouselModalLabel');
if (titleEl) {
titleEl.textContent = titleText;
}
}
// Ouvrir toutes les photos du contrôle dans le carrousel
function openAllControlPhotosCarousel(startIndex = 0) {
const thumbs = Array.from(document.querySelectorAll('#remarks-list .remark-photo-thumb, #archives-list .remark-photo-thumb'));
if (thumbs.length === 0) {
const fallbackThumbs = Array.from(document.querySelectorAll('.remark-photo-thumb'));
if (fallbackThumbs.length === 0) return;
thumbs.push(...fallbackThumbs);
}
currentRemarkCarouselPhotos = thumbs.map(t => ({
fullUrl: t.dataset.fullUrl || t.src,
thumbUrl: t.dataset.thumbUrl || t.src,
imageId: t.dataset.imageId || null,
caption: t.dataset.caption || '',
remarkId: t.dataset.remarkId || null,
element: t
}));
let targetIndex = 0;
if (typeof startIndex === 'number') {
targetIndex = Math.max(0, Math.min(startIndex, currentRemarkCarouselPhotos.length - 1));
} else if (startIndex instanceof HTMLElement) {
targetIndex = thumbs.indexOf(startIndex);
if (targetIndex < 0) targetIndex = 0;
}
const controlTitle = typeof gettext !== 'undefined' ? gettext('Photos du contrôle') : 'Photos du contrôle';
setCarouselModalTitle(controlTitle);
showRemarkCarouselModal(targetIndex);
}
// Ouvrir les photos d'une remarque spécifique dans le carrousel
function openRemarkPhotoCarousel(thumbElement) {
if (!thumbElement) return;
const remarkId = thumbElement.dataset.remarkId;
// Trouver toutes les photos appartenant à cette remarque
let thumbs = [];
const remarkCard = document.getElementById(`remark-${remarkId}`);
if (remarkCard) {
thumbs = Array.from(remarkCard.querySelectorAll('.remark-photo-thumb'));
} else if (remarkId) {
thumbs = Array.from(document.querySelectorAll(`.remark-photo-thumb[data-remark-id="${remarkId}"]`));
}
if (thumbs.length === 0) {
thumbs = [thumbElement];
}
currentRemarkCarouselPhotos = thumbs.map(t => ({
fullUrl: t.dataset.fullUrl || t.src,
thumbUrl: t.dataset.thumbUrl || t.src,
imageId: t.dataset.imageId || null,
caption: t.dataset.caption || '',
remarkId: t.dataset.remarkId || remarkId,
element: t
}));
let startIndex = thumbs.indexOf(thumbElement);
if (startIndex < 0) startIndex = 0;
const remarkTitle = typeof gettext !== 'undefined' ? gettext('Photos de la remarque') : 'Photos de la remarque';
setCarouselModalTitle(remarkTitle);
showRemarkCarouselModal(startIndex);
}
function openRemarkPhotoCarouselByRemarkId(remarkId) {
const remarkCard = document.getElementById(`remark-${remarkId}`);
let firstThumb = null;
if (remarkCard) {
firstThumb = remarkCard.querySelector('.remark-photo-thumb');
} else {
firstThumb = document.querySelector(`.remark-photo-thumb[data-remark-id="${remarkId}"]`);
}
if (firstThumb) {
openRemarkPhotoCarousel(firstThumb);
}
}
// Gestion des gestes tactiles (swipe mobile)
function setupCarouselTouchGestures(carouselEl) {
if (!carouselEl || carouselEl.dataset.touchAttached === 'true') return;
carouselEl.dataset.touchAttached = 'true';
let touchStartX = 0;
let touchStartY = 0;
let touchEndX = 0;
let touchEndY = 0;
carouselEl.addEventListener('touchstart', function(e) {
if (e.touches.length === 1) {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchEndX = touchStartX;
touchEndY = touchStartY;
}
}, { passive: true });
carouselEl.addEventListener('touchmove', function(e) {
if (e.touches.length === 1) {
touchEndX = e.touches[0].clientX;
touchEndY = e.touches[0].clientY;
}
}, { passive: true });
carouselEl.addEventListener('touchend', function(e) {
const diffX = touchEndX - touchStartX;
const diffY = touchEndY - touchStartY;
// Détecter un balayage horizontal net (distance > 35px et plus horizontal que vertical)
if (Math.abs(diffX) > 35 && Math.abs(diffX) > Math.abs(diffY) * 1.2) {
if (typeof bootstrap !== 'undefined' && bootstrap.Carousel) {
const bsCarousel = bootstrap.Carousel.getInstance(carouselEl);
if (bsCarousel) {
if (diffX < 0) {
bsCarousel.next(); // Balayage vers la gauche -> Photo suivante
} else {
bsCarousel.prev(); // Balayage vers la droite -> Photo précédente
}
}
}
}
}, { passive: true });
}
function showRemarkCarouselModal(startIndex) {
const modalEl = document.getElementById('remarkPhotoCarouselModal');
if (!modalEl) {
if (currentRemarkCarouselPhotos[startIndex]) {
window.open(currentRemarkCarouselPhotos[startIndex].fullUrl, '_blank');
}
return;
}
const carouselInner = document.getElementById('remark-carousel-inner');
const carouselIndicators = document.getElementById('remark-carousel-indicators');
const total = currentRemarkCarouselPhotos.length;
currentRemarkCarouselIndex = startIndex;
if (!carouselInner) return;
carouselInner.innerHTML = '';
if (carouselIndicators) carouselIndicators.innerHTML = '';
currentRemarkCarouselPhotos.forEach((photo, idx) => {
const item = document.createElement('div');
item.className = `carousel-item ${idx === startIndex ? 'active' : ''} h-100`;
item.innerHTML = `
<div class="d-flex align-items-center justify-content-center h-100 p-2">
<img src="${photo.fullUrl}" class="d-block img-fluid mh-100 mw-100 rounded" style="object-fit: contain; max-height: 75vh;" alt="${photo.caption}">
</div>
`;
carouselInner.appendChild(item);
if (carouselIndicators && total > 1) {
const ind = document.createElement('button');
ind.type = 'button';
ind.setAttribute('data-bs-target', '#remarkPhotoCarousel');
ind.setAttribute('data-bs-slide-to', idx.toString());
if (idx === startIndex) {
ind.className = 'active';
ind.setAttribute('aria-current', 'true');
}
ind.setAttribute('aria-label', `Photo ${idx + 1}`);
carouselIndicators.appendChild(ind);
}
});
const prevBtn = modalEl.querySelector('.carousel-control-prev');
const nextBtn = modalEl.querySelector('.carousel-control-next');
if (prevBtn) prevBtn.style.display = total > 1 ? 'flex' : 'none';
if (nextBtn) nextBtn.style.display = total > 1 ? 'flex' : 'none';
if (carouselIndicators) carouselIndicators.style.display = total > 1 ? 'flex' : 'none';
updateRemarkCarouselActiveSlide(startIndex);
const carouselEl = document.getElementById('remarkPhotoCarousel');
if (carouselEl && typeof bootstrap !== 'undefined' && bootstrap.Carousel) {
let bsCarousel = bootstrap.Carousel.getInstance(carouselEl);
if (bsCarousel) {
bsCarousel.dispose();
}
bsCarousel = new bootstrap.Carousel(carouselEl, { ride: false, interval: false, touch: true });
bsCarousel.to(startIndex);
setupCarouselTouchGestures(carouselEl);
carouselEl.addEventListener('slid.bs.carousel', function (e) {
updateRemarkCarouselActiveSlide(e.to);
}, { once: false });
}
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
const bsModal = bootstrap.Modal.getOrCreateInstance(modalEl);
bsModal.show();
}
}
function updateRemarkCarouselActiveSlide(idx) {
currentRemarkCarouselIndex = idx;
const photo = currentRemarkCarouselPhotos[idx];
const total = currentRemarkCarouselPhotos.length;
const counterBadge = document.getElementById('remark-carousel-counter');
const captionEl = document.getElementById('remark-carousel-caption');
const hdBtn = document.getElementById('remark-carousel-hd-btn');
const deleteBtn = document.getElementById('remark-carousel-delete-btn');
const rotateBtn = document.getElementById('remark-carousel-rotate-btn');
if (counterBadge) {
counterBadge.textContent = `${idx + 1} / ${total}`;
}
if (captionEl && photo) {
captionEl.textContent = photo.caption || '';
}
if (hdBtn && photo) {
hdBtn.href = photo.fullUrl;
}
if (rotateBtn) {
if (photo && photo.imageId) {
rotateBtn.style.display = 'inline-flex';
rotateBtn.dataset.imageId = photo.imageId;
rotateBtn.onclick = function () {
rotateRemarkImage(photo.imageId, rotateBtn);
};
} else {
rotateBtn.style.display = 'none';
}
}
if (deleteBtn) {
if (photo && photo.imageId) {
deleteBtn.style.display = 'inline-flex';
deleteBtn.dataset.imageId = photo.imageId;
deleteBtn.dataset.remarkId = photo.remarkId;
deleteBtn.onclick = function () {
if (photo.element) {
const trashBtn = photo.element.parentElement ? photo.element.parentElement.querySelector('.remark-image-delete') : null;
if (trashBtn) {
trashBtn.click();
} else {
deleteRemarkImage(photo.imageId, photo.element);
}
} else {
deleteRemarkImage(photo.imageId, deleteBtn);
}
};
} else {
deleteBtn.style.display = 'none';
}
}
}
function addTimestampToUrl(url) {
if (!url) return url;
try {
const u = new URL(url, window.location.origin);
u.searchParams.set('t', Date.now().toString());
return u.pathname + u.search + u.hash;
} catch (e) {
const sep = url.includes('?') ? '&' : '?';
return url.replace(/([&?]t=\d+)/, '') + sep + 't=' + Date.now();
}
}
async function rotateRemarkImage(imageId, triggerBtn) {
if (!imageId) return;
let originalHtml = '';
if (triggerBtn) {
triggerBtn.disabled = true;
originalHtml = triggerBtn.innerHTML;
triggerBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
}
try {
const formData = new FormData();
formData.append('angle', '90');
const csrf = (typeof getCookie === 'function' ? getCookie('csrftoken') : '') || (typeof csrftoken !== 'undefined' ? csrftoken : '');
const response = await fetch(`/controls/remark_images/${imageId}/rotate/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrf,
},
body: formData
});
const data = await response.json();
if (data.success) {
// Rafraîchir l'image active dans le carrousel si ouvert
const activeCarouselImg = document.querySelector('#remark-carousel-inner .carousel-item.active img');
if (activeCarouselImg) {
activeCarouselImg.src = addTimestampToUrl(activeCarouselImg.src);
}
// Rafraîchir toutes les vignettes et attributs data correspondants sur la page
document.querySelectorAll(`img[data-image-id="${imageId}"]`).forEach(img => {
img.src = addTimestampToUrl(img.src);
if (img.dataset.fullUrl) {
img.dataset.fullUrl = addTimestampToUrl(img.dataset.fullUrl);
}
if (img.dataset.thumbUrl) {
img.dataset.thumbUrl = addTimestampToUrl(img.dataset.thumbUrl);
}
});
// Mettre à jour l'état local du carrousel
if (currentRemarkCarouselPhotos && currentRemarkCarouselPhotos[currentRemarkCarouselIndex]) {
const curr = currentRemarkCarouselPhotos[currentRemarkCarouselIndex];
if (curr.imageId == imageId) {
curr.fullUrl = addTimestampToUrl(curr.fullUrl);
curr.thumbUrl = addTimestampToUrl(curr.thumbUrl);
const hdBtn = document.getElementById('remark-carousel-hd-btn');
if (hdBtn) hdBtn.href = curr.fullUrl;
}
}
} else {
alert(data.error || gettext("Erreur lors de la rotation."));
}
} catch (e) {
console.error("Rotation error:", e);
alert(gettext("Erreur réseau lors de la rotation."));
} finally {
if (triggerBtn) {
triggerBtn.disabled = false;
triggerBtn.innerHTML = originalHtml;
}
}
}
// Délégation d'événement pour les boutons de rotation sur les vignettes de remarques
document.addEventListener('click', function(e) {
const rotateBtn = e.target.closest('.remark-image-rotate');
if (rotateBtn) {
e.preventDefault();
e.stopPropagation();
const imageId = rotateBtn.dataset.imageId;
rotateRemarkImage(imageId, rotateBtn);
}
});
// Support navigation au clavier (flèche gauche / droite) pour le carrousel
document.addEventListener('DOMContentLoaded', function() {
const modalEl = document.getElementById('remarkPhotoCarouselModal');
if (modalEl) {
modalEl.addEventListener('keydown', function(e) {
const carouselEl = document.getElementById('remarkPhotoCarousel');
if (!carouselEl || typeof bootstrap === 'undefined' || !bootstrap.Carousel) return;
const bsCarousel = bootstrap.Carousel.getInstance(carouselEl);
if (!bsCarousel) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
bsCarousel.prev();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
bsCarousel.next();
}
});
const carouselEl = document.getElementById('remarkPhotoCarousel');
if (carouselEl) {
setupCarouselTouchGestures(carouselEl);
}
}
});
// Backward compatibility for single image preview
function showImageModal(imageUrl) {
currentRemarkCarouselPhotos = [{
fullUrl: imageUrl,
thumbUrl: imageUrl,
imageId: null,
caption: '',
remarkId: null,
element: null
}];
showRemarkCarouselModal(0);
}
window.handleToggleCancelControl = function(controlId, action) {
const defaultCancelMsg = "Êtes-vous sûr de vouloir annuler ce contrôle ?";
const defaultRestoreMsg = "Êtes-vous sûr de vouloir rétablir ce contrôle ?";
const confirmMsg = action === 'cancel'
? (typeof gettext === 'function' ? gettext(defaultCancelMsg) : defaultCancelMsg)
: (typeof gettext === 'function' ? gettext(defaultRestoreMsg) : defaultRestoreMsg);
if (!confirm(confirmMsg)) return;
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, 10) === 'csrftoken=') {
cookieValue = decodeURIComponent(cookie.substring(10));
break;
}
}
}
const formData = new FormData();
formData.append('action', action);
fetch(`/controls/api/${controlId}/toggle-cancel/`, {
method: 'POST',
headers: {
'X-CSRFToken': cookieValue || (typeof csrftoken !== 'undefined' ? csrftoken : '')
},
body: formData
})
.then(res => res.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
const defaultErrMsg = "Une erreur est survenue.";
alert(data.error || (typeof gettext === 'function' ? gettext(defaultErrMsg) : defaultErrMsg));
}
})
.catch(err => {
console.error(err);
const defaultNetworkErrMsg = "Erreur de communication avec le serveur.";
alert(typeof gettext === 'function' ? gettext(defaultNetworkErrMsg) : defaultNetworkErrMsg);
});
};