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 = [