diff --git a/public/userarea/class/VisualLimsApiClient.class.php b/public/userarea/class/VisualLimsApiClient.class.php index b3d3944..61bd945 100644 --- a/public/userarea/class/VisualLimsApiClient.class.php +++ b/public/userarea/class/VisualLimsApiClient.class.php @@ -206,21 +206,22 @@ class VisualLimsApiClient /** * POST a file as multipart/form-data (used for photo/attachment uploads). * - * @param string $endpoint OData endpoint, e.g. "Campione(613388)/UploadCampioneFile" - * @param string $filePath Absolute path to the file on disk - * @param string $fileName Original file name to send - * @return array|null Decoded JSON response + * @param string $endpoint OData endpoint, e.g. "Campione(613388)/UploadCampioneFile" + * @param string $filePath Absolute path to the file on disk + * @param string $fileName Original file name to send + * @param array $extraFields Additional form fields to include + * @return array|null Decoded JSON response */ - public function postMultipart($endpoint, $filePath, $fileName) + public function postMultipart($endpoint, $filePath, $fileName, array $extraFields = []) { $token = $this->getToken(); $url = "{$this->baseUrl}/api/odata/{$endpoint}"; $cfile = new CURLFile($filePath, mime_content_type($filePath) ?: 'application/octet-stream', $fileName); - $payload = [ + $payload = array_merge($extraFields, [ 'file' => $cfile, - ]; + ]); $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); diff --git a/public/userarea/class/VisualLimsApiClientMock.class.php b/public/userarea/class/VisualLimsApiClientMock.class.php index 71cd270..7c08ae6 100644 --- a/public/userarea/class/VisualLimsApiClientMock.class.php +++ b/public/userarea/class/VisualLimsApiClientMock.class.php @@ -126,7 +126,7 @@ class VisualLimsApiClientMock return []; } - public function postMultipart(string $endpoint, string $filePath, string $fileName): array + public function postMultipart(string $endpoint, string $filePath, string $fileName, array $extraFields = []): array { error_log("[SIMULATE] POST multipart {$endpoint} file={$fileName}"); diff --git a/public/userarea/export_to_lims.js b/public/userarea/export_to_lims.js index c8970da..023c580 100644 --- a/public/userarea/export_to_lims.js +++ b/public/userarea/export_to_lims.js @@ -4,13 +4,13 @@ document.addEventListener("DOMContentLoaded", () => { const exportButtons = document.querySelectorAll(".export-lims-btn"); console.log(`Found ${exportButtons.length} export-lims-btn buttons`); - if (exportButtons.length === 0) { - console.warn("No .export-lims-btn buttons found in the DOM"); - return; - } - // Tracks the active confirm handler so it can be replaced on re-open let pendingConfirmHandler = null; + let batchRunning = false; + // Expose for Save All to check + Object.defineProperty(window, "batchRunning", { + get: () => batchRunning, + }); // ── Helpers ────────────────────────────────────────────────────────────── @@ -22,10 +22,340 @@ document.addEventListener("DOMContentLoaded", () => { if (overlay) overlay.style.display = "none"; } - // ── Step 2: show export-confirm modal, send on "Conferma" ──────────────── + // ── Validation ─────────────────────────────────────────────────────────── + + /** + * Call the validation endpoint for an array of { iddatadb, index } objects. + * Returns the parsed JSON response. + */ + async function validateRows(rowsToValidate) { + const response = await fetch("validate_export.php", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rows: rowsToValidate }), + }); + if (!response.ok) + throw new Error(`Validation HTTP error: ${response.status}`); + return response.json(); + } + + /** + * Clear all validation-error highlights from the grid. + */ + function clearValidationErrors() { + document.querySelectorAll(".grid-cell.validation-error").forEach((cell) => { + cell.classList.remove("validation-error"); + cell.querySelectorAll(".input-validation-error").forEach((el) => { + el.classList.remove("input-validation-error"); + }); + const tooltip = cell.querySelector(".validation-tooltip"); + if (tooltip) tooltip.remove(); + }); + document.querySelectorAll(".grid-row.validation-row-error").forEach((row) => { + row.classList.remove("validation-row-error"); + }); + // Also clear batch-row-error that came from validation + clearAllRowErrors(); + } + + /** + * Highlight specific cells on a row based on validation errors. + * Each error has { field, message }. + * field can be: a data-col value, "parts", "field_label:SomeLabel", or null (row-level). + */ + function showValidationErrors(row, iddatadb, errors) { + row.classList.add("validation-row-error"); + + const messages = []; + + errors.forEach((err) => { + messages.push(err.message); + + if (!err.field) return; // row-level error, no specific cell + + let cell = null; + + if (err.field === "parts") { + // No specific cell to highlight, but we highlight the parts button area + // just add to messages + return; + } else if (err.field.startsWith("field_label:")) { + // Match by field_label text — find the header with this label, get its index + const label = err.field.substring("field_label:".length); + const headers = document.querySelectorAll(".grid-header"); + let targetIndex = null; + headers.forEach((h) => { + if (h.textContent.trim() === label) { + targetIndex = h.getAttribute("data-index"); + } + }); + if (targetIndex) { + const rowIndex = row.querySelector(".grid-cell[data-row]")?.getAttribute("data-row"); + if (rowIndex !== null) { + cell = row.querySelector(`.grid-cell[data-row="${rowIndex}"][data-index="${targetIndex}"]`); + } + } + } else { + // Direct data-col match + const rowIndex = row.querySelector(".grid-cell[data-row]")?.getAttribute("data-row"); + if (rowIndex !== null) { + cell = row.querySelector(`.grid-cell[data-col="${err.field}"][data-row="${rowIndex}"]`); + } + } + + if (cell) { + cell.classList.add("validation-error"); + // Mark the input/select inside the cell + cell.querySelectorAll("input, select").forEach((el) => { + el.classList.add("input-validation-error"); + }); + // Add tooltip with error message + let tooltip = cell.querySelector(".validation-tooltip"); + if (!tooltip) { + tooltip = document.createElement("div"); + tooltip.className = "validation-tooltip"; + cell.appendChild(tooltip); + } + tooltip.textContent = err.message; + } + }); + + // Show aggregated error on the row using existing mechanism + showRowError(row, iddatadb, messages.join("\n")); + } + + /** + * Send a single export request and update the row UI on success. + * Returns the parsed JSON response. + */ + async function sendExport(iddatadb, gridRow, batchUuid = null) { + const formData = new FormData(); + formData.append("iddatadb", iddatadb); + if (batchUuid) { + formData.append("batch_uuid", batchUuid); + } + + const response = await fetch("export_to_lims.php", { + method: "POST", + body: formData, + }); + if (!response.ok) + throw new Error(`HTTP error! status: ${response.status}`); + const data = await response.json(); + + if (data.success && gridRow) { + // Update status badge + const statusBadge = gridRow.querySelector( + '.grid-cell[data-col="status"] .status-badge', + ); + if (statusBadge) { + statusBadge.classList.remove("status-i", "status-P"); + statusBadge.classList.add("status-l"); + statusBadge.textContent = "To LIMS"; + } + + // Insert/update CommessaWeb code span + const statusCell = gridRow.querySelector( + '.grid-cell[data-col="status"]', + ); + if (statusCell && data.commessaweb) { + let cwSpan = statusCell.querySelector(".commessaweb-code"); + if (!cwSpan) { + cwSpan = document.createElement("span"); + cwSpan.className = "commessaweb-code"; + cwSpan.style.cssText = + "display:block; font-size:0.75em; color:#555; margin-top:2px;"; + cwSpan.title = "CommessaWeb"; + const hiddenInput = statusCell.querySelector( + 'input[type="hidden"]', + ); + hiddenInput + ? statusCell.insertBefore(cwSpan, hiddenInput) + : statusCell.appendChild(cwSpan); + } + cwSpan.textContent = data.commessaweb; + } + + // Disable export button for this row + const exportBtn = gridRow.querySelector(".export-lims-btn"); + if (exportBtn) { + exportBtn.disabled = true; + exportBtn.style.background = "#ccc"; + exportBtn.style.cursor = "not-allowed"; + exportBtn.style.opacity = "0.5"; + exportBtn.title = "Già esportato"; + } + } + + return data; + } + + /** + * Show the result of a single export in the response modal. + */ + function showExportResult(data) { + const responseModalElement = + document.getElementById("exportResponseModal"); + if (!responseModalElement) { + alert("Errore: Modale di risposta non trovato"); + return; + } + + const responseModal = new bootstrap.Modal(responseModalElement, { + keyboard: false, + }); + const responseMessage = document.getElementById( + "exportResponseMessage", + ); + + if (data.success) { + responseMessage.innerHTML = + `${data.message.replace(/\n/g, "
")}` + + `
ID CommessaWeb: ${data.idcommessaweb}` + + `
Codice CommessaWeb: ${data.commessaweb}` + + (data.totalPhotos > 0 + ? `
Foto trovate: ${data.totalPhotos}` + : ""); + document.getElementById("exportResponseModalLabel").textContent = + "Esportazione Completata"; + } else { + responseMessage.textContent = `Errore durante la generazione dei payload: ${data.message}`; + document.getElementById("exportResponseModalLabel").textContent = + "Errore Esportazione"; + } + + responseModal.show(); + responseModalElement.addEventListener( + "hidden.bs.modal", + cleanupBackdrop, + { once: true }, + ); + } + + // ── Row button helpers (disable/enable during batch) ──────────────────── + + function setRowExporting(row, active) { + const btnCell = row.querySelector(".button-cell"); + if (active) { + row.classList.remove("batch-disabled"); + row.classList.add("batch-exporting"); + if (btnCell) { + btnCell.querySelectorAll(".action-btn").forEach((b) => { + b.dataset.prevDisplay = b.style.display; + b.style.display = "none"; + }); + const spinner = document.createElement("span"); + spinner.className = "batch-row-spinner"; + spinner.innerHTML = + ' Exporting...'; + btnCell.appendChild(spinner); + } + } else { + row.classList.remove("batch-exporting"); + row.classList.add("batch-disabled"); + if (btnCell) { + const spinner = btnCell.querySelector(".batch-row-spinner"); + if (spinner) spinner.remove(); + btnCell.querySelectorAll(".action-btn").forEach((b) => { + b.style.display = b.dataset.prevDisplay || ""; + delete b.dataset.prevDisplay; + }); + } + } + } + + function showRowError(row, iddatadb, message) { + row.classList.add("batch-row-error"); + const btnCell = row.querySelector(".button-cell"); + if (btnCell) { + // Remove existing error msg + const old = btnCell.querySelector(".batch-error-msg"); + if (old) old.remove(); + + const errorEl = document.createElement("div"); + errorEl.className = "batch-error-msg"; + errorEl.textContent = "⚠ Errore — clicca per dettagli"; + errorEl.addEventListener("click", () => { + document.getElementById("exportResponseMessage").innerHTML = message.replace(/\n/g, "
"); + document.getElementById("exportResponseModalLabel").textContent = "Errore Validazione (id: " + iddatadb + ")"; + new bootstrap.Modal(document.getElementById("exportResponseModal"), { keyboard: false }).show(); + }); + btnCell.appendChild(errorEl); + } + } + + function clearAllRowErrors() { + document.querySelectorAll(".grid-row.batch-row-error").forEach((row) => { + row.classList.remove("batch-row-error"); + const msg = row.querySelector(".batch-error-msg"); + if (msg) msg.remove(); + }); + } + + function disableAllRowButtons() { + document.querySelectorAll(".grid-row[data-id]").forEach((row) => { + row.classList.add("batch-disabled"); + }); + // Disable Actions dropdown + const toggle = document.querySelector( + ".actions-dropdown .dropdown-toggle", + ); + if (toggle) { + toggle.disabled = true; + toggle.style.opacity = "0.5"; + toggle.style.pointerEvents = "none"; + } + } + + function enableAllRowButtons() { + document.querySelectorAll(".grid-row[data-id]").forEach((row) => { + row.classList.remove("batch-disabled"); + }); + const toggle = document.querySelector( + ".actions-dropdown .dropdown-toggle", + ); + if (toggle) { + toggle.disabled = false; + toggle.style.opacity = ""; + toggle.style.pointerEvents = ""; + } + } + + // ── Single row export: validate, confirm modal, then send ─────────────── function startExportConfirmFlow(iddatadb, btn) { - const confirmModalElement = document.getElementById("exportConfirmModal"); + const gridRow = btn.closest(".grid-row"); + const rowIndex = btn.dataset.row; + + // Validate first + clearValidationErrors(); + + validateRows([{ iddatadb: parseInt(iddatadb), index: parseInt(rowIndex) }]) + .then((validationData) => { + if (!validationData.success) { + showExportResult({ success: false, message: validationData.message || "Errore di validazione" }); + return; + } + + const result = validationData.results[rowIndex]; + if (result && !result.valid) { + // Show validation errors on the row + showValidationErrors(gridRow, iddatadb, result.errors); + return; + } + + // Validation passed — show confirm modal + showConfirmAndExport(iddatadb, btn); + }) + .catch((error) => { + console.error("Validation error:", error); + showExportResult({ success: false, message: "Errore di validazione: " + error.message }); + }); + } + + function showConfirmAndExport(iddatadb, btn) { + const confirmModalElement = + document.getElementById("exportConfirmModal"); if (!confirmModalElement) { alert("Errore: Modale di conferma non trovato"); return; @@ -49,118 +379,17 @@ document.addEventListener("DOMContentLoaded", () => { console.log(`Confirmed export for iddatadb: ${iddatadb}`); confirmModal.hide(); - const formData = new FormData(); - formData.append("iddatadb", iddatadb); - try { - const response = await fetch("export_to_lims.php", { - method: "POST", - body: formData, - }); - if (!response.ok) - throw new Error(`HTTP error! status: ${response.status}`); - const data = await response.json(); - + const gridRow = btn.closest(".grid-row"); + const data = await sendExport(iddatadb, gridRow); console.log("Export response:", data); - - const responseModalElement = - document.getElementById("exportResponseModal"); - if (!responseModalElement) { - alert("Errore: Modale di risposta non trovato"); - return; - } - - const responseModal = new bootstrap.Modal( - responseModalElement, - { keyboard: false }, - ); - const responseMessage = document.getElementById( - "exportResponseMessage", - ); - - if (data.success) { - responseMessage.innerHTML = - `${data.message.replace(/\n/g, "
")}` + - `
ID CommessaWeb: ${data.idcommessaweb}` + - `
Codice CommessaWeb: ${data.commessaweb}` + - (data.totalPhotos > 0 - ? `
Foto trovate: ${data.totalPhotos}` - : ""); - document.getElementById( - "exportResponseModalLabel", - ).textContent = "Esportazione Completata"; - responseModal.show(); - - // Update status badge - const gridRow = btn.closest(".grid-row"); - const statusBadge = gridRow?.querySelector( - '.grid-cell[data-col="status"] .status-badge', - ); - if (statusBadge) { - statusBadge.classList.remove("status-i", "status-P"); - statusBadge.classList.add("status-l"); - statusBadge.textContent = "To LIMS"; - } - - // Insert/update CommessaWeb code span - const statusCell = gridRow?.querySelector( - '.grid-cell[data-col="status"]', - ); - if (statusCell && data.commessaweb) { - let cwSpan = - statusCell.querySelector(".commessaweb-code"); - if (!cwSpan) { - cwSpan = document.createElement("span"); - cwSpan.className = "commessaweb-code"; - cwSpan.style.cssText = - "display:block; font-size:0.75em; color:#555; margin-top:2px;"; - cwSpan.title = "CommessaWeb"; - const hiddenInput = statusCell.querySelector( - 'input[type="hidden"]', - ); - hiddenInput - ? statusCell.insertBefore(cwSpan, hiddenInput) - : statusCell.appendChild(cwSpan); - } - cwSpan.textContent = data.commessaweb; - } - } else { - responseMessage.textContent = `Errore durante la generazione dei payload: ${data.message}`; - document.getElementById( - "exportResponseModalLabel", - ).textContent = "Errore Esportazione"; - responseModal.show(); - } - - responseModalElement.addEventListener( - "hidden.bs.modal", - cleanupBackdrop, - { once: true }, - ); + showExportResult(data); } catch (error) { console.error("Export error:", error); - const responseModalElement = - document.getElementById("exportResponseModal"); - if (!responseModalElement) { - alert("Errore: Modale di risposta non trovato"); - return; - } - const responseModal = new bootstrap.Modal( - responseModalElement, - { keyboard: false }, - ); - document.getElementById( - "exportResponseMessage", - ).textContent = `Errore: ${error.message}`; - document.getElementById( - "exportResponseModalLabel", - ).textContent = "Errore Esportazione"; - responseModal.show(); - responseModalElement.addEventListener( - "hidden.bs.modal", - cleanupBackdrop, - { once: true }, - ); + showExportResult({ + success: false, + message: error.message, + }); } }; @@ -171,11 +400,13 @@ document.addEventListener("DOMContentLoaded", () => { confirmBtn.addEventListener("click", confirmHandler, { once: true }); } - // ── Step 1: check unsaved changes, save if needed, then export ─────────── + // ── Single row click handler ──────────────────────────────────────────── exportButtons.forEach((btn) => { btn.addEventListener("click", (e) => { e.preventDefault(); + if (batchRunning) return; + const rowIndex = btn.dataset.row; const iddatadb = btn.dataset.iddatadb; console.log( @@ -191,31 +422,352 @@ document.addEventListener("DOMContentLoaded", () => { ); unsavedModal.show(); - document.getElementById("saveAndExportBtn").addEventListener("click", () => { - unsavedModal.hide(); - const saveBtn = gridRow.querySelector(".save-btn"); - if (!saveBtn) return; + document.getElementById("saveAndExportBtn").addEventListener( + "click", + () => { + unsavedModal.hide(); + const saveBtn = gridRow.querySelector(".save-btn"); + if (!saveBtn) return; - const observer = new MutationObserver(() => { - if (!gridRow.querySelector(".cell-changed")) { - observer.disconnect(); - startExportConfirmFlow(iddatadb, btn); - } - }); - observer.observe(gridRow, { - subtree: true, - attributes: true, - attributeFilter: ["class"], - }); + const observer = new MutationObserver(() => { + if (!gridRow.querySelector(".cell-changed")) { + observer.disconnect(); + startExportConfirmFlow(iddatadb, btn); + } + }); + observer.observe(gridRow, { + subtree: true, + attributes: true, + attributeFilter: ["class"], + }); - saveBtn.click(); - }, { once: true }); + saveBtn.click(); + }, + { once: true }, + ); return; } - // No unsaved changes — go straight to export confirm + // No unsaved changes — go straight to validate + export confirm startExportConfirmFlow(iddatadb, btn); }); }); + + // ── Batch export (Export All) ─────────────────────────────────────────── + + const exportAllBtn = document.querySelector(".export-all-lims-btn"); + if (!exportAllBtn) return; + + let batchCancelled = false; + let pendingBatchConfirmHandler = null; + + function collectEligibleRows() { + const allRows = document.querySelectorAll(".grid-row[data-id]"); + const eligible = []; + allRows.forEach((row) => { + const statusBadge = row.querySelector( + '.grid-cell[data-col="status"] .status-badge', + ); + if (statusBadge && !statusBadge.classList.contains("status-l")) { + const iddatadb = row.dataset.id; + if (iddatadb) { + eligible.push({ iddatadb, row }); + } + } + }); + return eligible; + } + + /** + * Get the data-row index for a grid row element. + */ + function getRowIndex(row) { + const cell = row.querySelector(".grid-cell[data-row]"); + return cell ? parseInt(cell.getAttribute("data-row")) : null; + } + + function hasUnsavedChanges() { + return !!document.querySelector(".grid-row[data-id] .cell-changed"); + } + + /** + * Validate all eligible rows, show errors, and return only the valid ones. + */ + async function validateAndFilter(eligibleRows) { + const rowsToValidate = eligibleRows.map(({ iddatadb, row }) => ({ + iddatadb: parseInt(iddatadb), + index: getRowIndex(row), + })); + + const validationData = await validateRows(rowsToValidate); + + if (!validationData.success) { + throw new Error(validationData.message || "Errore di validazione"); + } + + const validRows = []; + let invalidCount = 0; + + for (const { iddatadb, row } of eligibleRows) { + const rowIdx = getRowIndex(row); + const result = validationData.results[rowIdx]; + + if (result && !result.valid) { + showValidationErrors(row, iddatadb, result.errors); + invalidCount++; + } else { + validRows.push({ iddatadb, row }); + } + } + + return { validRows, invalidCount }; + } + + function showValidationSpinner(show) { + const bar = document.getElementById("batchExportBar"); + const statusEl = document.getElementById("batchExportStatus"); + const cancelBtn = document.getElementById("exportBatchCancelBtn"); + if (show) { + bar.style.display = ""; + statusEl.textContent = "Validazione in corso..."; + cancelBtn.style.display = "none"; + } else { + bar.style.display = "none"; + cancelBtn.style.display = ""; + } + } + + function showBatchConfirm(eligibleRows) { + clearValidationErrors(); + showValidationSpinner(true); + + // Validate before showing confirm + validateAndFilter(eligibleRows) + .then(({ validRows, invalidCount }) => { + showValidationSpinner(false); + if (validRows.length === 0) { + document.getElementById("exportResponseMessage").innerHTML = + `Nessuna riga valida per l'esportazione.
` + + `${invalidCount} righe con errori di validazione.`; + document.getElementById("exportResponseModalLabel").textContent = + "Validazione Fallita"; + new bootstrap.Modal( + document.getElementById("exportResponseModal"), + { keyboard: false }, + ).show(); + return; + } + + const confirmModal = new bootstrap.Modal( + document.getElementById("exportBatchConfirmModal"), + { keyboard: false }, + ); + + let countText = String(validRows.length); + if (invalidCount > 0) { + countText += ` (${invalidCount} escluse per errori di validazione)`; + } + document.getElementById("exportBatchCount").textContent = countText; + confirmModal.show(); + + const confirmBtn = document.getElementById("exportBatchConfirmBtn"); + if (pendingBatchConfirmHandler) { + confirmBtn.removeEventListener("click", pendingBatchConfirmHandler); + } + pendingBatchConfirmHandler = () => { + pendingBatchConfirmHandler = null; + confirmModal.hide(); + startBatchExport(validRows); + }; + confirmBtn.addEventListener("click", pendingBatchConfirmHandler, { once: true }); + }) + .catch((error) => { + showValidationSpinner(false); + console.error("Batch validation error:", error); + document.getElementById("exportResponseMessage").textContent = + "Errore di validazione: " + error.message; + document.getElementById("exportResponseModalLabel").textContent = + "Errore Validazione"; + new bootstrap.Modal( + document.getElementById("exportResponseModal"), + { keyboard: false }, + ).show(); + }); + } + + exportAllBtn.addEventListener("click", (e) => { + e.preventDefault(); + if (batchRunning) return; + + // Check unsaved changes first + if (hasUnsavedChanges()) { + const unsavedModal = new bootstrap.Modal( + document.getElementById("exportBatchUnsavedModal"), + { keyboard: false }, + ); + unsavedModal.show(); + + document + .getElementById("batchSaveAndExportBtn") + .addEventListener( + "click", + () => { + unsavedModal.hide(); + // Trigger Save All, then proceed + const saveAllEl = + document.querySelector(".save-all-btn"); + if (!saveAllEl) return; + + // Watch for all .cell-changed to disappear + const observer = new MutationObserver(() => { + if (!hasUnsavedChanges()) { + observer.disconnect(); + const eligibleRows = collectEligibleRows(); + if (eligibleRows.length === 0) { + document.getElementById( + "exportResponseMessage", + ).textContent = + "Tutte le righe sono già state esportate al LIMS."; + document.getElementById( + "exportResponseModalLabel", + ).textContent = "Export All"; + new bootstrap.Modal( + document.getElementById( + "exportResponseModal", + ), + { keyboard: false }, + ).show(); + return; + } + showBatchConfirm(eligibleRows); + } + }); + observer.observe( + document.querySelector(".grid-container"), + { + subtree: true, + attributes: true, + attributeFilter: ["class"], + }, + ); + + saveAllEl.click(); + }, + { once: true }, + ); + return; + } + + const eligibleRows = collectEligibleRows(); + + if (eligibleRows.length === 0) { + document.getElementById("exportResponseMessage").textContent = + "Tutte le righe sono già state esportate al LIMS."; + document.getElementById("exportResponseModalLabel").textContent = + "Export All"; + const modal = new bootstrap.Modal( + document.getElementById("exportResponseModal"), + { keyboard: false }, + ); + modal.show(); + return; + } + + showBatchConfirm(eligibleRows); + }); + + function startBatchExport(eligibleRows) { + batchCancelled = false; + batchRunning = true; + // Don't clear validation errors — they should stay visible for invalid rows + const batchUuid = crypto.randomUUID(); + const total = eligibleRows.length; + let processed = 0; + let succeeded = 0; + let failed = 0; + + // Disable all row buttons + disableAllRowButtons(); + + // Show inline status bar + const bar = document.getElementById("batchExportBar"); + const statusEl = document.getElementById("batchExportStatus"); + const cancelBtn = document.getElementById("exportBatchCancelBtn"); + bar.style.display = ""; + cancelBtn.disabled = false; + statusEl.textContent = `Esportazione 0 / ${total}...`; + + // Cancel handler + cancelBtn.addEventListener( + "click", + () => { + batchCancelled = true; + statusEl.textContent = + "Annullamento... (attendi riga corrente)"; + cancelBtn.disabled = true; + }, + { once: true }, + ); + + (async () => { + for (let i = 0; i < eligibleRows.length; i++) { + if (batchCancelled) break; + + const { iddatadb, row } = eligibleRows[i]; + statusEl.textContent = `Esportazione ${processed + 1} / ${total} (id: ${iddatadb})...`; + + // Highlight current row + setRowExporting(row, true); + + try { + const data = await sendExport(iddatadb, row, batchUuid); + processed++; + if (data.success) { + succeeded++; + } else { + failed++; + showRowError(row, iddatadb, data.message || "Errore sconosciuto"); + } + } catch (error) { + processed++; + failed++; + showRowError(row, iddatadb, error.message); + } + + // Remove highlight from current row + setRowExporting(row, false); + } + + // Finished + batchRunning = false; + enableAllRowButtons(); + bar.style.display = "none"; + + // Show result modal + const msgEl = document.getElementById("exportResponseMessage"); + const labelEl = document.getElementById("exportResponseModalLabel"); + + if (batchCancelled) { + labelEl.textContent = "Export All — Annullato"; + msgEl.innerHTML = + `Esportate: ${succeeded}
` + + `Errori: ${failed}
` + + `Non processate: ${total - processed}`; + } else if (failed === 0) { + labelEl.textContent = "Export All — Completato"; + msgEl.innerHTML = `Tutte le ${succeeded} righe esportate con successo.`; + } else { + labelEl.textContent = "Export All — Completato con errori"; + msgEl.innerHTML = + `Esportate: ${succeeded}
` + + `Errori: ${failed}`; + } + + const modalEl = document.getElementById("exportResponseModal"); + const modal = new bootstrap.Modal(modalEl, { keyboard: false }); + modal.show(); + modalEl.addEventListener("hidden.bs.modal", cleanupBackdrop, { once: true }); + })(); + } }); diff --git a/public/userarea/export_to_lims.php b/public/userarea/export_to_lims.php index ab410bb..b94ea45 100644 --- a/public/userarea/export_to_lims.php +++ b/public/userarea/export_to_lims.php @@ -18,6 +18,26 @@ $uploadDir = realpath(__DIR__ . '/../photostrf') . '/'; // 🔹 Base URL API $apiBaseUrl = 'https://93.43.5.102/limsapi/api/odata/'; +// 🔹 Batch UUID — if present, all logs go to a single file +$batchUuid = $_POST['batch_uuid'] ?? null; + +$writeLog = (function () use ($batchUuid, $logDir) { + $batchLogFile = $batchUuid ? $logDir . "batch_export_{$batchUuid}.log" : null; + + return function ($individualPath, $content, $stepLabel = null) use ($batchLogFile) { + if ($batchLogFile) { + $header = "\n" . str_repeat("=", 60) . "\n"; + if ($stepLabel) { + $header .= "[{$stepLabel}] " . date('Y-m-d H:i:s') . "\n"; + } + $header .= str_repeat("=", 60) . "\n"; + file_put_contents($batchLogFile, $header . $content . "\n", FILE_APPEND); + } else { + file_put_contents($individualPath, $content); + } + }; +})(); + // 🔹 Funzione per validare e convertire date function validateDate($value) { @@ -35,6 +55,11 @@ try { throw new Exception("Missing iddatadb"); } + // TEMP: simulate error on every other row for testing + if (env('SIMULATE_EXPORT_LIMS') && $iddatadb % 2 === 0) { + throw new Exception("Simulated error for iddatadb $iddatadb"); + } + // 🔹 STEP 1+2: Fetch Cliente ID from datadb and Schema ID from excel_templates // Also fetch fixed fields stored in datadb $stmt = $pdo->prepare(" @@ -77,6 +102,23 @@ try { $stmt->execute(['iddatadb' => $iddatadb]); $parts = $stmt->fetchAll(PDO::FETCH_ASSOC); + // 🔹 STEP 4a: Auto-populate export_date / export_time fields + $stmt = $pdo->prepare(" + UPDATE import_data_details idd + JOIN template_mapping m ON idd.mapping_id = m.id + SET idd.field_value = CASE m.auto_value + WHEN 'export_date' THEN :export_date + WHEN 'export_time' THEN :export_time + END + WHERE idd.id = :iddatadb + AND m.auto_value IN ('export_date', 'export_time') + "); + $stmt->execute([ + 'iddatadb' => $iddatadb, + 'export_date' => date('Y-m-d'), + 'export_time' => date('H:i'), + ]); + // 🔹 STEP 4: Fetch Field Values with Labels $stmt = $pdo->prepare(" SELECT @@ -139,7 +181,7 @@ try { // Salva log $logFileStep5 = $logDir . "commessa_create_step5_" . $iddatadb . "_" . time() . ".txt"; - file_put_contents($logFileStep5, $logContentStep5); + $writeLog($logFileStep5, $logContentStep5, "STEP 5 - Create CommessaWeb (iddatadb={$iddatadb})"); $commessaId = $commessaWeb["IdCommessa"]; $commessaWebCode = substr($commessaWeb["CodiceCommessa"] ?? "TEST CommessaWeb", 0, 30); // Limite a 30 caratteri @@ -187,11 +229,11 @@ try { // Salva log per STEP 6 $logFileStep6 = $logDir . "commessa_{$commessaId}_campioni_step6_" . time() . ".txt"; - file_put_contents($logFileStep6, $logContentStep6); + $writeLog($logFileStep6, $logContentStep6, "STEP 6 - Campioni (commessa={$commessaId})"); // 🔹 STEP 6.1: Fetch photos linked to this iddatadb $stmtPhotos = $pdo->prepare(" - SELECT id, file_path, file_name + SELECT id, file_path, file_name, StampaNelRapporto, PrimaPagina FROM datadb_photos WHERE iddatadb = :iddatadb ORDER BY id ASC @@ -222,11 +264,20 @@ try { } $photoEndpoint = "Campione({$campioneId})/UploadCampioneFile"; + $stampaNelRapporto = !empty($photo['StampaNelRapporto']) ? 'true' : 'false'; + $primaPagina = !empty($photo['PrimaPagina']) ? 'true' : 'false'; + $logContentPhotos .= "curl --location --request POST '{$apiBaseUrl}{$photoEndpoint}' \\\n" . "--header 'Authorization: Bearer ••••••' \\\n" . - "--form 'file=@{$fullPath}'\n\n"; + "--form 'file=@{$fullPath}' \\\n" . + "--form 'StampaNelRapporto={$stampaNelRapporto}' \\\n" . + "--form 'PrimaPagina={$primaPagina}'\n\n"; - $photoResult = $api->postMultipart($photoEndpoint, $fullPath, $photo['file_name']); + $extraFields = [ + 'StampaNelRapporto' => $stampaNelRapporto, + 'PrimaPagina' => $primaPagina, + ]; + $photoResult = $api->postMultipart($photoEndpoint, $fullPath, $photo['file_name'], $extraFields); $logContentPhotos .= "RESPONSE:\n" . json_encode($photoResult, JSON_PRETTY_PRINT) . "\n\n---\n"; $photosUploaded++; } @@ -238,7 +289,7 @@ try { } $logFilePhotos = $logDir . "commessa_{$commessaId}_photos_step5_2_" . time() . ".txt"; - file_put_contents($logFilePhotos, $logContentPhotos); + $writeLog($logFilePhotos, $logContentPhotos, "STEP 6.2 - Photos (commessa={$commessaId})"); // 🔹 STEP 7: Update Custom Fields for CommessaWeb if (!empty($fieldValues)) { @@ -251,7 +302,7 @@ try { "--header 'Authorization: Bearer ••••••'\n\n" . "RESPONSE:\n" . json_encode($commessaWithFields, JSON_PRETTY_PRINT); $logFileGet = $logDir . "commessa_{$commessaId}_get_step7_" . time() . ".txt"; - file_put_contents($logFileGet, $logContentGet); + $writeLog($logFileGet, $logContentGet, "STEP 7 - GET CustomFields (commessa={$commessaId})"); // Prepara payload PATCH $commessaCustomFields = []; @@ -288,7 +339,7 @@ try { $logContentStep7 .= "\n\nRESPONSE:\n" . json_encode($patchResponse, JSON_PRETTY_PRINT); $logFileStep7 = $logDir . "commessa_{$commessaId}_update_step7_" . time() . ".txt"; - file_put_contents($logFileStep7, $logContentStep7); + $writeLog($logFileStep7, $logContentStep7, "STEP 7 - PATCH CustomFields (commessa={$commessaId})"); } } @@ -315,7 +366,7 @@ try { "--data '{}'\n\n" . "RESPONSE:\n" . json_encode($sendResult, JSON_PRETTY_PRINT); $logFileStep9 = $logDir . "commessa_{$commessaId}_send_step9_" . time() . ".txt"; - file_put_contents($logFileStep9, $logContentStep9); + $writeLog($logFileStep9, $logContentStep9, "STEP 9 - InviaCommessa (commessa={$commessaId})"); // 🔹 STEP 9.5: Importazione da CommessaWeb a Commessa (commentato come richiesto) @@ -332,7 +383,7 @@ try { "--data '{$importPayloadLog}'\n\n" . "RESPONSE:\n" . json_encode($importResult, JSON_PRETTY_PRINT); $logFileStep91 = $logDir . "commessa_{$commessaId}_importa_step91_" . time() . ".txt"; - file_put_contents($logFileStep91, $logContentStep91); + $writeLog($logFileStep91, $logContentStep91, "STEP 9.5 - ImportaCommessa (commessa={$commessaId})"); // 🔹 STEP 10: GET di controllo post-PATCH $expand = "CommesseCustomFields(\$expand=CustomField)"; @@ -343,7 +394,7 @@ try { "--header 'Authorization: Bearer ••••••'\n\n" . "RESPONSE:\n" . json_encode($commessaAfterPatch, JSON_PRETTY_PRINT); $logFileStep10 = $logDir . "commessa_{$commessaId}_get_step10_" . time() . ".txt"; - file_put_contents($logFileStep10, $logContentStep10); + $writeLog($logFileStep10, $logContentStep10, "STEP 10 - GET verify (commessa={$commessaId})"); // 🔹 STEP 11: Prepare final response $finalCommessa = [ diff --git a/public/userarea/import_edit2.php b/public/userarea/import_edit2.php index 75591ca..a1d041d 100644 --- a/public/userarea/import_edit2.php +++ b/public/userarea/import_edit2.php @@ -347,6 +347,118 @@ function fixedDefaultValue(array $f): string background-color: #e9ecef; } + .grid-row.batch-exporting { + background: linear-gradient(90deg, #fff0f0 0%, #ffe0e0 50%, #fff0f0 100%) !important; + background-size: 200% 100% !important; + animation: batch-pulse 1.5s ease-in-out infinite; + position: relative; + z-index: 1; + } + + .grid-row.batch-exporting .button-cell { + background: transparent !important; + } + + @keyframes batch-pulse { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + } + + .batch-row-spinner { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #eb0b0b; + font-weight: 500; + } + + .batch-row-spinner i { + font-size: 16px; + } + + .grid-row.batch-disabled { + opacity: 0.5; + } + + .grid-row.batch-disabled .action-btn { + pointer-events: none; + } + + .grid-row.batch-row-error { + background: #fff3f3 !important; + border-left: 3px solid #dc3545; + } + + .grid-row.batch-row-error .button-cell { + background: #fff3f3 !important; + } + + .batch-error-msg { + color: #dc3545; + font-size: 10px; + line-height: 1.2; + display: block; + padding: 2px 0; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + } + + .batch-error-msg:hover { + text-decoration: underline; + } + + .grid-row.validation-row-error { + background: #fff3f3 !important; + border-left: 3px solid #dc3545; + } + + .grid-cell.validation-error { + background-color: #f8d7da !important; + position: relative; + } + + .input-validation-error, + .input-validation-error:focus { + border: 2px solid #dc3545 !important; + background-color: #fff5f5 !important; + box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.25) !important; + outline: none !important; + } + + .validation-tooltip { + display: none; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: #dc3545; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + z-index: 1050; + pointer-events: none; + } + + .validation-tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: #dc3545; + } + + .grid-cell.validation-error:hover .validation-tooltip { + display: block; + } + .grid-header, .grid-cell { flex: 1; @@ -472,7 +584,10 @@ function fixedDefaultValue(array $f): string .grid-top .save-all-cell { flex: 0 0 210px; - align-items: flex-start; + display: flex; + align-items: center; + justify-content: center; + overflow: visible; } .grid-top .grid-cell input, @@ -633,29 +748,51 @@ function fixedDefaultValue(array $f): string transition: background-color 0.3s ease; } - .save-all-btn { - background-color: #28a745; + .actions-dropdown .dropdown-toggle { + background-color: #6c757d; color: white; border: none; border-radius: 5px; - padding: 8px 16px; + padding: 6px 14px; cursor: pointer; - font-size: 14px; + font-size: 13px; } - .save-all-btn:hover { - background-color: #218838; + .actions-dropdown .dropdown-toggle:hover, + .actions-dropdown .dropdown-toggle:focus { + background-color: #5a6268; + } + + .actions-dropdown .dropdown-menu { + min-width: 160px; + font-size: 13px; + z-index: 1050; + } + + + .actions-dropdown .dropdown-item i { + width: 18px; + text-align: center; + margin-right: 6px; } #exportConfirmModal, #exportResponseModal, - #exportUnsavedModal { + #exportUnsavedModal, + #exportBatchConfirmModal, + #exportBatchUnsavedModal, + #saveAllConfirmModal, + #saveAllResultModal { z-index: 1300 !important; } #exportConfirmModal .modal-backdrop, #exportResponseModal .modal-backdrop, - #exportUnsavedModal .modal-backdrop { + #exportUnsavedModal .modal-backdrop, + #exportBatchConfirmModal .modal-backdrop, + #exportBatchUnsavedModal .modal-backdrop, + #saveAllConfirmModal .modal-backdrop, + #saveAllResultModal .modal-backdrop { z-index: 1299 !important; } @@ -745,21 +882,36 @@ function fixedDefaultValue(array $f): string
-
-
-
- -
+
hasRole('Admin'))) : ?> - + + @@ -1349,6 +1502,19 @@ function fixedDefaultValue(array $f): string // (se vuoi mantenere highlight cella gialla, lascia questa riga) if (gridCell) gridCell.classList.add("cell-changed"); + // Clear validation error on this field when user edits it + el.classList.remove("input-validation-error"); + if (gridCell) { + gridCell.classList.remove("validation-error"); + const tooltip = gridCell.querySelector(".validation-tooltip"); + if (tooltip) tooltip.remove(); + // If no more validation errors on the row, remove row highlight + const gridRow = gridCell.closest(".grid-row"); + if (gridRow && !gridRow.querySelector(".validation-error")) { + gridRow.classList.remove("validation-row-error"); + } + } + renderChangedRows(); }); }); @@ -1446,21 +1612,65 @@ function fixedDefaultValue(array $f): string }); }); - document.querySelector('.save-all-btn').addEventListener('click', async () => { - const rows = document.querySelectorAll('.grid-row'); + let saveAllRunning = false; + document.querySelector('.save-all-btn').addEventListener('click', (e) => { + e.preventDefault(); + if (saveAllRunning || window.batchRunning) return; + const confirmModal = new bootstrap.Modal(document.getElementById('saveAllConfirmModal'), { keyboard: false }); + confirmModal.show(); + }); + + document.getElementById('saveAllConfirmBtn').addEventListener('click', async () => { + bootstrap.Modal.getInstance(document.getElementById('saveAllConfirmModal')).hide(); + saveAllRunning = true; + + // Clear previous row errors + document.querySelectorAll('.grid-row.batch-row-error').forEach(r => { + r.classList.remove('batch-row-error'); + const msg = r.querySelector('.batch-error-msg'); + if (msg) msg.remove(); + }); + + // Disable all row buttons + document.querySelectorAll('.grid-row[data-id]').forEach(r => r.classList.add('batch-disabled')); + const toggle = document.querySelector('.actions-dropdown .dropdown-toggle'); + if (toggle) { toggle.disabled = true; toggle.style.opacity = '0.5'; toggle.style.pointerEvents = 'none'; } + + // Show status bar + const bar = document.getElementById('batchExportBar'); + const statusEl = document.getElementById('batchExportStatus'); + const cancelBtn = document.getElementById('exportBatchCancelBtn'); + bar.style.display = ''; + cancelBtn.style.display = 'none'; + + const rows = document.querySelectorAll('.grid-row[data-id]'); + const total = rows.length; + let processed = 0; let successCount = 0; let errorMessages = []; for (const row of rows) { const saveBtn = row.querySelector('.save-btn'); - if (!saveBtn) { - continue; - } + if (!saveBtn) { continue; } const rowIndex = saveBtn.dataset.row; const iddatadb = row.getAttribute('data-id'); - if (!rowIndex || !iddatadb) { - continue; + if (!rowIndex || !iddatadb) { continue; } + + processed++; + statusEl.textContent = `Salvataggio ${processed} / ${total} (id: ${iddatadb})...`; + + // Highlight current row + row.classList.remove('batch-disabled'); + row.classList.add('batch-exporting'); + const btnCell = row.querySelector('.button-cell'); + if (btnCell) { + btnCell.querySelectorAll('.action-btn').forEach(b => { b.dataset.prevDisplay = b.style.display; b.style.display = 'none'; }); + const spinner = document.createElement('span'); + spinner.className = 'batch-row-spinner'; + spinner.innerHTML = ' Saving...'; + btnCell.appendChild(spinner); } + const formData = new FormData(); const inputs = row.querySelectorAll(`input[name^="rows[${rowIndex}][details]"], select[name^="rows[${rowIndex}][details]"]`); @@ -1479,12 +1689,11 @@ function fixedDefaultValue(array $f): string if (idclientSelect) { formData.append('idclient', idclientSelect.value); } - // ---- FIXED FIELDS (NEW) ---- + // ---- FIXED FIELDS ---- const fixedInputs = row.querySelectorAll(`input[name^="rows[${rowIndex}]["], select[name^="rows[${rowIndex}]["]`); fixedInputs.forEach(inp => { if (!inp.classList.contains('fixed-input')) return; const m = inp.name.match(/rows\[\d+\]\[([^\]]+)\]/); - // Map: fixed key (logical) -> datadb real column const fixedAliasMap = { ClienteResponsabile: 'cliente_responsabile_id', MoltiplicatorePrezzo: 'moltiplicatore_prezzo_id', @@ -1492,13 +1701,10 @@ function fixedDefaultValue(array $f): string AnagraficaCertestService: 'anagrafica_certest_service_id', ConsegnaRichiesta: 'consegna_richiesta' }; - if (m && m[1]) { - const logicalKey = m[1]; - const realKey = fixedAliasMap[logicalKey] || logicalKey; + const realKey = fixedAliasMap[m[1]] || m[1]; formData.append(realKey, inp.value); } - }); formData.append('iddatadb', iddatadb); @@ -1524,24 +1730,59 @@ function fixedDefaultValue(array $f): string changedRows.delete(rowIndex); document.querySelectorAll(`.grid-cell[data-row="${rowIndex}"]`) .forEach(cell => cell.classList.remove("cell-changed")); - } else { errorMessages.push(`Riga ${parseInt(rowIndex) + 1}: ${data.message}`); + // Show error on the row + row.classList.add('batch-row-error'); + if (btnCell) { + const errEl = document.createElement('div'); + errEl.className = 'batch-error-msg'; + errEl.textContent = '⚠ ' + (data.message || 'Errore'); + btnCell.appendChild(errEl); + } } } catch (error) { errorMessages.push(`Riga ${parseInt(rowIndex) + 1}: ${error.message}`); + row.classList.add('batch-row-error'); + if (btnCell) { + const errEl = document.createElement('div'); + errEl.className = 'batch-error-msg'; + errEl.textContent = '⚠ ' + error.message; + btnCell.appendChild(errEl); + } + } + + // Remove highlight from current row + row.classList.remove('batch-exporting'); + row.classList.add('batch-disabled'); + if (btnCell) { + const sp = btnCell.querySelector('.batch-row-spinner'); + if (sp) sp.remove(); + btnCell.querySelectorAll('.action-btn').forEach(b => { b.style.display = b.dataset.prevDisplay || ''; delete b.dataset.prevDisplay; }); } } + // Finished renderChangedRows(); hasChanges = changedRows.size > 0; + saveAllRunning = false; + bar.style.display = 'none'; + cancelBtn.style.display = ''; + // Re-enable all row buttons + document.querySelectorAll('.grid-row[data-id]').forEach(r => r.classList.remove('batch-disabled')); + if (toggle) { toggle.disabled = false; toggle.style.opacity = ''; toggle.style.pointerEvents = ''; } + const resultEl = document.getElementById('saveAllResultMessage'); + const resultLabel = document.getElementById('saveAllResultModalLabel'); if (errorMessages.length === 0) { - alert(`Tutte le ${successCount} righe salvate con successo!`); + resultLabel.textContent = 'Salvataggio Completato'; + resultEl.innerHTML = ` Tutte le ${successCount} righe salvate con successo.`; } else { - alert(`Salvate ${successCount} righe con successo.\nErrori:\n${errorMessages.join('\n')}`); + resultLabel.textContent = 'Salvataggio con Errori'; + resultEl.innerHTML = ` Salvate ${successCount} righe con successo.

Errori:
` + errorMessages.join('
'); } + new bootstrap.Modal(document.getElementById('saveAllResultModal'), { keyboard: false }).show(); }); window.addEventListener("beforeunload", function(e) { @@ -1553,7 +1794,7 @@ function fixedDefaultValue(array $f): string }); // Gestisci la chiusura dei modali per rimuovere i backdrop - document.querySelectorAll('#exportConfirmModal, #exportResponseModal, #exportUnsavedModal').forEach(modal => { + document.querySelectorAll('#exportConfirmModal, #exportResponseModal, #exportUnsavedModal, #exportBatchConfirmModal, #exportBatchUnsavedModal, #saveAllConfirmModal, #saveAllResultModal').forEach(modal => { modal.addEventListener('hidden.bs.modal', () => { // Rimuovi tutti i backdrop residui document.querySelectorAll('.modal-backdrop').forEach(backdrop => { @@ -2744,6 +2985,82 @@ function fixedDefaultValue(array $f): string
+ + + + + + + + + + + +