2270 lines
No EOL
83 KiB
JavaScript
2270 lines
No EOL
83 KiB
JavaScript
// 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);
|
||
});
|
||
}; |