diff --git a/db/migrations/20260723100916_drop_uniq_part_analysis_index.php b/db/migrations/20260723100916_drop_uniq_part_analysis_index.php new file mode 100644 index 0000000..3927e7a --- /dev/null +++ b/db/migrations/20260723100916_drop_uniq_part_analysis_index.php @@ -0,0 +1,29 @@ +table('identification_parts_analyses'); + + if ($table->hasIndexByName('uniq_part_analysis')) { + $table->removeIndexByName('uniq_part_analysis')->update(); + } + } + + public function down(): void + { + $table = $this->table('identification_parts_analyses'); + + if (!$table->hasIndexByName('uniq_part_analysis')) { + $table->addIndex(['part_id', 'analysis_recordkey'], [ + 'unique' => true, + 'name' => 'uniq_part_analysis', + ])->update(); + } + } +} diff --git a/public/userarea/analysisModal.js b/public/userarea/analysisModal.js index dc813e5..96bee35 100644 --- a/public/userarea/analysisModal.js +++ b/public/userarea/analysisModal.js @@ -106,8 +106,9 @@ } container.innerHTML = items - .map(function (item) { + .map(function (item, index) { const recordKey = item.analysis_recordkey || ""; + const rowId = item.id != null ? item.id : ""; const title = item.analysis_name || "Unnamed analysis"; const method = item.analysis_method || ""; @@ -121,6 +122,8 @@ class="analysis-remove-btn" data-part-id="${escapeHtml(partId)}" data-recordkey="${escapeHtml(recordKey)}" + data-row-id="${escapeHtml(rowId)}" + data-index="${index}" title="Remove analysis">× `; @@ -166,19 +169,21 @@ }; } - function addAnalysisToLocalState(partId, payload, iddatadb, idmatrice) { + function addAnalysisToLocalState( + partId, + payload, + iddatadb, + idmatrice, + newId, + ) { const key = String(partId); if (!Array.isArray(analysisAssignedState[key])) { analysisAssignedState[key] = []; } - const exists = analysisAssignedState[key].some(function (item) { - return item.analysis_recordkey === payload.analysis_recordkey; - }); - - if (!exists) { + { analysisAssignedState[key].push({ - id: null, + id: newId || null, part_id: parseInt(partId, 10), iddatadb: iddatadb || null, idmatrice: idmatrice || null, @@ -195,17 +200,24 @@ } } - function removeAnalysisFromLocalState(partId, recordKey) { + function removeAnalysisFromLocalState(partId, recordKey, index) { const key = String(partId); if (!Array.isArray(analysisAssignedState[key])) { return; } - analysisAssignedState[key] = analysisAssignedState[key].filter( - function (item) { - return item.analysis_recordkey !== recordKey; - }, - ); + const i = parseInt(index, 10); + if (!isNaN(i) && i >= 0 && i < analysisAssignedState[key].length) { + analysisAssignedState[key].splice(i, 1); + return; + } + + const found = analysisAssignedState[key].findIndex(function (item) { + return item.analysis_recordkey === recordKey; + }); + if (found !== -1) { + analysisAssignedState[key].splice(found, 1); + } } function saveAnalysisAssociation(partId, payload, callback) { @@ -237,12 +249,13 @@ is_accredited: payload.is_accredited, }, }) - .done(function () { + .done(function (resp) { addAnalysisToLocalState( partId, payload, iddatadb, idmatrice !== "NO_MATRIX" ? idmatrice : null, + resp && resp.id ? resp.id : null, ); renderAssignedAnalysesForPart(partId); if (typeof callback === "function") callback(true); @@ -257,7 +270,13 @@ }); } - function deleteAnalysisAssociation(partId, recordKey, callback) { + function deleteAnalysisAssociation( + partId, + recordKey, + rowId, + index, + callback, + ) { $.ajax({ url: "delete_part_analysis.php", method: "POST", @@ -265,10 +284,11 @@ data: { part_id: partId, analysis_recordkey: recordKey, + row_id: rowId || "", }, }) .done(function () { - removeAnalysisFromLocalState(partId, recordKey); + removeAnalysisFromLocalState(partId, recordKey, index); renderAssignedAnalysesForPart(partId); if (typeof callback === "function") callback(true); }) @@ -707,10 +727,18 @@ const partId = removeBtn.getAttribute("data-part-id"); const recordKey = removeBtn.getAttribute("data-recordkey"); + const rowId = removeBtn.getAttribute("data-row-id"); + const index = removeBtn.getAttribute("data-index"); - deleteAnalysisAssociation(partId, recordKey, function () { - const stillUsed = Object.keys(analysisAssignedState).some( - function (pid) { + deleteAnalysisAssociation( + partId, + recordKey, + rowId, + index, + function () { + const stillUsed = Object.keys( + analysisAssignedState, + ).some(function (pid) { return ( Array.isArray(analysisAssignedState[pid]) && analysisAssignedState[pid].some( @@ -722,15 +750,15 @@ }, ) ); - }, - ); + }); - if (!stillUsed) { - delete analysisSelectedState[recordKey]; - } + if (!stillUsed) { + delete analysisSelectedState[recordKey]; + } - syncSelectedAnalysisRows(); - }); + syncSelectedAnalysisRows(); + }, + ); return; } @@ -751,17 +779,6 @@ } const recordKey = payload.analysis_recordkey; - const alreadySelected = !!analysisSelectedState[recordKey]; - - if (alreadySelected) { - selectedPartIds.forEach(function (partId) { - deleteAnalysisAssociation(partId, recordKey); - }); - - delete analysisSelectedState[recordKey]; - syncSelectedAnalysisRows(); - return; - } let pending = selectedPartIds.length; let atLeastOneSaved = false; diff --git a/public/userarea/clone_parts_to_visible.php b/public/userarea/clone_parts_to_visible.php index 23a4f3a..a2a9222 100644 --- a/public/userarea/clone_parts_to_visible.php +++ b/public/userarea/clone_parts_to_visible.php @@ -14,6 +14,7 @@ $targetList = $data['target_iddatadb_list'] ?? []; // (comportamento di default richiesto, coerente con la checkbox pre-spuntata) $cloneNotes = array_key_exists('clone_notes', $data) ? !empty($data['clone_notes']) : true; $cloneAnalyses = !empty($data['clone_analyses']); +$overwrite = !empty($data['overwrite']); $targetIds = array_values(array_unique(array_filter(array_map('intval', (array)$targetList), function ($v) use ($sourceIddatadb) { return $v > 0 && $v !== $sourceIddatadb; @@ -93,6 +94,28 @@ try { $details = []; $totalClonedParts = 0; $totalClonedAnalyses = 0; + $totalDeletedParts = 0; + + // 2b. Se overwrite: elimina le parti esistenti dei target (e i loro figli) + if ($overwrite) { + $stmtSelectExisting = $pdo->prepare("SELECT id FROM identification_parts WHERE iddatadb = ?"); + $stmtDeleteCFByPart = $pdo->prepare("DELETE FROM identification_parts_customfields WHERE part_id = ?"); + $stmtDeleteAnByPart = $pdo->prepare("DELETE FROM identification_parts_analyses WHERE part_id = ?"); + $stmtDeletePart = $pdo->prepare("DELETE FROM identification_parts WHERE id = ?"); + + foreach ($targetIds as $targetIddatadb) { + $stmtSelectExisting->execute([$targetIddatadb]); + $existingIds = $stmtSelectExisting->fetchAll(PDO::FETCH_COLUMN); + + foreach ($existingIds as $existingPartId) { + $existingPartId = (int)$existingPartId; + $stmtDeleteCFByPart->execute([$existingPartId]); + $stmtDeleteAnByPart->execute([$existingPartId]); + $stmtDeletePart->execute([$existingPartId]); + $totalDeletedParts++; + } + } + } // 3. Clone source parts to each target record foreach ($targetIds as $targetIddatadb) { diff --git a/public/userarea/delete_part_analysis.php b/public/userarea/delete_part_analysis.php index d8bb468..91ed5b0 100644 --- a/public/userarea/delete_part_analysis.php +++ b/public/userarea/delete_part_analysis.php @@ -15,8 +15,9 @@ try { $partId = isset($_POST['part_id']) ? (int)$_POST['part_id'] : 0; $analysisRecordkey = trim($_POST['analysis_recordkey'] ?? ''); + $rowId = isset($_POST['row_id']) && $_POST['row_id'] !== '' ? (int)$_POST['row_id'] : 0; - if ($partId <= 0 || $analysisRecordkey === '') { + if ($partId <= 0 || ($analysisRecordkey === '' && $rowId <= 0)) { http_response_code(400); echo json_encode(['success' => false, 'message' => 'Missing required data']); exit; @@ -25,15 +26,29 @@ try { $db = DBHandlerSelect::getInstance(); $pdo = $db->getConnection(); - $stmt = $pdo->prepare(" - DELETE FROM identification_parts_analyses - WHERE part_id = :part_id - AND analysis_recordkey = :analysis_recordkey - "); - $stmt->execute([ - ':part_id' => $partId, - ':analysis_recordkey' => $analysisRecordkey, - ]); + if ($rowId > 0) { + $stmt = $pdo->prepare(" + DELETE FROM identification_parts_analyses + WHERE id = :id + AND part_id = :part_id + LIMIT 1 + "); + $stmt->execute([ + ':id' => $rowId, + ':part_id' => $partId, + ]); + } else { + $stmt = $pdo->prepare(" + DELETE FROM identification_parts_analyses + WHERE part_id = :part_id + AND analysis_recordkey = :analysis_recordkey + LIMIT 1 + "); + $stmt->execute([ + ':part_id' => $partId, + ':analysis_recordkey' => $analysisRecordkey, + ]); + } echo json_encode([ 'success' => true, diff --git a/public/userarea/exportLims_gridData.js b/public/userarea/exportLims_gridData.js index 3c1730a..44bb57a 100644 --- a/public/userarea/exportLims_gridData.js +++ b/public/userarea/exportLims_gridData.js @@ -5,7 +5,7 @@ * Single export + batch export (Export All) with validation. */ (function () { - 'use strict'; + "use strict"; let pendingConfirmHandler = null; let batchRunning = false; @@ -17,7 +17,7 @@ // ── Helpers ────────────────────────────────────────────────────────── function cleanupBackdrop() { - document.querySelectorAll(".modal-backdrop").forEach(b => b.remove()); + document.querySelectorAll(".modal-backdrop").forEach((b) => b.remove()); document.body.classList.remove("modal-open"); document.body.style.paddingRight = ""; const overlay = document.querySelector(".overlay.toggle-icon"); @@ -29,7 +29,9 @@ } function getRowIndexByIddatadb(iddatadb) { - return (window.gridData || []).findIndex(r => String(r.iddatadb) === String(iddatadb)); + return (window.gridData || []).findIndex( + (r) => String(r.iddatadb) === String(iddatadb), + ); } // ── Validation ────────────────────────────────────────────────────── @@ -40,21 +42,31 @@ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rows: rowsToValidate }), }); - if (!response.ok) throw new Error(`Validation HTTP error: ${response.status}`); + if (!response.ok) + throw new Error(`Validation HTTP error: ${response.status}`); return response.json(); } function clearValidationErrors() { // Clear from gridData - (window.gridData || []).forEach(row => { delete row._validationErrors; delete row._exportError; }); - - 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(); + (window.gridData || []).forEach((row) => { + delete row._validationErrors; + delete row._exportError; }); - document.querySelectorAll(".grid-row.validation-row-error").forEach(row => row.classList.remove("validation-row-error")); + + 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")); clearAllRowErrors(); } @@ -67,7 +79,7 @@ gridRow.classList.add("validation-row-error"); const messages = []; - errors.forEach(err => { + errors.forEach((err) => { messages.push(err.message); if (!err.field) return; @@ -76,19 +88,26 @@ 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"); + headers.forEach((h) => { + if (h.textContent.trim() === label) + targetIndex = h.getAttribute("data-index"); }); if (targetIndex) { - cell = gridRow.querySelector(`.grid-cell[data-index="${targetIndex}"]`); + cell = gridRow.querySelector( + `.grid-cell[data-index="${targetIndex}"]`, + ); } } else { - cell = gridRow.querySelector(`.grid-cell[data-col="${err.field}"]`); + cell = gridRow.querySelector( + `.grid-cell[data-col="${err.field}"]`, + ); } if (cell) { cell.classList.add("validation-error"); - cell.querySelectorAll("input, select").forEach(el => el.classList.add("input-validation-error")); + cell.querySelectorAll("input, select").forEach((el) => + el.classList.add("input-validation-error"), + ); let tooltip = cell.querySelector(".validation-tooltip"); if (!tooltip) { tooltip = document.createElement("div"); @@ -109,34 +128,43 @@ 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 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) { // Update gridData const idx = getRowIndexByIddatadb(iddatadb); if (idx >= 0) { - window.gridData[idx].status = 'l'; + window.gridData[idx].status = "l"; window.gridData[idx].commessaweb = data.commessaweb; } // Update visible DOM row const gridRow = getGridRow(iddatadb); if (gridRow) { - const statusBadge = gridRow.querySelector('.grid-cell[data-col="status"] .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"; } - const statusCell = gridRow.querySelector('.grid-cell[data-col="status"]'); + 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.style.cssText = + "display:block; font-size:0.75em; color:#555; margin-top:2px;"; statusCell.appendChild(cwSpan); } cwSpan.textContent = data.commessaweb; @@ -164,7 +192,8 @@ const label = document.getElementById("exportResponseModalLabel"); if (data.success) { - msg.innerHTML = `${data.message.replace(/\n/g, "
")}` + + msg.innerHTML = + `${data.message.replace(/\n/g, "
")}` + `
ID CommessaWeb: ${data.idcommessaweb}` + `
Codice CommessaWeb: ${data.commessaweb}` + (data.totalPhotos > 0 ? `
Foto: ${data.totalPhotos}` : ""); @@ -186,10 +215,14 @@ 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"; }); + 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...'; + spinner.innerHTML = + ' Exporting...'; btnCell.appendChild(spinner); } } else { @@ -197,7 +230,10 @@ 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; }); + btnCell.querySelectorAll(".action-btn").forEach((b) => { + b.style.display = b.dataset.prevDisplay || ""; + delete b.dataset.prevDisplay; + }); } } } @@ -213,32 +249,56 @@ errorEl.className = "batch-error-msg"; errorEl.textContent = "Warning — click for details"; errorEl.addEventListener("click", () => { - document.getElementById("exportResponseMessage").innerHTML = message.replace(/\n/g, "
"); - document.getElementById("exportResponseModalLabel").textContent = "Error (id: " + iddatadb + ")"; - new bootstrap.Modal(document.getElementById("exportResponseModal"), { keyboard: false }).show(); + document.getElementById("exportResponseMessage").innerHTML = + message.replace(/\n/g, "
"); + document.getElementById( + "exportResponseModalLabel", + ).textContent = "Error (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(); - }); + 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")); - const toggle = document.querySelector(".actions-dropdown .dropdown-toggle"); - if (toggle) { toggle.disabled = true; toggle.style.opacity = "0.5"; toggle.style.pointerEvents = "none"; } + document + .querySelectorAll(".grid-row[data-id]") + .forEach((row) => row.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"; + } } 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 = ""; } + 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 → send ──────────────────── @@ -250,40 +310,61 @@ if (gridRow) { setRowExporting(gridRow, true); const spinner = gridRow.querySelector(".batch-row-spinner"); - if (spinner) spinner.innerHTML = ' Validating...'; + if (spinner) + spinner.innerHTML = + ' Validating...'; } validateRows([{ iddatadb: parseInt(iddatadb), index: rowIndex }]) - .then(validationData => { - if (gridRow) { setRowExporting(gridRow, false); gridRow.classList.remove("batch-disabled"); } + .then((validationData) => { + if (gridRow) { + setRowExporting(gridRow, false); + gridRow.classList.remove("batch-disabled"); + } if (!validationData.success) { - showExportResult({ success: false, message: validationData.message || "Validation error" }); + showExportResult({ + success: false, + message: validationData.message || "Validation error", + }); return; } const result = validationData.results[rowIndex]; if (result && !result.valid) { - if (gridRow) showValidationErrors(gridRow, iddatadb, result.errors); + if (gridRow) + showValidationErrors(gridRow, iddatadb, result.errors); return; } showConfirmAndExport(iddatadb, rowIndex); }) - .catch(error => { - if (gridRow) { setRowExporting(gridRow, false); gridRow.classList.remove("batch-disabled"); } - showExportResult({ success: false, message: "Validation error: " + error.message }); + .catch((error) => { + if (gridRow) { + setRowExporting(gridRow, false); + gridRow.classList.remove("batch-disabled"); + } + showExportResult({ + success: false, + message: "Validation error: " + error.message, + }); }); } function showConfirmAndExport(iddatadb, rowIndex) { - const confirmModalElement = document.getElementById("exportConfirmModal"); + const confirmModalElement = + document.getElementById("exportConfirmModal"); if (!confirmModalElement) return; - const confirmModal = new bootstrap.Modal(confirmModalElement, { keyboard: false }); + const confirmModal = new bootstrap.Modal(confirmModalElement, { + keyboard: false, + }); document.getElementById("exportIddatadb").textContent = iddatadb; confirmModal.show(); const confirmBtn = document.getElementById("exportConfirmBtn"); - if (!confirmBtn) { confirmModal.hide(); return; } + if (!confirmBtn) { + confirmModal.hide(); + return; + } const confirmHandler = async () => { pendingConfirmHandler = null; @@ -294,24 +375,36 @@ try { const data = await sendExport(iddatadb); - if (gridRow) { setRowExporting(gridRow, false); gridRow.classList.remove("batch-disabled"); } - if (!data.success) showRowError(gridRow, iddatadb, data.message || "Unknown error"); + if (gridRow) { + setRowExporting(gridRow, false); + gridRow.classList.remove("batch-disabled"); + } + if (!data.success) + showRowError( + gridRow, + iddatadb, + data.message || "Unknown error", + ); showExportResult(data); } catch (error) { - if (gridRow) { setRowExporting(gridRow, false); gridRow.classList.remove("batch-disabled"); } + if (gridRow) { + setRowExporting(gridRow, false); + gridRow.classList.remove("batch-disabled"); + } showRowError(gridRow, iddatadb, error.message); showExportResult({ success: false, message: error.message }); } }; - if (pendingConfirmHandler) confirmBtn.removeEventListener("click", pendingConfirmHandler); + if (pendingConfirmHandler) + confirmBtn.removeEventListener("click", pendingConfirmHandler); pendingConfirmHandler = confirmHandler; confirmBtn.addEventListener("click", confirmHandler, { once: true }); } // ── Single row click (event delegation) ───────────────────────────── - $(document).on('click', '.export-lims-btn', function (e) { + $(document).on("click", ".export-lims-btn", function (e) { e.preventDefault(); if (batchRunning) return; @@ -322,24 +415,34 @@ // Check unsaved changes for this row const dataRow = window.gridData?.[rowIndex]; if (dataRow && dataRow._dirty) { - const unsavedModal = new bootstrap.Modal(document.getElementById("exportUnsavedModal"), { keyboard: false }); + const unsavedModal = new bootstrap.Modal( + document.getElementById("exportUnsavedModal"), + { keyboard: false }, + ); unsavedModal.show(); - document.getElementById("saveAndExportBtn")?.addEventListener("click", () => { - unsavedModal.hide(); - // Save first, then export - const formData = window.buildSavePayload(rowIndex); - fetch('save_edited_row.php', { method: 'POST', body: formData }) - .then(r => r.json()) - .then(result => { - if (result.success) { - dataRow._dirty = false; - startExportConfirmFlow(iddatadb, rowIndex); - } else { - alert('Save failed: ' + result.message); - } - }); - }, { once: true }); + document.getElementById("saveAndExportBtn")?.addEventListener( + "click", + () => { + unsavedModal.hide(); + // Save first, then export + const formData = window.buildSavePayload(rowIndex); + fetch("save_edited_row.php", { + method: "POST", + body: formData, + }) + .then((r) => r.json()) + .then((result) => { + if (result.success) { + dataRow._dirty = false; + startExportConfirmFlow(iddatadb, rowIndex); + } else { + alert("Save failed: " + result.message); + } + }); + }, + { once: true }, + ); return; } @@ -349,18 +452,33 @@ // ── Batch export (Export All) ─────────────────────────────────────── function collectEligibleRows() { - // Read from gridData, not DOM + // Read from gridData, not DOM. Se i filtri sono attivi, limita al target set. + const target = + typeof window.getTargetGridIds === "function" + ? window.getTargetGridIds() + : null; const eligible = []; (window.gridData || []).forEach((row, index) => { - if (row.status !== 'l') { - eligible.push({ iddatadb: row.iddatadb, index, row: getGridRow(row.iddatadb) }); - } + if (row.status === "l") return; + if (target && !target.has(parseInt(row.iddatadb, 10))) return; + eligible.push({ + iddatadb: row.iddatadb, + index, + row: getGridRow(row.iddatadb), + }); }); return eligible; } function hasUnsavedChanges() { - return (window.gridData || []).some(r => r._dirty); + const target = + typeof window.getTargetGridIds === "function" + ? window.getTargetGridIds() + : null; + return (window.gridData || []).some( + (r) => + r._dirty && (!target || target.has(parseInt(r.iddatadb, 10))), + ); } async function validateAndFilter(eligibleRows) { @@ -369,7 +487,8 @@ index, })); const validationData = await validateRows(rowsToValidate); - if (!validationData.success) throw new Error(validationData.message || "Validation error"); + if (!validationData.success) + throw new Error(validationData.message || "Validation error"); const validRows = []; let invalidCount = 0; @@ -377,7 +496,12 @@ for (const item of eligibleRows) { const result = validationData.results[item.index]; if (result && !result.valid) { - if (item.row) showValidationErrors(item.row, item.iddatadb, result.errors); + if (item.row) + showValidationErrors( + item.row, + item.iddatadb, + result.errors, + ); invalidCount++; } else { validRows.push(item); @@ -410,54 +534,92 @@ if (validRows.length === 0) { document.getElementById("exportResponseMessage").innerHTML = `No valid rows for export.
${invalidCount} rows with validation errors.`; - document.getElementById("exportResponseModalLabel").textContent = "Validation Failed"; - new bootstrap.Modal(document.getElementById("exportResponseModal"), { keyboard: false }).show(); + document.getElementById( + "exportResponseModalLabel", + ).textContent = "Validation Failed"; + new bootstrap.Modal( + document.getElementById("exportResponseModal"), + { keyboard: false }, + ).show(); return; } - const confirmModal = new bootstrap.Modal(document.getElementById("exportBatchConfirmModal"), { keyboard: false }); + const confirmModal = new bootstrap.Modal( + document.getElementById("exportBatchConfirmModal"), + { keyboard: false }, + ); let countText = String(validRows.length); - if (invalidCount > 0) countText += ` (${invalidCount} excluded due to errors)`; - document.getElementById("exportBatchCount").textContent = countText; + if (invalidCount > 0) + countText += ` (${invalidCount} excluded due to errors)`; + document.getElementById("exportBatchCount").textContent = + countText; confirmModal.show(); - const confirmBtn = document.getElementById("exportBatchConfirmBtn"); - if (pendingBatchConfirmHandler) confirmBtn.removeEventListener("click", pendingBatchConfirmHandler); + const confirmBtn = document.getElementById( + "exportBatchConfirmBtn", + ); + if (pendingBatchConfirmHandler) + confirmBtn.removeEventListener( + "click", + pendingBatchConfirmHandler, + ); pendingBatchConfirmHandler = () => { pendingBatchConfirmHandler = null; confirmModal.hide(); startBatchExport(validRows); }; - confirmBtn.addEventListener("click", pendingBatchConfirmHandler, { once: true }); + confirmBtn.addEventListener( + "click", + pendingBatchConfirmHandler, + { once: true }, + ); }) - .catch(error => { + .catch((error) => { showValidationSpinner(false); - document.getElementById("exportResponseMessage").textContent = "Validation error: " + error.message; - document.getElementById("exportResponseModalLabel").textContent = "Validation Error"; - new bootstrap.Modal(document.getElementById("exportResponseModal"), { keyboard: false }).show(); + document.getElementById("exportResponseMessage").textContent = + "Validation error: " + error.message; + document.getElementById( + "exportResponseModalLabel", + ).textContent = "Validation Error"; + new bootstrap.Modal( + document.getElementById("exportResponseModal"), + { keyboard: false }, + ).show(); }); } - $(document).on('click', '.export-all-lims-btn', function (e) { + $(document).on("click", ".export-all-lims-btn", function (e) { e.preventDefault(); if (batchRunning) return; if (hasUnsavedChanges()) { - const unsavedModal = new bootstrap.Modal(document.getElementById("exportBatchUnsavedModal"), { keyboard: false }); + const unsavedModal = new bootstrap.Modal( + document.getElementById("exportBatchUnsavedModal"), + { keyboard: false }, + ); unsavedModal.show(); - document.getElementById("batchSaveAndExportBtn")?.addEventListener("click", () => { - unsavedModal.hide(); - // Trigger save all first — listen for completion - alert("Please Save All first, then Export All."); - }, { once: true }); + document.getElementById("batchSaveAndExportBtn")?.addEventListener( + "click", + () => { + unsavedModal.hide(); + // Trigger save all first — listen for completion + alert("Please Save All first, then Export All."); + }, + { once: true }, + ); return; } const eligibleRows = collectEligibleRows(); if (eligibleRows.length === 0) { - document.getElementById("exportResponseMessage").textContent = "All rows already exported to LIMS."; - document.getElementById("exportResponseModalLabel").textContent = "Export All"; - new bootstrap.Modal(document.getElementById("exportResponseModal"), { keyboard: false }).show(); + document.getElementById("exportResponseMessage").textContent = + "All rows already exported to LIMS."; + document.getElementById("exportResponseModalLabel").textContent = + "Export All"; + new bootstrap.Modal( + document.getElementById("exportResponseModal"), + { keyboard: false }, + ).show(); return; } showBatchConfirm(eligibleRows); @@ -468,7 +630,9 @@ batchRunning = true; const batchUuid = crypto.randomUUID(); const total = eligibleRows.length; - let processed = 0, succeeded = 0, failed = 0; + let processed = 0, + succeeded = 0, + failed = 0; disableAllRowButtons(); @@ -479,18 +643,25 @@ if (cancelBtn) cancelBtn.disabled = false; if (statusEl) statusEl.textContent = `Exporting 0 / ${total}...`; - cancelBtn?.addEventListener("click", () => { - batchCancelled = true; - if (statusEl) statusEl.textContent = "Cancelling... (waiting for current row)"; - if (cancelBtn) cancelBtn.disabled = true; - }, { once: true }); + cancelBtn?.addEventListener( + "click", + () => { + batchCancelled = true; + if (statusEl) + statusEl.textContent = + "Cancelling... (waiting for current row)"; + if (cancelBtn) cancelBtn.disabled = true; + }, + { once: true }, + ); (async () => { for (let i = 0; i < eligibleRows.length; i++) { if (batchCancelled) break; const { iddatadb, row } = eligibleRows[i]; - if (statusEl) statusEl.textContent = `Exporting ${processed + 1} / ${total} (id: ${iddatadb})...`; + if (statusEl) + statusEl.textContent = `Exporting ${processed + 1} / ${total} (id: ${iddatadb})...`; const gridRow = row || getGridRow(iddatadb); if (gridRow) setRowExporting(gridRow, true); @@ -503,18 +674,29 @@ } else { failed++; const errIdx = getRowIndexByIddatadb(iddatadb); - if (errIdx >= 0) window.gridData[errIdx]._exportError = data.message || "Unknown error"; - if (gridRow) showRowError(gridRow, iddatadb, data.message || "Unknown error"); + if (errIdx >= 0) + window.gridData[errIdx]._exportError = + data.message || "Unknown error"; + if (gridRow) + showRowError( + gridRow, + iddatadb, + data.message || "Unknown error", + ); } } catch (error) { processed++; failed++; const errIdx = getRowIndexByIddatadb(iddatadb); - if (errIdx >= 0) window.gridData[errIdx]._exportError = error.message; + if (errIdx >= 0) + window.gridData[errIdx]._exportError = error.message; if (gridRow) showRowError(gridRow, iddatadb, error.message); } - if (gridRow) { setRowExporting(gridRow, false); gridRow.classList.remove("batch-disabled"); } + if (gridRow) { + setRowExporting(gridRow, false); + gridRow.classList.remove("batch-disabled"); + } } batchRunning = false; @@ -529,7 +711,8 @@ (window.gridData || []).forEach((row, idx) => { if (row._exportError) { const gridRow = getGridRow(row.iddatadb); - if (gridRow) showRowError(gridRow, row.iddatadb, row._exportError); + if (gridRow) + showRowError(gridRow, row.iddatadb, row._exportError); } }); @@ -549,7 +732,9 @@ const modalEl = document.getElementById("exportResponseModal"); new bootstrap.Modal(modalEl, { keyboard: false }).show(); - modalEl.addEventListener("hidden.bs.modal", cleanupBackdrop, { once: true }); + modalEl.addEventListener("hidden.bs.modal", cleanupBackdrop, { + once: true, + }); })(); } })(); diff --git a/public/userarea/filter_records.php b/public/userarea/filter_records.php new file mode 100644 index 0000000..6c47bda --- /dev/null +++ b/public/userarea/filter_records.php @@ -0,0 +1,394 @@ +getConnection(); + +const FILTER_ALLOWED_PER_PAGE = [20, 40, 60, 100]; +const FILTER_ALLIDS_CAP = 5000; + +$body = json_decode(file_get_contents('php://input'), true) ?: []; + +$templateId = (int)($body['template_id'] ?? 0); +if ($templateId <= 0) { + echo json_encode(['success' => false, 'message' => 'template_id mancante']); + exit; +} + +$status = in_array($body['status'] ?? 'i', ['i', 'P', 'l'], true) ? $body['status'] : 'i'; +$showAll = !empty($body['all_users']); +$importref = trim((string)($body['importref'] ?? '')); // scope su un singolo import +$filters = is_array($body['filters'] ?? null) ? $body['filters'] : []; +$userId = (int)($iduserlogin ?? 0); +$perPage = in_array((int)($body['per_page'] ?? 20), FILTER_ALLOWED_PER_PAGE, true) ? (int)$body['per_page'] : 20; +$page = max(1, (int)($body['page'] ?? 1)); +$wantAllIds = !empty($body['want_all_ids']); + +$cacheDir = __DIR__ . '/cache'; + +$explicitIds = []; +if (isset($body['ids']) && is_array($body['ids'])) { + $explicitIds = array_values(array_unique(array_filter( + array_map('intval', $body['ids']), + fn($v) => $v > 0 + ))); +} +if (!empty($explicitIds)) { + // Scoping di sicurezza: solo record del template/utente/import corretti. + $conds = ['d.templateid = ?', 'd.status = ?']; + $params = [$templateId, $status]; + if (!$showAll) { $conds[] = 'd.user_id = ?'; $params[] = $userId; } + if ($importref !== '') { $conds[] = 'd.importreferencecode = ?'; $params[] = $importref; } + $ph = implode(',', array_fill(0, count($explicitIds), '?')); + $conds[] = "d.iddatadb IN ($ph)"; + foreach ($explicitIds as $id) $params[] = $id; + $whereSql = implode(' AND ', $conds); + + try { + $countStmt = $pdo->prepare("SELECT COUNT(*) FROM datadb d WHERE {$whereSql}"); + $countStmt->execute($params); + $total = (int)$countStmt->fetchColumn(); + $totalPages = max(1, (int)ceil($total / $perPage)); + if ($page > $totalPages) $page = $totalPages; + $offset = ($page - 1) * $perPage; + + $idStmt = $pdo->prepare(" + SELECT d.iddatadb FROM datadb d + WHERE {$whereSql} + ORDER BY d.excelrow ASC, d.iddatadb ASC + LIMIT {$perPage} OFFSET {$offset} + "); + $idStmt->execute($params); + $pageIds = array_map('intval', $idStmt->fetchAll(PDO::FETCH_COLUMN)); + + $config = buildGridConfig($pdo, $templateId); + $rows = buildGridRows($pdo, $pageIds, $config); + + echo json_encode([ + 'success' => true, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'total_pages' => $totalPages, + 'rows' => $rows, + 'mode' => 'selection', + ]); + } catch (Exception $e) { + error_log('filter_records (ids mode) error: ' . $e->getMessage()); + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } + exit; +} + +$fixedAliasMap = [ + 'ClienteResponsabile' => 'cliente_responsabile_id', + 'ClienteFornitore' => 'cliente_fornitore_id', + 'ClienteAnalisi' => 'clienteAnalisi', + 'ClienteFatturazione' => 'ClienteFatturazione', + 'MoltiplicatorePrezzo' => 'moltiplicatore_prezzo_id', + 'AnagraficaCertestObject' => 'anagrafica_certest_object_id', + 'AnagraficaCertestService' => 'anagrafica_certest_service_id', + 'ConsegnaRichiesta' => 'consegna_richiesta', +]; +$staticCols = ['importreferencecode', 'filename_import', 'importdate', 'commessaweb']; + +// ── Risolutori "etichetta → lista di ID" via cache ──────────────────────── +function cacheItems(string $file): array +{ + if (!is_file($file)) return []; + $data = json_decode(file_get_contents($file), true); + if (!is_array($data)) return []; + return $data['value'] ?? $data ?? []; +} + +/** Clienti: label "Nome - X - id" (come search_clienti.php). */ +function resolveClientIdsByTerm(string $cacheDir, string $term): array +{ + $term = mb_strtolower(trim($term)); + if ($term === '') return []; + $ids = []; + foreach (cacheItems($cacheDir . '/clienti.json') as $c) { + $name = trim($c['Nominativo'] ?? ''); + $id = trim((string)($c['IdCliente'] ?? '')); + $code = trim((string)($c['CodiceCliente'] ?? '')); + $parts = explode('_', $code); + $suffix = trim($parts[1] ?? ''); + if ($suffix === '' && $code !== '') $suffix = substr($code, 0, 1); + if ($suffix === '') $suffix = '--'; + $label = mb_strtolower($name . ' - ' . $suffix . ' - ' . $id); + if (mb_strpos($label, $term) !== false) $ids[] = (int)$c['IdCliente']; + } + return array_values(array_unique($ids)); +} + +/** Dropdown generico: matcha il termine su qualsiasi label field, ritorna gli id. */ +function resolveIdsFromCache(string $file, string $idField, array $labelFields, string $term): array +{ + $term = mb_strtolower(trim($term)); + if ($term === '') return []; + $ids = []; + foreach (cacheItems($file) as $it) { + $hay = ''; + foreach ($labelFields as $lf) $hay .= ' ' . ($it[$lf] ?? ''); + if (mb_strpos(mb_strtolower($hay), $term) !== false && isset($it[$idField])) { + $ids[] = (int)$it[$idField]; + } + } + return array_values(array_unique($ids)); +} + +/** + * Per un fixed field che memorizza un ID, risolve il termine in lista di id. + * Ritorna: array di id (IN), [] se il termine non matcha nulla, null se la colonna + * non è risolvibile (→ il chiamante la salta invece di fare LIKE sbagliato). + */ +function resolveFixedIds(string $cacheDir, string $key, string $term): ?array +{ + switch ($key) { + case 'ClienteAnalisi': + case 'ClienteFatturazione': + return resolveClientIdsByTerm($cacheDir, $term); + case 'MoltiplicatorePrezzo': + return resolveIdsFromCache($cacheDir . '/moltiplicatori_prezzo.json', 'IdMoltiplicatorePrezzo', ['Codice', 'Descrizione'], $term); + case 'AnagraficaCertestObject': + return resolveIdsFromCache($cacheDir . '/anagrafica_certest_object.json', 'IdAnagrafica', ['Codice', 'NomeAnagrafica'], $term); + case 'AnagraficaCertestService': + return resolveIdsFromCache($cacheDir . '/anagrafica_certest_service.json', 'IdAnagrafica', ['Codice', 'NomeAnagrafica'], $term); + default: + // ClienteResponsabile (per-cliente) e altri: non risolvibili globalmente + return null; + } +} + +/** + * @return array lista di [idclient, idResponsabile] + */ +function resolveResponsabilePairs(string $cacheDir, string $term): array +{ + $term = mb_strtolower(trim($term)); + if ($term === '') return []; + $pairs = []; + foreach (glob($cacheDir . '/cliente_responsabili_*.json') ?: [] as $file) { + if (!preg_match('/cliente_responsabili_(\d+)\.json$/', $file, $m)) continue; + $cid = (int)$m[1]; + $data = json_decode(file_get_contents($file), true); + foreach (($data['Responsabili'] ?? []) as $r) { + $name = mb_strtolower((string)($r['Nominativo'] ?? '')); + if ($name !== '' && mb_strpos($name, $term) !== false && isset($r['IdClienteResponsabile'])) { + $pairs[] = [$cid, (int)$r['IdClienteResponsabile']]; + } + } + } + return $pairs; +} + +/** Meta di un mapping (data_type, field_id), con cache statica. */ +function mappingMeta(PDO $pdo, int $mappingId): array +{ + static $cache = []; + if (!array_key_exists($mappingId, $cache)) { + $st = $pdo->prepare("SELECT data_type, field_id FROM template_mapping WHERE id = ?"); + $st->execute([$mappingId]); + $cache[$mappingId] = $st->fetch(PDO::FETCH_ASSOC) ?: ['data_type' => 'Testo', 'field_id' => null]; + } + return $cache[$mappingId]; +} + +function resolveCustomFieldValueIds(string $cacheDir, $fieldId, string $term): array +{ + $fieldId = (int)$fieldId; + $term = mb_strtolower(trim($term)); + if ($fieldId <= 0 || $term === '') return []; + $ids = []; + foreach (cacheItems($cacheDir . "/customfield_{$fieldId}.json") as $it) { + $label = mb_strtolower((string)($it['Valore'] ?? '')); + if (mb_strpos($label, $term) !== false && isset($it['IdCustomFieldsValue'])) { + $ids[] = (int)$it['IdCustomFieldsValue']; + } + } + return array_values(array_unique($ids)); +} + +// ── Costruzione WHERE ────────────────────────────────────────────────────── +$conds = ['d.templateid = ?', 'd.status = ?']; +$params = [$templateId, $status]; +$needUserJoin = false; + +if (!$showAll) { + $conds[] = 'd.user_id = ?'; + $params[] = $userId; +} + +// Scope su un singolo import (come imported.php ?importref=) +if ($importref !== '') { + $conds[] = 'd.importreferencecode = ?'; + $params[] = $importref; +} + +/** Aggiunge una condizione "colonna id IN (lista risolta)". */ +function addIdInCondition(array &$conds, array &$params, string $col, array $ids): void +{ + if (empty($ids)) { + $conds[] = '1 = 0'; // termine dato ma nessun match → zero risultati + return; + } + $ph = implode(',', array_fill(0, count($ids), '?')); + $conds[] = "d.`{$col}` IN ($ph)"; + foreach ($ids as $id) $params[] = (int)$id; +} + +foreach ($filters as $f) { + $term = trim((string)($f['term'] ?? '')); + if ($term === '') continue; + $type = (string)($f['type'] ?? ''); + $key = (string)($f['key'] ?? ''); + $like = '%' . $term . '%'; + + switch ($type) { + case 'detail': + case 'main_field': + $mappingId = (int)$key; + if ($mappingId <= 0) break; + $mm = mappingMeta($pdo, $mappingId); + if (($mm['data_type'] ?? '') === 'SceltaMultipla') { + // Dropdown: field_value = ID valore → risolvi label→id, match IN (= non LIKE) + $vids = resolveCustomFieldValueIds($cacheDir, $mm['field_id'], $term); + if (empty($vids)) { + $conds[] = '1 = 0'; + break; + } + $inph = implode(',', array_fill(0, count($vids), '?')); + $conds[] = "EXISTS (SELECT 1 FROM import_data_details x + WHERE x.id = d.iddatadb AND x.mapping_id = ? AND x.field_value IN ($inph))"; + $params[] = $mappingId; + foreach ($vids as $vid) $params[] = $vid; + } else { + // Testo / Data: match testuale + $conds[] = "EXISTS (SELECT 1 FROM import_data_details x + WHERE x.id = d.iddatadb AND x.mapping_id = ? AND x.field_value LIKE ?)"; + $params[] = $mappingId; + $params[] = $like; + } + break; + + case 'idclient': + addIdInCondition($conds, $params, 'idclient', resolveClientIdsByTerm($cacheDir, $term)); + break; + + case 'cliente_fornitore_id': + addIdInCondition($conds, $params, 'cliente_fornitore_id', resolveClientIdsByTerm($cacheDir, $term)); + break; + + case 'tested_component': + $conds[] = 'd.tested_component LIKE ?'; + $params[] = $like; + break; + + case 'status': + // Il dropdown invia direttamente il codice ('i'/'P'/'l'). + if (in_array($term, ['i', 'P', 'l'], true)) { + $conds[] = 'd.status = ?'; + $params[] = $term; + } else { + $conds[] = '1 = 0'; + } + break; + + case 'fixed': + $col = $fixedAliasMap[$key] ?? null; + if ($col === null) break; + if ($key === 'ConsegnaRichiesta') { + // Data: match testuale sul valore data (es. "2026-03"). + $conds[] = "CAST(d.`{$col}` AS CHAR) LIKE ?"; + $params[] = $like; + } elseif ($key === 'ClienteResponsabile') { + // Match sulla coppia (idclient, id responsabile). + $pairs = resolveResponsabilePairs($cacheDir, $term); + if (empty($pairs)) { + $conds[] = '1 = 0'; + break; + } + $rowvals = implode(',', array_fill(0, count($pairs), '(?,?)')); + $conds[] = "(d.idclient, d.`{$col}`) IN ($rowvals)"; + foreach ($pairs as $pr) { + $params[] = $pr[0]; + $params[] = $pr[1]; + } + } else { + $ids = resolveFixedIds($cacheDir, $key, $term); + if ($ids === null) break; // non risolvibile → non filtrare (niente LIKE su id) + addIdInCondition($conds, $params, $col, $ids); + } + break; + + case 'static': + if ($key === 'user_name') { + $needUserJoin = true; + $conds[] = "CONCAT(COALESCE(u.first_name,''),' ',COALESCE(u.last_name,'')) LIKE ?"; + $params[] = $like; + } elseif (in_array($key, $staticCols, true)) { + $conds[] = "d.`{$key}` LIKE ?"; + $params[] = $like; + } + break; + + default: + break; // tracking/awb ecc. → non filtrabili + } +} + +$whereSql = implode(' AND ', $conds); +$joinSql = $needUserJoin ? 'LEFT JOIN auth_users u ON d.user_id = u.id' : ''; + +try { + // Totale + pagine + $countStmt = $pdo->prepare("SELECT COUNT(*) FROM datadb d {$joinSql} WHERE {$whereSql}"); + $countStmt->execute($params); + $total = (int)$countStmt->fetchColumn(); + $totalPages = max(1, (int)ceil($total / $perPage)); + if ($page > $totalPages) $page = $totalPages; + $offset = ($page - 1) * $perPage; + + // Pagina corrente + $pageStmt = $pdo->prepare(" + SELECT d.iddatadb + FROM datadb d {$joinSql} + WHERE {$whereSql} + ORDER BY d.excelrow ASC, d.iddatadb ASC + LIMIT {$perPage} OFFSET {$offset} + "); + $pageStmt->execute($params); + $pageIds = array_map('intval', $pageStmt->fetchAll(PDO::FETCH_COLUMN)); + + $config = buildGridConfig($pdo, $templateId); + $rows = buildGridRows($pdo, $pageIds, $config); + + $out = [ + 'success' => true, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'total_pages' => $totalPages, + 'rows' => $rows, + ]; + + // Tutti gli id del match (per "seleziona tutti"), solo su richiesta + if ($wantAllIds) { + $allStmt = $pdo->prepare(" + SELECT d.iddatadb FROM datadb d {$joinSql} + WHERE {$whereSql} + ORDER BY d.excelrow ASC, d.iddatadb ASC + LIMIT " . FILTER_ALLIDS_CAP . " + "); + $allStmt->execute($params); + $out['all_ids'] = array_map('intval', $allStmt->fetchAll(PDO::FETCH_COLUMN)); + $out['all_ids_capped'] = $total > FILTER_ALLIDS_CAP; + } + + echo json_encode($out); +} catch (Exception $e) { + error_log('filter_records error: ' . $e->getMessage()); + echo json_encode(['success' => false, 'message' => $e->getMessage()]); +} diff --git a/public/userarea/gridFilter.js b/public/userarea/gridFilter.js new file mode 100644 index 0000000..7b8b721 --- /dev/null +++ b/public/userarea/gridFilter.js @@ -0,0 +1,844 @@ +/** + * gridFilter.js — Selezione righe + filtri colonna a scomparsa per imported.php + * Dipende da window.gridRenderer (gridRenderer.js). Includere DOPO gridRenderer.js. + */ +(function () { + "use strict"; + + let filtersActive = false; + let restricted = false; + const selected = new Set(); // iddatadb selezionati + const colFilters = {}; // colKey -> testo filtro + let restrictedIds = null; // Set di iddatadb visibili quando ristretto + + function R() { + return window.gridRenderer; + } + function data() { + return R().getData(); + } + function meta() { + return R().getMeta(); + } + + // ── Filtro SERVER-SIDE (paginato, come la lista normale ma filtrata) ────── + // Il filtro gira su TUTTO il template; il server restituisce UNA PAGINA di righe + // filtrate + il totale, e navighiamo le pagine del set filtrato via AJAX. + const TEMPLATE_ID = + new URLSearchParams(location.search).get("id") || (meta().templateId ?? ""); + const SHOW_ALL = + new URLSearchParams(location.search).get("all_users") === "1"; + const IMPORTREF = + new URLSearchParams(location.search).get("importref") || ""; + const FILTER_PER_PAGE = + parseInt(new URLSearchParams(location.search).get("limit"), 10) || 20; + + let serverFiltered = false; + let filterPage = 1; + let filterTotalPages = 1; + let filterTotal = 0; + let originalData = null; // snapshot della pagina originale (per ripristino) + let filterReqSeq = 0; + let showingSelection = false; // true quando mostriamo l'UNIONE dei selezionati + let pagerFn = null; // paginazione corrente (applyServerFilter | showSelectedRecords) + + function collectActiveFilters() { + const cols = meta().columns || []; + const out = []; + for (const [key, term] of Object.entries(colFilters)) { + if (!term || !String(term).trim()) continue; + const col = cols.find((c) => String(c.key) === String(key)); + if (!col) continue; + out.push({ key: String(key), type: col.type, term: String(term).trim() }); + } + return out; + } + + // Sostituisce il dataset del renderer in-place e ridisegna. + function swapData(rows) { + const arr = data(); + arr.length = 0; + (rows || []).forEach((r) => arr.push(r)); + R().renderVisibleRows(); + } + + function hidePagination(hide) { + // Durante il filtro la paginazione server (per pagina, via URL) è fuorviante. + document.querySelectorAll(".pager-bar").forEach((el) => { + el.style.display = hide ? "none" : ""; + }); + } + + // Pager del set filtrato (Prec / pag X di Y / Succ) nella toolbar. + function updateFilterPager() { + const el = document.getElementById("filterPager"); + if (!el) return; + if (!serverFiltered) { + el.style.display = "none"; + el.innerHTML = ""; + return; + } + el.style.display = "inline-flex"; + const label = showingSelection ? "selezionati" : "trovati"; + el.innerHTML = + `` + + `${filterTotal} ${label} · pag ${filterPage}/${filterTotalPages}` + + ``; + const go = pagerFn || applyServerFilter; + const prev = document.getElementById("filterPrevBtn"); + const next = document.getElementById("filterNextBtn"); + if (prev) prev.addEventListener("click", () => go(filterPage - 1)); + if (next) next.addEventListener("click", () => go(filterPage + 1)); + } + + function restoreOriginal() { + serverFiltered = false; + filterPage = 1; + filterTotalPages = 1; + filterTotal = 0; + if (originalData) swapData(originalData); + updateFilterPager(); + hidePagination(false); + updateToolbar(); + } + + async function applyServerFilter(page = 1) { + const active = collectActiveFilters(); + if (active.length === 0) { + if (serverFiltered) restoreOriginal(); + return; + } + if (!originalData) originalData = [...data()]; // snapshot pagina 1 originale + + const seq = ++filterReqSeq; + try { + const resp = await fetch("filter_records.php", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + template_id: parseInt(TEMPLATE_ID, 10), + all_users: SHOW_ALL, + importref: IMPORTREF, + status: "i", + filters: active, + page: Math.max(1, page), + per_page: FILTER_PER_PAGE, + }), + }); + const json = await resp.json(); + if (seq !== filterReqSeq) return; // risposta obsoleta: ignora + if (!json.success) { + console.error("[gridFilter] filtro:", json.message); + return; + } + serverFiltered = true; + showingSelection = false; + pagerFn = applyServerFilter; + filterPage = json.page; + filterTotalPages = json.total_pages; + filterTotal = json.total; + swapData(json.rows || []); + updateFilterPager(); + hidePagination(true); + updateToolbar(); + } catch (e) { + console.error("[gridFilter] fetch filtro fallita", e); + } + } + + // ── Mostra i SELEZIONATI (unione tra filtri diversi) ────────────────── + // La selezione si accumula tra filtri; questo carica dal server TUTTI i record + // selezionati (per id) e li mostra, paginati, a prescindere dai filtri correnti. + async function showSelectedRecords(page = 1) { + const ids = [...selected]; + if (!ids.length) { + showingSelection = false; + updateToolbar(); + return; + } + pagerFn = showSelectedRecords; + try { + const resp = await fetch("filter_records.php", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + template_id: parseInt(TEMPLATE_ID, 10), + all_users: SHOW_ALL, + importref: IMPORTREF, + status: "i", + ids: ids, + page: Math.max(1, page), + per_page: FILTER_PER_PAGE, + }), + }); + const json = await resp.json(); + if (!json.success) { + console.error("[gridFilter] mostra selezionati:", json.message); + return; + } + if (!originalData) originalData = [...data()]; + showingSelection = true; + serverFiltered = true; + filterPage = json.page; + filterTotalPages = json.total_pages; + filterTotal = json.total; + swapData(json.rows || []); + updateFilterPager(); + hidePagination(true); + updateToolbar(); + } catch (e) { + console.error("[gridFilter] mostra selezionati fallita", e); + } + } + + // Tumbler "Mostra selezionati" / "Torna al filtro". + function toggleShowSelected() { + if (!showingSelection) { + if (!selected.size) return; + showSelectedRecords(1); + } else { + exitSelectionView(); + } + } + + // Torna dal "mostra selezionati" al filtro corrente (o alla pagina originale). + function exitSelectionView() { + showingSelection = false; + pagerFn = applyServerFilter; + if (collectActiveFilters().length) { + applyServerFilter(1); + } else { + restoreOriginal(); + } + updateToolbar(); + } + + // Deseleziona tutto. + function clearSelection() { + selected.clear(); + document + .querySelectorAll("#gridRowContainer .grid-row.row-selected") + .forEach((el) => el.classList.remove("row-selected")); + document + .querySelectorAll(".filter-row-checkbox") + .forEach((cb) => (cb.checked = false)); + const sa = document.getElementById("filterSelectAll"); + if (sa) sa.checked = false; + if (showingSelection) { + exitSelectionView(); + } else { + updateToolbar(); + } + } + + // "Seleziona tutti i filtrati": chiede al server TUTTI gli id del match e li seleziona. + async function selectAllMatching(on) { + if (!serverFiltered) return; + const active = collectActiveFilters(); + try { + const resp = await fetch("filter_records.php", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + template_id: parseInt(TEMPLATE_ID, 10), + all_users: SHOW_ALL, + importref: IMPORTREF, + status: "i", + filters: active, + page: filterPage, + per_page: FILTER_PER_PAGE, + want_all_ids: true, + }), + }); + const json = await resp.json(); + if (!json.success) return; + (json.all_ids || []).forEach((id) => (on ? selected.add(id) : selected.delete(id))); + document + .querySelectorAll("#gridRowContainer .grid-row") + .forEach((rowEl) => { + rowEl.classList.toggle("row-selected", on); + const cb = rowEl.querySelector(".filter-row-checkbox"); + if (cb) cb.checked = on; + }); + updateToolbar(); + if (json.all_ids_capped) { + alert("Selezione limitata a " + (json.all_ids || []).length + " record."); + } + } catch (e) { + console.error("[gridFilter] select-all fallita", e); + } + } + + function syncFilterRow() { + const fr = document.getElementById("gridFilterRow"); + const header = document.getElementById("gridHeaderContainer"); + if (!fr || !header) return; + + const actHeader = header.querySelector(".button-header"); + const actCell = fr.querySelector(".filter-actions-cell"); + if (actHeader && actCell) { + const aw = actHeader.offsetWidth; + actCell.style.flex = `0 0 ${aw}px`; + actCell.style.minWidth = `${aw}px`; + } + + const headerCells = header.querySelectorAll( + ".grid-header:not(.button-header)", + ); + const filterCells = fr.querySelectorAll( + ".grid-cell:not(.filter-actions-cell)", + ); + headerCells.forEach((hc, i) => { + const fc = filterCells[i]; + if (fc) fc.style.flex = `0 0 ${hc.offsetWidth}px`; + }); + } + + function syncStickyLeft() { + const btnHeader = document.querySelector( + "#gridHeaderContainer .button-header", + ); + if (!btnHeader) return; + const w1 = btnHeader.offsetWidth; // larghezza reale colonna Actions + + const fixRow = (rowEl) => { + if (!rowEl) return; + const cells = rowEl.querySelectorAll( + ":scope > .grid-cell, :scope > .grid-header", + ); + const c2 = cells[1]; + const c3 = cells[2]; + if (c2 && getComputedStyle(c2).position === "sticky") { + c2.style.left = `${w1}px`; + } + if (c3 && getComputedStyle(c3).position === "sticky") { + c3.style.left = `${w1 + (c2 ? c2.offsetWidth : 0)}px`; + } + }; + + fixRow(document.getElementById("gridHeaderContainer")); + document.querySelectorAll(".grid-top").forEach(fixRow); + document + .querySelectorAll("#gridRowContainer .grid-row") + .forEach(fixRow); + fixRow(document.getElementById("gridFilterRow")); + } + + let _headerObs = null; + let _rowObs = null; + let _syncRaf = null; + + function reconnectObservers() { + const header = document.getElementById("gridHeaderContainer"); + const rows = document.getElementById("gridRowContainer"); + if (_headerObs && header) + _headerObs.observe(header, { + attributes: true, + attributeFilter: ["style"], + subtree: true, + }); + if (_rowObs && rows) _rowObs.observe(rows, { childList: true }); + } + + function scheduleSync() { + if (_syncRaf) return; + _syncRaf = requestAnimationFrame(() => { + _syncRaf = null; + if (_headerObs) _headerObs.disconnect(); + if (_rowObs) _rowObs.disconnect(); + syncStickyLeft(); + syncFilterRow(); + reconnectObservers(); + }); + } + + function hookGridSync() { + _headerObs = new MutationObserver(scheduleSync); + _rowObs = new MutationObserver(scheduleSync); + reconnectObservers(); + scheduleSync(); // primo allineamento + } + + function rowMatchesFilters(row) { + if (serverFiltered) return true; + for (const [key, term] of Object.entries(colFilters)) { + if (!term) continue; + const t = term.toLowerCase(); + const val = getRowColValue(row, key); + if (!String(val).toLowerCase().includes(t)) return false; + } + return true; + } + + function getRowColValue(row, key) { + const cols = meta().columns || []; + const col = cols.find((c) => String(c.key) === String(key)); + if (!col) return ""; + switch (col.type) { + case "detail": + case "main_field": + return row.details?.[String(key)] ?? ""; + case "fixed": + return row.fixedFields?.[key] ?? ""; + case "idclient": + return row.idclient ?? ""; + case "cliente_fornitore_id": + return row.cliente_fornitore_id ?? ""; + case "tested_component": + return row.tested_component ?? ""; + case "static": + return row[key] ?? ""; + case "status": + return row.status ?? ""; + default: + return row[key] ?? ""; + } + } + + // ── Applica visibilità righe (filtri + restrizione) ── + function applyVisibility() { + const rows = document.querySelectorAll("#gridRowContainer .grid-row"); + rows.forEach((rowEl) => { + const id = parseInt(rowEl.dataset.id, 10); + const row = data().find((r) => String(r.iddatadb) === String(id)); + let show = true; + if (row && !rowMatchesFilters(row)) show = false; + if (restricted && restrictedIds && !restrictedIds.has(id)) + show = false; + rowEl.style.display = show ? "" : "none"; + }); + window.dispatchEvent(new Event("resize")); // ricalcola scrollbar top + } + + // ── Lista iddatadb attualmente visibili (per propagazione/export ecc.) ── + window.getVisibleGridIds = function () { + if (serverFiltered) { + return data() + .map((r) => parseInt(r.iddatadb, 10)) + .filter(Boolean); + } + const ids = []; + document + .querySelectorAll("#gridRowContainer .grid-row") + .forEach((el) => { + if (el.style.display !== "none") + ids.push(parseInt(el.dataset.id, 10)); + }); + return ids.filter(Boolean); + }; + + // ── Righe target per operazioni bulk (propaga/export/clona/save) ── + // Regola: filtri attivi + selezione → solo selezionate; + // filtri attivi senza selezione → solo visibili; + // filtri non attivi → null (= tutte, comportamento originale). + window.getTargetGridIds = function () { + if (!filtersActive) return null; // null = nessun vincolo + if (selected.size > 0) return new Set([...selected]); + return new Set(window.getVisibleGridIds()); + }; + + // Helper: true se l'id è tra i target (o se non c'è vincolo) + window.isTargetGridId = function (id) { + const t = window.getTargetGridIds(); + if (t === null) return true; + return t.has(parseInt(id, 10)); + }; + + // ── Toggle selezione riga ── + function toggleRow(id, on) { + id = parseInt(id, 10); + if (on) selected.add(id); + else selected.delete(id); + const rowEl = document.querySelector( + `#gridRowContainer .grid-row[data-id="${id}"]`, + ); + if (rowEl) { + rowEl.classList.toggle("row-selected", selected.has(id)); + const cb = rowEl.querySelector(".filter-row-checkbox"); + if (cb) cb.checked = selected.has(id); + } + updateToolbar(); + } + + // ── Inietta checkbox nelle righe visibili ── + // ── Inietta checkbox dentro la cella Actions (già sticky) ── + function injectCheckboxes() { + document + .querySelectorAll("#gridRowContainer .grid-row") + .forEach((rowEl) => { + const btnCell = rowEl.querySelector(".button-cell"); + if (!btnCell) return; + if (btnCell.querySelector(".filter-row-checkbox")) return; + const id = parseInt(rowEl.dataset.id, 10); + const wrap = document.createElement("label"); + wrap.className = "filter-cb-wrap"; + wrap.innerHTML = ``; + btnCell.insertBefore(wrap, btnCell.firstChild); + if (selected.has(id)) rowEl.classList.add("row-selected"); + }); + } + + function removeCheckboxes() { + document + .querySelectorAll(".filter-cb-wrap") + .forEach((el) => el.remove()); + document + .querySelectorAll(".grid-row.row-selected") + .forEach((el) => el.classList.remove("row-selected")); + } + + // ── Riga input filtri colonna (allineata alle larghezze reali) ── + // ── Riga input filtri colonna (allineata alle larghezze reali) ── + function injectFilterRow() { + if (document.getElementById("gridFilterRow")) return; + const top = document.getElementById("gridTopContainer"); + const header = document.getElementById("gridHeaderContainer"); + if (!top || !header) return; + + const fr = document.createElement("div"); + fr.className = "grid-row grid-filter-row"; + fr.id = "gridFilterRow"; + + // cella allineata alla colonna Actions (sticky, legge larghezza reale) + const actHeader = header.querySelector(".button-header"); + const actCell = document.createElement("div"); + actCell.className = "grid-cell button-cell filter-actions-cell"; + const aw = actHeader ? actHeader.offsetWidth : 220; + actCell.style.flex = `0 0 ${aw}px`; + actCell.style.minWidth = `${aw}px`; + actCell.innerHTML = ``; + fr.appendChild(actCell); + + // una cella per colonna, larghezza letta dall'header reale + const headerCells = header.querySelectorAll( + ".grid-header:not(.button-header)", + ); + (meta().columns || []).forEach((col, i) => { + const hc = headerCells[i]; + const w = hc ? hc.offsetWidth : col.width || 150; + const cell = document.createElement("div"); + cell.className = "grid-cell"; + cell.style.flex = `0 0 ${w}px`; + if (col.type === "tracking" || col.type === "awb") { + // niente filtro + } else if (col.type === "status") { + // Status: dropdown (3 valori) invece del testo libero. + const cur = colFilters[col.key] || ""; + const opt = (v, lbl) => + ``; + cell.innerHTML = + ``; + } else { + cell.innerHTML = ``; + } + fr.appendChild(cell); + }); + + top.parentNode.insertBefore(fr, top); + scheduleSync(); // allinea larghezze filtri + offset sticky + } + + function removeFilterRow() { + const fr = document.getElementById("gridFilterRow"); + if (fr) fr.remove(); + } + + // ── Toolbar (bottoni contestuali) ── + function updateToolbar() { + const bar = document.getElementById("filterActionBar"); + if (!bar) return; + const count = selected.size; + bar.style.display = filtersActive ? "inline-flex" : "none"; + const cntEl = document.getElementById("filterSelCount"); + if (cntEl) cntEl.textContent = count; + document.getElementById("filterDeleteBtn").disabled = count === 0; + + const showBtn = document.getElementById("filterRestrictBtn"); + showBtn.disabled = count === 0 && !showingSelection; + showBtn.innerHTML = showingSelection + ? ' Torna al filtro' + : ' Mostra selezionati'; + + const clearSelBtn = document.getElementById("filterClearSelBtn"); + if (clearSelBtn) clearSelBtn.disabled = count === 0; + } + + // ── Main toggle ── + function toggleFilters() { + filtersActive = !filtersActive; + const grid = document.getElementById("gridContainer"); + const btn = document.getElementById("filtersToggleBtn"); + if (filtersActive) { + grid.classList.add("filters-on"); + btn.classList.add("active"); + injectCheckboxes(); + injectFilterRow(); + } else { + grid.classList.remove("filters-on"); + btn.classList.remove("active"); + removeCheckboxes(); + removeFilterRow(); + // reset filtri colonna + ripristina la pagina originale + Object.keys(colFilters).forEach((k) => delete colFilters[k]); + if (serverFiltered) restoreOriginal(); + } + updateToolbar(); + } + + // ── Batch delete ── + async function batchDelete() { + const ids = [...selected]; + if (!ids.length) return; + if ( + !confirm( + `Eliminare ${ids.length} righe dal database? Operazione irreversibile.`, + ) + ) + return; + + const btn = document.getElementById("filterDeleteBtn"); + btn.disabled = true; + const original = btn.innerHTML; + + let ok = 0, + fail = 0; + for (const id of ids) { + try { + btn.innerHTML = ` ${ok + fail + 1}/${ids.length}`; + const resp = await fetch("delete_record.php", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: id }), + }); + const json = await resp.json(); + if (json.success) { + ok++; + // rimuovi da gridData + const arr = data(); + const idx = arr.findIndex( + (r) => String(r.iddatadb) === String(id), + ); + if (idx !== -1) arr.splice(idx, 1); + selected.delete(id); + if (restrictedIds) restrictedIds.delete(id); + } else fail++; + } catch (e) { + fail++; + } + } + + btn.innerHTML = original; + R().renderVisibleRows(); + if (filtersActive) { + injectCheckboxes(); + } + applyVisibility(); + updateToolbar(); + alert(`Eliminate: ${ok}${fail ? " — Falliti: " + fail : ""}`); + } + + // ── Restringi / mostra tutti ── + function toggleRestrict() { + if (!restricted) { + if (!selected.size) return; + restrictedIds = new Set([...selected]); + restricted = true; + } else { + restricted = false; + restrictedIds = null; + } + applyVisibility(); + updateToolbar(); + } + + // ── Costruzione UI toolbar (bottoni in cima) ── + function buildUI() { + const addBtn = document.getElementById("addRowBtn"); + if (!addBtn) return; + + // bottone Filters + const fBtn = document.createElement("button"); + fBtn.type = "button"; + fBtn.id = "filtersToggleBtn"; + fBtn.className = "btn btn-outline-info btn-sm"; + fBtn.style.flexShrink = "0"; + fBtn.innerHTML = ' Filters'; + addBtn.parentNode.insertBefore(fBtn, addBtn.nextSibling); + + // barra azioni contestuali + const bar = document.createElement("span"); + bar.id = "filterActionBar"; + bar.style.cssText = + "display:none;align-items:center;gap:8px;flex-shrink:0;"; + bar.innerHTML = ` + + + Selezionate: 0 + + + + `; + fBtn.parentNode.insertBefore(bar, fBtn.nextSibling); + + fBtn.addEventListener("click", toggleFilters); + document + .getElementById("filterClearBtn") + .addEventListener("click", clearFilters); + document + .getElementById("filterDeleteBtn") + .addEventListener("click", batchDelete); + document + .getElementById("filterRestrictBtn") + .addEventListener("click", toggleShowSelected); + document + .getElementById("filterClearSelBtn") + .addEventListener("click", clearSelection); + } + + // Svuota tutti i filtri di colonna (input + select) e torna alla pagina originale. + function clearFilters() { + Object.keys(colFilters).forEach((k) => delete colFilters[k]); + document + .querySelectorAll("#gridFilterRow .filter-col-input") + .forEach((el) => { + el.value = ""; + }); + if (serverFiltered) restoreOriginal(); + } + + // ── Event delegation ── + function attachEvents() { + // checkbox click + document.addEventListener("change", function (e) { + if (e.target.classList.contains("filter-row-checkbox")) { + const rowEl = e.target.closest(".grid-row"); + toggleRow(rowEl.dataset.id, e.target.checked); + } + }); + + // Seleziona / deseleziona tutti i FILTRATI + document.addEventListener("change", function (e) { + if (e.target.id !== "filterSelectAll") return; + const on = e.target.checked; + + // Con filtro server-side: seleziona TUTTI i match (anche altre pagine). + if (serverFiltered) { + selectAllMatching(on); + return; + } + + document + .querySelectorAll("#gridRowContainer .grid-row") + .forEach((rowEl) => { + if (rowEl.style.display === "none") return; + const id = parseInt(rowEl.dataset.id, 10); + if (!id) return; + if (on) selected.add(id); + else selected.delete(id); + rowEl.classList.toggle("row-selected", on); + const cb = rowEl.querySelector(".filter-row-checkbox"); + if (cb) cb.checked = on; + }); + updateToolbar(); + }); + + // click su QUALSIASI punto della riga (tranne campi interattivi) = seleziona + document.addEventListener("click", function (e) { + if (!filtersActive) return; + const rowEl = e.target.closest("#gridRowContainer .grid-row"); + if (!rowEl) return; + + // ignora click su elementi interattivi + if ( + e.target.closest( + "input, select, textarea, button, a, .select2-container, .action-btn, .propagate-btn, .add-part-btn", + ) + ) { + // ma se ho cliccato proprio la checkbox, lascia fare al change + return; + } + + const cb = rowEl.querySelector(".filter-row-checkbox"); + if (!cb) return; + cb.checked = !cb.checked; + toggleRow(rowEl.dataset.id, cb.checked); + }); + + // filtri colonna testo (debounce) → filtro server-side su tutto il template + let t = null; + document.addEventListener("input", function (e) { + if (!e.target.classList.contains("filter-col-input")) return; + if (e.target.tagName === "SELECT") return; // i select li gestisce 'change' + const key = e.target.dataset.colKey; + colFilters[key] = e.target.value; + clearTimeout(t); + t = setTimeout(applyServerFilter, 250); + }); + + // dropdown (es. Status): applica subito, senza debounce + document.addEventListener("change", function (e) { + if (!e.target.classList.contains("filter-col-input")) return; + if (e.target.tagName !== "SELECT") return; + colFilters[e.target.dataset.colKey] = e.target.value; + applyServerFilter(); + }); + } + + // ── Ripristina stato filtri dopo un re-render (checkbox + restrizione) ── + let _restoring = false; + function restoreFilterState() { + if (!filtersActive || _restoring) return; + _restoring = true; // evita loop con l'observer mentre iniettiamo/nascondiamo + try { + injectCheckboxes(); + applyVisibility(); // ri-applica filtri colonna E restringi selezione + } finally { + // rilascia dopo il tick così le nostre mutazioni non ci ri-triggerano subito + setTimeout(() => { + _restoring = false; + }, 0); + } + } + + // ── Observer: qualunque cosa ricrei le righe (propagazione, save, ecc.) + // ripristina automaticamente checkbox + restrizione. Robusto perché + // non dipende da chi chiama renderVisibleRows. ── + function hookRerender() { + const rowC = document.getElementById("gridRowContainer"); + if (!rowC) return; + + const obs = new MutationObserver(() => { + if (!filtersActive || _restoring) return; + // debounce leggero: aspetta che il render finisca + clearTimeout(hookRerender._t); + hookRerender._t = setTimeout(restoreFilterState, 30); + }); + + obs.observe(rowC, { childList: true }); + } + + function init() { + if (!window.gridRenderer) { + console.error("[gridFilter] gridRenderer non trovato"); + return; + } + buildUI(); + attachEvents(); + hookRerender(); + hookGridSync(); + } + + // parte dopo che gridRenderer ha finito init + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => + setTimeout(init, 300), + ); + } else { + setTimeout(init, 300); + } +})(); diff --git a/public/userarea/gridRenderer.js b/public/userarea/gridRenderer.js index 4bd40ac..d35b92e 100644 --- a/public/userarea/gridRenderer.js +++ b/public/userarea/gridRenderer.js @@ -143,7 +143,54 @@ }), ); } + // Ranking inizio/centro/fine per select con dati già in memoria. + // Restituisce { matcher, sorter } da passare a select2(). + function rankedLocalSelect2() { + return { + matcher: function (params, data) { + const term = $.trim(params.term || "").toLowerCase(); + if (term === "") return data; + if (typeof data.text === "undefined") return null; + // AND multi-termine, posizione indipendente + const text = data.text.toLowerCase(); + const terms = term.split(/\s+/).filter(Boolean); + const all = terms.every((t) => text.indexOf(t) !== -1); + return all ? data : null; + }, + sorter: function (results) { + const term = $( + ".select2-container--open .select2-search__field", + ).val(); + const search = (term || "").toLowerCase().trim(); + if (!search) return results; + + const first = search.split(/\s+/)[0]; + + function score(text) { + const t = (text || "").toLowerCase(); + if (t === search) return 0; // match esatto + if (t.indexOf(first) === 0) return 1; // inizia col termine + if (new RegExp("(^|\\s)" + escapeRegExp(first)).test(t)) + return 2; // inizio parola + return 3; // in mezzo + } + + return results.slice().sort(function (a, b) { + const sa = score(a.text); + const sb = score(b.text); + if (sa !== sb) return sa - sb; + return (a.text || "").localeCompare(b.text || "", "it", { + sensitivity: "base", + }); + }); + }, + }; + } + + function escapeRegExp(s) { + return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } function sortSelect2ResultsByStart(data) { const term = $(".select2-container--open .select2-search__field").val(); @@ -683,7 +730,7 @@ const reqCls = col.isRequired ? " required-input" : ""; const req = col.isRequired ? " required" : ""; - return ``; + return ``; } function buildDropdownOptionsHTML(fieldId, selectedValue) { @@ -967,6 +1014,11 @@ if (!row) return; + // Salta le righe nascoste (filtrate / non nella selezione ristretta): + // non devono essere sincronizzate né marcate dirty. + const rowEl = cell.closest(".grid-row"); + if (rowEl && rowEl.style.display === "none") return; + const colType = cell.dataset.colType; const colKey = cell.dataset.col; const input = cell.querySelector(".cell-input"); @@ -1198,6 +1250,12 @@ items.forEach((item) => sel.add(new Option(item.text, item.id)), ); + $(sel).select2({ + placeholder: "Seleziona...", + allowClear: true, + width: "100%", + ...rankedLocalSelect2(), + }); } }); @@ -1220,6 +1278,59 @@ function attachEvents() { if (!rowContainer) return; + // ── Auto-copy: Sample Arrival date/time → Unlock date/time ────────── + // Coppie basate sui fieldId dei campi (source → target) + const AUTO_COPY_FIELD_PAIRS = [ + { from: 259, to: 260 }, // Sample Arrival date → Unlock Date + { from: 348, to: 349 }, // Sample Arrival time → Unlock time + ]; + + // Risolve fieldId → col.key (mapping id) usando le definizioni colonne + function colKeyByFieldId(fieldId) { + const col = columns.find( + (c) => + (c.type === "detail" || c.type === "main_field") && + String(c.fieldId) === String(fieldId), + ); + return col ? String(col.key) : null; + } + + // Precalcola le coppie come col.key (sourceKey → targetKey) + const AUTO_COPY_KEY_PAIRS = AUTO_COPY_FIELD_PAIRS.map((p) => ({ + fromKey: colKeyByFieldId(p.from), + toKey: colKeyByFieldId(p.to), + })).filter((p) => p.fromKey && p.toKey); + + function applyAutoCopy(rowIndex, changedColKey, value) { + const pair = AUTO_COPY_KEY_PAIRS.find( + (p) => p.fromKey === String(changedColKey), + ); + if (!pair) return; + + const row = data[rowIndex]; + if (!row) return; + if (!row.details) row.details = {}; + + row.details[pair.toKey] = value; + row._dirty = true; + + // Aggiorna la cella target visibile, se presente + const targetCell = rowContainer.querySelector( + `.grid-cell[data-row="${rowIndex}"][data-col="${pair.toKey}"]`, + ); + if (targetCell) { + const input = targetCell.querySelector(".cell-input"); + if (input) { + if ($(input).hasClass("select2-hidden-accessible")) { + $(input).val(value).trigger("change.select2"); + } else { + input.value = value; + } + } + targetCell.classList.add("cell-changed"); + } + } + // Cell value changes → write to gridData rowContainer.addEventListener("change", function (e) { const cell = e.target.closest(".grid-cell"); @@ -1234,6 +1345,7 @@ data[rowIndex].mainFieldValue = value; } setDetailValue(rowIndex, colKey, value); + applyAutoCopy(rowIndex, colKey, value); } else if (colType === "fixed") { setFixedValue(rowIndex, colKey, value); } else if (colType === "idclient") { @@ -1387,6 +1499,11 @@ if (column === "idclient") { data.forEach((row) => { + if ( + window.isTargetGridId && + !window.isTargetGridId(row.iddatadb) + ) + return; const oldClientId = row.idclient || ""; row.idclient = value; @@ -1420,6 +1537,11 @@ if (column === "cliente_fornitore_id") { data.forEach((row) => { + if ( + window.isTargetGridId && + !window.isTargetGridId(row.iddatadb) + ) + return; row.cliente_fornitore_id = value; row._dirty = true; }); @@ -1433,6 +1555,11 @@ const fixedKey = column.replace("fixed_", ""); data.forEach((row) => { + if ( + window.isTargetGridId && + !window.isTargetGridId(row.iddatadb) + ) + return; if (!row.fixedFields) row.fixedFields = {}; row.fixedFields[fixedKey] = value; row._dirty = true; @@ -1445,6 +1572,11 @@ if (col && (col.type === "detail" || col.type === "main_field")) { data.forEach((row) => { + if ( + window.isTargetGridId && + !window.isTargetGridId(row.iddatadb) + ) + return; if (!row.details) row.details = {}; row.details[col.key] = value; @@ -1626,9 +1758,18 @@ const fieldId = this.dataset.fieldId; if (fieldId) $(this).select2(sceltaSelect2Config(fieldId)); }); + $(this) + .find(".searchable-fixed:not(.select2-hidden-accessible)") + .each(function () { + $(this).select2({ + placeholder: "Seleziona...", + allowClear: true, + width: "100%", + ...rankedLocalSelect2(), + }); + }); }); } - // ── Column resize ────────────────────────────────────────────────────── function initColumnResizers() { @@ -1759,3 +1900,127 @@ }, }; })(); +// =================== +// NAVIGAZIONE GRIGLIA CON FRECCE TASTIERA +// =================== +(function () { + // Naviga solo tra input di testo/numero/time e date-picker. + // I Select2 (client, fornitore, SceltaMultipla) sono esclusi: + // lì le frecce servono al dropdown. + function isNavigable(el) { + if (!el || !el.classList) return false; + if (!el.classList.contains("cell-input")) return false; + // Escludi i select (nativi o Select2) + if (el.tagName === "SELECT") return false; + if (el.classList.contains("select2-hidden-accessible")) return false; + return true; + } + + // Tutte le celle navigabili della riga, ordinate per data-index + function navCellsInRow($row) { + return $row.find(".grid-cell[data-index]").filter(function () { + const input = this.querySelector(".cell-input"); + return isNavigable(input); + }); + } + + function focusCellInput($cell) { + if (!$cell || !$cell.length) return; + const input = $cell.get(0).querySelector(".cell-input"); + if (!input) return; + input.focus(); + if (input.type !== "date" && typeof input.select === "function") { + try { + input.select(); + } catch (e) {} + } + } + + function atStart(el) { + if (el.type === "date" || el.type === "number" || el.type === "time") + return true; + return el.selectionStart === 0 && el.selectionEnd === 0; + } + + function atEnd(el) { + if (el.type === "date" || el.type === "number" || el.type === "time") + return true; + const len = (el.value || "").length; + return el.selectionStart === len && el.selectionEnd === len; + } + + $(document).on("keydown", "#gridRowContainer .cell-input", function (e) { + const el = this; + if (!isNavigable(el)) return; + + // Ignora se un dropdown Select2 è aperto + if ($(".select2-container--open").length) return; + + const key = e.key; + if ( + key !== "ArrowUp" && + key !== "ArrowDown" && + key !== "ArrowLeft" && + key !== "ArrowRight" + ) { + return; + } + + const $cell = $(el).closest(".grid-cell"); + const $row = $cell.closest(".grid-row"); + if (!$cell.length || !$row.length) return; + + const colIndex = $cell.attr("data-index"); + + // --- SU / GIÙ: stessa colonna (data-index), riga adiacente --- + if (key === "ArrowUp" || key === "ArrowDown") { + const $targetRow = + key === "ArrowUp" + ? $row.prev(".grid-row") + : $row.next(".grid-row"); + if (!$targetRow.length) return; + + e.preventDefault(); + + // Cella stessa colonna nella riga di destinazione + let $target = $targetRow.find( + `.grid-cell[data-index="${colIndex}"]`, + ); + + // Se quella colonna non è navigabile (es. è un select), + // cerca la più vicina navigabile nella riga + if ( + !$target.length || + !isNavigable($target.get(0).querySelector(".cell-input")) + ) { + const $cells = navCellsInRow($targetRow); + if (!$cells.length) return; + $target = $cells.first(); + } + + focusCellInput($target); + return; + } + + // --- SINISTRA / DESTRA: cella navigabile precedente/successiva nella riga --- + const $cells = navCellsInRow($row); + const curPos = $cells.index($cell); + if (curPos === -1) return; + + if (key === "ArrowLeft") { + if (!atStart(el)) return; + if (curPos <= 0) return; + e.preventDefault(); + focusCellInput($cells.eq(curPos - 1)); + return; + } + + if (key === "ArrowRight") { + if (!atEnd(el)) return; + if (curPos >= $cells.length - 1) return; + e.preventDefault(); + focusCellInput($cells.eq(curPos + 1)); + return; + } + }); +})(); diff --git a/public/userarea/imported.php b/public/userarea/imported.php index c728f3b..7f242fe 100644 --- a/public/userarea/imported.php +++ b/public/userarea/imported.php @@ -787,6 +787,7 @@ $gridMeta = [ padding: 10px 0; min-height: 0; flex-wrap: nowrap; + min-width: fit-content; } .grid-top .grid-cell { @@ -1328,6 +1329,117 @@ $gridMeta = [ outline: 2px solid #dc3545 !important; outline-offset: -2px; } + + /* ── Filtri / selezione righe ── */ + #filtersToggleBtn.active { + background-color: #0dcaf0 !important; + color: #fff !important; + border-color: #0dcaf0 !important; + } + + /* checkbox dentro la cella Actions */ + .filter-cb-wrap { + display: none; + align-items: center; + justify-content: center; + margin-right: 6px; + flex-shrink: 0; + } + + .grid-container.filters-on .filter-cb-wrap { + display: inline-flex; + } + + .filter-cb-wrap input { + width: 18px !important; + height: 18px !important; + cursor: pointer; + } + + .grid-container.filters-on .button-cell { + display: flex; + align-items: center; + } + + .grid-row.row-selected { + background-color: #cfe9ff !important; + } + + .grid-row.row-selected .button-cell, + .grid-row.row-selected .grid-cell:nth-child(2), + .grid-row.row-selected .grid-cell:nth-child(3) { + background-color: #cfe9ff !important; + } + + .grid-filter-row { + background: #eef6f9 !important; + border-bottom: 2px solid #0dcaf0; + } + + .grid-filter-row .grid-cell { + padding: 4px 8px; + } + + .grid-filter-row .filter-actions-cell { + background: #eef6f9 !important; + } + + .grid-container.filters-on .grid-row { + cursor: pointer; + } + + .grid-container.filters-on .grid-row input, + .grid-container.filters-on .grid-row select, + .grid-container.filters-on .grid-row textarea, + .grid-container.filters-on .grid-row button, + .grid-container.filters-on .grid-row a { + cursor: auto; + } + + /* Sticky su riga propagazione e filtri (fix scroll orizzontale) */ + .grid-top .grid-cell.save-all-cell { + position: sticky !important; + left: 0; + z-index: 9; + background: #fff; + } + + .grid-top .grid-cell:nth-child(2) { + position: sticky !important; + left: 210px; + z-index: 8; + background: #fff; + } + + = 2): ?>.grid-top .grid-cell:nth-child(3) { + position: sticky !important; + left: 360px; + z-index: 7; + background: #fff; + } + + .grid-filter-row .filter-actions-cell { + position: sticky !important; + left: 0; + z-index: 9; + background: #eef6f9 !important; + } + + .grid-filter-row .grid-cell:nth-child(2) { + position: sticky !important; + left: 210px; + z-index: 8; + background: #eef6f9 !important; + } + + = 2): ?>.grid-filter-row .grid-cell:nth-child(3) { + position: sticky !important; + left: 360px; + z-index: 7; + background: #eef6f9 !important; + } + + Edit Imported Data - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?> @@ -1456,6 +1568,7 @@ $gridMeta = [ + @@ -1694,4 +1807,4 @@ $gridMeta = [ - \ No newline at end of file + diff --git a/public/userarea/include/grid_data_builder.php b/public/userarea/include/grid_data_builder.php new file mode 100644 index 0000000..5463f0c --- /dev/null +++ b/public/userarea/include/grid_data_builder.php @@ -0,0 +1,213 @@ + 'cliente_responsabile_id', + 'ClienteFornitore' => 'cliente_fornitore_id', + 'ClienteAnalisi' => 'clienteAnalisi', + 'ClienteFatturazione' => 'ClienteFatturazione', + 'MoltiplicatorePrezzo' => 'moltiplicatore_prezzo_id', + 'AnagraficaCertestObject' => 'anagrafica_certest_object_id', + 'AnagraficaCertestService' => 'anagrafica_certest_service_id', + 'ConsegnaRichiesta' => 'consegna_richiesta', + ]; + + // Fixed fields visibili, ordinati come imported.php + $fixedStmt = $pdo->prepare(" + SELECT id, fixed_field_key, is_manual, data_type, is_required, default_value, is_visible_import + FROM template_fixed_mapping + WHERE template_id = ? AND is_visible_import = 1 + ORDER BY id + "); + $fixedStmt->execute([$templateId]); + $fixedFieldsRaw = $fixedStmt->fetchAll(PDO::FETCH_ASSOC); + + $desiredOrder = [ + 'ClienteResponsabile', 'ClienteFornitore', 'ClienteAnalisi', 'ClienteFatturazione', + 'AnagraficaCertestObject', 'AnagraficaCertestService', 'MoltiplicatorePrezzo', 'ConsegnaRichiesta', + ]; + $excludeFromFixed = ['ClienteFornitore']; // reso come colonna a sé + + $fixedFields = []; + $tempMap = []; + foreach ($fixedFieldsRaw as $f) { + if (in_array($f['fixed_field_key'], $excludeFromFixed, true)) continue; + $tempMap[$f['fixed_field_key']] = $f; + } + foreach ($desiredOrder as $key) { + if (isset($tempMap[$key])) { + $fixedFields[] = $tempMap[$key]; + unset($tempMap[$key]); + } + } + foreach ($tempMap as $f) { + $fixedFields[] = $f; + } + + // Main field mappings (max 2), come imported.php + $mapStmt = $pdo->prepare(" + SELECT id, field_label, data_type, is_required, field_id, field_order, + main_field, is_visible_import, manual_default + FROM template_mapping + WHERE template_id = ? + ORDER BY field_order ASC, id ASC + "); + $mapStmt->execute([$templateId]); + $allMappings = $mapStmt->fetchAll(PDO::FETCH_ASSOC); + + $mainFieldMappings = []; + foreach ($allMappings as $mapping) { + if ((string)$mapping['main_field'] === '1' && (int)$mapping['is_visible_import'] === 1) { + $mainFieldMappings[] = $mapping; + } + if (count($mainFieldMappings) >= 2) break; + } + + // Default idclient dal template + $tplStmt = $pdo->prepare("SELECT idclient FROM excel_templates WHERE id = ?"); + $tplStmt->execute([$templateId]); + $default_idclient = $tplStmt->fetchColumn(); + $default_idclient = $default_idclient !== false ? $default_idclient : null; + + return [ + 'fixedFields' => $fixedFields, + 'fixedAliasMap' => $fixedAliasMap, + 'mainFieldMappings' => $mainFieldMappings, + 'default_idclient' => $default_idclient, + ]; + } +} + +if (!function_exists('buildGridRows')) { + /** + * Costruisce le righe gridData per la lista di iddatadb data, nell'ordine passato. + * + * @param int[] $iddatadbList + * @param array $config output di buildGridConfig() + * @return array righe nella stessa forma di imported.php $gridDataArray + */ + function buildGridRows(PDO $pdo, array $iddatadbList, array $config): array + { + $iddatadbList = array_values(array_filter(array_map('intval', $iddatadbList), fn($v) => $v > 0)); + if (empty($iddatadbList)) { + return []; + } + + $fixedFields = $config['fixedFields']; + $fixedAliasMap = $config['fixedAliasMap']; + $mainFieldMappings = $config['mainFieldMappings']; + $default_idclient = $config['default_idclient']; + + $ph = implode(',', array_fill(0, count($iddatadbList), '?')); + + // Righe datadb + user_name + $stmt = $pdo->prepare(" + SELECT d.*, CONCAT(u.first_name, ' ', u.last_name) AS user_name + FROM datadb d + LEFT JOIN auth_users u ON d.user_id = u.id + WHERE d.iddatadb IN ($ph) + "); + $stmt->execute($iddatadbList); + $byId = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) { + $byId[(int)$r['iddatadb']] = $r; + } + + // Dettagli (import_data_details) + $detStmt = $pdo->prepare(" + SELECT d.id AS datadb_id, d.mapping_id, d.field_value, + m.field_id, m.field_label, m.data_type, m.is_required, m.manual_default + FROM import_data_details d + JOIN template_mapping m ON d.mapping_id = m.id + WHERE d.id IN ($ph) + "); + $detStmt->execute($iddatadbList); + $detailsByRow = []; + foreach ($detStmt->fetchAll(PDO::FETCH_ASSOC) as $d) { + $detailsByRow[(int)$d['datadb_id']][] = $d; + } + + // Costruzione righe nell'ordine della lista passata + $rows = []; + foreach ($iddatadbList as $id) { + $row = $byId[$id] ?? null; + if ($row === null) continue; + + $rowObj = [ + 'iddatadb' => (int)$row['iddatadb'], + 'status' => $row['status'] ?? 'i', + 'idclient' => $row['idclient'] ?? $default_idclient, + 'cliente_fornitore_id' => $row['cliente_fornitore_id'] ?? null, + 'tested_component' => $row['tested_component'] ?? '', + 'commessaweb' => $row['commessaweb'] ?? null, + 'user_name' => $row['user_name'] ?? '', + 'importreferencecode' => $row['importreferencecode'] ?? '', + 'filename_import' => $row['filename_import'] ?? '', + 'importdate' => $row['importdate'] ?? '', + ]; + + // Fixed fields + $rowObj['fixedFields'] = []; + foreach ($fixedFields as $f) { + $key = $f['fixed_field_key']; + $dbCol = $fixedAliasMap[$key] ?? $key; + $val = $row[$dbCol] ?? ''; + if ($val === '' || $val === null) { + $val = gdbFixedDefaultValue($f); + } + $rowObj['fixedFields'][$key] = (string)$val; + } + + // Details + $rowObj['details'] = []; + $rowDetails = $detailsByRow[$id] ?? []; + foreach ($rowDetails as $d) { + $rowObj['details'][(string)$d['mapping_id']] = $d['field_value'] ?? ''; + } + + // Main field values + foreach ($mainFieldMappings as $mainMapping) { + $found = null; + foreach ($rowDetails as $d) { + if ($d['mapping_id'] == $mainMapping['id']) { + $found = $d; + break; + } + } + $rowObj['details'][(string)$mainMapping['id']] = + ($found['field_value'] ?? null) ?? ($mainMapping['manual_default'] ?? ''); + } + + if (!empty($mainFieldMappings)) { + $firstMain = $mainFieldMappings[0]; + $rowObj['mainFieldValue'] = $rowObj['details'][(string)$firstMain['id']] ?? ''; + } + + $rowObj['_dirty'] = false; + $rows[] = $rowObj; + } + + return $rows; + } +} diff --git a/public/userarea/include/navbar.php b/public/userarea/include/navbar.php index 2b5f6d3..101588c 100644 --- a/public/userarea/include/navbar.php +++ b/public/userarea/include/navbar.php @@ -103,6 +103,14 @@ +
  • + +
    + +
    + +
    +
  • diff --git a/public/userarea/mapping_template_xls_scheme2.php b/public/userarea/mapping_template_xls_scheme2.php index 11db485..07a68ce 100644 --- a/public/userarea/mapping_template_xls_scheme2.php +++ b/public/userarea/mapping_template_xls_scheme2.php @@ -617,7 +617,11 @@ $apiSampleJson = $template['api_sample_json'] ?? ''; - + 'Buyer']; + $fixedKey = $fm['fixed_field_key']; + echo htmlspecialchars($fixedFieldLabels[$fixedKey] ?? $fixedKey); + ?> @@ -2192,6 +2196,10 @@ $apiSampleJson = $template['api_sample_json'] ?? ''; const tbody = document.querySelector('#fixedFieldsTable tbody'); if (!tbody) return; + const fixedFieldLabels = { + 'ClienteAnalisi': 'Buyer' + }; + const keysWithDropdown = [ 'ClienteResponsabile', 'ClienteFornitore', @@ -2223,7 +2231,7 @@ $apiSampleJson = $template['api_sample_json'] ?? ''; ${parseInt(r.is_required) === 1 ? 'checked' : ''}> - ${escapeHtml(r.fixed_field_key)} + ${escapeHtml(fixedFieldLabels[r.fixed_field_key] || r.fixed_field_key)} ${escapeHtml(r.data_type || '')} diff --git a/public/userarea/modal_partsTable.php b/public/userarea/modal_partsTable.php index 143623a..422c913 100644 --- a/public/userarea/modal_partsTable.php +++ b/public/userarea/modal_partsTable.php @@ -29,6 +29,9 @@
    Elenco Parti
    + + @@ -211,6 +217,12 @@ Duplica anche le analisi collegate
    +
    + + +
    + + +
    + L'operazione può richiedere da qualche secondo a un paio di minuti. +
    + + +
    +
    + Aggiornamento in corso… + 0s +
    + +
    +
    0%
    +
    + + + + + +
    +
    
    +                            
    +
    + + +
    + + +
    + + + + + + + +
    + + + + + + + + + + \ No newline at end of file