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