Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a19cdf6074 | |||
| 14a7cc2738 | |||
| 601e72ca61 | |||
| 54c164478c | |||
| 965d2fa5bb | |||
| a1e91f8d76 | |||
| 153cdbca98 | |||
| 2603c86c5b | |||
| e7d733a136 | |||
| eb34cba7bb | |||
| f01bfa06c9 | |||
| 71d6fe1d98 | |||
| 33615f65d7 | |||
| 7b6d85766d | |||
| b2a79aec1b | |||
| a300932421 | |||
| df1ddce0a6 | |||
| 0277fb5262 | |||
| 0407bcdcc4 | |||
| b1810211d5 | |||
| 48d775f4d5 | |||
| bd9bd52c10 | |||
| 9dda4630a4 | |||
| 6d0b98bd41 | |||
| 7db96a1f9c | |||
| 699bffaa78 | |||
| e92e107a3c | |||
| 9330a49008 | |||
| 80dbd26929 | |||
| 69bb029d9d | |||
| c3f3e824dc | |||
| 05356fc98b | |||
| 232f8244bf | |||
| 1107e003f8 | |||
| c2fd2dc470 | |||
| 86b1c8420d | |||
| 1b5dd04740 | |||
| ff676376ab | |||
| 1a47d5649a | |||
| 05f5722ebd | |||
| 546b4ced69 | |||
| d256370712 |
@@ -62,3 +62,7 @@ public/userarea/schemi_base_response.json
|
||||
|
||||
public/userarea/cache/
|
||||
public/userarea/error_log.txt
|
||||
|
||||
# File PDF di esempio e importati
|
||||
/public/userarea/pdftemplates/*.pdf
|
||||
/public/userarea/pdftemplates/imported/*.pdf
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AddTemplateNoteToExcelTemplates extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$this->table('excel_templates')
|
||||
->addColumn('template_note', 'text', [
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
'after' => 'idroutine',
|
||||
'comment' => 'Nota opzionale del template, aggiunta in fondo alle note in esportazione',
|
||||
])
|
||||
->update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class DropUniqPartAnalysisIndex extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$table = $this->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class ChangeExcelrowToVarchar extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->table('datadb')
|
||||
->changeColumn('excelrow', 'string', [
|
||||
'limit' => 100,
|
||||
'null' => true,
|
||||
])
|
||||
->update();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->table('datadb')
|
||||
->changeColumn('excelrow', 'integer', [
|
||||
'null' => true,
|
||||
])
|
||||
->update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AddPdfSupportToTemplates extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
// 1) PDF di esempio + metadati pagine sul template
|
||||
$templates = $this->table('excel_templates');
|
||||
|
||||
if (!$templates->hasColumn('sample_pdf')) {
|
||||
$templates->addColumn('sample_pdf', 'string', [
|
||||
'limit' => 255,
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
'after' => 'sample_xlsx',
|
||||
'comment' => 'Filename of the sample PDF used to draw regions',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$templates->hasColumn('pdf_page_meta')) {
|
||||
$templates->addColumn('pdf_page_meta', 'text', [
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
'comment' => 'JSON: pages count and rendered dimensions per page',
|
||||
]);
|
||||
}
|
||||
|
||||
$templates->update();
|
||||
|
||||
// 2) Riquadri (etichetta + dato) per ogni campo mappato
|
||||
$mapping = $this->table('template_mapping');
|
||||
|
||||
if (!$mapping->hasColumn('pdf_regions')) {
|
||||
$mapping->addColumn('pdf_regions', 'text', [
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
'after' => 'json_node',
|
||||
'comment' => 'JSON: {page, label:{x,y,w,h}, value:{x,y,w,h}}',
|
||||
]);
|
||||
}
|
||||
|
||||
$mapping->update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AddPdfToSourceTypeEnum extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->execute(
|
||||
"ALTER TABLE `excel_templates`
|
||||
MODIFY `source_type` ENUM('XLS','API','PDF') NOT NULL DEFAULT 'XLS'"
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->execute(
|
||||
"ALTER TABLE `excel_templates`
|
||||
MODIFY `source_type` ENUM('XLS','API') NOT NULL DEFAULT 'XLS'"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
function loadAnalysisMatrixNames() {
|
||||
return $.ajax({
|
||||
url: "get_matrici_db.php",
|
||||
url: "search_matrici.php?full_list=1",
|
||||
method: "GET",
|
||||
dataType: "json",
|
||||
})
|
||||
@@ -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">×</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -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);
|
||||
})
|
||||
@@ -517,6 +537,13 @@
|
||||
.done(function (response) {
|
||||
const analyses = Array.isArray(response.value)
|
||||
? response.value.filter(function (item) {
|
||||
const name =
|
||||
item.NomeAnalisiTraduzione ||
|
||||
item.NomeAnalisi ||
|
||||
"";
|
||||
if (name.trim().startsWith("*")) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
item.SelezionabileSuWeb === true ||
|
||||
item.SelezionabileSuWeb === 1 ||
|
||||
@@ -700,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(
|
||||
@@ -715,15 +750,15 @@
|
||||
},
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (!stillUsed) {
|
||||
delete analysisSelectedState[recordKey];
|
||||
}
|
||||
if (!stillUsed) {
|
||||
delete analysisSelectedState[recordKey];
|
||||
}
|
||||
|
||||
syncSelectedAnalysisRows();
|
||||
});
|
||||
syncSelectedAnalysisRows();
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -744,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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -265,7 +265,11 @@ if (!array_key_exists($currentButtonTextColor, array_change_key_case($buttonText
|
||||
<label class="form-label"><?= htmlspecialchars($desctemplate, ENT_QUOTES, 'UTF-8'); ?></label>
|
||||
<textarea name="description" class="form-control"><?php echo htmlspecialchars($template['description'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Template Note</label>
|
||||
<textarea name="template_note" class="form-control" rows="3"><?php echo htmlspecialchars($template['template_note'] ?? ''); ?></textarea>
|
||||
<small class="text-muted">If set, this note will be appended at the end of the notes during export.</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= htmlspecialchars($desttable, ENT_QUOTES, 'UTF-8'); ?> *</label>
|
||||
<input type="text" name="target_table" class="form-control" value="<?php echo htmlspecialchars($template['target_table'] ?? 'datadb'); ?>" readonly required>
|
||||
|
||||
@@ -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, "<br>")}` +
|
||||
msg.innerHTML =
|
||||
`${data.message.replace(/\n/g, "<br>")}` +
|
||||
`<br>ID CommessaWeb: ${data.idcommessaweb}` +
|
||||
`<br>Codice CommessaWeb: ${data.commessaweb}` +
|
||||
(data.totalPhotos > 0 ? `<br>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 = '<i class="fas fa-spinner fa-spin"></i> Exporting...';
|
||||
spinner.innerHTML =
|
||||
'<i class="fas fa-spinner fa-spin"></i> 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, "<br>");
|
||||
document.getElementById("exportResponseModalLabel").textContent = "Error (id: " + iddatadb + ")";
|
||||
new bootstrap.Modal(document.getElementById("exportResponseModal"), { keyboard: false }).show();
|
||||
document.getElementById("exportResponseMessage").innerHTML =
|
||||
message.replace(/\n/g, "<br>");
|
||||
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 = '<i class="fas fa-spinner fa-spin"></i> Validating...';
|
||||
if (spinner)
|
||||
spinner.innerHTML =
|
||||
'<i class="fas fa-spinner fa-spin"></i> 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.<br><strong>${invalidCount}</strong> 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,
|
||||
});
|
||||
})();
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -151,6 +151,7 @@ try {
|
||||
// Also fetch fixed fields stored in datadb
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT d.idclient AS clienteId, et.idschema AS schemaId, et.id AS templateId,
|
||||
et.template_note,
|
||||
d.cliente_responsabile_id,
|
||||
d.moltiplicatore_prezzo_id,
|
||||
d.anagrafica_certest_object_id,
|
||||
@@ -280,6 +281,14 @@ try {
|
||||
|
||||
$addnoteText = implode(' - ', $addnoteChunks);
|
||||
|
||||
// Nota del template: appesa in fondo alle note in esportazione
|
||||
$templateNote = trim((string)($result['template_note'] ?? ''));
|
||||
if ($templateNote !== '') {
|
||||
$addnoteText = $addnoteText !== ''
|
||||
? $addnoteText . ' - ' . $templateNote
|
||||
: $templateNote;
|
||||
}
|
||||
|
||||
// Logga i fieldValues in error_log
|
||||
$logFieldValues = "FieldValues dal DB (iddatadb={$iddatadb}):\n" . json_encode($fieldValues, JSON_PRETTY_PRINT);
|
||||
error_log($logFieldValues);
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
<?php
|
||||
|
||||
include('include/headscript.php');
|
||||
require_once __DIR__ . '/include/grid_data_builder.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$pdo = DBHandlerSelect::getInstance()->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<array{0:int,1:int}> 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()]);
|
||||
}
|
||||
@@ -69,6 +69,11 @@ try {
|
||||
|
||||
$analisi = $data['value'] ?? [];
|
||||
|
||||
// Escludi le analisi con NominativoCliente compilato
|
||||
$analisi = array_values(array_filter($analisi, function ($item) {
|
||||
return empty(trim((string)($item['NominativoCliente'] ?? '')));
|
||||
}));
|
||||
|
||||
echo json_encode([
|
||||
'value' => $analisi,
|
||||
'count' => is_array($analisi) ? count($analisi) : 0
|
||||
|
||||
@@ -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 =
|
||||
`<button type="button" class="btn btn-outline-secondary btn-sm" id="filterPrevBtn" ${filterPage <= 1 ? "disabled" : ""}>‹</button>` +
|
||||
`<span style="font-size:12px;color:#333;">${filterTotal} ${label} · pag ${filterPage}/${filterTotalPages}</span>` +
|
||||
`<button type="button" class="btn btn-outline-secondary btn-sm" id="filterNextBtn" ${filterPage >= filterTotalPages ? "disabled" : ""}>›</button>`;
|
||||
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 = `<input type="checkbox" class="filter-row-checkbox" ${selected.has(id) ? "checked" : ""}>`;
|
||||
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 = `<label class="filter-cb-wrap" style="display:inline-flex;" title="Seleziona tutti i filtrati"><input type="checkbox" id="filterSelectAll"></label>`;
|
||||
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) =>
|
||||
`<option value="${v}" ${cur === v ? "selected" : ""}>${lbl}</option>`;
|
||||
cell.innerHTML =
|
||||
`<select class="filter-col-input" data-col-key="${col.key}" style="width:100%;padding:3px 6px;font-size:12px;border:1px solid #ced4da;border-radius:4px;">` +
|
||||
opt("", "— tutti —") +
|
||||
opt("i", "Imported") +
|
||||
opt("P", "In Progress") +
|
||||
opt("l", "To LIMS") +
|
||||
`</select>`;
|
||||
} else {
|
||||
cell.innerHTML = `<input type="text" class="filter-col-input" data-col-key="${col.key}" placeholder="🔍 ${col.label || ""}" value="${colFilters[col.key] || ""}" style="width:100%;padding:3px 6px;font-size:12px;border:1px solid #ced4da;border-radius:4px;">`;
|
||||
}
|
||||
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
|
||||
? '<i class="fas fa-eye"></i> Torna al filtro'
|
||||
: '<i class="fas fa-list-check"></i> 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 = `<i class="fas fa-spinner fa-spin"></i> ${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 = '<i class="fas fa-filter"></i> 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 = `
|
||||
<span id="filterPager" style="display:none;align-items:center;gap:6px;"></span>
|
||||
<button type="button" id="filterClearBtn" class="btn btn-outline-secondary btn-sm"><i class="fas fa-times"></i> Pulisci filtri</button>
|
||||
<span style="font-size:12px;color:#555;">Selezionate: <strong id="filterSelCount">0</strong></span>
|
||||
<button type="button" id="filterRestrictBtn" class="btn btn-outline-primary btn-sm"><i class="fas fa-list-check"></i> Mostra selezionati</button>
|
||||
<button type="button" id="filterClearSelBtn" class="btn btn-outline-secondary btn-sm"><i class="fas fa-square"></i> Deseleziona</button>
|
||||
<button type="button" id="filterDeleteBtn" class="btn btn-outline-danger btn-sm"><i class="fas fa-trash"></i> Elimina selezionati</button>
|
||||
`;
|
||||
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);
|
||||
}
|
||||
})();
|
||||
@@ -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();
|
||||
|
||||
@@ -392,7 +439,6 @@
|
||||
allowClear: true,
|
||||
width: "100%",
|
||||
minimumInputLength: 0,
|
||||
sorter: sortSelect2ResultsByStart,
|
||||
ajax: {
|
||||
url: "search_customfield_values.php",
|
||||
dataType: "json",
|
||||
@@ -684,7 +730,7 @@
|
||||
|
||||
const reqCls = col.isRequired ? " required-input" : "";
|
||||
const req = col.isRequired ? " required" : "";
|
||||
return `<select class="cell-input manual-input fixed-input ${selectClass}${reqCls}" data-fixed-key="${escAttr(col.key)}" data-current-value="${escAttr(value)}"${req}>${options}</select>`;
|
||||
return `<select class="cell-input manual-input fixed-input searchable-fixed ${selectClass}${reqCls}" data-fixed-key="${escAttr(col.key)}" data-current-value="${escAttr(value)}"${req}>${options}</select>`;
|
||||
}
|
||||
|
||||
function buildDropdownOptionsHTML(fieldId, selectedValue) {
|
||||
@@ -968,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");
|
||||
@@ -1199,6 +1250,12 @@
|
||||
items.forEach((item) =>
|
||||
sel.add(new Option(item.text, item.id)),
|
||||
);
|
||||
$(sel).select2({
|
||||
placeholder: "Seleziona...",
|
||||
allowClear: true,
|
||||
width: "100%",
|
||||
...rankedLocalSelect2(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1221,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)
|
||||
var 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");
|
||||
@@ -1235,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") {
|
||||
@@ -1388,6 +1499,11 @@
|
||||
|
||||
if (column === "idclient") {
|
||||
data.forEach((row) => {
|
||||
if (
|
||||
window.isTargetGridId &&
|
||||
!window.isTargetGridId(row.iddatadb)
|
||||
)
|
||||
return;
|
||||
const oldClientId = row.idclient || "";
|
||||
|
||||
row.idclient = value;
|
||||
@@ -1421,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;
|
||||
});
|
||||
@@ -1434,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,10 +1571,23 @@
|
||||
}
|
||||
|
||||
if (col && (col.type === "detail" || col.type === "main_field")) {
|
||||
const autoPair = AUTO_COPY_KEY_PAIRS.find(
|
||||
(p) => p.fromKey === String(col.key),
|
||||
);
|
||||
|
||||
data.forEach((row) => {
|
||||
if (
|
||||
window.isTargetGridId &&
|
||||
!window.isTargetGridId(row.iddatadb)
|
||||
)
|
||||
return;
|
||||
if (!row.details) row.details = {};
|
||||
row.details[col.key] = value;
|
||||
|
||||
if (autoPair) {
|
||||
row.details[autoPair.toKey] = value;
|
||||
}
|
||||
|
||||
if (col.type === "main_field") {
|
||||
row.mainFieldValue = value;
|
||||
}
|
||||
@@ -1627,9 +1766,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() {
|
||||
@@ -1760,3 +1908,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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -297,7 +297,9 @@
|
||||
wrapper.className = "template-btn-wrapper";
|
||||
|
||||
const btn = document.createElement("a");
|
||||
btn.href = `import_xls2.php?id=${template.id}`;
|
||||
btn.href = (sourceType === 'PDF') ?
|
||||
`import_pdf.php?id=${template.id}` :
|
||||
`import_xls2.php?id=${template.id}`;
|
||||
btn.className = `btn ${sizeClass}`;
|
||||
btn.style.backgroundColor = template.button_bg_color || '#0d6efd';
|
||||
btn.style.color = template.button_text_color || '#ffffff';
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
ini_set('display_errors', 0); // in AJAX gli errori devono NON sporcare il JSON
|
||||
error_reporting(E_ALL);
|
||||
ini_set('log_errors', 1);
|
||||
ini_set('error_log', __DIR__ . '/import_debug.log');
|
||||
|
||||
include('include/headscript.php');
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Legge il body JSON
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (
|
||||
!$input ||
|
||||
!isset($input['template_id']) ||
|
||||
!isset($input['importreferencecode']) ||
|
||||
!isset($input['filename']) ||
|
||||
!isset($input['columns']) ||
|
||||
!isset($input['batch_rows']) ||
|
||||
!isset($input['batch_excelrows'])
|
||||
) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Richiesta non valida']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$template_id = intval($input['template_id']);
|
||||
$importReferenceCode = (string)$input['importreferencecode'];
|
||||
$newFilename = (string)$input['filename'];
|
||||
$columns = $input['columns']; // array
|
||||
$batchRows = $input['batch_rows']; // array di righe (ognuna array di celle)
|
||||
$batchExcelrows = $input['batch_excelrows']; // array di excelrow paralleli a batchRows
|
||||
|
||||
$user_id = $iduserlogin ?? 1;
|
||||
|
||||
function normalizeColName($s): string
|
||||
{
|
||||
$s = (string)$s;
|
||||
$s = str_replace(
|
||||
["\xC2\xA0", "\xE2\x80\xAF", "\xE2\x80\x87", "\xE2\x80\x89", "\xE2\x80\x8A", "\xEF\xBB\xBF"],
|
||||
' ',
|
||||
$s
|
||||
);
|
||||
$s = preg_replace('/\s+/u', ' ', $s);
|
||||
return trim((string)$s);
|
||||
}
|
||||
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
// Recupera i mapping
|
||||
$stmt = $pdo->prepare("SELECT id, excel_column, data_type, is_required, manual_default, is_manual, field_label, field_id, main_field, auto_value FROM template_mapping WHERE template_id = ?");
|
||||
$stmt->execute([$template_id]);
|
||||
$allMappings = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($allMappings)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Nessun mapping trovato per il template']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// idclient di default (una volta sola)
|
||||
$template_stmt = $pdo->prepare("SELECT idclient FROM excel_templates WHERE id = ?");
|
||||
$template_stmt->execute([$template_id]);
|
||||
$tpl = $template_stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$default_idclient = $tpl['idclient'] ?? null;
|
||||
|
||||
// lims_user_id per il campo 244 (una volta sola)
|
||||
$stmtUser = $pdo->prepare("SELECT lims_user_id FROM auth_users WHERE id = ? LIMIT 1");
|
||||
$stmtUser->execute([(int)$user_id]);
|
||||
$limsUserId = $stmtUser->fetchColumn();
|
||||
$limsUserId = ($limsUserId !== false && $limsUserId !== null && $limsUserId !== '') ? (string)$limsUserId : '';
|
||||
|
||||
$stmtMap = $pdo->prepare("SELECT id FROM template_mapping WHERE template_id = ? AND field_id = 244 LIMIT 1");
|
||||
$stmtMap->execute([(int)$template_id]);
|
||||
$mappingId244 = (int)$stmtMap->fetchColumn();
|
||||
|
||||
$insertedIds = [];
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$insDatadb = $pdo->prepare("INSERT INTO datadb (templateid, importreferencecode, filename_import, status, user_id, limscode, importdate, excelrow, idclient) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
$insDetail = $pdo->prepare("INSERT INTO import_data_details (id, mapping_id, field_value) VALUES (?, ?, ?)");
|
||||
|
||||
foreach ($batchRows as $i => $row) {
|
||||
$excelrow = $batchExcelrows[$i] ?? null;
|
||||
if ($row === null || $excelrow === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$insDatadb->execute([
|
||||
$template_id,
|
||||
$importReferenceCode,
|
||||
$newFilename,
|
||||
'i',
|
||||
$user_id,
|
||||
null,
|
||||
date('Y-m-d'),
|
||||
$excelrow,
|
||||
$default_idclient
|
||||
]);
|
||||
|
||||
$iddatadb = $pdo->lastInsertId();
|
||||
$insertedIds[] = $iddatadb;
|
||||
|
||||
foreach ($allMappings as $mapping) {
|
||||
$fieldValue = null;
|
||||
if (!$mapping['is_manual']) {
|
||||
$excelColumn = trim($mapping['excel_column']);
|
||||
$excelColumnIndex = array_search(
|
||||
normalizeColName($excelColumn),
|
||||
array_map('normalizeColName', $columns)
|
||||
);
|
||||
if ($excelColumnIndex !== false && isset($row[$excelColumnIndex]) && $row[$excelColumnIndex] !== '') {
|
||||
$fieldValue = $row[$excelColumnIndex];
|
||||
} else {
|
||||
$fieldValue = $mapping['manual_default'] ?? '';
|
||||
}
|
||||
switch ($mapping['data_type']) {
|
||||
case 'INT':
|
||||
$fieldValue = is_numeric($fieldValue) ? (int)$fieldValue : ($mapping['manual_default'] ?? 0);
|
||||
break;
|
||||
case 'DATE':
|
||||
$fieldValue = !empty($fieldValue) ? date('Y-m-d', strtotime($fieldValue)) : ($mapping['manual_default'] === 'today' ? date('Y-m-d') : ($mapping['manual_default'] ?? ''));
|
||||
break;
|
||||
case 'CHAR':
|
||||
$fieldValue = !empty($fieldValue) ? substr((string)$fieldValue, 0, 1) : ($mapping['manual_default'] ?? '');
|
||||
break;
|
||||
case 'Testo':
|
||||
case 'VARCHAR':
|
||||
default:
|
||||
$fieldValue = !empty($fieldValue) ? (string)$fieldValue : ($mapping['manual_default'] ?? '');
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$fieldValue = $mapping['manual_default'] ?? '';
|
||||
if ($mapping['data_type'] === 'DATE' && $mapping['manual_default'] === 'today') {
|
||||
$fieldValue = date('Y-m-d');
|
||||
}
|
||||
}
|
||||
if (($fieldValue === null || $fieldValue === '') && !empty($mapping['auto_value']) && $mapping['auto_value'] !== 'none') {
|
||||
if ($mapping['auto_value'] === 'import_date') {
|
||||
$fieldValue = date('Y-m-d');
|
||||
} elseif ($mapping['auto_value'] === 'import_time') {
|
||||
$fieldValue = date('H:i');
|
||||
}
|
||||
}
|
||||
|
||||
$insDetail->execute([$iddatadb, $mapping['id'], $fieldValue]);
|
||||
}
|
||||
|
||||
// Campo 244 (Accettatore) con lims_user_id
|
||||
if ($limsUserId !== '' && $mappingId244 > 0) {
|
||||
$insDetail->execute([$iddatadb, $mappingId244, $limsUserId]);
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'inserted' => count($insertedIds)
|
||||
]);
|
||||
exit;
|
||||
} catch (Exception $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
error_log("[BATCH IMPORT] " . $e->getMessage());
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
include('include/headscript.php');
|
||||
|
||||
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
|
||||
header("Location: template_dashboard.php?status=error&message=" . urlencode("Invalid ID"));
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = intval($_GET['id']);
|
||||
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id, name, source_type, idclient, sample_pdf FROM excel_templates WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$template = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$template) {
|
||||
header("Location: template_dashboard.php?status=error&message=" . urlencode("Template not found"));
|
||||
exit;
|
||||
}
|
||||
|
||||
$sourceType = strtoupper($template['source_type'] ?? '');
|
||||
if ($sourceType !== 'PDF') {
|
||||
// Non è un template PDF: rimanda al flusso corretto
|
||||
header("Location: import_xls2.php?id=" . $id);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Conta i mapping con riquadri, per avvisare se non è configurato
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM template_mapping WHERE template_id = ? AND pdf_regions IS NOT NULL AND pdf_regions <> ''");
|
||||
$stmt->execute([$id]);
|
||||
$mappedRegions = (int)$stmt->fetchColumn();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
|
||||
<?php include('cssinclude.php'); ?>
|
||||
<title><?= htmlspecialchars($template['name']) ?> - PDF Import</title>
|
||||
<style>
|
||||
#dropZone {
|
||||
border: 2px dashed #adb5bd;
|
||||
border-radius: 10px;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
background: #f8f9fa;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
#dropZone.dragover {
|
||||
background: #e7f1ff;
|
||||
border-color: #0d6efd;
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
#dropZone .big {
|
||||
font-size: 42px;
|
||||
line-height: 1;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.file-row .status-icon {
|
||||
font-size: 18px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-row .fname {
|
||||
font-weight: 600;
|
||||
flex: 0 0 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-row .fvals {
|
||||
flex: 1;
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.file-row.ok {
|
||||
background: #eaffea;
|
||||
}
|
||||
|
||||
.file-row.err {
|
||||
background: #ffecec;
|
||||
}
|
||||
|
||||
.file-row.pending {
|
||||
background: #fff8e1;
|
||||
}
|
||||
|
||||
.val-pair {
|
||||
display: inline-block;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.val-pair b {
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<?php include('include/navbar.php'); ?>
|
||||
<?php include('include/topbar.php'); ?>
|
||||
<div class="page-wrapper">
|
||||
<div class="page-content">
|
||||
<?php include('top_stat_widget.php'); ?>
|
||||
|
||||
<div class="card radius-10">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><?= htmlspecialchars($template['name']) ?> <span class="badge bg-danger">PDF</span></h6>
|
||||
<small>Template ID: <?= $id ?> — Ogni PDF diventa una riga importata.</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<?php if ($mappedRegions === 0): ?>
|
||||
<div class="alert alert-warning">
|
||||
Questo template non ha ancora riquadri PDF configurati. Configura il mapping prima di importare.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div id="dropZone">
|
||||
<div class="big">📄⬇</div>
|
||||
<div><strong>Trascina qui i PDF</strong> oppure clicca per selezionarli</div>
|
||||
<div style="font-size:13px; margin-top:6px;">Puoi caricarne più di uno insieme</div>
|
||||
<input type="file" id="pdfInput" accept="application/pdf" multiple style="display:none;">
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mt-3">
|
||||
<span class="badge bg-secondary" id="fileCount">0 file</span>
|
||||
<button id="startImportBtn" class="btn btn-primary" disabled>Importa PDF</button>
|
||||
</div>
|
||||
|
||||
<div id="fileList" class="mt-3"></div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="imported.php?id=<?= $id ?>" class="btn btn-warning">Vai agli Imported</a>
|
||||
<a href="template_dashboard.php" class="btn btn-secondary">Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overlay toggle-icon"></div>
|
||||
<a href="javaScript:;" class="back-to-top"><i class='bx bxs-up-arrow-alt'></i></a>
|
||||
<?php include('include/footer.php'); ?>
|
||||
</div>
|
||||
|
||||
<?php include('jsinclude.php'); ?>
|
||||
<script>
|
||||
(function() {
|
||||
const TEMPLATE_ID = <?= (int)$id ?>;
|
||||
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const pdfInput = document.getElementById('pdfInput');
|
||||
const fileList = document.getElementById('fileList');
|
||||
const fileCount = document.getElementById('fileCount');
|
||||
const startBtn = document.getElementById('startImportBtn');
|
||||
|
||||
let selectedFiles = [];
|
||||
|
||||
function updateCount() {
|
||||
fileCount.textContent = selectedFiles.length + ' file';
|
||||
startBtn.disabled = selectedFiles.length === 0;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
fileList.innerHTML = '';
|
||||
selectedFiles.forEach((f, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'file-row pending';
|
||||
row.id = 'file-row-' + idx;
|
||||
row.innerHTML = `
|
||||
<span class="status-icon">⏳</span>
|
||||
<span class="fname">${escapeHtml(f.name)}</span>
|
||||
<span class="fvals">In attesa...</span>
|
||||
`;
|
||||
fileList.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function addFiles(files) {
|
||||
for (const f of files) {
|
||||
if (f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')) {
|
||||
selectedFiles.push(f);
|
||||
}
|
||||
}
|
||||
updateCount();
|
||||
renderList();
|
||||
}
|
||||
|
||||
// Click per selezionare
|
||||
dropZone.addEventListener('click', () => pdfInput.click());
|
||||
pdfInput.addEventListener('change', (e) => addFiles(e.target.files));
|
||||
|
||||
// Drag & drop
|
||||
['dragenter', 'dragover'].forEach(ev => {
|
||||
dropZone.addEventListener(ev, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropZone.classList.add('dragover');
|
||||
});
|
||||
});
|
||||
['dragleave', 'drop'].forEach(ev => {
|
||||
dropZone.addEventListener(ev, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropZone.classList.remove('dragover');
|
||||
});
|
||||
});
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
addFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
// Genera un importreferencecode condiviso da tutto il gruppo
|
||||
function makeImportRef() {
|
||||
return new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14) +
|
||||
'-' + Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
startBtn.addEventListener('click', async () => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
startBtn.disabled = true;
|
||||
startBtn.textContent = 'Importazione...';
|
||||
|
||||
const importRef = makeImportRef();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('template_id', TEMPLATE_ID);
|
||||
formData.append('importreferencecode', importRef);
|
||||
selectedFiles.forEach(f => formData.append('pdf_files[]', f));
|
||||
|
||||
// segna tutte le righe come "in corso"
|
||||
selectedFiles.forEach((f, idx) => {
|
||||
const row = document.getElementById('file-row-' + idx);
|
||||
if (row) row.querySelector('.fvals').textContent = 'Elaborazione...';
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await fetch('process_import_pdf.php', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (!data.ok) {
|
||||
alert('Errore: ' + (data.error || 'sconosciuto'));
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = 'Importa PDF';
|
||||
return;
|
||||
}
|
||||
|
||||
// Aggiorna ogni riga con l'esito (l'ordine dei results segue quello inviato)
|
||||
data.results.forEach((res, idx) => {
|
||||
const row = document.getElementById('file-row-' + idx);
|
||||
if (!row) return;
|
||||
|
||||
const icon = row.querySelector('.status-icon');
|
||||
const vals = row.querySelector('.fvals');
|
||||
|
||||
if (res.ok) {
|
||||
row.className = 'file-row ok';
|
||||
icon.textContent = '✅';
|
||||
const pairs = Object.entries(res.values || {})
|
||||
.map(([k, v]) => `<span class="val-pair"><b>${escapeHtml(k)}:</b> ${escapeHtml(v || '—')}</span>`)
|
||||
.join('');
|
||||
vals.innerHTML = pairs || 'Importato (nessun valore estratto)';
|
||||
} else {
|
||||
row.className = 'file-row err';
|
||||
icon.textContent = '❌';
|
||||
vals.textContent = res.error || 'Errore sconosciuto';
|
||||
}
|
||||
});
|
||||
|
||||
startBtn.textContent = 'Importa altri PDF';
|
||||
startBtn.disabled = false;
|
||||
// svuota la selezione (i file sono stati processati)
|
||||
selectedFiles = [];
|
||||
pdfInput.value = '';
|
||||
updateCount();
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Errore di rete durante l\'importazione.');
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = 'Importa PDF';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+172
-20
@@ -50,6 +50,9 @@ error_log("Loaded template: " . print_r($template, true));
|
||||
border-radius: 0.25rem;
|
||||
background: #f8f9fa;
|
||||
display: none;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1020;
|
||||
}
|
||||
|
||||
.top-scrollbar-inner {
|
||||
@@ -341,8 +344,9 @@ error_log("Loaded template: " . print_r($template, true));
|
||||
<input type="hidden" name="excelrows" id="selectedExcelRowsData" value="">
|
||||
<input type="hidden" name="filename" value="${data.filename}">
|
||||
|
||||
<!-- TOP BUTTON -->
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<!-- TOP BUTTON -->
|
||||
<div class="d-flex justify-content-end align-items-center mb-3 gap-2">
|
||||
<span class="badge bg-secondary" id="selectedCountTop">0 selezionati</span>
|
||||
<button type="submit" class="btn btn-primary" id="proceedButtonTop" disabled>Prosegui</button>
|
||||
</div>
|
||||
|
||||
@@ -383,46 +387,187 @@ error_log("Loaded template: " . print_r($template, true));
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- BOTTOM BUTTON -->
|
||||
<button type="submit" class="btn btn-primary mt-3" id="proceedButtonBottom" disabled>Prosegui</button>
|
||||
<!-- BOTTOM BUTTON -->
|
||||
<div class="d-flex align-items-center mt-3 gap-2">
|
||||
<span class="badge bg-secondary" id="selectedCountBottom">0 selezionati</span>
|
||||
<button type="submit" class="btn btn-primary" id="proceedButtonBottom" disabled>Prosegui</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
tableContainer.innerHTML = html;
|
||||
|
||||
const selectRowsForm = document.getElementById('selectRowsForm');
|
||||
|
||||
selectRowsForm.addEventListener('submit', function(e) {
|
||||
// Clic sulla cella (oltre che sulla checkbox) per selezionare/deselezionare la riga
|
||||
document.querySelectorAll('#importPreviewTable tbody tr').forEach(tr => {
|
||||
tr.style.cursor = 'pointer';
|
||||
tr.addEventListener('click', function(e) {
|
||||
// Se ho cliccato proprio sulla checkbox, lascio fare a lei (evita doppio toggle)
|
||||
if (e.target.classList.contains('row-checkbox')) return;
|
||||
|
||||
const cb = this.querySelector('.row-checkbox');
|
||||
if (!cb) return;
|
||||
cb.checked = !cb.checked;
|
||||
cb.dispatchEvent(new Event('change', {
|
||||
bubbles: true
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
selectRowsForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault(); // gestiamo tutto via AJAX a batch
|
||||
|
||||
const checkedBoxes = Array.from(document.querySelectorAll('.row-checkbox:checked'));
|
||||
|
||||
if (checkedBoxes.length === 0) {
|
||||
e.preventDefault();
|
||||
alert('Seleziona almeno una riga.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Raccoglie le righe selezionate (riga mappata + excelrow parallelo)
|
||||
const selectedRows = [];
|
||||
const selectedExcelRows = [];
|
||||
|
||||
checkedBoxes.forEach((cb, newIndex) => {
|
||||
checkedBoxes.forEach(cb => {
|
||||
const originalIndex = parseInt(cb.value, 10);
|
||||
|
||||
if (data.rows && data.rows[originalIndex]) {
|
||||
selectedRows.push(data.rows[originalIndex]);
|
||||
selectedExcelRows.push(
|
||||
(data.excel_data && data.excel_data[originalIndex]) ?
|
||||
data.excel_data[originalIndex].excelrow :
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
if (data.excel_data && data.excel_data[originalIndex]) {
|
||||
selectedExcelRows.push(data.excel_data[originalIndex].excelrow);
|
||||
}
|
||||
|
||||
// Reindex selected_rows so import_insert.php receives only the reduced rows array
|
||||
cb.value = newIndex;
|
||||
});
|
||||
|
||||
document.getElementById('selectedRowsData').value =
|
||||
encodeURIComponent(JSON.stringify(selectedRows));
|
||||
const total = selectedRows.length;
|
||||
if (total === 0) {
|
||||
alert('Nessuna riga valida da importare.');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('selectedExcelRowsData').value =
|
||||
encodeURIComponent(JSON.stringify(selectedExcelRows));
|
||||
// importreferencecode generato UNA volta, condiviso da tutti i batch
|
||||
const importReferenceCode =
|
||||
new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14) +
|
||||
'-' + Math.random().toString(36).slice(2, 10);
|
||||
|
||||
const BATCH_SIZE = 20;
|
||||
|
||||
// Disabilita i pulsanti e mostra la barra di progresso
|
||||
const btnTop = document.getElementById('proceedButtonTop');
|
||||
const btnBottom = document.getElementById('proceedButtonBottom');
|
||||
if (btnTop) btnTop.disabled = true;
|
||||
if (btnBottom) btnBottom.disabled = true;
|
||||
|
||||
const progressHtml = `
|
||||
<div id="importProgressWrap" style="
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 20000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;">
|
||||
<div style="
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 25px 30px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.3);">
|
||||
<div class="mb-2"><strong>Importazione in corso...</strong>
|
||||
<span id="importProgressText" class="float-end">0 / ${total}</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 26px;">
|
||||
<div id="importProgressBar" class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar" style="width: 0%;">0%</div>
|
||||
</div>
|
||||
<div id="importProgressError" class="alert alert-danger mt-3 mb-0" style="display:none;"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.insertAdjacentHTML('beforeend', progressHtml);
|
||||
|
||||
const progressBar = document.getElementById('importProgressBar');
|
||||
const progressText = document.getElementById('importProgressText');
|
||||
const progressErr = document.getElementById('importProgressError');
|
||||
|
||||
let insertedTotal = 0;
|
||||
|
||||
// Invia un batch e ritorna la risposta JSON
|
||||
async function sendBatch(startIndex) {
|
||||
const batchRows = selectedRows.slice(startIndex, startIndex + BATCH_SIZE);
|
||||
const batchExcelrows = selectedExcelRows.slice(startIndex, startIndex + BATCH_SIZE);
|
||||
|
||||
const resp = await fetch('import_insert_batch.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
template_id: data.template_id,
|
||||
importreferencecode: importReferenceCode,
|
||||
filename: data.filename,
|
||||
columns: data.columns,
|
||||
batch_rows: batchRows,
|
||||
batch_excelrows: batchExcelrows
|
||||
})
|
||||
});
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// Cicla i batch in sequenza
|
||||
for (let start = 0; start < total; start += BATCH_SIZE) {
|
||||
let result;
|
||||
try {
|
||||
result = await sendBatch(start);
|
||||
} catch (err) {
|
||||
result = {
|
||||
ok: false,
|
||||
error: err.message
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
// Batch fallito: chiediamo all'utente se continuare
|
||||
progressErr.textContent =
|
||||
'Errore su un blocco (righe ' + (start + 1) + '-' +
|
||||
Math.min(start + BATCH_SIZE, total) + '): ' + (result.error || 'errore sconosciuto');
|
||||
progressErr.style.display = 'block';
|
||||
|
||||
const continua = confirm(
|
||||
'Un blocco di righe non è stato importato.\n\n' +
|
||||
'Righe inserite finora: ' + insertedTotal + ' di ' + total + '.\n\n' +
|
||||
'Vuoi continuare con i blocchi successivi? (Annulla = interrompi)'
|
||||
);
|
||||
|
||||
if (!continua) {
|
||||
progressBar.classList.remove('progress-bar-animated');
|
||||
progressBar.classList.add('bg-warning');
|
||||
return;
|
||||
}
|
||||
// se continua, prosegue col prossimo blocco
|
||||
continue;
|
||||
}
|
||||
|
||||
insertedTotal += (result.inserted || 0);
|
||||
const done = Math.min(start + BATCH_SIZE, total);
|
||||
const pct = Math.round((done / total) * 100);
|
||||
progressBar.style.width = pct + '%';
|
||||
progressBar.textContent = pct + '%';
|
||||
progressText.textContent = insertedTotal + ' / ' + total;
|
||||
}
|
||||
|
||||
// Fine: barra verde e redirect a imported.php
|
||||
progressBar.classList.remove('progress-bar-animated', 'progress-bar-striped');
|
||||
progressBar.classList.add('bg-success');
|
||||
progressText.textContent = insertedTotal + ' / ' + total + ' completate';
|
||||
|
||||
// PAUSA temporanea di 2 secondi per vedere la barra completa (da rimuovere dopo il test)
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
window.location.href = 'imported.php?id=' +
|
||||
encodeURIComponent(data.template_id) +
|
||||
'&importref=' + encodeURIComponent(importReferenceCode);
|
||||
});
|
||||
|
||||
const topTableScrollbar = document.getElementById('topTableScrollbar');
|
||||
@@ -472,10 +617,17 @@ error_log("Loaded template: " . print_r($template, true));
|
||||
const checkboxes = document.querySelectorAll('.row-checkbox');
|
||||
|
||||
function updateProceedButton() {
|
||||
const enabled = Array.from(checkboxes).some(cb => cb.checked);
|
||||
const checkedCount = Array.from(checkboxes).filter(cb => cb.checked).length;
|
||||
const enabled = checkedCount > 0;
|
||||
|
||||
if (proceedButtonTop) proceedButtonTop.disabled = !enabled;
|
||||
if (proceedButtonBottom) proceedButtonBottom.disabled = !enabled;
|
||||
|
||||
const label = checkedCount + ' selezionati';
|
||||
const countTop = document.getElementById('selectedCountTop');
|
||||
const countBottom = document.getElementById('selectedCountBottom');
|
||||
if (countTop) countTop.textContent = label;
|
||||
if (countBottom) countBottom.textContent = label;
|
||||
}
|
||||
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
|
||||
@@ -99,7 +99,7 @@ $stmt = $pdo->prepare("
|
||||
FROM datadb d
|
||||
LEFT JOIN auth_users u ON d.user_id = u.id
|
||||
{$baseWhere}
|
||||
ORDER BY d.excelrow ASC, d.iddatadb ASC
|
||||
ORDER BY CAST(d.excelrow AS UNSIGNED) ASC, d.excelrow ASC, d.iddatadb ASC
|
||||
{$limitClause}
|
||||
");
|
||||
$stmt->execute($baseParams);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
<?php if (isset($mainFieldMappings) && count($mainFieldMappings) >= 2): ?>.grid-top .grid-cell:nth-child(3) {
|
||||
position: sticky !important;
|
||||
left: 360px;
|
||||
z-index: 7;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
<?php endif; ?>.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;
|
||||
}
|
||||
|
||||
<?php if (isset($mainFieldMappings) && count($mainFieldMappings) >= 2): ?>.grid-filter-row .grid-cell:nth-child(3) {
|
||||
position: sticky !important;
|
||||
left: 360px;
|
||||
z-index: 7;
|
||||
background: #eef6f9 !important;
|
||||
}
|
||||
|
||||
<?php endif; ?>
|
||||
</style>
|
||||
<title>Edit Imported Data - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
|
||||
</head>
|
||||
@@ -1456,6 +1568,7 @@ $gridMeta = [
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<script src="tracking.js"></script>
|
||||
<script src="gridRenderer.js"></script>
|
||||
<script src="gridFilter.js"></script>
|
||||
<script src="saveAll.js"></script>
|
||||
<script src="exportLims_gridData.js"></script>
|
||||
<script src="modals_gridData.js"></script>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
if (!function_exists('gdbFixedDefaultValue')) {
|
||||
/** Default di un fixed field (DATE 'today' → data odierna). */
|
||||
function gdbFixedDefaultValue(array $f): string
|
||||
{
|
||||
$v = $f['default_value'] ?? '';
|
||||
if (($f['data_type'] ?? '') === 'DATE' && $v === 'today') {
|
||||
return date('Y-m-d');
|
||||
}
|
||||
return (string)$v;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('buildGridConfig')) {
|
||||
/**
|
||||
* Config del template necessaria per costruire le righe: fixed fields (ordinati
|
||||
* come in imported.php), main field mappings, alias map, default idclient.
|
||||
*
|
||||
* @return array{fixedFields:array, fixedAliasMap:array, mainFieldMappings:array, default_idclient:mixed}
|
||||
*/
|
||||
function buildGridConfig(PDO $pdo, int $templateId): array
|
||||
{
|
||||
// Mappa logica fixed_field_key → colonna reale su datadb (come imported.php)
|
||||
$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',
|
||||
];
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
<ul>
|
||||
<li>
|
||||
<a href="import_dashboard.php">
|
||||
<i class='bx bx-radio-circle'></i>XLS Import
|
||||
<i class='bx bx-radio-circle'></i>Import Dashboard
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -98,11 +98,19 @@
|
||||
<li>
|
||||
<a href="stats_export_lims.php">
|
||||
<div class="parent-icon">
|
||||
<i class="bx bx-user-plus"></i>
|
||||
<i class="bx bx-bar-chart-alt-2"></i>
|
||||
</div>
|
||||
<div class="menu-title">Stats</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="warm_cache_run.php">
|
||||
<div class="parent-icon">
|
||||
<i class="bx bx-bar-chart-alt-2"></i>
|
||||
</div>
|
||||
<div class="menu-title">Warm Cache</div>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<li class="menu-label">Others</li>
|
||||
|
||||
@@ -291,6 +291,7 @@ $apiConfigurations = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
const isXls = selectedSource === 'XLS';
|
||||
const isApiJson = selectedSource === 'API';
|
||||
const isPdf = selectedSource === 'PDF';
|
||||
|
||||
if (isXls) {
|
||||
headerRowWrapper.style.display = 'block';
|
||||
@@ -327,6 +328,8 @@ $apiConfigurations = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
apiConfigSelect.required = true;
|
||||
apiConfigSelect.disabled = false;
|
||||
} else {
|
||||
// PDF (o qualsiasi altro): niente config API, niente campi XLS.
|
||||
// I riquadri PDF si tracciano nella pagina di configurazione.
|
||||
apiConfigWrapper.style.display = 'none';
|
||||
apiConfigSelect.required = false;
|
||||
apiConfigSelect.disabled = true;
|
||||
@@ -516,6 +519,9 @@ $apiConfigurations = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
return;
|
||||
}
|
||||
|
||||
// PDF: nessuna validazione extra qui. Il PDF di esempio e i
|
||||
// riquadri (etichetta + dato) si configurano nella pagina successiva.
|
||||
|
||||
const clientSelect = document.getElementById("clientSelect");
|
||||
const clientId = clientSelect.value;
|
||||
const selectedClientOption = clientSelect.options[clientSelect.selectedIndex];
|
||||
|
||||
@@ -24,7 +24,9 @@ $stmt = $pdo->prepare("
|
||||
schemajson,
|
||||
xls_headers,
|
||||
api_sample_json,
|
||||
json_nodes
|
||||
json_nodes,
|
||||
sample_pdf,
|
||||
pdf_page_meta
|
||||
FROM excel_templates
|
||||
WHERE id = ?
|
||||
");
|
||||
@@ -61,6 +63,7 @@ $stmt = $pdo->prepare("
|
||||
field_order,
|
||||
excel_column,
|
||||
json_node,
|
||||
pdf_regions,
|
||||
is_manual,
|
||||
manual_default,
|
||||
auto_value,
|
||||
@@ -117,6 +120,14 @@ if (!is_array($jsonNodes)) {
|
||||
}
|
||||
|
||||
$apiSampleJson = $template['api_sample_json'] ?? '';
|
||||
|
||||
// Dati PDF: nome file di esempio e metadati pagine
|
||||
$samplePdf = $template['sample_pdf'] ?? '';
|
||||
|
||||
$pdfPageMeta = $template['pdf_page_meta'] ? json_decode($template['pdf_page_meta'], true) : [];
|
||||
if (!is_array($pdfPageMeta)) {
|
||||
$pdfPageMeta = [];
|
||||
}
|
||||
?>
|
||||
|
||||
<!doctype html>
|
||||
@@ -129,6 +140,7 @@ $apiSampleJson = $template['api_sample_json'] ?? '';
|
||||
<?php include('cssinclude.php'); ?>
|
||||
<title>Configure Template <?= htmlspecialchars($template['name'], ENT_QUOTES, 'UTF-8'); ?></title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
|
||||
<style>
|
||||
@@ -383,8 +395,20 @@ $apiSampleJson = $template['api_sample_json'] ?? '';
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($sourceType === 'PDF'): ?>
|
||||
<div class="alert alert-warning">
|
||||
PDF source type is not implemented yet.
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Upload PDF Example:</label>
|
||||
<input type="file" id="pdfUpload" class="form-control" accept="application/pdf">
|
||||
<small id="pdfStatus" class="text-muted d-block mt-1">
|
||||
<?php if (!empty($samplePdf)): ?>
|
||||
✅ Current file: <a href="pdftemplates/<?php echo htmlspecialchars($samplePdf); ?>" target="_blank"><?php echo htmlspecialchars($samplePdf); ?></a>
|
||||
<?php else: ?>
|
||||
No PDF uploaded yet.
|
||||
<?php endif; ?>
|
||||
</small>
|
||||
<div class="alert alert-info mt-2 mb-2" style="font-size:13px;">
|
||||
Per ogni campo, clicca <strong>Traccia PDF</strong> nella tabella qui sotto: prima disegna il riquadro dell'<strong>etichetta</strong> (blu), poi quello del <strong>dato</strong> (verde).
|
||||
</div>
|
||||
<div id="pdfPreviewContainer" style="max-height:70vh; overflow:auto; background:#525659; padding:20px 0; border-radius:6px;"></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -467,6 +491,8 @@ $apiSampleJson = $template['api_sample_json'] ?? '';
|
||||
$mappingValue = 'xls';
|
||||
} elseif ($sourceType === 'API' && !empty($mapping['json_node'])) {
|
||||
$mappingValue = 'json';
|
||||
} elseif ($sourceType === 'PDF' && !empty($mapping['pdf_regions'])) {
|
||||
$mappingValue = 'pdf';
|
||||
} elseif ((int)$mapping['is_manual'] === 1) {
|
||||
$mappingValue = 'manual';
|
||||
} else {
|
||||
@@ -486,8 +512,9 @@ $apiSampleJson = $template['api_sample_json'] ?? '';
|
||||
<option value="xls" <?php echo ($mappingValue === 'xls') ? 'selected' : ''; ?>>Map to XLS Column</option>
|
||||
<?php elseif ($sourceType === 'API'): ?>
|
||||
<option value="json" <?php echo ($mappingValue === 'json') ? 'selected' : ''; ?>>Map to JSON Node</option>
|
||||
<?php elseif ($sourceType === 'PDF'): ?>
|
||||
<option value="pdf" <?php echo ($mappingValue === 'pdf') ? 'selected' : ''; ?>>Map to PDF Region</option>
|
||||
<?php endif; ?>
|
||||
|
||||
<option value="auto" <?php echo ($mappingValue === 'auto') ? 'selected' : ''; ?>>Auto value</option>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -520,6 +547,33 @@ $apiSampleJson = $template['api_sample_json'] ?? '';
|
||||
<option value="export_time" <?php echo ($autoValue === 'export_time') ? 'selected' : ''; ?>>Current time (export)</option>
|
||||
</select>
|
||||
|
||||
<?php if ($sourceType === 'PDF'): ?>
|
||||
<div class="pdf-track-wrap"
|
||||
style="display:<?php echo ($mappingValue === 'pdf') ? 'block' : 'none'; ?>; margin-top:6px;">
|
||||
<button type="button"
|
||||
class="btn btn-outline-danger btn-sm pdf-track-btn"
|
||||
data-id="<?php echo (int)$mapping['id']; ?>">
|
||||
Traccia PDF
|
||||
</button>
|
||||
<span class="pdf-mapped-info"
|
||||
data-id="<?php echo (int)$mapping['id']; ?>"
|
||||
style="margin-left:6px; font-weight:600; color:#198754; display:<?php echo !empty($mapping['pdf_regions']) ? 'inline' : 'none'; ?>;">
|
||||
<?php
|
||||
if (!empty($mapping['pdf_regions'])) {
|
||||
$reg = json_decode($mapping['pdf_regions'], true);
|
||||
echo '✓ pag.' . (isset($reg['page']) ? (int)$reg['page'] : '?');
|
||||
}
|
||||
?>
|
||||
</span>
|
||||
<button type="button"
|
||||
class="btn btn-danger btn-sm pdf-remove-btn"
|
||||
data-id="<?php echo (int)$mapping['id']; ?>"
|
||||
style="margin-left:5px; display:<?php echo !empty($mapping['pdf_regions']) ? 'inline-block' : 'none'; ?>;">
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($sourceType === 'XLS' && !empty($mapping['excel_column'])): ?>
|
||||
<span class="mapped-column" style="margin-left:5px;">
|
||||
(<?php echo htmlspecialchars($mapping['excel_column']); ?>)
|
||||
@@ -617,7 +671,11 @@ $apiSampleJson = $template['api_sample_json'] ?? '';
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<strong><?php echo htmlspecialchars($fm['fixed_field_key']); ?></strong>
|
||||
<strong><?php
|
||||
$fixedFieldLabels = ['ClienteAnalisi' => 'Buyer'];
|
||||
$fixedKey = $fm['fixed_field_key'];
|
||||
echo htmlspecialchars($fixedFieldLabels[$fixedKey] ?? $fixedKey);
|
||||
?></strong>
|
||||
</td>
|
||||
|
||||
<td><?php echo htmlspecialchars($fm['data_type']); ?></td>
|
||||
@@ -1684,6 +1742,12 @@ $apiSampleJson = $template['api_sample_json'] ?? '';
|
||||
const removeBtn = tr.querySelector('.remove-xls');
|
||||
const removeJsonBtn = tr.querySelector('.remove-json');
|
||||
|
||||
const pdfTrackWrap = tr.querySelector('.pdf-track-wrap');
|
||||
// Mostra il blocco "Traccia PDF" solo quando il tipo è pdf
|
||||
if (pdfTrackWrap) {
|
||||
pdfTrackWrap.style.display = (mappingSelect.value === 'pdf') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function destroyJsonSelect2() {
|
||||
if (jsonSelect && window.jQuery && $(jsonSelect).hasClass('select2-hidden-accessible')) {
|
||||
$(jsonSelect).select2('destroy');
|
||||
@@ -1806,14 +1870,18 @@ $apiSampleJson = $template['api_sample_json'] ?? '';
|
||||
if (removeJsonBtn) removeJsonBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
saveMapping(
|
||||
mappingId,
|
||||
mappingSelect.value,
|
||||
manualInput ? manualInput.value : '',
|
||||
xlsSelect ? xlsSelect.value : null,
|
||||
autoSelect ? autoSelect.value : null,
|
||||
jsonSelect ? jsonSelect.value : null
|
||||
);
|
||||
// Per il PDF il salvataggio avviene tramite save_pdf_regions.php
|
||||
// quando l'utente traccia i riquadri, non qui.
|
||||
if (mappingSelect.value !== 'pdf') {
|
||||
saveMapping(
|
||||
mappingId,
|
||||
mappingSelect.value,
|
||||
manualInput ? manualInput.value : '',
|
||||
xlsSelect ? xlsSelect.value : null,
|
||||
autoSelect ? autoSelect.value : null,
|
||||
jsonSelect ? jsonSelect.value : null
|
||||
);
|
||||
}
|
||||
|
||||
if (sourceType === 'XLS') updateXlsDropdowns();
|
||||
});
|
||||
@@ -2192,6 +2260,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 +2295,7 @@ $apiSampleJson = $template['api_sample_json'] ?? '';
|
||||
${parseInt(r.is_required) === 1 ? 'checked' : ''}>
|
||||
</td>
|
||||
|
||||
<td><strong>${escapeHtml(r.fixed_field_key)}</strong></td>
|
||||
<td><strong>${escapeHtml(fixedFieldLabels[r.fixed_field_key] || r.fixed_field_key)}</strong></td>
|
||||
|
||||
|
||||
<td>${escapeHtml(r.data_type || '')}</td>
|
||||
@@ -2563,6 +2635,30 @@ $apiSampleJson = $template['api_sample_json'] ?? '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php if ($sourceType === 'PDF'): ?>
|
||||
<script>
|
||||
// Variabili globali usate da pdf_mapping.js
|
||||
window.PDF_TEMPLATE_ID = <?php echo (int)$id; ?>;
|
||||
window.PDF_SAMPLE_FILE = <?php echo json_encode($samplePdf); ?>;
|
||||
window.PDF_PAGE_META = <?php echo json_encode($pdfPageMeta); ?>;
|
||||
window.PDF_TARGET_TABLE = <?php echo json_encode($template['target_table']); ?>;
|
||||
|
||||
// Riquadri già salvati nel DB: mapping_id -> regions object
|
||||
window.PDF_SAVED_REGIONS = {
|
||||
<?php foreach ($mappings as $m): ?>
|
||||
<?php if (!empty($m['pdf_regions'])): ?>
|
||||
<?php
|
||||
$decodedRegions = json_decode($m['pdf_regions'], true);
|
||||
if (is_array($decodedRegions)):
|
||||
?> "<?php echo (int)$m['id']; ?>": <?php echo json_encode($decodedRegions); ?>,
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
};
|
||||
</script>
|
||||
<script src="pdf_mapping.js"></script>
|
||||
<?php endif; ?>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -136,14 +136,9 @@ $matrixGroups = array_values($matrixGroups);
|
||||
<button type="button"
|
||||
class="list-group-item list-group-item-action analysis-matrix-item <?= $index === 0 ? 'active' : '' ?>"
|
||||
data-matrix-id="<?= htmlspecialchars((string)($group['idmatrice'] ?? 'NO_MATRIX'), ENT_QUOTES, 'UTF-8') ?>">
|
||||
<div class="fw-semibold">
|
||||
<?= htmlspecialchars(
|
||||
!empty($group['NomeMatriceTraduzione'])
|
||||
? $group['NomeMatriceTraduzione']
|
||||
: (!empty($group['idmatrice']) ? ('Matrix without translation') : 'No Matrix'),
|
||||
ENT_QUOTES,
|
||||
'UTF-8'
|
||||
) ?>
|
||||
<div class="fw-semibold analysis-matrix-name"
|
||||
data-matrix-id="<?= htmlspecialchars((string)($group['idmatrice'] ?? 'NO_MATRIX'), ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?= !empty($group['idmatrice']) ? ('#' . (int)$group['idmatrice']) : 'No Matrix' ?>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
ID: <?= !empty($group['idmatrice']) ? (int)$group['idmatrice'] : '-' ?>
|
||||
@@ -193,14 +188,9 @@ $matrixGroups = array_values($matrixGroups);
|
||||
|
||||
<div class="small text-muted mt-1">
|
||||
Matrix:
|
||||
<strong>
|
||||
<?= htmlspecialchars(
|
||||
!empty($part['NomeMatriceTraduzione'])
|
||||
? $part['NomeMatriceTraduzione']
|
||||
: (!empty($part['idmatrice']) ? 'Matrix without translation' : 'No Matrix'),
|
||||
ENT_QUOTES,
|
||||
'UTF-8'
|
||||
) ?>
|
||||
<strong class="analysis-part-matrix-name"
|
||||
data-matrix-id="<?= htmlspecialchars($matrixId, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?= !empty($part['idmatrice']) ? ('#' . (int)$part['idmatrice']) : 'No Matrix' ?>
|
||||
</strong>
|
||||
<?php if (!empty($part['idmatrice'])): ?>
|
||||
<span class="small text-muted">(ID: <?= (int)$part['idmatrice'] ?>)</span>
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
<button type="button" class="btn btn-primary btn-sm" id="clonePartsBtn" style="padding: 0.1rem 0.5rem; font-size: 0.8rem;">
|
||||
<i class="fas fa-clone"></i> Clona Parti
|
||||
</button>
|
||||
<button type="button" class="btn btn-warning btn-sm d-none" id="quotationeBtn" style="padding: 0.1rem 0.5rem; font-size: 0.8rem;">
|
||||
<i class="fas fa-file-invoice-dollar"></i> Collega Quotazione
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm ms-2" id="toggleVoiceBtn" style="padding: 0.1rem 0.5rem; font-size: 0.8rem;">
|
||||
<i class="fas fa-microphone"></i> Voce
|
||||
</button>
|
||||
@@ -214,6 +217,12 @@
|
||||
Duplica anche le analisi collegate
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="cloneOverwriteCheckbox" checked>
|
||||
<label class="form-check-label" for="cloneOverwriteCheckbox">
|
||||
<strong>Sovrascrivi parti esistenti</strong> (elimina le parti già presenti nei record di destinazione prima di inserire)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Annulla</button>
|
||||
|
||||
@@ -171,6 +171,11 @@
|
||||
);
|
||||
if (idx >= 0) window.gridData.splice(idx, 1);
|
||||
|
||||
// Aggiorna la lista dei record visibili (navigazione parti + clone)
|
||||
window.visibleIddatadbList = window.gridData
|
||||
.map((r) => parseInt(r.iddatadb, 10))
|
||||
.filter(Boolean);
|
||||
|
||||
// Re-render
|
||||
const gr = window.gridRenderer;
|
||||
if (gr) gr.renderVisibleRows();
|
||||
@@ -233,6 +238,11 @@
|
||||
// Add to beginning of gridData
|
||||
window.gridData.unshift(newRow);
|
||||
|
||||
// Aggiorna la lista dei record visibili (navigazione parti + clone)
|
||||
window.visibleIddatadbList = window.gridData
|
||||
.map((r) => parseInt(r.iddatadb, 10))
|
||||
.filter(Boolean);
|
||||
|
||||
// Re-render
|
||||
const gr = window.gridRenderer;
|
||||
if (gr) gr.renderVisibleRows();
|
||||
|
||||
@@ -32,6 +32,16 @@ $(document).ready(function () {
|
||||
return id === "new" || id === "" || id === null ? null : id;
|
||||
}
|
||||
|
||||
// Legge ConsegnaRichiesta del record corrente dalla griglia (fixed field data)
|
||||
function getConsegnaRichiestaDefault() {
|
||||
const iddatadb = $("#partsModal").data("iddatadb");
|
||||
if (!iddatadb) return "";
|
||||
if (typeof getGridRecordById !== "function") return "";
|
||||
const record = getGridRecordById(iddatadb);
|
||||
if (!record || !record.fixedFields) return "";
|
||||
return record.fixedFields.ConsegnaRichiesta || "";
|
||||
}
|
||||
|
||||
function loadPartsExtraField(iddatadb, done) {
|
||||
partsExtraField = null;
|
||||
|
||||
@@ -210,23 +220,60 @@ $(document).ready(function () {
|
||||
dropdownParent: $("#partsModal"),
|
||||
minimumResultsForSearch: 0,
|
||||
sorter: function (data) {
|
||||
return data.sort(function (a, b) {
|
||||
const textA = (a.text || "").toLowerCase();
|
||||
const textB = (b.text || "").toLowerCase();
|
||||
return textA.localeCompare(textB, "it", {
|
||||
sensitivity: "base",
|
||||
numeric: true,
|
||||
});
|
||||
const rawTerm = ((window.__extraFieldSearchTerm || "") + "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
const terms = rawTerm.split(/\s+/).filter(Boolean);
|
||||
const firstTerm = terms[0] || "";
|
||||
|
||||
const scoreOf = function (text) {
|
||||
const t = (text || "").toLowerCase();
|
||||
if (!firstTerm) return 3;
|
||||
if (t === rawTerm) return 0; // match esatto
|
||||
if (t.indexOf(firstTerm) === 0) return 1; // inizia col primo termine
|
||||
if (
|
||||
new RegExp(
|
||||
"(^|\\s)" +
|
||||
firstTerm.replace(
|
||||
/[.*+?^${}()|[\]\\]/g,
|
||||
"\\$&",
|
||||
),
|
||||
).test(t)
|
||||
)
|
||||
return 2; // primo termine a inizio parola
|
||||
return 3; // presente ma non a inizio parola
|
||||
};
|
||||
|
||||
return data.slice().sort(function (a, b) {
|
||||
const sa = scoreOf(a.text);
|
||||
const sb = scoreOf(b.text);
|
||||
if (sa !== sb) return sa - sb;
|
||||
return (a.text || "")
|
||||
.toLowerCase()
|
||||
.localeCompare((b.text || "").toLowerCase(), "it", {
|
||||
sensitivity: "base",
|
||||
numeric: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
matcher: function (params, data) {
|
||||
if ($.trim(params.term) === "") return data;
|
||||
if (typeof data.text === "undefined") return null;
|
||||
|
||||
const term = params.term.toLowerCase();
|
||||
// memorizza il termine per il sorter (Select2 non lo passa al sorter)
|
||||
window.__extraFieldSearchTerm = params.term;
|
||||
|
||||
const terms = params.term
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
const text = data.text.toLowerCase();
|
||||
|
||||
return text.indexOf(term) > -1 ? data : null;
|
||||
// AND: tutti i termini devono essere presenti (posizione-indipendente)
|
||||
for (let i = 0; i < terms.length; i++) {
|
||||
if (text.indexOf(terms[i]) === -1) return null;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -555,6 +602,14 @@ $(document).ready(function () {
|
||||
// ===================
|
||||
|
||||
function getVisiblePartsRecordList() {
|
||||
// Se i filtri sono attivi, usa la regola selezione/visibili di gridFilter.
|
||||
if (typeof window.getTargetGridIds === "function") {
|
||||
const target = window.getTargetGridIds();
|
||||
if (target && target.size > 0) {
|
||||
return [...target].map((v) => parseInt(v, 10)).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
const listFromModal = $("#partsModal").data("visible-iddatadb-list");
|
||||
|
||||
if (Array.isArray(listFromModal) && listFromModal.length > 0) {
|
||||
@@ -1243,6 +1298,13 @@ $(document).ready(function () {
|
||||
initializeSelect2($select, nextPartNumber, "", null, selectedMacro);
|
||||
initializeExtraFieldSelect2($newRow);
|
||||
|
||||
// Default: data di consegna richiesta dal record (solo se il campo è vuoto)
|
||||
const $dateInput = $newRow.find(".part-dateexpiry");
|
||||
if ($dateInput.length && !$dateInput.val()) {
|
||||
const consegna = getConsegnaRichiestaDefault();
|
||||
if (consegna) $dateInput.val(consegna);
|
||||
}
|
||||
|
||||
updateRowButtons();
|
||||
|
||||
if (!isLoadingPartsRecord) {
|
||||
@@ -1573,6 +1635,13 @@ $(document).ready(function () {
|
||||
`#partsTableBody tr[data-part-id="${part.id}"]`,
|
||||
);
|
||||
setRowMix($row, part.mix === "Y" ? "Y" : "N");
|
||||
// Default consegna richiesta se la parte non ha già una data
|
||||
if (!(part.dateexpiry || "")) {
|
||||
const consegnaDef = getConsegnaRichiestaDefault();
|
||||
if (consegnaDef) {
|
||||
$row.find(".part-dateexpiry").val(consegnaDef);
|
||||
}
|
||||
}
|
||||
if (
|
||||
part.extra_value_id !== undefined &&
|
||||
part.extra_value_id !== null
|
||||
@@ -2265,8 +2334,11 @@ $(document).ready(function () {
|
||||
|
||||
const sourceIddatadb =
|
||||
parseInt($("#partsModal").data("iddatadb"), 10) || null;
|
||||
const visibleListRaw =
|
||||
$("#partsModal").data("visible-iddatadb-list") || [];
|
||||
// Target = selezione/visibili secondo gridFilter, con fallback alla lista del modal
|
||||
let visibleListRaw =
|
||||
typeof getVisiblePartsRecordList === "function"
|
||||
? getVisiblePartsRecordList()
|
||||
: $("#partsModal").data("visible-iddatadb-list") || [];
|
||||
|
||||
if (!sourceIddatadb) {
|
||||
const errorMsg = $(
|
||||
@@ -2308,6 +2380,7 @@ $(document).ready(function () {
|
||||
);
|
||||
$("#cloneNotesCheckbox").prop("checked", true);
|
||||
$("#cloneAnalysesCheckbox").prop("checked", false);
|
||||
$("#cloneOverwriteCheckbox").prop("checked", true);
|
||||
|
||||
const modalInstance = new bootstrap.Modal(
|
||||
document.getElementById("cloneConfirmModal"),
|
||||
@@ -2323,6 +2396,7 @@ $(document).ready(function () {
|
||||
const targetIds = $("#cloneConfirmModal").data("target-ids") || [];
|
||||
const cloneNotes = $("#cloneNotesCheckbox").is(":checked");
|
||||
const cloneAnalyses = $("#cloneAnalysesCheckbox").is(":checked");
|
||||
const cloneOverwrite = $("#cloneOverwriteCheckbox").is(":checked");
|
||||
|
||||
if (!sourceIddatadb || !targetIds.length) {
|
||||
return;
|
||||
@@ -2351,6 +2425,7 @@ $(document).ready(function () {
|
||||
target_iddatadb_list: targetIds,
|
||||
clone_notes: cloneNotes,
|
||||
clone_analyses: cloneAnalyses,
|
||||
overwrite: cloneOverwrite,
|
||||
}),
|
||||
success: function (response) {
|
||||
$btn.prop("disabled", false).html(originalHtml);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
/*
|
||||
* pdf_extract_lib.php
|
||||
* ------------------------------------------------------------------
|
||||
* Cuore dell'estrazione PDF. Dato un file PDF e i riquadri (pdf_regions)
|
||||
* salvati per ogni campo, usa `pdftotext -bbox -enc UTF-8` per ottenere
|
||||
* le parole con le loro coordinate, e per ogni riquadro "value" raccoglie
|
||||
* le parole il cui centro cade dentro il rettangolo.
|
||||
*
|
||||
* Le coordinate di pdftotext -bbox sono in punti PDF, origine in alto a
|
||||
* sinistra: lo stesso sistema in cui PDF.js ha salvato i riquadri a scala
|
||||
* 1.0. Quindi il match è diretto, senza conversioni.
|
||||
*
|
||||
* Funzioni pubbliche:
|
||||
* - pdftotext_binary(): trova il percorso del binario su Win/Linux
|
||||
* - pdf_extract_words($pdfPath): array di pagine con parole+coordinate
|
||||
* - pdf_extract_values($pdfPath, $mappings): mapping_id => testo estratto
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Trova il binario pdftotext in base al sistema operativo.
|
||||
* Su Windows cerca prima il percorso noto, poi il PATH.
|
||||
* Su Linux usa /usr/bin/pdftotext, poi il PATH.
|
||||
*/
|
||||
function pdftotext_binary(): string
|
||||
{
|
||||
$isWindows = (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN');
|
||||
|
||||
if ($isWindows) {
|
||||
// Percorso noto sull'ambiente locale
|
||||
$known = 'C:\\poppler\\poppler-26.02.0\\Library\\bin\\pdftotext.exe';
|
||||
if (is_file($known)) {
|
||||
return $known;
|
||||
}
|
||||
// Fallback: confida nel PATH
|
||||
return 'pdftotext.exe';
|
||||
}
|
||||
|
||||
// Linux / VPS
|
||||
if (is_file('/usr/bin/pdftotext')) {
|
||||
return '/usr/bin/pdftotext';
|
||||
}
|
||||
return 'pdftotext';
|
||||
}
|
||||
|
||||
/**
|
||||
* Lancia pdftotext -bbox e restituisce l'XHTML grezzo.
|
||||
* @throws RuntimeException se il comando fallisce.
|
||||
*/
|
||||
function pdf_run_bbox(string $pdfPath): string
|
||||
{
|
||||
if (!is_file($pdfPath)) {
|
||||
throw new RuntimeException("PDF non trovato: {$pdfPath}");
|
||||
}
|
||||
|
||||
$bin = pdftotext_binary();
|
||||
|
||||
// -bbox: coordinate parole; -enc UTF-8: fix simboli (€, accenti); - : stdout
|
||||
$cmd = escapeshellarg($bin)
|
||||
. ' -bbox -enc UTF-8 '
|
||||
. escapeshellarg($pdfPath)
|
||||
. ' -';
|
||||
|
||||
$descriptors = [
|
||||
1 => ['pipe', 'w'], // stdout
|
||||
2 => ['pipe', 'w'], // stderr
|
||||
];
|
||||
|
||||
$proc = proc_open($cmd, $descriptors, $pipes);
|
||||
if (!is_resource($proc)) {
|
||||
throw new RuntimeException("Impossibile avviare pdftotext.");
|
||||
}
|
||||
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$exit = proc_close($proc);
|
||||
|
||||
if ($exit !== 0) {
|
||||
throw new RuntimeException("pdftotext ha restituito codice {$exit}: {$stderr}");
|
||||
}
|
||||
|
||||
return $stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsa l'XHTML di pdftotext -bbox in una struttura:
|
||||
* [
|
||||
* 1 => [ 'width'=>612, 'height'=>792, 'words'=>[ ['t'=>'Invoice','xMin'=>..,'yMin'=>..,'xMax'=>..,'yMax'=>..], ... ] ],
|
||||
* 2 => [ ... ],
|
||||
* ]
|
||||
* Chiave = numero pagina (1-based).
|
||||
*/
|
||||
function pdf_parse_bbox(string $xhtml): array
|
||||
{
|
||||
$pages = [];
|
||||
|
||||
// Isola ogni blocco <page ...> ... </page>
|
||||
if (!preg_match_all('#<page\b[^>]*>(.*?)</page>#si', $xhtml, $pageMatches, PREG_OFFSET_CAPTURE)) {
|
||||
return $pages;
|
||||
}
|
||||
|
||||
// Recupera anche gli attributi width/height di ogni page
|
||||
preg_match_all('#<page\s+width="([0-9.]+)"\s+height="([0-9.]+)"#i', $xhtml, $dimMatches, PREG_SET_ORDER);
|
||||
|
||||
$pageNum = 0;
|
||||
foreach ($pageMatches[1] as $idx => $inner) {
|
||||
$pageNum++;
|
||||
$body = $inner[0];
|
||||
|
||||
$width = isset($dimMatches[$idx][1]) ? (float)$dimMatches[$idx][1] : 0.0;
|
||||
$height = isset($dimMatches[$idx][2]) ? (float)$dimMatches[$idx][2] : 0.0;
|
||||
|
||||
$words = [];
|
||||
if (preg_match_all(
|
||||
'#<word\s+xMin="([0-9.\-]+)"\s+yMin="([0-9.\-]+)"\s+xMax="([0-9.\-]+)"\s+yMax="([0-9.\-]+)"\s*>(.*?)</word>#si',
|
||||
$body,
|
||||
$wm,
|
||||
PREG_SET_ORDER
|
||||
)) {
|
||||
foreach ($wm as $w) {
|
||||
$text = html_entity_decode($w[5], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$words[] = [
|
||||
't' => $text,
|
||||
'xMin' => (float)$w[1],
|
||||
'yMin' => (float)$w[2],
|
||||
'xMax' => (float)$w[3],
|
||||
'yMax' => (float)$w[4],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$pages[$pageNum] = [
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'words' => $words,
|
||||
];
|
||||
}
|
||||
|
||||
return $pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estrae tutte le parole con coordinate da un PDF.
|
||||
* Ritorna la struttura di pdf_parse_bbox().
|
||||
*/
|
||||
function pdf_extract_words(string $pdfPath): array
|
||||
{
|
||||
$xhtml = pdf_run_bbox($pdfPath);
|
||||
return pdf_parse_bbox($xhtml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dato un rettangolo {x,y,w,h} e la lista parole di una pagina, restituisce
|
||||
* il testo delle parole il cui CENTRO cade dentro il rettangolo, ordinate
|
||||
* per riga (yMin) e poi per colonna (xMin), unite da spazio.
|
||||
*
|
||||
* $tolerance espande il rettangolo di N punti su ogni lato (default 2),
|
||||
* per non perdere parole tracciate al pelo del bordo.
|
||||
*/
|
||||
function pdf_text_in_rect(array $rect, array $words, float $tolerance = 2.0): string
|
||||
{
|
||||
$x1 = $rect['x'] - $tolerance;
|
||||
$y1 = $rect['y'] - $tolerance;
|
||||
$x2 = $rect['x'] + $rect['w'] + $tolerance;
|
||||
$y2 = $rect['y'] + $rect['h'] + $tolerance;
|
||||
|
||||
$hits = [];
|
||||
foreach ($words as $w) {
|
||||
$cx = ($w['xMin'] + $w['xMax']) / 2.0;
|
||||
$cy = ($w['yMin'] + $w['yMax']) / 2.0;
|
||||
|
||||
if ($cx >= $x1 && $cx <= $x2 && $cy >= $y1 && $cy <= $y2) {
|
||||
$hits[] = $w;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($hits)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Ordina per riga (yMin arrotondato a gruppi di ~3pt) poi per xMin
|
||||
usort($hits, function ($a, $b) {
|
||||
$ra = round($a['yMin'] / 3.0);
|
||||
$rb = round($b['yMin'] / 3.0);
|
||||
if ($ra !== $rb) {
|
||||
return $ra <=> $rb;
|
||||
}
|
||||
return $a['xMin'] <=> $b['xMin'];
|
||||
});
|
||||
|
||||
$parts = array_map(fn($w) => $w['t'], $hits);
|
||||
$text = implode(' ', $parts);
|
||||
|
||||
// Normalizza spazi multipli
|
||||
$text = preg_replace('/\s+/u', ' ', $text);
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estrae i valori per ogni mapping che ha pdf_regions.
|
||||
*
|
||||
* @param string $pdfPath percorso del PDF da leggere
|
||||
* @param array $mappings righe di template_mapping (devono contenere: id, pdf_regions)
|
||||
* @return array [ mapping_id => testo_estratto ] (solo per i mapping con regione value)
|
||||
*
|
||||
* Nota: usa la regione "value" per estrarre il dato. La regione "label" al
|
||||
* momento non è usata per l'estrazione (serve per la strategia futura di
|
||||
* ricerca per etichetta, es. PDF con layout variabile).
|
||||
*/
|
||||
function pdf_extract_values(string $pdfPath, array $mappings): array
|
||||
{
|
||||
$pages = pdf_extract_words($pdfPath);
|
||||
$result = [];
|
||||
|
||||
foreach ($mappings as $m) {
|
||||
if (empty($m['pdf_regions'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$regions = json_decode($m['pdf_regions'], true);
|
||||
if (!is_array($regions) || empty($regions['value']) || empty($regions['page'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$page = (int)$regions['page'];
|
||||
if (!isset($pages[$page])) {
|
||||
// pagina inesistente in questo PDF: valore vuoto
|
||||
$result[(int)$m['id']] = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $pdf_value_rect = $regions['value'];
|
||||
$text = pdf_text_in_rect($value, $pages[$page]['words']);
|
||||
$result[(int)$m['id']] = $text;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
/*
|
||||
* pdf_mapping.js
|
||||
* ------------------------------------------------------------------
|
||||
* Logica di anteprima PDF e tracciamento riquadri (etichetta + dato)
|
||||
* per la pagina mapping_template_xls_scheme2.php quando source = PDF.
|
||||
*
|
||||
* Dipendenze (caricate dalla pagina PHP):
|
||||
* - PDF.js (pdfjsLib) via CDN
|
||||
* - Le variabili globali definite inline nella pagina:
|
||||
* window.PDF_TEMPLATE_ID -> id del template (int)
|
||||
* window.PDF_SAMPLE_FILE -> nome file PDF di esempio (string, può essere '')
|
||||
* window.PDF_PAGE_META -> metadati pagine salvati (object/array)
|
||||
*
|
||||
* Convenzioni:
|
||||
* - Le coordinate dei riquadri sono salvate in "unità PDF" (punti PDF,
|
||||
* scala 1.0), NON in pixel schermo. Così restano valide a qualsiasi zoom.
|
||||
* - Ogni campo mappato ha un bottone "Traccia PDF" che attiva la modalità
|
||||
* disegno per quel campo: prima l'etichetta, poi il dato.
|
||||
*
|
||||
* NB: questo file NON tocca la logica XLS/API della pagina. Si attiva solo
|
||||
* se sourceType === 'PDF'.
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Esci subito se non siamo su un template PDF
|
||||
if (typeof sourceType === "undefined" || sourceType !== "PDF") {
|
||||
return;
|
||||
}
|
||||
|
||||
// ----- Stato modulo -----
|
||||
const state = {
|
||||
pdfDoc: null, // documento PDF.js caricato
|
||||
pageCanvases: {}, // page number -> { canvas, viewport, scale }
|
||||
renderScale: 1.5, // scala di rendering a schermo
|
||||
drawing: null, // { mappingId, phase:'label'|'value', startX, startY, rectEl, page }
|
||||
pendingLabel: null, // riquadro etichetta in attesa (unità PDF) durante il disegno a due fasi
|
||||
activeMappingId: null, // campo attualmente in tracciamento
|
||||
};
|
||||
|
||||
// ----- Utility DOM -----
|
||||
function el(tag, attrs = {}, children = []) {
|
||||
const node = document.createElement(tag);
|
||||
Object.entries(attrs).forEach(([k, v]) => {
|
||||
if (k === "style" && typeof v === "object") {
|
||||
Object.assign(node.style, v);
|
||||
} else if (k === "class") {
|
||||
node.className = v;
|
||||
} else if (k === "text") {
|
||||
node.textContent = v;
|
||||
} else {
|
||||
node.setAttribute(k, v);
|
||||
}
|
||||
});
|
||||
(Array.isArray(children) ? children : [children]).forEach((c) => {
|
||||
if (c)
|
||||
node.appendChild(
|
||||
typeof c === "string" ? document.createTextNode(c) : c,
|
||||
);
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
function getPdfContainer() {
|
||||
return document.getElementById("pdfPreviewContainer");
|
||||
}
|
||||
|
||||
// ----- Caricamento e rendering PDF -----
|
||||
async function loadPdf(url) {
|
||||
const status = document.getElementById("pdfStatus");
|
||||
try {
|
||||
if (status) status.textContent = "Loading PDF...";
|
||||
|
||||
const loadingTask = pdfjsLib.getDocument(url);
|
||||
state.pdfDoc = await loadingTask.promise;
|
||||
|
||||
await renderAllPages();
|
||||
|
||||
if (status)
|
||||
status.textContent = `✅ PDF loaded: ${state.pdfDoc.numPages} page(s)`;
|
||||
|
||||
// Salva i metadati pagine (dimensioni a scala 1.0)
|
||||
savePageMeta();
|
||||
|
||||
// Ridisegna i riquadri già salvati nel DB
|
||||
redrawSavedRegions();
|
||||
} catch (err) {
|
||||
console.error("PDF load error:", err);
|
||||
if (status)
|
||||
status.textContent = "❌ Error loading PDF: " + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderAllPages() {
|
||||
const container = getPdfContainer();
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
state.pageCanvases = {};
|
||||
|
||||
for (let pageNum = 1; pageNum <= state.pdfDoc.numPages; pageNum++) {
|
||||
const page = await state.pdfDoc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale: state.renderScale });
|
||||
|
||||
// Wrapper posizionato per contenere canvas + overlay riquadri
|
||||
const pageWrap = el("div", {
|
||||
class: "pdf-page-wrap",
|
||||
"data-page": pageNum,
|
||||
style: {
|
||||
position: "relative",
|
||||
margin: "0 auto 20px auto",
|
||||
width: viewport.width + "px",
|
||||
height: viewport.height + "px",
|
||||
boxShadow: "0 0 6px rgba(0,0,0,0.3)",
|
||||
},
|
||||
});
|
||||
|
||||
const canvas = el("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.style.display = "block";
|
||||
|
||||
const overlay = el("div", {
|
||||
class: "pdf-region-overlay",
|
||||
"data-page": pageNum,
|
||||
style: {
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: viewport.width + "px",
|
||||
height: viewport.height + "px",
|
||||
cursor: "crosshair",
|
||||
},
|
||||
});
|
||||
|
||||
pageWrap.appendChild(canvas);
|
||||
pageWrap.appendChild(overlay);
|
||||
container.appendChild(pageWrap);
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
|
||||
state.pageCanvases[pageNum] = {
|
||||
canvas,
|
||||
overlay,
|
||||
viewport,
|
||||
scale: state.renderScale,
|
||||
pdfWidth: viewport.width / state.renderScale, // larghezza a scala 1.0
|
||||
pdfHeight: viewport.height / state.renderScale, // altezza a scala 1.0
|
||||
};
|
||||
|
||||
attachOverlayEvents(overlay, pageNum);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Conversione pixel schermo <-> unità PDF -----
|
||||
function screenToPdf(pageNum, xPx, yPx) {
|
||||
const p = state.pageCanvases[pageNum];
|
||||
return { x: xPx / p.scale, y: yPx / p.scale };
|
||||
}
|
||||
|
||||
function pdfToScreen(pageNum, xPdf, yPdf) {
|
||||
const p = state.pageCanvases[pageNum];
|
||||
return { x: xPdf * p.scale, y: yPdf * p.scale };
|
||||
}
|
||||
|
||||
// ----- Disegno riquadri sull'overlay -----
|
||||
function attachOverlayEvents(overlay, pageNum) {
|
||||
let startX = 0,
|
||||
startY = 0;
|
||||
let rectEl = null;
|
||||
|
||||
overlay.addEventListener("mousedown", (e) => {
|
||||
if (!state.activeMappingId) return; // nessun campo in tracciamento
|
||||
const rect = overlay.getBoundingClientRect();
|
||||
startX = e.clientX - rect.left;
|
||||
startY = e.clientY - rect.top;
|
||||
|
||||
rectEl = el("div", {
|
||||
class: "pdf-draw-rect",
|
||||
style: {
|
||||
position: "absolute",
|
||||
left: startX + "px",
|
||||
top: startY + "px",
|
||||
width: "0px",
|
||||
height: "0px",
|
||||
border: "2px solid #dc3545",
|
||||
background: "rgba(220,53,69,0.15)",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
});
|
||||
overlay.appendChild(rectEl);
|
||||
});
|
||||
|
||||
overlay.addEventListener("mousemove", (e) => {
|
||||
if (!rectEl) return;
|
||||
const rect = overlay.getBoundingClientRect();
|
||||
const curX = e.clientX - rect.left;
|
||||
const curY = e.clientY - rect.top;
|
||||
const x = Math.min(startX, curX);
|
||||
const y = Math.min(startY, curY);
|
||||
const w = Math.abs(curX - startX);
|
||||
const h = Math.abs(curY - startY);
|
||||
Object.assign(rectEl.style, {
|
||||
left: x + "px",
|
||||
top: y + "px",
|
||||
width: w + "px",
|
||||
height: h + "px",
|
||||
});
|
||||
});
|
||||
|
||||
overlay.addEventListener("mouseup", (e) => {
|
||||
if (!rectEl) return;
|
||||
const rect = overlay.getBoundingClientRect();
|
||||
const endX = e.clientX - rect.left;
|
||||
const endY = e.clientY - rect.top;
|
||||
|
||||
const x = Math.min(startX, endX);
|
||||
const y = Math.min(startY, endY);
|
||||
const w = Math.abs(endX - startX);
|
||||
const h = Math.abs(endY - startY);
|
||||
|
||||
overlay.removeChild(rectEl);
|
||||
rectEl = null;
|
||||
|
||||
if (w < 4 || h < 4) return; // click accidentale, ignora
|
||||
|
||||
// Converti in unità PDF
|
||||
const topLeft = screenToPdf(pageNum, x, y);
|
||||
const pdfRect = {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
w: w / state.pageCanvases[pageNum].scale,
|
||||
h: h / state.pageCanvases[pageNum].scale,
|
||||
};
|
||||
|
||||
handleRectDrawn(pageNum, pdfRect);
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Logica a due fasi: prima etichetta, poi dato -----
|
||||
function startTracking(mappingId) {
|
||||
state.activeMappingId = mappingId;
|
||||
state.pendingLabel = null;
|
||||
setBanner(
|
||||
`Traccia il riquadro dell'ETICHETTA per il campo #${mappingId} (poi il dato). ESC per annullare.`,
|
||||
);
|
||||
}
|
||||
|
||||
function handleRectDrawn(pageNum, pdfRect) {
|
||||
if (!state.activeMappingId) return;
|
||||
|
||||
if (state.pendingLabel === null) {
|
||||
// Fase 1: etichetta
|
||||
state.pendingLabel = { page: pageNum, rect: pdfRect };
|
||||
drawPersistentRect(
|
||||
pageNum,
|
||||
pdfRect,
|
||||
"label",
|
||||
state.activeMappingId,
|
||||
);
|
||||
setBanner(
|
||||
`Ora traccia il riquadro del DATO per il campo #${state.activeMappingId}. ESC per annullare.`,
|
||||
);
|
||||
} else {
|
||||
// Fase 2: dato — deve stare sulla stessa pagina dell'etichetta
|
||||
if (pageNum !== state.pendingLabel.page) {
|
||||
alert("Etichetta e dato devono stare sulla stessa pagina.");
|
||||
return;
|
||||
}
|
||||
const regions = {
|
||||
page: pageNum,
|
||||
label: state.pendingLabel.rect,
|
||||
value: pdfRect,
|
||||
};
|
||||
drawPersistentRect(
|
||||
pageNum,
|
||||
pdfRect,
|
||||
"value",
|
||||
state.activeMappingId,
|
||||
);
|
||||
saveRegions(state.activeMappingId, regions);
|
||||
|
||||
// Reset stato tracciamento
|
||||
const doneId = state.activeMappingId;
|
||||
state.activeMappingId = null;
|
||||
state.pendingLabel = null;
|
||||
clearBanner();
|
||||
updateRowBadge(doneId, regions);
|
||||
}
|
||||
}
|
||||
|
||||
// Disegna un riquadro persistente (già confermato) sull'overlay
|
||||
function drawPersistentRect(pageNum, pdfRect, kind, mappingId) {
|
||||
const p = state.pageCanvases[pageNum];
|
||||
if (!p) return;
|
||||
const tl = pdfToScreen(pageNum, pdfRect.x, pdfRect.y);
|
||||
const w = pdfRect.w * p.scale;
|
||||
const h = pdfRect.h * p.scale;
|
||||
|
||||
const color = kind === "label" ? "#0d6efd" : "#198754";
|
||||
const box = el(
|
||||
"div",
|
||||
{
|
||||
class: "pdf-saved-rect",
|
||||
"data-mapping-id": mappingId,
|
||||
"data-kind": kind,
|
||||
style: {
|
||||
position: "absolute",
|
||||
left: tl.x + "px",
|
||||
top: tl.y + "px",
|
||||
width: w + "px",
|
||||
height: h + "px",
|
||||
border: "2px solid " + color,
|
||||
background:
|
||||
kind === "label"
|
||||
? "rgba(13,110,253,0.12)"
|
||||
: "rgba(25,135,84,0.12)",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
},
|
||||
[
|
||||
el("span", {
|
||||
style: {
|
||||
position: "absolute",
|
||||
top: "-16px",
|
||||
left: "0",
|
||||
fontSize: "10px",
|
||||
fontWeight: "600",
|
||||
color: "#fff",
|
||||
background: color,
|
||||
padding: "0 4px",
|
||||
borderRadius: "3px",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
text:
|
||||
(kind === "label" ? "Etichetta" : "Dato") +
|
||||
" #" +
|
||||
mappingId,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
p.overlay.appendChild(box);
|
||||
}
|
||||
|
||||
// Rimuove i riquadri disegnati per un dato campo (prima di ridisegnarli)
|
||||
function clearRectsForMapping(mappingId) {
|
||||
document
|
||||
.querySelectorAll(`.pdf-saved-rect[data-mapping-id="${mappingId}"]`)
|
||||
.forEach((node) => node.remove());
|
||||
}
|
||||
|
||||
// ----- Banner istruzioni -----
|
||||
function setBanner(msg) {
|
||||
let banner = document.getElementById("pdfTrackingBanner");
|
||||
if (!banner) {
|
||||
banner = el("div", {
|
||||
id: "pdfTrackingBanner",
|
||||
class: "alert alert-info",
|
||||
style: {
|
||||
position: "sticky",
|
||||
top: "0",
|
||||
zIndex: "1000",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
});
|
||||
const container = getPdfContainer();
|
||||
container.parentNode.insertBefore(banner, container);
|
||||
}
|
||||
banner.textContent = msg;
|
||||
banner.style.display = "block";
|
||||
}
|
||||
|
||||
function clearBanner() {
|
||||
const banner = document.getElementById("pdfTrackingBanner");
|
||||
if (banner) banner.style.display = "none";
|
||||
}
|
||||
|
||||
// ESC annulla il tracciamento in corso
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && state.activeMappingId) {
|
||||
clearRectsForMapping(state.activeMappingId);
|
||||
// ridisegna eventuali riquadri salvati per quel campo
|
||||
const saved = getSavedRegionsForMapping(state.activeMappingId);
|
||||
if (saved) {
|
||||
if (saved.label)
|
||||
drawPersistentRect(
|
||||
saved.page,
|
||||
saved.label,
|
||||
"label",
|
||||
state.activeMappingId,
|
||||
);
|
||||
if (saved.value)
|
||||
drawPersistentRect(
|
||||
saved.page,
|
||||
saved.value,
|
||||
"value",
|
||||
state.activeMappingId,
|
||||
);
|
||||
}
|
||||
state.activeMappingId = null;
|
||||
state.pendingLabel = null;
|
||||
clearBanner();
|
||||
}
|
||||
});
|
||||
|
||||
// ----- Persistenza -----
|
||||
function saveRegions(mappingId, regions) {
|
||||
fetch("save_pdf_regions.php", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: mappingId,
|
||||
mapping_type: "pdf",
|
||||
pdf_regions: regions,
|
||||
manual_default: null,
|
||||
auto_value: "none",
|
||||
tablename:
|
||||
typeof PDF_TARGET_TABLE !== "undefined"
|
||||
? PDF_TARGET_TABLE
|
||||
: "",
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!d.success) {
|
||||
console.error("❌ Error saving PDF regions:", d.message);
|
||||
alert("Errore nel salvataggio dei riquadri: " + d.message);
|
||||
} else {
|
||||
console.log("✅ PDF regions saved for mapping", mappingId);
|
||||
// aggiorna la cache locale
|
||||
savedRegionsCache[mappingId] = regions;
|
||||
}
|
||||
})
|
||||
.catch((err) =>
|
||||
console.error("❌ Fetch error saving PDF regions:", err),
|
||||
);
|
||||
}
|
||||
|
||||
function savePageMeta() {
|
||||
const dims = [];
|
||||
Object.entries(state.pageCanvases).forEach(([pageNum, p]) => {
|
||||
dims.push({
|
||||
page: parseInt(pageNum, 10),
|
||||
width: Math.round(p.pdfWidth),
|
||||
height: Math.round(p.pdfHeight),
|
||||
});
|
||||
});
|
||||
const meta = { pages: state.pdfDoc.numPages, dims };
|
||||
|
||||
fetch("update_pdf_page_meta.php", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
template_id: PDF_TEMPLATE_ID,
|
||||
pdf_page_meta: JSON.stringify(meta),
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!d.success)
|
||||
console.error("❌ Error saving page meta:", d.message);
|
||||
})
|
||||
.catch((err) =>
|
||||
console.error("❌ Fetch error saving page meta:", err),
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Riquadri già salvati (letti dal DOM al load) -----
|
||||
// Popolata dalla pagina PHP: mapping_id -> regions object
|
||||
const savedRegionsCache =
|
||||
typeof window.PDF_SAVED_REGIONS === "object" && window.PDF_SAVED_REGIONS
|
||||
? window.PDF_SAVED_REGIONS
|
||||
: {};
|
||||
|
||||
function getSavedRegionsForMapping(mappingId) {
|
||||
return savedRegionsCache[mappingId] || null;
|
||||
}
|
||||
|
||||
function redrawSavedRegions() {
|
||||
Object.entries(savedRegionsCache).forEach(([mappingId, regions]) => {
|
||||
if (!regions) return;
|
||||
const page = regions.page;
|
||||
if (!state.pageCanvases[page]) return;
|
||||
if (regions.label)
|
||||
drawPersistentRect(page, regions.label, "label", mappingId);
|
||||
if (regions.value)
|
||||
drawPersistentRect(page, regions.value, "value", mappingId);
|
||||
updateRowBadge(mappingId, regions);
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Aggiorna il badge/etichetta nella riga della tabella -----
|
||||
function updateRowBadge(mappingId, regions) {
|
||||
const badge = document.querySelector(
|
||||
`.pdf-mapped-info[data-id="${mappingId}"]`,
|
||||
);
|
||||
if (badge) {
|
||||
if (regions && regions.value) {
|
||||
badge.textContent = `✓ pag.${regions.page}`;
|
||||
badge.style.display = "inline";
|
||||
} else {
|
||||
badge.textContent = "";
|
||||
badge.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Aggancio dei bottoni "Traccia PDF" nelle righe -----
|
||||
function wireTrackButtons() {
|
||||
document.querySelectorAll(".pdf-track-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const mappingId = btn.getAttribute("data-id");
|
||||
if (!state.pdfDoc) {
|
||||
alert("Carica prima un PDF di esempio.");
|
||||
return;
|
||||
}
|
||||
// se il campo aveva già riquadri, li ripuliamo per ridisegnarli
|
||||
clearRectsForMapping(mappingId);
|
||||
startTracking(mappingId);
|
||||
// scrolla alla preview
|
||||
getPdfContainer().scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Bottone rimuovi riquadri per campo -----
|
||||
function wireRemoveButtons() {
|
||||
document.querySelectorAll(".pdf-remove-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const mappingId = btn.getAttribute("data-id");
|
||||
clearRectsForMapping(mappingId);
|
||||
delete savedRegionsCache[mappingId];
|
||||
updateRowBadge(mappingId, null);
|
||||
// salva reset (mapping vuoto)
|
||||
fetch("save_pdf_regions.php", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: mappingId,
|
||||
mapping_type: "",
|
||||
pdf_regions: null,
|
||||
manual_default: null,
|
||||
auto_value: "none",
|
||||
tablename:
|
||||
typeof PDF_TARGET_TABLE !== "undefined"
|
||||
? PDF_TARGET_TABLE
|
||||
: "",
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!d.success)
|
||||
console.error(
|
||||
"❌ Error removing PDF regions:",
|
||||
d.message,
|
||||
);
|
||||
})
|
||||
.catch((err) =>
|
||||
console.error(
|
||||
"❌ Fetch error removing PDF regions:",
|
||||
err,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Upload del PDF di esempio -----
|
||||
function wireUpload() {
|
||||
const input = document.getElementById("pdfUpload");
|
||||
if (!input) return;
|
||||
input.addEventListener("change", (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const status = document.getElementById("pdfStatus");
|
||||
if (status) status.textContent = "Uploading...";
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("pdf_file", file);
|
||||
formData.append("template_id", PDF_TEMPLATE_ID);
|
||||
|
||||
fetch("upload_pdf_example.php", { method: "POST", body: formData })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (!data.success) {
|
||||
if (status)
|
||||
status.textContent =
|
||||
"❌ Upload failed: " + data.message;
|
||||
return;
|
||||
}
|
||||
if (status) {
|
||||
status.innerHTML = `✅ File uploaded: <a href="pdftemplates/${data.filename}" target="_blank">${data.filename}</a>`;
|
||||
}
|
||||
// carica subito per l'anteprima usando il file locale scelto
|
||||
const localUrl = URL.createObjectURL(file);
|
||||
loadPdf(localUrl);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (status)
|
||||
status.textContent = "❌ Upload failed. Check console.";
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Init -----
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Configura il worker di PDF.js
|
||||
if (typeof pdfjsLib !== "undefined" && pdfjsLib.GlobalWorkerOptions) {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
}
|
||||
|
||||
wireUpload();
|
||||
wireTrackButtons();
|
||||
wireRemoveButtons();
|
||||
|
||||
// Se c'è già un PDF di esempio salvato, caricalo dal server
|
||||
if (typeof PDF_SAMPLE_FILE !== "undefined" && PDF_SAMPLE_FILE) {
|
||||
loadPdf("pdftemplates/" + PDF_SAMPLE_FILE);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -39,6 +39,7 @@ try {
|
||||
$button_bg_color = trim($_POST['button_bg_color'] ?? '#007bff');
|
||||
$button_text_color = trim($_POST['button_text_color'] ?? '#ffffff');
|
||||
$button_label = trim($_POST['button_label'] ?? 'Click Me');
|
||||
$template_note = trim($_POST['template_note'] ?? '');
|
||||
|
||||
// Allowed source types
|
||||
if (!in_array($source_type, ['XLS', 'API', 'JSON', 'PDF'], true)) {
|
||||
@@ -122,6 +123,7 @@ try {
|
||||
button_bg_color = ?,
|
||||
button_text_color = ?,
|
||||
button_label = ?,
|
||||
template_note = ?,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
@@ -144,6 +146,7 @@ try {
|
||||
$button_bg_color,
|
||||
$button_text_color,
|
||||
$button_label,
|
||||
$template_note,
|
||||
$id
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
/*
|
||||
* process_import_pdf.php
|
||||
* ------------------------------------------------------------------
|
||||
* Riceve N PDF (multipart, campo pdf_files[]) + template_id + importreferencecode.
|
||||
* Per ogni PDF:
|
||||
* 1. salva il file in public/userarea/imported_pdf/
|
||||
* 2. estrae i valori dai riquadri (pdf_extract_lib.php)
|
||||
* 3. inserisce UNA riga in datadb + i dettagli in import_data_details
|
||||
* applicando la STESSA logica di import_insert_batch.php
|
||||
* (tipi di dato, auto_value, campo 244 con lims_user_id)
|
||||
*
|
||||
* Tutti i PDF del gruppo condividono lo stesso importreferencecode.
|
||||
*
|
||||
* Ritorna JSON: { ok, results: [ {filename, ok, iddatadb, values{}, error} ] }
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
ini_set('log_errors', 1);
|
||||
ini_set('error_log', __DIR__ . '/pdf_import_debug.log');
|
||||
|
||||
include('include/headscript.php');
|
||||
require_once __DIR__ . '/pdf_extract_lib.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
function respond(array $payload): void
|
||||
{
|
||||
echo json_encode($payload);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ----- Validazione input -----
|
||||
$template_id = isset($_POST['template_id']) ? (int)$_POST['template_id'] : 0;
|
||||
$importReferenceCode = isset($_POST['importreferencecode']) ? (string)$_POST['importreferencecode'] : '';
|
||||
|
||||
if ($template_id <= 0) {
|
||||
respond(['ok' => false, 'error' => 'template_id non valido']);
|
||||
}
|
||||
|
||||
if ($importReferenceCode === '') {
|
||||
respond(['ok' => false, 'error' => 'importreferencecode mancante']);
|
||||
}
|
||||
|
||||
if (empty($_FILES['pdf_files']) || !is_array($_FILES['pdf_files']['name'])) {
|
||||
respond(['ok' => false, 'error' => 'Nessun PDF ricevuto']);
|
||||
}
|
||||
|
||||
$user_id = $iduserlogin ?? 1;
|
||||
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
// ----- Mapping del template -----
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT id, pdf_regions, data_type, is_required, manual_default, is_manual,
|
||||
field_label, field_id, main_field, auto_value
|
||||
FROM template_mapping
|
||||
WHERE template_id = ?
|
||||
");
|
||||
$stmt->execute([$template_id]);
|
||||
$allMappings = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($allMappings)) {
|
||||
respond(['ok' => false, 'error' => 'Nessun mapping trovato per il template']);
|
||||
}
|
||||
|
||||
// ----- idclient di default -----
|
||||
$tplStmt = $pdo->prepare("SELECT idclient FROM excel_templates WHERE id = ?");
|
||||
$tplStmt->execute([$template_id]);
|
||||
$tpl = $tplStmt->fetch(PDO::FETCH_ASSOC);
|
||||
$default_idclient = $tpl['idclient'] ?? null;
|
||||
|
||||
// ----- lims_user_id per il campo 244 (come nel batch XLS) -----
|
||||
$stmtUser = $pdo->prepare("SELECT lims_user_id FROM auth_users WHERE id = ? LIMIT 1");
|
||||
$stmtUser->execute([(int)$user_id]);
|
||||
$limsUserId = $stmtUser->fetchColumn();
|
||||
$limsUserId = ($limsUserId !== false && $limsUserId !== null && $limsUserId !== '') ? (string)$limsUserId : '';
|
||||
|
||||
$stmtMap = $pdo->prepare("SELECT id FROM template_mapping WHERE template_id = ? AND field_id = 244 LIMIT 1");
|
||||
$stmtMap->execute([(int)$template_id]);
|
||||
$mappingId244 = (int)$stmtMap->fetchColumn();
|
||||
|
||||
// ----- Cartella di destinazione dei PDF importati -----
|
||||
$importFolder = __DIR__ . DIRECTORY_SEPARATOR . 'pdftemplates' . DIRECTORY_SEPARATOR . 'imported';
|
||||
if (!is_dir($importFolder)) {
|
||||
@mkdir($importFolder, 0775, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applica la conversione del valore in base al data_type,
|
||||
* REPLICA della logica di import_insert_batch.php.
|
||||
*/
|
||||
function convertFieldValue(array $mapping, ?string $rawValue): string
|
||||
{
|
||||
$fieldValue = $rawValue;
|
||||
|
||||
if (!$mapping['is_manual']) {
|
||||
// Valore estratto dal PDF; se vuoto, fallback al manual_default
|
||||
if ($fieldValue === null || $fieldValue === '') {
|
||||
$fieldValue = $mapping['manual_default'] ?? '';
|
||||
}
|
||||
|
||||
switch ($mapping['data_type']) {
|
||||
case 'INT':
|
||||
$fieldValue = is_numeric($fieldValue) ? (int)$fieldValue : ($mapping['manual_default'] ?? 0);
|
||||
break;
|
||||
case 'DATE':
|
||||
$fieldValue = !empty($fieldValue)
|
||||
? date('Y-m-d', strtotime($fieldValue))
|
||||
: ($mapping['manual_default'] === 'today' ? date('Y-m-d') : ($mapping['manual_default'] ?? ''));
|
||||
break;
|
||||
case 'CHAR':
|
||||
$fieldValue = !empty($fieldValue) ? substr((string)$fieldValue, 0, 1) : ($mapping['manual_default'] ?? '');
|
||||
break;
|
||||
case 'Testo':
|
||||
case 'VARCHAR':
|
||||
default:
|
||||
$fieldValue = !empty($fieldValue) ? (string)$fieldValue : ($mapping['manual_default'] ?? '');
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Campo manuale: usa manual_default
|
||||
$fieldValue = $mapping['manual_default'] ?? '';
|
||||
if ($mapping['data_type'] === 'DATE' && $mapping['manual_default'] === 'today') {
|
||||
$fieldValue = date('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
// auto_value se ancora vuoto (come nel batch XLS)
|
||||
if (($fieldValue === null || $fieldValue === '') && !empty($mapping['auto_value']) && $mapping['auto_value'] !== 'none') {
|
||||
if ($mapping['auto_value'] === 'import_date') {
|
||||
$fieldValue = date('Y-m-d');
|
||||
} elseif ($mapping['auto_value'] === 'import_time') {
|
||||
$fieldValue = date('H:i');
|
||||
}
|
||||
}
|
||||
|
||||
return (string)($fieldValue ?? '');
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$nFiles = count($_FILES['pdf_files']['name']);
|
||||
|
||||
for ($i = 0; $i < $nFiles; $i++) {
|
||||
$origName = $_FILES['pdf_files']['name'][$i];
|
||||
$tmpName = $_FILES['pdf_files']['tmp_name'][$i];
|
||||
$err = $_FILES['pdf_files']['error'][$i];
|
||||
|
||||
$fileResult = [
|
||||
'filename' => $origName,
|
||||
'ok' => false,
|
||||
'iddatadb' => null,
|
||||
'values' => [],
|
||||
'error' => '',
|
||||
];
|
||||
|
||||
// Errore di upload
|
||||
if ($err !== UPLOAD_ERR_OK) {
|
||||
$fileResult['error'] = 'Errore upload (codice ' . $err . ')';
|
||||
$results[] = $fileResult;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Estensione PDF
|
||||
if (strtolower(pathinfo($origName, PATHINFO_EXTENSION)) !== 'pdf') {
|
||||
$fileResult['error'] = 'Non è un PDF';
|
||||
$results[] = $fileResult;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nome sicuro e univoco
|
||||
$safeBase = preg_replace('/[^A-Za-z0-9_\-]/', '_', pathinfo($origName, PATHINFO_FILENAME));
|
||||
$safeBase = trim($safeBase, '_') ?: 'pdf';
|
||||
$storedName = 'imp' . $template_id . '_' . $safeBase . '_' . date('YmdHis') . '_' . $i . '.pdf';
|
||||
$storedPath = $importFolder . DIRECTORY_SEPARATOR . $storedName;
|
||||
|
||||
if (!move_uploaded_file($tmpName, $storedPath)) {
|
||||
$fileResult['error'] = 'Impossibile salvare il file';
|
||||
$results[] = $fileResult;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ----- Estrazione dai riquadri -----
|
||||
try {
|
||||
$extracted = pdf_extract_values($storedPath, $allMappings); // [mapping_id => testo]
|
||||
} catch (Throwable $e) {
|
||||
$fileResult['error'] = 'Estrazione fallita: ' . $e->getMessage();
|
||||
$results[] = $fileResult;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ----- Inserimento in datadb + dettagli (una riga per PDF) -----
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$insDatadb = $pdo->prepare("INSERT INTO datadb (templateid, importreferencecode, filename_import, status, user_id, limscode, importdate, excelrow, idclient) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
$insDetail = $pdo->prepare("INSERT INTO import_data_details (id, mapping_id, field_value) VALUES (?, ?, ?)");
|
||||
|
||||
// excelrow non ha senso per il PDF: usiamo 0 (o l'indice del file)
|
||||
$insDatadb->execute([
|
||||
$template_id,
|
||||
$importReferenceCode,
|
||||
$storedName,
|
||||
'i',
|
||||
$user_id,
|
||||
null,
|
||||
date('Y-m-d'),
|
||||
0,
|
||||
$default_idclient
|
||||
]);
|
||||
|
||||
$iddatadb = $pdo->lastInsertId();
|
||||
$fileResult['iddatadb'] = $iddatadb;
|
||||
|
||||
$shownValues = [];
|
||||
foreach ($allMappings as $mapping) {
|
||||
$rawValue = $extracted[(int)$mapping['id']] ?? null;
|
||||
$fieldValue = convertFieldValue($mapping, $rawValue);
|
||||
|
||||
$insDetail->execute([$iddatadb, $mapping['id'], $fieldValue]);
|
||||
|
||||
// per il feedback UI mostra solo i campi con riquadro
|
||||
if (!empty($mapping['pdf_regions'])) {
|
||||
$shownValues[$mapping['field_label'] ?? ('#' . $mapping['id'])] = $fieldValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Campo 244 (Accettatore) con lims_user_id
|
||||
if ($limsUserId !== '' && $mappingId244 > 0) {
|
||||
$insDetail->execute([$iddatadb, $mappingId244, $limsUserId]);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
$fileResult['ok'] = true;
|
||||
$fileResult['values'] = $shownValues;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
error_log('[PDF IMPORT] ' . $e->getMessage());
|
||||
$fileResult['error'] = 'Inserimento DB fallito: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
$results[] = $fileResult;
|
||||
}
|
||||
|
||||
respond([
|
||||
'ok' => true,
|
||||
'importreferencecode' => $importReferenceCode,
|
||||
'template_id' => $template_id,
|
||||
'results' => $results,
|
||||
]);
|
||||
+77
-41
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* saveAll.js — Save All functionality using gridData
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
let saveAllRunning = false;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
}
|
||||
|
||||
// ── Save single row ──────────────────────────────────────────────────
|
||||
$(document).on('click', '.save-btn', async function() {
|
||||
$(document).on("click", ".save-btn", async function () {
|
||||
const btn = this;
|
||||
const rowIndex = parseInt(btn.dataset.row);
|
||||
const row = window.gridData?.[rowIndex];
|
||||
@@ -23,29 +23,44 @@
|
||||
|
||||
try {
|
||||
const formData = window.buildSavePayload(rowIndex);
|
||||
const resp = await fetch('save_edited_row.php', { method: 'POST', body: formData });
|
||||
const resp = await fetch("save_edited_row.php", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const result = await resp.json();
|
||||
|
||||
if (result.success) {
|
||||
row._dirty = false;
|
||||
if (window.gridRenderer?.clearDirty) window.gridRenderer.clearDirty(rowIndex);
|
||||
if (window.gridRenderer?.clearDirty)
|
||||
window.gridRenderer.clearDirty(rowIndex);
|
||||
// Flash success on row without re-rendering (preserves Select2 state)
|
||||
const gridRow = document.querySelector(`.grid-row[data-id="${row.iddatadb}"]`);
|
||||
const gridRow = document.querySelector(
|
||||
`.grid-row[data-id="${row.iddatadb}"]`,
|
||||
);
|
||||
if (gridRow) {
|
||||
gridRow.classList.remove('row-dirty');
|
||||
gridRow.querySelectorAll('.grid-cell').forEach(cell => {
|
||||
cell.classList.remove('cell-changed');
|
||||
cell.classList.add('flash-success');
|
||||
gridRow.classList.remove("row-dirty");
|
||||
gridRow.querySelectorAll(".grid-cell").forEach((cell) => {
|
||||
cell.classList.remove("cell-changed");
|
||||
cell.classList.add("flash-success");
|
||||
});
|
||||
setTimeout(() => gridRow.querySelectorAll('.flash-success').forEach(c => c.classList.remove('flash-success')), 500);
|
||||
setTimeout(
|
||||
() =>
|
||||
gridRow
|
||||
.querySelectorAll(".flash-success")
|
||||
.forEach((c) =>
|
||||
c.classList.remove("flash-success"),
|
||||
),
|
||||
500,
|
||||
);
|
||||
}
|
||||
const toastEl = document.getElementById('saveSuccessToast');
|
||||
if (toastEl) bootstrap.Toast.getOrCreateInstance(toastEl).show();
|
||||
const toastEl = document.getElementById("saveSuccessToast");
|
||||
if (toastEl)
|
||||
bootstrap.Toast.getOrCreateInstance(toastEl).show();
|
||||
} else {
|
||||
alert('Errore: ' + result.message);
|
||||
alert("Errore: " + result.message);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Errore: ' + e.message);
|
||||
alert("Errore: " + e.message);
|
||||
} finally {
|
||||
btn.innerHTML = origHtml;
|
||||
btn.disabled = false;
|
||||
@@ -53,49 +68,68 @@
|
||||
});
|
||||
|
||||
// ── Save All ─────────────────────────────────────────────────────────
|
||||
$(document).on('click', '.save-all-btn', function(e) {
|
||||
$(document).on("click", ".save-all-btn", function (e) {
|
||||
e.preventDefault();
|
||||
if (isBusy()) return;
|
||||
const modalEl = document.getElementById('saveAllConfirmModal');
|
||||
const modalEl = document.getElementById("saveAllConfirmModal");
|
||||
if (!modalEl) return;
|
||||
new bootstrap.Modal(modalEl, { keyboard: false }).show();
|
||||
});
|
||||
|
||||
$(document).on('click', '#saveAllConfirmBtn', async function() {
|
||||
const confirmModal = bootstrap.Modal.getInstance(document.getElementById('saveAllConfirmModal'));
|
||||
$(document).on("click", "#saveAllConfirmBtn", async function () {
|
||||
const confirmModal = bootstrap.Modal.getInstance(
|
||||
document.getElementById("saveAllConfirmModal"),
|
||||
);
|
||||
if (confirmModal) confirmModal.hide();
|
||||
saveAllRunning = true;
|
||||
|
||||
const bar = document.getElementById('batchExportBar');
|
||||
const statusEl = document.getElementById('batchExportStatus');
|
||||
const cancelBtn = document.getElementById('exportBatchCancelBtn');
|
||||
if (bar) bar.style.display = '';
|
||||
if (cancelBtn) cancelBtn.style.display = 'none';
|
||||
if (statusEl) statusEl.textContent = 'Saving...';
|
||||
const bar = document.getElementById("batchExportBar");
|
||||
const statusEl = document.getElementById("batchExportStatus");
|
||||
const cancelBtn = document.getElementById("exportBatchCancelBtn");
|
||||
if (bar) bar.style.display = "";
|
||||
if (cancelBtn) cancelBtn.style.display = "none";
|
||||
if (statusEl) statusEl.textContent = "Saving...";
|
||||
|
||||
const data = window.gridData || [];
|
||||
const dirtyRows = data.map((r, i) => r._dirty ? i : -1).filter(i => i >= 0);
|
||||
const target =
|
||||
typeof window.getTargetGridIds === "function"
|
||||
? window.getTargetGridIds()
|
||||
: null;
|
||||
const dirtyRows = data
|
||||
.map((r, i) => (r._dirty ? i : -1))
|
||||
.filter((i) => i >= 0)
|
||||
.filter(
|
||||
(i) => !target || target.has(parseInt(data[i].iddatadb, 10)),
|
||||
);
|
||||
|
||||
if (dirtyRows.length === 0) {
|
||||
saveAllRunning = false;
|
||||
if (bar) bar.style.display = 'none';
|
||||
const msgEl = document.getElementById('saveAllResultMessage');
|
||||
if (msgEl) msgEl.textContent = 'No changes to save.';
|
||||
new bootstrap.Modal(document.getElementById('saveAllResultModal')).show();
|
||||
if (bar) bar.style.display = "none";
|
||||
const msgEl = document.getElementById("saveAllResultMessage");
|
||||
if (msgEl) msgEl.textContent = "No changes to save.";
|
||||
new bootstrap.Modal(
|
||||
document.getElementById("saveAllResultModal"),
|
||||
).show();
|
||||
return;
|
||||
}
|
||||
|
||||
let success = 0, fail = 0;
|
||||
let success = 0,
|
||||
fail = 0;
|
||||
|
||||
for (const idx of dirtyRows) {
|
||||
if (statusEl) statusEl.textContent = `Saving ${success + fail + 1} / ${dirtyRows.length}...`;
|
||||
if (statusEl)
|
||||
statusEl.textContent = `Saving ${success + fail + 1} / ${dirtyRows.length}...`;
|
||||
try {
|
||||
const formData = window.buildSavePayload(idx);
|
||||
const resp = await fetch('save_edited_row.php', { method: 'POST', body: formData });
|
||||
const resp = await fetch("save_edited_row.php", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (result.success) {
|
||||
data[idx]._dirty = false;
|
||||
if (window.gridRenderer?.clearDirty) window.gridRenderer.clearDirty(idx);
|
||||
if (window.gridRenderer?.clearDirty)
|
||||
window.gridRenderer.clearDirty(idx);
|
||||
success++;
|
||||
} else {
|
||||
fail++;
|
||||
@@ -106,22 +140,24 @@
|
||||
}
|
||||
|
||||
saveAllRunning = false;
|
||||
if (bar) bar.style.display = 'none';
|
||||
if (bar) bar.style.display = "none";
|
||||
|
||||
const gr = window.gridRenderer;
|
||||
if (gr) gr.renderVisibleRows();
|
||||
|
||||
const msg = `Saved: ${success}` + (fail > 0 ? `, Errors: ${fail}` : '');
|
||||
const msgEl = document.getElementById('saveAllResultMessage');
|
||||
const msg = `Saved: ${success}` + (fail > 0 ? `, Errors: ${fail}` : "");
|
||||
const msgEl = document.getElementById("saveAllResultMessage");
|
||||
if (msgEl) msgEl.textContent = msg;
|
||||
new bootstrap.Modal(document.getElementById('saveAllResultModal')).show();
|
||||
new bootstrap.Modal(
|
||||
document.getElementById("saveAllResultModal"),
|
||||
).show();
|
||||
});
|
||||
|
||||
// ── beforeunload ─────────────────────────────────────────────────────
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (window.gridData && window.gridData.some(r => r._dirty)) {
|
||||
window.addEventListener("beforeunload", function (e) {
|
||||
if (window.gridData && window.gridData.some((r) => r._dirty)) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
e.returnValue = "";
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -54,15 +54,6 @@ try {
|
||||
:is_web_selectable,
|
||||
:is_accredited
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
analysis_name = VALUES(analysis_name),
|
||||
analysis_method = VALUES(analysis_method),
|
||||
analysis_level = VALUES(analysis_level),
|
||||
is_web_selectable = VALUES(is_web_selectable),
|
||||
is_accredited = VALUES(is_accredited),
|
||||
iddatadb = VALUES(iddatadb),
|
||||
idmatrice = VALUES(idmatrice),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
@@ -79,7 +70,8 @@ try {
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Association saved'
|
||||
'message' => 'Association saved',
|
||||
'id' => (int)$pdo->lastInsertId()
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
|
||||
@@ -4,6 +4,7 @@ include('include/headscript.php');
|
||||
|
||||
$dbHandler = DBHandlerSelect::getInstance();
|
||||
$pdo = $dbHandler->getConnection();
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
@@ -15,46 +16,73 @@ if (!$idquotations || empty($parts)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$part = $parts[0];
|
||||
$partId = $part['id'] ?? null;
|
||||
$partNumber = $part['part_number'] ?? null;
|
||||
$partDescription = $part['part_description'] ?? '';
|
||||
$mix = $part['mix'] ?? 'N';
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$results = [];
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$partId = $part['id'] ?? null;
|
||||
$partNumber = $part['part_number'] ?? null;
|
||||
$partDescription = $part['part_description'] ?? '';
|
||||
$mix = $part['mix'] ?? 'N';
|
||||
$idmatrice = $part['idmatrice'] ?? null;
|
||||
$note = $part['note'] ?? null;
|
||||
$dateexpiry = $part['dateexpiry'] ?? null;
|
||||
|
||||
if ($partDescription) {
|
||||
try {
|
||||
if ($partId) {
|
||||
// UPDATE se esiste già la parte
|
||||
// UPDATE se la parte esiste
|
||||
$stmt = $pdo->prepare("UPDATE identification_parts
|
||||
SET part_number = :part_number,
|
||||
part_description = :part_description,
|
||||
mix = :mix,
|
||||
idmatrice = :idmatrice,
|
||||
note = :note,
|
||||
dateexpiry = :dateexpiry,
|
||||
updated_at = NOW()
|
||||
WHERE id = :id");
|
||||
WHERE id = :id AND idquotations = :idquotations");
|
||||
$stmt->execute([
|
||||
':id' => $partId,
|
||||
':part_number' => $partNumber,
|
||||
':part_description' => $partDescription,
|
||||
':mix' => $mix
|
||||
':mix' => $mix,
|
||||
':idmatrice' => $idmatrice,
|
||||
':note' => $note,
|
||||
':dateexpiry' => $dateexpiry,
|
||||
':idquotations' => $idquotations,
|
||||
]);
|
||||
echo json_encode(['success' => true, 'part_id' => $partId, 'part_number' => $partNumber, 'message' => 'Parte aggiornata con successo']);
|
||||
} else {
|
||||
// INSERT se è nuova
|
||||
|
||||
$results[] = [
|
||||
'part_id' => $partId,
|
||||
'part_number' => $partNumber,
|
||||
'message' => 'Parte aggiornata con successo'
|
||||
];
|
||||
} else if ($partDescription || $note || $dateexpiry) {
|
||||
// INSERT per nuova parte (solo se ha contenuto)
|
||||
$stmt = $pdo->prepare("INSERT INTO identification_parts
|
||||
(idquotations, part_number, part_description, mix, created_at, updated_at)
|
||||
VALUES (:idquotations, :part_number, :part_description, :mix, NOW(), NOW())");
|
||||
(idquotations, part_number, part_description, mix, idmatrice, note, dateexpiry, created_at, updated_at)
|
||||
VALUES (:idquotations, :part_number, :part_description, :mix, :idmatrice, :note, :dateexpiry, NOW(), NOW())");
|
||||
$stmt->execute([
|
||||
':idquotations' => $idquotations,
|
||||
':part_number' => $partNumber,
|
||||
':part_description' => $partDescription,
|
||||
':mix' => $mix
|
||||
':mix' => $mix,
|
||||
':idmatrice' => $idmatrice,
|
||||
':note' => $note,
|
||||
':dateexpiry' => $dateexpiry,
|
||||
]);
|
||||
$newId = $pdo->lastInsertId();
|
||||
echo json_encode(['success' => true, 'part_id' => $newId, 'part_number' => $partNumber, 'message' => 'Parte salvata con successo']);
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
|
||||
$results[] = [
|
||||
'part_id' => $newId,
|
||||
'part_number' => $partNumber,
|
||||
'message' => 'Parte salvata con successo'
|
||||
];
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['success' => false, 'message' => 'Errore nel salvataggio: ' . $e->getMessage()]);
|
||||
}
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Descrizione mancante']);
|
||||
|
||||
$pdo->commit();
|
||||
echo json_encode(['success' => true, 'results' => $results]);
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
echo json_encode(['success' => false, 'message' => 'Errore nel salvataggio: ' . $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
// save_pdf_regions.php
|
||||
// Salva i riquadri (etichetta + dato) di un singolo campo mappato
|
||||
// nella colonna pdf_regions della tabella template_mapping.
|
||||
//
|
||||
// Riceve JSON:
|
||||
// {
|
||||
// id: <mapping id>,
|
||||
// mapping_type: 'pdf' | 'manual' | 'auto' | '',
|
||||
// pdf_regions: { page, label:{x,y,w,h}, value:{x,y,w,h} } | null,
|
||||
// manual_default: <string|null>,
|
||||
// auto_value: <string>,
|
||||
// tablename: <string>
|
||||
// }
|
||||
//
|
||||
// Coerente con save_mapping_json.php: quando mapping_type = 'pdf'
|
||||
// azzera excel_column/json_node e scrive pdf_regions; per gli altri
|
||||
// tipi azzera pdf_regions.
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
include('include/headscript.php');
|
||||
|
||||
function respond(bool $success, string $message = ''): void
|
||||
{
|
||||
echo json_encode(['success' => $success, 'message' => $message]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
respond(false, 'Invalid JSON payload.');
|
||||
}
|
||||
|
||||
$mappingId = isset($data['id']) ? (int)$data['id'] : 0;
|
||||
$mappingType = isset($data['mapping_type']) ? (string)$data['mapping_type'] : '';
|
||||
$manualDef = $data['manual_default'] ?? null;
|
||||
$autoValue = $data['auto_value'] ?? 'none';
|
||||
$regions = $data['pdf_regions'] ?? null;
|
||||
|
||||
if ($mappingId <= 0) {
|
||||
respond(false, 'Missing or invalid mapping id.');
|
||||
}
|
||||
|
||||
// --- Validazione e normalizzazione dei riquadri ---
|
||||
// Accettiamo solo se mapping_type = 'pdf'. Negli altri casi pdf_regions = NULL.
|
||||
$pdfRegionsJson = null;
|
||||
|
||||
if ($mappingType === 'pdf') {
|
||||
if (!is_array($regions)) {
|
||||
respond(false, 'pdf_regions must be an object when mapping_type is pdf.');
|
||||
}
|
||||
|
||||
// Valida un singolo rettangolo {x,y,w,h} con numeri >= 0
|
||||
$validateRect = function ($rect): ?array {
|
||||
if (!is_array($rect)) {
|
||||
return null;
|
||||
}
|
||||
$out = [];
|
||||
foreach (['x', 'y', 'w', 'h'] as $k) {
|
||||
if (!isset($rect[$k]) || !is_numeric($rect[$k])) {
|
||||
return null;
|
||||
}
|
||||
$out[$k] = (float)$rect[$k];
|
||||
if ($out[$k] < 0) {
|
||||
$out[$k] = 0;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
};
|
||||
|
||||
$page = isset($regions['page']) ? (int)$regions['page'] : 0;
|
||||
if ($page < 1) {
|
||||
respond(false, 'pdf_regions.page must be >= 1.');
|
||||
}
|
||||
|
||||
// value è obbligatorio, label è opzionale (modalità "solo dato")
|
||||
$valueRect = $validateRect($regions['value'] ?? null);
|
||||
if ($valueRect === null) {
|
||||
respond(false, 'pdf_regions.value rectangle is invalid.');
|
||||
}
|
||||
|
||||
$labelRect = null;
|
||||
if (isset($regions['label']) && $regions['label'] !== null) {
|
||||
$labelRect = $validateRect($regions['label']);
|
||||
if ($labelRect === null) {
|
||||
respond(false, 'pdf_regions.label rectangle is invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
$clean = [
|
||||
'page' => $page,
|
||||
'label' => $labelRect,
|
||||
'value' => $valueRect,
|
||||
];
|
||||
|
||||
$pdfRegionsJson = json_encode($clean, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
// --- Normalizza manual/auto in base al tipo ---
|
||||
$manualToSave = ($mappingType === 'manual') ? $manualDef : null;
|
||||
$autoToSave = ($mappingType === 'auto') ? ($autoValue ?: 'none') : 'none';
|
||||
|
||||
// is_manual coerente con il tipo
|
||||
$isManual = ($mappingType === 'manual') ? 1 : 0;
|
||||
|
||||
try {
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
// Quando è PDF: azzero excel_column e json_node, scrivo pdf_regions.
|
||||
// Quando NON è PDF: azzero pdf_regions.
|
||||
$sql = "
|
||||
UPDATE template_mapping
|
||||
SET
|
||||
pdf_regions = :pdf_regions,
|
||||
excel_column = CASE WHEN :is_pdf = 1 THEN NULL ELSE excel_column END,
|
||||
json_node = CASE WHEN :is_pdf2 = 1 THEN NULL ELSE json_node END,
|
||||
is_manual = :is_manual,
|
||||
manual_default = :manual_default,
|
||||
auto_value = :auto_value
|
||||
WHERE id = :id
|
||||
";
|
||||
|
||||
$isPdf = ($mappingType === 'pdf') ? 1 : 0;
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
':pdf_regions' => $pdfRegionsJson,
|
||||
':is_pdf' => $isPdf,
|
||||
':is_pdf2' => $isPdf,
|
||||
':is_manual' => $isManual,
|
||||
':manual_default' => $manualToSave,
|
||||
':auto_value' => $autoToSave,
|
||||
':id' => $mappingId,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
respond(false, 'Database update failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
respond(true, 'PDF regions saved.');
|
||||
@@ -912,6 +912,12 @@
|
||||
"ConteggioClienti": 0,
|
||||
"Nome": "ETON",
|
||||
"Descrizione": "Schema da usare per ETON\r\n"
|
||||
},
|
||||
{
|
||||
"IdSchemaCustomFields": 209,
|
||||
"ConteggioClienti": 0,
|
||||
"Nome": "LBS Flammability",
|
||||
"Descrizione": "Schema per tutti i campioni di LBS con Flammability\r\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -51,18 +51,74 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Search by query
|
||||
$results = [];
|
||||
foreach ($values as $v) {
|
||||
$text = $v['Valore'] ?? '';
|
||||
if ($q === '' || mb_strpos(mb_strtolower($text), $q) !== false) {
|
||||
$results[] = ['id' => $v['IdCustomFieldsValue'], 'text' => $text];
|
||||
if ($limit > 0 && count($results) >= $limit) break;
|
||||
// Search — multi-term AND, position-independent, ranked
|
||||
$terms = array_values(array_filter(
|
||||
preg_split('/\s+/u', $q, -1, PREG_SPLIT_NO_EMPTY)
|
||||
));
|
||||
|
||||
// Nessun termine digitato: alfabetico, taglio al limite
|
||||
if (empty($terms)) {
|
||||
$all = [];
|
||||
foreach ($values as $v) {
|
||||
$all[] = ['id' => $v['IdCustomFieldsValue'], 'text' => $v['Valore'] ?? ''];
|
||||
}
|
||||
usort($all, fn($a, $b) => strcasecmp($a['text'], $b['text']));
|
||||
if ($limit > 0) $all = array_slice($all, 0, $limit);
|
||||
echo json_encode(['results' => $all]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Sort alphabetically
|
||||
usort($results, fn($a, $b) => strcasecmp($a['text'], $b['text']));
|
||||
$firstTerm = $terms[0];
|
||||
$scored = [];
|
||||
|
||||
foreach ($values as $v) {
|
||||
$text = $v['Valore'] ?? '';
|
||||
$textLower = mb_strtolower($text);
|
||||
|
||||
// AND: tutti i termini devono essere presenti, in qualsiasi posizione
|
||||
$matchAll = true;
|
||||
foreach ($terms as $t) {
|
||||
if (mb_strpos($textLower, $t) === false) {
|
||||
$matchAll = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$matchAll) continue;
|
||||
|
||||
// Punteggio di pertinenza (più basso = più rilevante)
|
||||
if ($textLower === $q) {
|
||||
$score = 0; // match esatto
|
||||
} elseif (mb_strpos($textLower, $firstTerm) === 0) {
|
||||
$score = 1; // inizia col primo termine
|
||||
} elseif (preg_match('/(^|\s)' . preg_quote($firstTerm, '/') . '/u', $textLower)) {
|
||||
$score = 2; // primo termine a inizio di una parola
|
||||
} else {
|
||||
$score = 3; // presente ma non a inizio parola
|
||||
}
|
||||
|
||||
$scored[] = [
|
||||
'id' => $v['IdCustomFieldsValue'],
|
||||
'text' => $text,
|
||||
'_s' => $score,
|
||||
'_n' => $textLower
|
||||
];
|
||||
}
|
||||
|
||||
// Ordina per punteggio, poi alfabetico
|
||||
usort($scored, function ($a, $b) {
|
||||
if ($a['_s'] !== $b['_s']) return $a['_s'] - $b['_s'];
|
||||
return strcmp($a['_n'], $b['_n']);
|
||||
});
|
||||
|
||||
// Taglia DOPO l'ordinamento e rimuovi i campi interni
|
||||
if ($limit > 0) {
|
||||
$scored = array_slice($scored, 0, $limit);
|
||||
}
|
||||
|
||||
$results = array_map(
|
||||
fn($r) => ['id' => $r['id'], 'text' => $r['text']],
|
||||
$scored
|
||||
);
|
||||
|
||||
echo json_encode(['results' => $results]);
|
||||
} catch (Exception $e) {
|
||||
|
||||
@@ -62,7 +62,11 @@ if (isset($_GET['macro_list'])) {
|
||||
echo json_encode(['success' => true, 'value' => $macros]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Return full matrix list (same shape as get_matrici_db.php)
|
||||
if (isset($_GET['full_list'])) {
|
||||
echo json_encode(['success' => true, 'value' => $matrici]);
|
||||
exit;
|
||||
}
|
||||
// Search (with optional MacroMatrice filter) - multi-term AND, position-independent, ranked
|
||||
$terms = array_values(array_filter(
|
||||
preg_split('/\s+/u', $q, -1, PREG_SPLIT_NO_EMPTY)
|
||||
|
||||
@@ -47,6 +47,27 @@
|
||||
.filter-bar {
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
.stat-card-extra {
|
||||
background: linear-gradient(135deg, #021bff 0%, #010983 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.stat-card-extra .stat-label {
|
||||
color: #d7d2f5;
|
||||
}
|
||||
|
||||
.stat-card-extra .stat-value {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.stat-card-extra small.text-muted {
|
||||
color: #c9c2ec !important;
|
||||
}
|
||||
|
||||
.kpi-row {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -71,7 +92,10 @@
|
||||
<button class="btn btn-outline-secondary" id="btnToday">Oggi</button>
|
||||
<button class="btn btn-outline-secondary" id="btnYesterday">Ieri</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm ms-auto" id="btnRefresh">
|
||||
<a href="stats_export_lims_monthly.php" class="btn btn-outline-primary btn-sm ms-auto">
|
||||
<i class="bx bx-calendar-alt"></i> Vista mensile
|
||||
</a>
|
||||
<button class="btn btn-primary btn-sm ms-2" id="btnRefresh">
|
||||
<i class="bx bx-refresh"></i> Aggiorna
|
||||
</button>
|
||||
</div>
|
||||
@@ -79,39 +103,50 @@
|
||||
</div>
|
||||
|
||||
<!-- ===== KPI ===== -->
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-3 col-md-6">
|
||||
<div class="card radius-10 stat-card">
|
||||
<div class="row row-cols-2 row-cols-md-3 row-cols-lg-5 g-3 kpi-row">
|
||||
<div class="col">
|
||||
<div class="card radius-10 stat-card stat-card-extra h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">Export (status L)</div>
|
||||
<div class="stat-value text-primary" id="kpiExport">0</div>
|
||||
<div class="stat-label">Campioni mese <span id="kpiMonthLabel"></span></div>
|
||||
<div class="stat-value" id="kpiPartsMonth">0</div>
|
||||
<small class="text-muted">Commesse: <span id="kpiExportMonth">0</span> · dal 16/07/2026</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-3 col-md-6">
|
||||
<div class="card radius-10 stat-card">
|
||||
<div class="col">
|
||||
<div class="card radius-10 stat-card stat-card-extra h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">Campioni anno <span id="kpiYearLabel"></span></div>
|
||||
<div class="stat-value" id="kpiPartsYear">0</div>
|
||||
<small class="text-muted">Commesse: <span id="kpiExportYear">0</span> · dal 16/07/2026</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card radius-10 stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">Campioni esportati (giorno)</div>
|
||||
<div class="stat-value text-primary" id="kpiParts">0</div>
|
||||
<small class="text-muted">Commesse: <span id="kpiExport">0</span></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card radius-10 stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">Campioni inseriti LIMS</div>
|
||||
<div class="stat-value text-info" id="kpiLimsSamples">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-3 col-md-6">
|
||||
<div class="card radius-10 stat-card">
|
||||
<div class="col">
|
||||
<div class="card radius-10 stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">% Esportati / inseriti</div>
|
||||
<div class="stat-value text-success" id="kpiPerc">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-3 col-md-6">
|
||||
<div class="card radius-10 stat-card">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">Clienti distinti</div>
|
||||
<div class="stat-value text-warning" id="kpiClienti">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== DATO GIORNALIERO CAMPIONI LIMS ===== -->
|
||||
@@ -180,7 +215,8 @@
|
||||
<tr>
|
||||
<th>ID Cliente</th>
|
||||
<th>Cliente</th>
|
||||
<th>Export</th>
|
||||
<th>Campioni</th>
|
||||
<th>Commesse</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
@@ -201,7 +237,8 @@
|
||||
<tr>
|
||||
<th>ID Utente</th>
|
||||
<th>Utente</th>
|
||||
<th>Export</th>
|
||||
<th>Campioni</th>
|
||||
<th>Commesse</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
@@ -253,6 +290,10 @@
|
||||
data: 'clientname',
|
||||
defaultContent: '—'
|
||||
},
|
||||
{
|
||||
data: 'parts',
|
||||
className: 'text-end'
|
||||
},
|
||||
{
|
||||
data: 'tot',
|
||||
className: 'text-end'
|
||||
@@ -276,6 +317,10 @@
|
||||
data: 'username',
|
||||
defaultContent: '—'
|
||||
},
|
||||
{
|
||||
data: 'parts',
|
||||
className: 'text-end'
|
||||
},
|
||||
{
|
||||
data: 'tot',
|
||||
className: 'text-end'
|
||||
@@ -386,32 +431,52 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// KPI
|
||||
$('#kpiExport').text(res.totals.export);
|
||||
$('#kpiClienti').text(res.byClient.length);
|
||||
// KPI — dato principale = parti/campioni, sotto le commesse
|
||||
$('#kpiParts').text(res.totals.parts ?? 0);
|
||||
$('#kpiExport').text(res.totals.export ?? 0);
|
||||
$('#kpiPartsMonth').text(res.totalsMonth ? (res.totalsMonth.parts ?? 0) : 0);
|
||||
$('#kpiExportMonth').text(res.totalsMonth ? (res.totalsMonth.export ?? 0) : 0);
|
||||
$('#kpiPartsYear').text(res.totalsYear ? (res.totalsYear.parts ?? 0) : 0);
|
||||
$('#kpiExportYear').text(res.totalsYear ? (res.totalsYear.export ?? 0) : 0);
|
||||
$('#kpiLimsSamples').text(res.limsSamples ?? 0);
|
||||
$('#limsSamplesInput').val(res.limsSamples ?? '');
|
||||
|
||||
// Etichette mese/anno dei box totali
|
||||
if (res.month) {
|
||||
const MESI_IT = ['Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno',
|
||||
'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'
|
||||
];
|
||||
const mParts = res.month.split('-');
|
||||
const mIdx = parseInt(mParts[1], 10) - 1;
|
||||
if (mIdx >= 0 && mIdx < 12) {
|
||||
$('#kpiMonthLabel').text('(' + MESI_IT[mIdx] + ' ' + mParts[0] + ')');
|
||||
}
|
||||
}
|
||||
if (res.year) {
|
||||
$('#kpiYearLabel').text('(' + res.year + ')');
|
||||
}
|
||||
|
||||
const limsTot = parseInt(res.limsSamples ?? 0, 10);
|
||||
const partsTot = parseInt(res.totals.parts ?? 0, 10);
|
||||
if (limsTot > 0) {
|
||||
const perc = (res.totals.export / limsTot) * 100;
|
||||
const perc = (partsTot / limsTot) * 100;
|
||||
$('#kpiPerc').text(perc.toFixed(1) + '%');
|
||||
} else {
|
||||
$('#kpiPerc').text('—');
|
||||
}
|
||||
|
||||
// Torta esportati vs totale
|
||||
renderPie(res.totals.export, limsTot);
|
||||
// Torta parti esportate vs totale inserito nel LIMS
|
||||
renderPie(partsTot, limsTot);
|
||||
|
||||
// Grafici
|
||||
// Grafici — valori = campioni/parti
|
||||
const clientTop = topN(res.byClient);
|
||||
const userTop = topN(res.byUser);
|
||||
chartClienti = renderBarChart(chartClienti, 'chartClienti',
|
||||
clientTop.map(r => r.clientname ? (r.clientname + ' (' + r.idclient + ')') : ('ID ' + r.idclient)),
|
||||
clientTop.map(r => r.tot), 'Export');
|
||||
clientTop.map(r => r.parts), 'Campioni');
|
||||
chartUtenti = renderBarChart(chartUtenti, 'chartUtenti',
|
||||
userTop.map(r => r.username ? (r.username + ' (' + r.user_id + ')') : ('ID ' + r.user_id)),
|
||||
userTop.map(r => r.tot), 'Export');
|
||||
userTop.map(r => r.parts), 'Campioni');
|
||||
|
||||
// Tabelle
|
||||
dtClienti.clear().rows.add(res.byClient).draw();
|
||||
|
||||
@@ -15,14 +15,17 @@ try {
|
||||
}
|
||||
|
||||
// --- Export per cliente (status = 'l', giornata su export_date) ---
|
||||
// tot = numero commesse, parts = numero parti/campioni
|
||||
$stmtC = $pdo->prepare("
|
||||
SELECT d.idclient AS idclient,
|
||||
COUNT(*) AS tot
|
||||
COUNT(DISTINCT d.iddatadb) AS tot,
|
||||
COUNT(ip.id) AS parts
|
||||
FROM datadb d
|
||||
LEFT JOIN identification_parts ip ON ip.iddatadb = d.iddatadb
|
||||
WHERE d.status = 'l'
|
||||
AND DATE(d.export_date) = :d
|
||||
GROUP BY d.idclient
|
||||
ORDER BY tot DESC
|
||||
ORDER BY parts DESC
|
||||
");
|
||||
$stmtC->execute(['d' => $date]);
|
||||
$byClient = $stmtC->fetchAll(PDO::FETCH_ASSOC);
|
||||
@@ -38,16 +41,19 @@ try {
|
||||
}
|
||||
|
||||
// --- Export per utente (status = 'l', giornata su export_date) ---
|
||||
// tot = numero commesse, parts = numero parti/campioni
|
||||
$stmtU = $pdo->prepare("
|
||||
SELECT d.user_id AS user_id,
|
||||
TRIM(CONCAT(COALESCE(u.first_name,''),' ',COALESCE(u.last_name,''))) AS username,
|
||||
COUNT(*) AS tot
|
||||
COUNT(DISTINCT d.iddatadb) AS tot,
|
||||
COUNT(ip.id) AS parts
|
||||
FROM datadb d
|
||||
LEFT JOIN auth_users u ON u.id = d.user_id
|
||||
LEFT JOIN identification_parts ip ON ip.iddatadb = d.iddatadb
|
||||
WHERE d.status = 'l'
|
||||
AND DATE(d.export_date) = :d
|
||||
GROUP BY d.user_id, username
|
||||
ORDER BY tot DESC
|
||||
ORDER BY parts DESC
|
||||
");
|
||||
$stmtU->execute(['d' => $date]);
|
||||
$byUser = $stmtU->fetchAll(PDO::FETCH_ASSOC);
|
||||
@@ -56,6 +62,7 @@ try {
|
||||
foreach ($byClient as &$r) {
|
||||
$r['idclient'] = $r['idclient'] !== null ? (int)$r['idclient'] : null;
|
||||
$r['tot'] = (int)$r['tot'];
|
||||
$r['parts'] = (int)$r['parts'];
|
||||
$nm = $clientNames[$r['idclient']] ?? '';
|
||||
$r['clientname'] = $nm !== '' ? $nm : null;
|
||||
}
|
||||
@@ -63,6 +70,7 @@ try {
|
||||
foreach ($byUser as &$r) {
|
||||
$r['user_id'] = $r['user_id'] !== null ? (int)$r['user_id'] : null;
|
||||
$r['tot'] = (int)$r['tot'];
|
||||
$r['parts'] = (int)$r['parts'];
|
||||
if (isset($r['username'])) $r['username'] = $r['username'] !== null && $r['username'] !== '' ? $r['username'] : null;
|
||||
}
|
||||
unset($r);
|
||||
@@ -70,6 +78,60 @@ try {
|
||||
// --- Totale export della giornata ---
|
||||
$totExport = array_sum(array_column($byClient, 'tot'));
|
||||
|
||||
// --- Totale PARTI/CAMPIONI della giornata (righe identification_parts legate
|
||||
// ai datadb esportati status='l' in questa giornata) ---
|
||||
$stmtP = $pdo->prepare("
|
||||
SELECT COUNT(*)
|
||||
FROM identification_parts ip
|
||||
INNER JOIN datadb d ON d.iddatadb = ip.iddatadb
|
||||
WHERE d.status = 'l'
|
||||
AND DATE(d.export_date) = :d
|
||||
");
|
||||
$stmtP->execute(['d' => $date]);
|
||||
$totParts = (int)$stmtP->fetchColumn();
|
||||
|
||||
// --- Totale COMMESSE del MESE (status = 'l') ---
|
||||
$stmtM = $pdo->prepare("
|
||||
SELECT COUNT(*)
|
||||
FROM datadb d
|
||||
WHERE d.status = 'l'
|
||||
AND DATE_FORMAT(d.export_date, '%Y-%m') = :m
|
||||
");
|
||||
$stmtM->execute(['m' => $d->format('Y-m')]);
|
||||
$totExportMonth = (int)$stmtM->fetchColumn();
|
||||
|
||||
// --- Totale PARTI del MESE (status = 'l') ---
|
||||
$stmtMP = $pdo->prepare("
|
||||
SELECT COUNT(*)
|
||||
FROM identification_parts ip
|
||||
INNER JOIN datadb d ON d.iddatadb = ip.iddatadb
|
||||
WHERE d.status = 'l'
|
||||
AND DATE_FORMAT(d.export_date, '%Y-%m') = :m
|
||||
");
|
||||
$stmtMP->execute(['m' => $d->format('Y-m')]);
|
||||
$totPartsMonth = (int)$stmtMP->fetchColumn();
|
||||
|
||||
// --- Totale COMMESSE dell'ANNO (status = 'l') ---
|
||||
$stmtY = $pdo->prepare("
|
||||
SELECT COUNT(*)
|
||||
FROM datadb d
|
||||
WHERE d.status = 'l'
|
||||
AND YEAR(d.export_date) = :y
|
||||
");
|
||||
$stmtY->execute(['y' => $d->format('Y')]);
|
||||
$totExportYear = (int)$stmtY->fetchColumn();
|
||||
|
||||
// --- Totale PARTI dell'ANNO (status = 'l') ---
|
||||
$stmtYP = $pdo->prepare("
|
||||
SELECT COUNT(*)
|
||||
FROM identification_parts ip
|
||||
INNER JOIN datadb d ON d.iddatadb = ip.iddatadb
|
||||
WHERE d.status = 'l'
|
||||
AND YEAR(d.export_date) = :y
|
||||
");
|
||||
$stmtYP->execute(['y' => $d->format('Y')]);
|
||||
$totPartsYear = (int)$stmtYP->fetchColumn();
|
||||
|
||||
// --- Campioni totali inseriti nel LIMS per la giornata (tabella lims_daily_samples) ---
|
||||
$stmtL = $pdo->prepare("SELECT total_samples FROM lims_daily_samples WHERE sample_date = :d LIMIT 1");
|
||||
$stmtL->execute(['d' => $date]);
|
||||
@@ -77,12 +139,16 @@ try {
|
||||
$limsSamples = ($limsSamples !== false) ? (int)$limsSamples : null;
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'date' => $date,
|
||||
'totals' => ['export' => (int)$totExport],
|
||||
'byClient' => $byClient,
|
||||
'byUser' => $byUser,
|
||||
'limsSamples' => $limsSamples,
|
||||
'success' => true,
|
||||
'date' => $date,
|
||||
'month' => $d->format('Y-m'),
|
||||
'year' => (int)$d->format('Y'),
|
||||
'totals' => ['export' => (int)$totExport, 'parts' => $totParts],
|
||||
'totalsMonth' => ['export' => $totExportMonth, 'parts' => $totPartsMonth],
|
||||
'totalsYear' => ['export' => $totExportYear, 'parts' => $totPartsYear],
|
||||
'byClient' => $byClient,
|
||||
'byUser' => $byUser,
|
||||
'limsSamples' => $limsSamples,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
error_log('Stats export LIMS error: ' . $e->getMessage());
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
<?php include('include/headscript.php'); ?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
|
||||
<?php include('cssinclude.php'); ?>
|
||||
<title>TRF-Project - Statistiche Export LIMS Mensili</title>
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css">
|
||||
<style>
|
||||
.stat-card .card-body {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .03em;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
height: 420px;
|
||||
}
|
||||
|
||||
#tblClienti,
|
||||
#tblUtenti {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#tblClienti th,
|
||||
#tblClienti td,
|
||||
#tblUtenti th,
|
||||
#tblUtenti td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-card-extra {
|
||||
background: linear-gradient(135deg, #021bff 0%, #010983 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.stat-card-extra .stat-label {
|
||||
color: #c9cfff;
|
||||
}
|
||||
|
||||
.stat-card-extra .stat-value {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.stat-card-extra small.text-muted {
|
||||
color: #b3bbff !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<?php include('include/navbar.php'); ?>
|
||||
<?php include('include/topbar.php'); ?>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<div class="page-content">
|
||||
|
||||
<!-- ===== FILTRO MESE ===== -->
|
||||
<div class="card radius-10">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center flex-wrap filter-bar">
|
||||
<h6 class="mb-0 me-3"><i class="bx bx-bar-chart-alt-2"></i> Statistiche Export LIMS — Mensili</h6>
|
||||
<div class="d-flex align-items-center">
|
||||
<label for="filterMonth" class="me-2 mb-0 fw-semibold">Mese:</label>
|
||||
<input type="month" id="filterMonth" class="form-control form-control-sm" style="width:auto;">
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm ms-2" role="group">
|
||||
<button class="btn btn-outline-secondary" id="btnCurrentMonth">Mese corrente</button>
|
||||
<button class="btn btn-outline-secondary" id="btnPrevMonth">Mese precedente</button>
|
||||
</div>
|
||||
<span class="month-label ms-2 text-primary" id="monthLabel">—</span>
|
||||
<a href="stats_export_lims.php" class="btn btn-outline-primary btn-sm ms-auto">
|
||||
<i class="bx bx-calendar"></i> Vista giornaliera
|
||||
</a>
|
||||
<button class="btn btn-primary btn-sm ms-2" id="btnRefresh">
|
||||
<i class="bx bx-refresh"></i> Aggiorna
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== KPI (una sola riga) ===== -->
|
||||
<div class="row row-cols-2 row-cols-md-3 row-cols-lg-5 g-3">
|
||||
<div class="col">
|
||||
<div class="card radius-10 stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">Campioni mese</div>
|
||||
<div class="stat-value text-primary" id="kpiParts">0</div>
|
||||
<small class="text-muted">Commesse: <span id="kpiExport">0</span></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card radius-10 stat-card stat-card-extra h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">Campioni anno <span id="yearLabel">—</span></div>
|
||||
<div class="stat-value" id="kpiPartsYear">0</div>
|
||||
<small class="text-muted">Commesse: <span id="kpiExportYear">0</span> · dal 16/07/2026</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card radius-10 stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">Campioni inseriti LIMS (mese)</div>
|
||||
<div class="stat-value text-info" id="kpiLimsSamples">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card radius-10 stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">% Esportati / inseriti</div>
|
||||
<div class="stat-value text-success" id="kpiPerc">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card radius-10 stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">Clienti distinti (mese)</div>
|
||||
<div class="stat-value text-warning" id="kpiClienti">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== GRAFICI ===== -->
|
||||
<div class="row mt-1">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card radius-10">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Campioni per cliente (idclient) <small class="text-muted">— top 15</small></h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-wrap"><canvas id="chartClienti"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card radius-10">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Campioni per utente (user_id) <small class="text-muted">— top 15</small></h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-wrap"><canvas id="chartUtenti"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== LISTE ===== -->
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card radius-10">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Dettaglio per cliente</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table id="tblClienti" class="table table-striped table-bordered table-sm w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID Cliente</th>
|
||||
<th>Cliente</th>
|
||||
<th>Campioni</th>
|
||||
<th>Commesse</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card radius-10">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Dettaglio per utente</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table id="tblUtenti" class="table table-striped table-bordered table-sm w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID Utente</th>
|
||||
<th>Utente</th>
|
||||
<th>Campioni</th>
|
||||
<th>Commesse</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overlay toggle-icon"></div>
|
||||
<a href="javaScript:;" class="back-to-top"><i class='bx bxs-up-arrow-alt'></i></a>
|
||||
<?php include('include/footer.php'); ?>
|
||||
</div>
|
||||
|
||||
<?php include('jsinclude.php'); ?>
|
||||
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
|
||||
<script>
|
||||
let chartClienti = null;
|
||||
let chartUtenti = null;
|
||||
let dtClienti = null;
|
||||
let dtUtenti = null;
|
||||
|
||||
const MESI_IT = ['Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno',
|
||||
'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'
|
||||
];
|
||||
|
||||
function currentMonthStr() {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
return `${y}-${m}`;
|
||||
}
|
||||
|
||||
function monthLabelFromStr(monthStr) {
|
||||
const parts = (monthStr || '').split('-');
|
||||
if (parts.length !== 2) return '—';
|
||||
const y = parseInt(parts[0], 10);
|
||||
const m = parseInt(parts[1], 10);
|
||||
if (isNaN(y) || isNaN(m) || m < 1 || m > 12) return '—';
|
||||
return `${MESI_IT[m - 1]} ${y}`;
|
||||
}
|
||||
|
||||
function initTables() {
|
||||
dtClienti = $('#tblClienti').DataTable({
|
||||
paging: false,
|
||||
searching: false,
|
||||
info: false,
|
||||
order: [
|
||||
[2, 'desc']
|
||||
],
|
||||
columns: [{
|
||||
data: 'idclient'
|
||||
},
|
||||
{
|
||||
data: 'clientname',
|
||||
defaultContent: '—'
|
||||
},
|
||||
{
|
||||
data: 'parts',
|
||||
className: 'text-end'
|
||||
},
|
||||
{
|
||||
data: 'tot',
|
||||
className: 'text-end'
|
||||
}
|
||||
],
|
||||
language: {
|
||||
emptyTable: 'Nessun export in questo mese'
|
||||
}
|
||||
});
|
||||
dtUtenti = $('#tblUtenti').DataTable({
|
||||
paging: false,
|
||||
searching: false,
|
||||
info: false,
|
||||
order: [
|
||||
[2, 'desc']
|
||||
],
|
||||
columns: [{
|
||||
data: 'user_id'
|
||||
},
|
||||
{
|
||||
data: 'username',
|
||||
defaultContent: '—'
|
||||
},
|
||||
{
|
||||
data: 'parts',
|
||||
className: 'text-end'
|
||||
},
|
||||
{
|
||||
data: 'tot',
|
||||
className: 'text-end'
|
||||
}
|
||||
],
|
||||
language: {
|
||||
emptyTable: 'Nessun export in questo mese'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderBarChart(existing, canvasId, labels, values, label) {
|
||||
if (existing) existing.destroy();
|
||||
const ctx = document.getElementById(canvasId).getContext('2d');
|
||||
return new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: label,
|
||||
data: values,
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
precision: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const CHART_TOP_N = 15;
|
||||
|
||||
function topN(arr) {
|
||||
return arr.slice(0, CHART_TOP_N);
|
||||
}
|
||||
|
||||
function loadStats() {
|
||||
const month = $('#filterMonth').val() || currentMonthStr();
|
||||
$('#monthLabel').text(monthLabelFromStr(month));
|
||||
|
||||
$.getJSON('stats_export_lims_monthly_data.php', {
|
||||
month: month
|
||||
}, function(res) {
|
||||
if (!res || !res.success) {
|
||||
Swal.fire('Errore', (res && res.message) || 'Errore nel caricamento', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// KPI mese — dato principale = campioni/parti, sotto le commesse
|
||||
$('#kpiParts').text(res.totals.parts ?? 0);
|
||||
$('#kpiExport').text(res.totals.export ?? 0);
|
||||
$('#kpiClienti').text(res.byClient.length);
|
||||
$('#kpiLimsSamples').text(res.limsSamples ?? 0);
|
||||
|
||||
const limsTot = parseInt(res.limsSamples ?? 0, 10);
|
||||
const partsTot = parseInt(res.totals.parts ?? 0, 10);
|
||||
if (limsTot > 0) {
|
||||
const perc = (partsTot / limsTot) * 100;
|
||||
$('#kpiPerc').text(perc.toFixed(1) + '%');
|
||||
} else {
|
||||
$('#kpiPerc').text('—');
|
||||
}
|
||||
|
||||
// Box annuale — campioni + commesse
|
||||
$('#kpiPartsYear').text(res.totalsYear ? (res.totalsYear.parts ?? 0) : 0);
|
||||
$('#kpiExportYear').text(res.totalsYear ? (res.totalsYear.export ?? 0) : 0);
|
||||
$('#yearLabel').text(res.year);
|
||||
|
||||
// Grafici — valori = campioni/parti
|
||||
const clientTop = topN(res.byClient);
|
||||
const userTop = topN(res.byUser);
|
||||
chartClienti = renderBarChart(chartClienti, 'chartClienti',
|
||||
clientTop.map(r => r.clientname ? (r.clientname + ' (' + r.idclient + ')') : ('ID ' + r.idclient)),
|
||||
clientTop.map(r => r.parts), 'Campioni');
|
||||
chartUtenti = renderBarChart(chartUtenti, 'chartUtenti',
|
||||
userTop.map(r => r.username ? (r.username + ' (' + r.user_id + ')') : ('ID ' + r.user_id)),
|
||||
userTop.map(r => r.parts), 'Campioni');
|
||||
|
||||
// Tabelle
|
||||
dtClienti.clear().rows.add(res.byClient).draw();
|
||||
dtUtenti.clear().rows.add(res.byUser).draw();
|
||||
}).fail(function(xhr) {
|
||||
Swal.fire('Errore', 'Richiesta fallita: ' + xhr.status, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
$('#filterMonth').val(currentMonthStr());
|
||||
initTables();
|
||||
loadStats();
|
||||
|
||||
$('#btnRefresh').on('click', loadStats);
|
||||
$('#filterMonth').on('change', loadStats);
|
||||
|
||||
$('#btnCurrentMonth').on('click', function() {
|
||||
$('#filterMonth').val(currentMonthStr());
|
||||
loadStats();
|
||||
});
|
||||
$('#btnPrevMonth').on('click', function() {
|
||||
const cur = $('#filterMonth').val() || currentMonthStr();
|
||||
const parts = cur.split('-');
|
||||
let y = parseInt(parts[0], 10);
|
||||
let m = parseInt(parts[1], 10);
|
||||
m -= 1;
|
||||
if (m < 1) {
|
||||
m = 12;
|
||||
y -= 1;
|
||||
}
|
||||
$('#filterMonth').val(`${y}-${String(m).padStart(2, '0')}`);
|
||||
loadStats();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
include('include/headscript.php');
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$dbHandler = DBHandlerSelect::getInstance();
|
||||
$pdo = $dbHandler->getConnection();
|
||||
|
||||
try {
|
||||
// Mese richiesto (default mese corrente). Formato atteso: Y-m
|
||||
$month = $_GET['month'] ?? date('Y-m');
|
||||
$dm = DateTime::createFromFormat('Y-m', $month);
|
||||
if (!$dm || $dm->format('Y-m') !== $month) {
|
||||
throw new Exception('Mese non valido');
|
||||
}
|
||||
|
||||
$year = $dm->format('Y');
|
||||
|
||||
// --- Export per cliente (status = 'l', filtro mese) ---
|
||||
// tot = numero commesse, parts = numero parti/campioni
|
||||
$stmtC = $pdo->prepare("
|
||||
SELECT d.idclient AS idclient,
|
||||
COUNT(DISTINCT d.iddatadb) AS tot,
|
||||
COUNT(ip.id) AS parts
|
||||
FROM datadb d
|
||||
LEFT JOIN identification_parts ip ON ip.iddatadb = d.iddatadb
|
||||
WHERE d.status = 'l'
|
||||
AND DATE_FORMAT(d.export_date, '%Y-%m') = :m
|
||||
GROUP BY d.idclient
|
||||
ORDER BY parts DESC
|
||||
");
|
||||
$stmtC->execute(['m' => $month]);
|
||||
$byClient = $stmtC->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// --- Risolvi Nominativo cliente dalla cache locale ---
|
||||
$clientNames = [];
|
||||
$cacheFile = __DIR__ . '/cache/clienti.json';
|
||||
if (is_file($cacheFile)) {
|
||||
$cache = json_decode(file_get_contents($cacheFile), true);
|
||||
foreach (($cache['value'] ?? []) as $c) {
|
||||
$clientNames[(int)($c['IdCliente'] ?? 0)] = trim($c['Nominativo'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Export per utente (status = 'l', filtro mese) ---
|
||||
// tot = numero commesse, parts = numero parti/campioni
|
||||
$stmtU = $pdo->prepare("
|
||||
SELECT d.user_id AS user_id,
|
||||
TRIM(CONCAT(COALESCE(u.first_name,''),' ',COALESCE(u.last_name,''))) AS username,
|
||||
COUNT(DISTINCT d.iddatadb) AS tot,
|
||||
COUNT(ip.id) AS parts
|
||||
FROM datadb d
|
||||
LEFT JOIN auth_users u ON u.id = d.user_id
|
||||
LEFT JOIN identification_parts ip ON ip.iddatadb = d.iddatadb
|
||||
WHERE d.status = 'l'
|
||||
AND DATE_FORMAT(d.export_date, '%Y-%m') = :m
|
||||
GROUP BY d.user_id, username
|
||||
ORDER BY parts DESC
|
||||
");
|
||||
$stmtU->execute(['m' => $month]);
|
||||
$byUser = $stmtU->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Normalizza tipi
|
||||
foreach ($byClient as &$r) {
|
||||
$r['idclient'] = $r['idclient'] !== null ? (int)$r['idclient'] : null;
|
||||
$r['tot'] = (int)$r['tot'];
|
||||
$r['parts'] = (int)$r['parts'];
|
||||
$nm = $clientNames[$r['idclient']] ?? '';
|
||||
$r['clientname'] = $nm !== '' ? $nm : null;
|
||||
}
|
||||
unset($r);
|
||||
foreach ($byUser as &$r) {
|
||||
$r['user_id'] = $r['user_id'] !== null ? (int)$r['user_id'] : null;
|
||||
$r['tot'] = (int)$r['tot'];
|
||||
$r['parts'] = (int)$r['parts'];
|
||||
if (isset($r['username'])) $r['username'] = $r['username'] !== null && $r['username'] !== '' ? $r['username'] : null;
|
||||
}
|
||||
unset($r);
|
||||
|
||||
// --- Totale COMMESSE del mese ---
|
||||
$totExport = array_sum(array_column($byClient, 'tot'));
|
||||
|
||||
// --- Totale PARTI/CAMPIONI del mese (status = 'l') ---
|
||||
$stmtP = $pdo->prepare("
|
||||
SELECT COUNT(*)
|
||||
FROM identification_parts ip
|
||||
INNER JOIN datadb d ON d.iddatadb = ip.iddatadb
|
||||
WHERE d.status = 'l'
|
||||
AND DATE_FORMAT(d.export_date, '%Y-%m') = :m
|
||||
");
|
||||
$stmtP->execute(['m' => $month]);
|
||||
$totParts = (int)$stmtP->fetchColumn();
|
||||
|
||||
// --- Totale COMMESSE dell'ANNO del mese selezionato ---
|
||||
$stmtY = $pdo->prepare("
|
||||
SELECT COUNT(*)
|
||||
FROM datadb d
|
||||
WHERE d.status = 'l'
|
||||
AND YEAR(d.export_date) = :y
|
||||
");
|
||||
$stmtY->execute(['y' => $year]);
|
||||
$totExportYear = (int)$stmtY->fetchColumn();
|
||||
|
||||
// --- Totale PARTI dell'ANNO del mese selezionato ---
|
||||
$stmtYP = $pdo->prepare("
|
||||
SELECT COUNT(*)
|
||||
FROM identification_parts ip
|
||||
INNER JOIN datadb d ON d.iddatadb = ip.iddatadb
|
||||
WHERE d.status = 'l'
|
||||
AND YEAR(d.export_date) = :y
|
||||
");
|
||||
$stmtYP->execute(['y' => $year]);
|
||||
$totPartsYear = (int)$stmtYP->fetchColumn();
|
||||
|
||||
// --- Campioni LIMS: somma dei total_samples di tutti i giorni del mese ---
|
||||
$stmtL = $pdo->prepare("
|
||||
SELECT COALESCE(SUM(total_samples), 0)
|
||||
FROM lims_daily_samples
|
||||
WHERE DATE_FORMAT(sample_date, '%Y-%m') = :m
|
||||
");
|
||||
$stmtL->execute(['m' => $month]);
|
||||
$limsSamples = (int)$stmtL->fetchColumn();
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'month' => $month,
|
||||
'year' => (int)$year,
|
||||
'totals' => ['export' => (int)$totExport, 'parts' => $totParts],
|
||||
'totalsYear' => ['export' => $totExportYear, 'parts' => $totPartsYear],
|
||||
'byClient' => $byClient,
|
||||
'byUser' => $byUser,
|
||||
'limsSamples' => $limsSamples,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
error_log('Stats export LIMS monthly error: ' . $e->getMessage());
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
@@ -78,6 +78,11 @@
|
||||
color: #198754;
|
||||
}
|
||||
|
||||
.badge-source-pdf {
|
||||
background-color: #ffeaea;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
#xlsTemplatesTable {
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -245,6 +250,9 @@
|
||||
if (sourceType === 'API') {
|
||||
return '<span class="badge-source badge-source-api">API</span>';
|
||||
}
|
||||
if (sourceType === 'PDF') {
|
||||
return '<span class="badge-source badge-source-pdf">PDF</span>';
|
||||
}
|
||||
return '<span class="badge-source badge-source-xls">XLS</span>';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
/*
|
||||
* test_pdf_extract.php
|
||||
* ------------------------------------------------------------------
|
||||
* Pagina di test ISOLATA per verificare l'estrazione PDF.
|
||||
* Da aprire nel browser passando ?id=<template_id> di un template PDF
|
||||
* che ha già i riquadri tracciati e un sample_pdf caricato.
|
||||
*
|
||||
* http://localhost/trf_certest/test_pdf_extract.php?id=67
|
||||
*
|
||||
* Mostra, per ogni campo mappato con pdf_regions, il valore estratto dal
|
||||
* PDF di esempio. Serve a confermare che i riquadri prendano i dati giusti
|
||||
* PRIMA di collegare l'inserimento in datadb.
|
||||
*
|
||||
* DA CANCELLARE dopo il test.
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
include('include/headscript.php');
|
||||
require_once __DIR__ . '/pdf_extract_lib.php';
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
if ($id <= 0) {
|
||||
die('Passa ?id=<template_id> di un template PDF.');
|
||||
}
|
||||
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
// Template
|
||||
$stmt = $pdo->prepare("SELECT id, name, source_type, sample_pdf FROM excel_templates WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$template = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$template) {
|
||||
die('Template non trovato.');
|
||||
}
|
||||
|
||||
if (strtoupper($template['source_type']) !== 'PDF') {
|
||||
die('Questo template non è di tipo PDF (source_type = ' . htmlspecialchars($template['source_type']) . ').');
|
||||
}
|
||||
|
||||
if (empty($template['sample_pdf'])) {
|
||||
die('Nessun PDF di esempio caricato per questo template.');
|
||||
}
|
||||
|
||||
// Percorso del PDF di esempio (adatta se la cartella è diversa)
|
||||
$pdfPath = __DIR__ . '/pdftemplates/' . $template['sample_pdf'];
|
||||
|
||||
// Mapping con eventuali riquadri
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT id, field_label, data_type, pdf_regions
|
||||
FROM template_mapping
|
||||
WHERE template_id = ?
|
||||
ORDER BY field_order ASC, id ASC
|
||||
");
|
||||
$stmt->execute([$id]);
|
||||
$mappings = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo "<h2>Test estrazione PDF — Template: " . htmlspecialchars($template['name']) . "</h2>";
|
||||
echo "<p><strong>PDF:</strong> " . htmlspecialchars($template['sample_pdf']) . "<br>";
|
||||
echo "<strong>Percorso:</strong> " . htmlspecialchars($pdfPath) . "</p>";
|
||||
|
||||
if (!is_file($pdfPath)) {
|
||||
die('<p style="color:red">❌ File PDF non trovato al percorso indicato. Correggi $pdfPath nel test.</p>');
|
||||
}
|
||||
|
||||
echo "<p><strong>Binario pdftotext:</strong> " . htmlspecialchars(pdftotext_binary()) . "</p>";
|
||||
|
||||
try {
|
||||
$values = pdf_extract_values($pdfPath, $mappings);
|
||||
} catch (Throwable $e) {
|
||||
die('<p style="color:red">❌ Errore estrazione: ' . htmlspecialchars($e->getMessage()) . '</p>');
|
||||
}
|
||||
|
||||
echo '<table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;font-family:sans-serif;font-size:14px;">';
|
||||
echo '<tr style="background:#f0f0f0;"><th>ID</th><th>Campo</th><th>Tipo</th><th>Pagina</th><th>Riquadro value</th><th>VALORE ESTRATTO</th></tr>';
|
||||
|
||||
foreach ($mappings as $m) {
|
||||
$hasRegions = !empty($m['pdf_regions']);
|
||||
$regions = $hasRegions ? json_decode($m['pdf_regions'], true) : null;
|
||||
|
||||
$page = $regions['page'] ?? '';
|
||||
$valueRect = isset($regions['value'])
|
||||
? sprintf(
|
||||
'x=%.0f y=%.0f w=%.0f h=%.0f',
|
||||
$regions['value']['x'],
|
||||
$regions['value']['y'],
|
||||
$regions['value']['w'],
|
||||
$regions['value']['h']
|
||||
)
|
||||
: '—';
|
||||
|
||||
$extracted = $values[(int)$m['id']] ?? '';
|
||||
$rowBg = $hasRegions ? ($extracted !== '' ? '#eaffea' : '#fff3cd') : '#ffffff';
|
||||
|
||||
echo '<tr style="background:' . $rowBg . ';">';
|
||||
echo '<td>' . (int)$m['id'] . '</td>';
|
||||
echo '<td>' . htmlspecialchars($m['field_label'] ?? '') . '</td>';
|
||||
echo '<td>' . htmlspecialchars($m['data_type'] ?? '') . '</td>';
|
||||
echo '<td style="text-align:center;">' . htmlspecialchars((string)$page) . '</td>';
|
||||
echo '<td style="font-family:monospace;font-size:12px;">' . htmlspecialchars($valueRect) . '</td>';
|
||||
echo '<td><strong>' . htmlspecialchars($extracted) . '</strong></td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
|
||||
echo '</table>';
|
||||
|
||||
echo '<p style="color:#666;margin-top:15px;">Verde = valore estratto. Giallo = riquadro presente ma nessun testo trovato. Bianco = campo senza riquadro PDF.</p>';
|
||||
echo '<p style="color:#a00;"><strong>Ricorda di cancellare questo file dopo il test.</strong></p>';
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
echo "<pre>";
|
||||
|
||||
echo "== SO ==\n";
|
||||
echo PHP_OS . "\n\n";
|
||||
|
||||
echo "== disable_functions ==\n";
|
||||
echo ini_get('disable_functions') ?: "(nessuna)";
|
||||
echo "\n\n";
|
||||
|
||||
echo "== test exec con percorso completo ==\n";
|
||||
if (function_exists('exec')) {
|
||||
$cmd = '"C:\\poppler\\poppler-26.02.0\\Library\\bin\\pdftotext.exe" -v 2>&1';
|
||||
exec($cmd, $out, $ret);
|
||||
echo "return code: $ret\n";
|
||||
echo implode("\n", $out);
|
||||
} else {
|
||||
echo "exec() NON disponibile";
|
||||
}
|
||||
|
||||
echo "\n</pre>";
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
// update_pdf_page_meta.php
|
||||
// Salva i metadati delle pagine del PDF (numero pagine e dimensioni renderizzate)
|
||||
// nella colonna pdf_page_meta del template.
|
||||
//
|
||||
// Riceve JSON: { template_id, pdf_page_meta }
|
||||
// dove pdf_page_meta è una stringa JSON tipo:
|
||||
// {"pages":3,"dims":[{"page":1,"width":1000,"height":1414}, ...]}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
include('include/headscript.php');
|
||||
|
||||
function respond(bool $success, string $message = ''): void
|
||||
{
|
||||
echo json_encode(['success' => $success, 'message' => $message]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
respond(false, 'Invalid JSON payload.');
|
||||
}
|
||||
|
||||
$templateId = isset($data['template_id']) ? (int)$data['template_id'] : 0;
|
||||
$pageMeta = $data['pdf_page_meta'] ?? null;
|
||||
|
||||
if ($templateId <= 0) {
|
||||
respond(false, 'Missing or invalid template_id.');
|
||||
}
|
||||
|
||||
// pdf_page_meta deve essere una stringa JSON valida
|
||||
if (!is_string($pageMeta)) {
|
||||
respond(false, 'pdf_page_meta must be a JSON string.');
|
||||
}
|
||||
|
||||
$decoded = json_decode($pageMeta, true);
|
||||
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
|
||||
respond(false, 'pdf_page_meta is not valid JSON: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
try {
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE excel_templates SET pdf_page_meta = :meta WHERE id = :id");
|
||||
$stmt->execute([
|
||||
':meta' => $pageMeta,
|
||||
':id' => $templateId,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
respond(false, 'Database update failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
respond(true, 'Page metadata saved.');
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
// upload_pdf_example.php
|
||||
// Riceve un PDF di esempio via POST (multipart), lo salva in pdftemplates/
|
||||
// e aggiorna la colonna sample_pdf del template.
|
||||
//
|
||||
// Rispecchia la logica di upload_xls_example.php ma per i PDF.
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
include('include/headscript.php');
|
||||
|
||||
// --- Helper risposta JSON ---
|
||||
function respond(bool $success, string $message = '', array $extra = []): void
|
||||
{
|
||||
echo json_encode(array_merge([
|
||||
'success' => $success,
|
||||
'message' => $message,
|
||||
], $extra));
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Validazione input ---
|
||||
$templateId = isset($_POST['template_id']) ? (int)$_POST['template_id'] : 0;
|
||||
|
||||
if ($templateId <= 0) {
|
||||
respond(false, 'Missing or invalid template_id.');
|
||||
}
|
||||
|
||||
if (!isset($_FILES['pdf_file']) || $_FILES['pdf_file']['error'] !== UPLOAD_ERR_OK) {
|
||||
respond(false, 'No file uploaded or upload error.');
|
||||
}
|
||||
|
||||
$file = $_FILES['pdf_file'];
|
||||
|
||||
// --- Controllo estensione e MIME ---
|
||||
$originalName = $file['name'];
|
||||
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
|
||||
if ($ext !== 'pdf') {
|
||||
respond(false, 'Only PDF files are allowed.');
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if ($mime !== 'application/pdf') {
|
||||
respond(false, 'The uploaded file is not a valid PDF (mime: ' . htmlspecialchars($mime) . ').');
|
||||
}
|
||||
|
||||
// --- Cartella di destinazione ---
|
||||
$targetDir = __DIR__ . '/pdftemplates';
|
||||
|
||||
if (!is_dir($targetDir)) {
|
||||
if (!mkdir($targetDir, 0775, true) && !is_dir($targetDir)) {
|
||||
respond(false, 'Cannot create destination folder pdftemplates/.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_writable($targetDir)) {
|
||||
respond(false, 'Destination folder pdftemplates/ is not writable.');
|
||||
}
|
||||
|
||||
// --- Nome file sicuro e univoco ---
|
||||
$safeBase = preg_replace('/[^A-Za-z0-9_\-]/', '_', pathinfo($originalName, PATHINFO_FILENAME));
|
||||
$safeBase = trim($safeBase, '_');
|
||||
if ($safeBase === '') {
|
||||
$safeBase = 'sample';
|
||||
}
|
||||
|
||||
$filename = 'tpl' . $templateId . '_' . $safeBase . '_' . date('YmdHis') . '.pdf';
|
||||
$targetPath = $targetDir . '/' . $filename;
|
||||
|
||||
// --- Sposta il file ---
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
||||
respond(false, 'Failed to move uploaded file.');
|
||||
}
|
||||
|
||||
// --- Aggiorna il database ---
|
||||
try {
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE excel_templates SET sample_pdf = :sample_pdf WHERE id = :id");
|
||||
$stmt->execute([
|
||||
':sample_pdf' => $filename,
|
||||
':id' => $templateId,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// Se il DB fallisce, rimuovo il file per non lasciare orfani
|
||||
@unlink($targetPath);
|
||||
respond(false, 'Database update failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
respond(true, 'File uploaded successfully.', ['filename' => $filename]);
|
||||
@@ -217,6 +217,10 @@ try {
|
||||
}
|
||||
|
||||
// Required custom fields that are visible in the import grid (excluding filed_id = 189 Tested component)
|
||||
// Required custom fields that are visible in the import grid (excluding filed_id = 189 Tested component)
|
||||
// Also exclude field_id 258 (Login Date) and 350 (Login Time): auto-populated at
|
||||
// export time (save_export_lims.php STEP 4a), so they must not block validation
|
||||
// even when empty at this stage.
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT id AS mapping_id, template_id, field_label
|
||||
FROM template_mapping
|
||||
@@ -224,6 +228,7 @@ try {
|
||||
AND is_required = 1
|
||||
AND is_visible_import = 1
|
||||
AND id <> 189
|
||||
AND field_id NOT IN (258, 350)
|
||||
");
|
||||
$stmt->execute($templateIds);
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
|
||||
$startTime = microtime(true);
|
||||
$isCli = (php_sapi_name() === 'cli');
|
||||
$isStream = (!$isCli && isset($_GET['stream']));
|
||||
|
||||
// Analisi incluse? Default SÌ (il cron non passa il parametro → fa tutto).
|
||||
// Solo una richiesta HTTP con analisi=0 le salta.
|
||||
$includeAnalisi = !(!$isCli && isset($_GET['analisi']) && $_GET['analisi'] === '0');
|
||||
|
||||
if (!$isCli) {
|
||||
// When called via HTTP, require auth
|
||||
@@ -15,7 +20,17 @@ if (!$isCli) {
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($isStream) {
|
||||
// Streaming mode: send progress line-by-line (Server-Sent Events)
|
||||
set_time_limit(0);
|
||||
header('Content-Type: text/event-stream');
|
||||
header('Cache-Control: no-cache');
|
||||
header('X-Accel-Buffering: no'); // disable nginx buffering
|
||||
while (ob_get_level() > 0) ob_end_flush();
|
||||
} else {
|
||||
header('Content-Type: application/json');
|
||||
}
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
@@ -23,15 +38,38 @@ require_once __DIR__ . '/class/VisualLimsApiClient.class.php';
|
||||
require_once __DIR__ . '/class/db-functions.php';
|
||||
|
||||
$cacheDir = __DIR__ . '/cache';
|
||||
if (!is_dir($cacheDir)) mkdir($cacheDir, 0777, true);
|
||||
if (!is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
|
||||
|
||||
$log = [];
|
||||
|
||||
// Prevent two runs at the same time (e.g. cron + manual click)
|
||||
$lockHandle = fopen($cacheDir . '/.warm.lock', 'w');
|
||||
if ($lockHandle === false || !flock($lockHandle, LOCK_EX | LOCK_NB)) {
|
||||
$busyMsg = 'Un aggiornamento è già in corso in questo momento. Riprova tra poco.';
|
||||
if ($isCli) {
|
||||
echo $busyMsg . PHP_EOL;
|
||||
} elseif ($isStream) {
|
||||
header('Content-Type: text/event-stream');
|
||||
echo "event: busy\ndata: " . $busyMsg . "\n\n";
|
||||
@flush();
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'busy' => true, 'message' => $busyMsg]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
function warmLog(string $msg, bool $isCli)
|
||||
{
|
||||
global $log;
|
||||
global $log, $isStream;
|
||||
$line = date('H:i:s') . " $msg";
|
||||
$log[] = $line;
|
||||
if ($isCli) echo $line . PHP_EOL;
|
||||
if ($isCli) {
|
||||
echo $line . PHP_EOL;
|
||||
} elseif (!empty($isStream)) {
|
||||
echo "data: " . str_replace("\n", ' ', $line) . "\n\n";
|
||||
@ob_flush();
|
||||
@flush();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -115,30 +153,38 @@ try {
|
||||
|
||||
// 6. Analisi — only for matrici actually used by parts (warming all ~2500 matrici
|
||||
// would mean one API call each; the used set is a few dozen).
|
||||
$stmt = $pdo->query("SELECT DISTINCT idmatrice FROM identification_parts WHERE idmatrice IS NOT NULL AND idmatrice > 0 ORDER BY idmatrice ASC");
|
||||
$matriceIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
warmLog("[analisi] Fetching for " . count($matriceIds) . " matrici in use...", $isCli);
|
||||
if (!$includeAnalisi) {
|
||||
warmLog('[analisi] Saltate su richiesta.', $isCli);
|
||||
} else {
|
||||
$stmt = $pdo->query("SELECT DISTINCT idmatrice FROM identification_parts WHERE idmatrice IS NOT NULL AND idmatrice > 0 ORDER BY idmatrice ASC");
|
||||
$matriceIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
warmLog("[analisi] Fetching for " . count($matriceIds) . " matrici in use...", $isCli);
|
||||
|
||||
foreach ($matriceIds as $mid) {
|
||||
$mid = (int)$mid;
|
||||
// Same query as get_analisi_matrice_filter.php with web_only=1 (what the analysis modal requests)
|
||||
$filter = rawurlencode("Matrice/IdMatrice eq $mid and SelezionabileSuWeb eq true");
|
||||
try {
|
||||
$data = $api->get("Analisi?\$filter={$filter}");
|
||||
$values = $data['value'] ?? [];
|
||||
file_put_contents($cacheDir . '/analisi_matrice_' . $mid . '.json', json_encode(['value' => $values]));
|
||||
warmLog("[analisi] Matrice $mid: " . count($values) . " analisi", $isCli);
|
||||
} catch (Exception $e) {
|
||||
warmLog("[analisi] Matrice $mid: ERROR " . $e->getMessage(), $isCli);
|
||||
foreach ($matriceIds as $mid) {
|
||||
$mid = (int)$mid;
|
||||
// Same query as get_analisi_matrice_filter.php with web_only=1 (what the analysis modal requests)
|
||||
$filter = rawurlencode("Matrice/IdMatrice eq $mid and SelezionabileSuWeb eq true");
|
||||
try {
|
||||
$data = $api->get("Analisi?\$filter={$filter}");
|
||||
$values = $data['value'] ?? [];
|
||||
file_put_contents($cacheDir . '/analisi_matrice_' . $mid . '.json', json_encode(['value' => $values]));
|
||||
warmLog("[analisi] Matrice $mid: " . count($values) . " analisi", $isCli);
|
||||
} catch (Exception $e) {
|
||||
warmLog("[analisi] Matrice $mid: ERROR " . $e->getMessage(), $isCli);
|
||||
}
|
||||
}
|
||||
}
|
||||
} // fine if includeAnalisi
|
||||
|
||||
$elapsed = round(microtime(true) - $startTime, 1);
|
||||
warmLog("Done in {$elapsed}s", $isCli);
|
||||
warmLog("Aggiornamento completato in {$elapsed}s", $isCli);
|
||||
} catch (Exception $e) {
|
||||
warmLog("FATAL: " . $e->getMessage(), $isCli);
|
||||
$elapsed = round(microtime(true) - $startTime, 1);
|
||||
warmLog("ERRORE: " . $e->getMessage(), $isCli);
|
||||
}
|
||||
|
||||
if (!$isCli) {
|
||||
if ($isStream) {
|
||||
echo "event: done\ndata: " . json_encode(['elapsed' => $elapsed ?? 0]) . "\n\n";
|
||||
@flush();
|
||||
} elseif (!$isCli) {
|
||||
echo json_encode(['success' => true, 'log' => $log]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
<?php
|
||||
include('include/headscript.php');
|
||||
|
||||
/*
|
||||
* NOTE PERMESSI:
|
||||
* Il login è già gestito da headscript.php (Auth::check + redirect).
|
||||
* Se vuoi limitare questa pagina a determinati ruoli, aggiungi qui il tuo
|
||||
* controllo, esattamente come nelle altre pagine. Esempio:
|
||||
*
|
||||
* if (!$user->hasRole('Admin') && !$user->hasRole('SuperUser')) {
|
||||
* header('Location: import_dashboard.php');
|
||||
* exit;
|
||||
* }
|
||||
*/
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
|
||||
<?php include('cssinclude.php'); ?>
|
||||
|
||||
<title>Aggiornamento dati - <?= htmlspecialchars($titlewebsite ?? 'SmartTRF', ENT_QUOTES, 'UTF-8'); ?></title>
|
||||
|
||||
<style>
|
||||
.warm-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.warm-hero .warm-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #0f172a, #1d4ed8);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.warm-progress-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.warm-progress-wrap.is-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.progress.warm-progress {
|
||||
height: 22px;
|
||||
border-radius: 12px;
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.progress.warm-progress .progress-bar {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warm-steps {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 18px 0 0 0;
|
||||
}
|
||||
|
||||
.warm-steps li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 6px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #eef2f7;
|
||||
transition: background .2s, border-color .2s;
|
||||
}
|
||||
|
||||
.warm-steps li .step-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
background: #e5e7eb;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.warm-steps li.is-active {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.warm-steps li.is-active .step-icon {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.warm-steps li.is-done {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
.warm-steps li.is-done .step-icon {
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.warm-steps li .step-label {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.warm-steps li .step-detail {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.warm-log {
|
||||
display: none;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.warm-log.is-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.warm-log pre {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
background: #0f172a;
|
||||
color: #cbd5e1;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.warm-result {
|
||||
display: none;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.warm-result.is-visible {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<?php include('include/navbar.php'); ?>
|
||||
<?php include('include/topbar.php'); ?>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<div class="page-content">
|
||||
|
||||
<div class="card radius-10">
|
||||
<div class="card-body p-4">
|
||||
|
||||
<div class="warm-hero">
|
||||
<div class="warm-icon"><i class="bx bx-refresh"></i></div>
|
||||
<div>
|
||||
<h5 class="mb-1">Aggiornamento dati dal gestionale</h5>
|
||||
<p class="mb-0 text-muted">
|
||||
Ricarica clienti, matrici, analisi e le altre liste dal software di laboratorio.
|
||||
Da usare quando sono state fatte modifiche sul gestionale e vuoi vederle subito
|
||||
senza aspettare l'aggiornamento automatico.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="form-check form-switch mb-3" id="analisiSwitchWrap">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="includeAnalisi" checked>
|
||||
<label class="form-check-label" for="includeAnalisi">
|
||||
Aggiorna anche le <strong>analisi</strong>
|
||||
<span class="text-muted small">(più lento — togli la spunta se hai modificato solo clienti o altre liste)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-primary btn-lg px-4" id="btnStart">
|
||||
<i class="bx bx-play-circle me-1"></i> Avvia aggiornamento
|
||||
</button>
|
||||
<div class="small text-muted mt-2" id="idleHint">
|
||||
L'operazione può richiedere da qualche secondo a un paio di minuti.
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="warm-progress-wrap mt-4" id="progressWrap">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="fw-semibold" id="progressTitle">Aggiornamento in corso…</span>
|
||||
<span class="text-muted small" id="progressElapsed">0s</span>
|
||||
</div>
|
||||
|
||||
<div class="progress warm-progress">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated bg-primary"
|
||||
id="progressBar" role="progressbar" style="width: 0%;">0%</div>
|
||||
</div>
|
||||
|
||||
<ul class="warm-steps" id="stepsList">
|
||||
<!-- steps injected by JS -->
|
||||
</ul>
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="javascript:;" class="small text-decoration-none" id="toggleLog">
|
||||
<i class="bx bx-code-alt"></i> Mostra dettaglio tecnico
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="warm-log" id="logBox">
|
||||
<pre id="logPre"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<div class="warm-result" id="resultBox">
|
||||
<div class="alert d-flex align-items-center" id="resultAlert" role="alert">
|
||||
<i class="bx me-2 fs-4" id="resultIcon"></i>
|
||||
<div id="resultText"></div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary" id="btnAgain">
|
||||
<i class="bx bx-refresh me-1"></i> Esegui di nuovo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overlay toggle-icon"></div>
|
||||
<a href="javaScript:;" class="back-to-top"><i class='bx bxs-up-arrow-alt'></i></a>
|
||||
<?php include('include/footer.php'); ?>
|
||||
</div>
|
||||
|
||||
<?php include('jsinclude.php'); ?>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
/*
|
||||
* Se sposti warm_cache.php in un'altra cartella, cambia SOLO questa riga.
|
||||
*/
|
||||
const WARM_ENDPOINT = 'warm_cache.php';
|
||||
|
||||
const btnStart = document.getElementById('btnStart');
|
||||
const btnAgain = document.getElementById('btnAgain');
|
||||
const idleHint = document.getElementById('idleHint');
|
||||
const includeAnalisi = document.getElementById('includeAnalisi');
|
||||
const progressWrap = document.getElementById('progressWrap');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressTitle = document.getElementById('progressTitle');
|
||||
const progressElapsed = document.getElementById('progressElapsed');
|
||||
const stepsList = document.getElementById('stepsList');
|
||||
const toggleLog = document.getElementById('toggleLog');
|
||||
const logBox = document.getElementById('logBox');
|
||||
const logPre = document.getElementById('logPre');
|
||||
const resultBox = document.getElementById('resultBox');
|
||||
const resultAlert = document.getElementById('resultAlert');
|
||||
const resultIcon = document.getElementById('resultIcon');
|
||||
const resultText = document.getElementById('resultText');
|
||||
|
||||
/*
|
||||
* I "passi" mostrati al cliente in modo leggibile.
|
||||
* key = frammento che compare nel log tecnico (dentro le parentesi quadre)
|
||||
* label = nome umano
|
||||
* Il progresso avanza man mano che questi tag compaiono nel log.
|
||||
*/
|
||||
const STEPS = [{
|
||||
key: 'clients',
|
||||
label: 'Clienti'
|
||||
},
|
||||
{
|
||||
key: 'MoltiplicatorePrezzo',
|
||||
label: 'Listini prezzi'
|
||||
},
|
||||
{
|
||||
key: 'AnagraficaCertest',
|
||||
label: 'Anagrafiche Certest'
|
||||
},
|
||||
{
|
||||
key: 'ClienteResponsabile',
|
||||
label: 'Responsabili cliente'
|
||||
},
|
||||
{
|
||||
key: 'CustomField',
|
||||
label: 'Campi personalizzati'
|
||||
},
|
||||
{
|
||||
key: 'matrici',
|
||||
label: 'Matrici'
|
||||
},
|
||||
{
|
||||
key: 'analisi',
|
||||
label: 'Analisi'
|
||||
}
|
||||
];
|
||||
|
||||
let stepEls = {};
|
||||
let currentStepIndex = -1;
|
||||
let timerId = null;
|
||||
let startedAt = 0;
|
||||
let activeSteps = []; // i passi effettivamente in gioco per questa esecuzione
|
||||
|
||||
function buildSteps() {
|
||||
stepsList.innerHTML = '';
|
||||
stepEls = {};
|
||||
// Se le analisi sono disattivate, escludo quel passo dalla lista.
|
||||
const withAnalisi = includeAnalisi.checked;
|
||||
activeSteps = STEPS.filter(function(s) {
|
||||
return withAnalisi || s.key !== 'analisi';
|
||||
});
|
||||
activeSteps.forEach(function(step, i) {
|
||||
const li = document.createElement('li');
|
||||
li.dataset.index = i;
|
||||
li.innerHTML =
|
||||
'<span class="step-icon"><i class="bx bx-time"></i></span>' +
|
||||
'<span class="step-label">' + step.label + '</span>' +
|
||||
'<span class="step-detail"></span>';
|
||||
stepsList.appendChild(li);
|
||||
stepEls[i] = li;
|
||||
});
|
||||
}
|
||||
|
||||
function markStep(index, state, detail) {
|
||||
const li = stepEls[index];
|
||||
if (!li) return;
|
||||
li.classList.remove('is-active', 'is-done');
|
||||
const icon = li.querySelector('.step-icon i');
|
||||
if (state === 'active') {
|
||||
li.classList.add('is-active');
|
||||
icon.className = 'bx bx-loader-alt bx-spin';
|
||||
} else if (state === 'done') {
|
||||
li.classList.add('is-done');
|
||||
icon.className = 'bx bx-check';
|
||||
}
|
||||
if (detail !== undefined) {
|
||||
li.querySelector('.step-detail').textContent = detail;
|
||||
}
|
||||
}
|
||||
|
||||
function advanceTo(index, detail) {
|
||||
if (index <= currentStepIndex) {
|
||||
// stesso passo, aggiorna solo il dettaglio (es. conteggio)
|
||||
if (detail !== undefined) markStep(index, 'active', detail);
|
||||
return;
|
||||
}
|
||||
// chiudi i passi precedenti
|
||||
for (let i = 0; i <= currentStepIndex; i++) markStep(i, 'done');
|
||||
currentStepIndex = index;
|
||||
markStep(index, 'active', detail);
|
||||
const pct = Math.round(((index) / activeSteps.length) * 100);
|
||||
setProgress(Math.max(pct, 3));
|
||||
}
|
||||
|
||||
function setProgress(pct) {
|
||||
pct = Math.max(0, Math.min(100, pct));
|
||||
progressBar.style.width = pct + '%';
|
||||
progressBar.textContent = pct + '%';
|
||||
}
|
||||
|
||||
function matchStep(line) {
|
||||
// cerca [clients], [matrici], [analisi] ecc. nella riga di log
|
||||
for (let i = 0; i < activeSteps.length; i++) {
|
||||
if (line.indexOf('[' + activeSteps[i].key) !== -1 ||
|
||||
line.indexOf(activeSteps[i].key + ']') !== -1) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function extractDetail(line) {
|
||||
// prova a estrarre un conteggio tipo "Cached 128 clients" o "34 analisi"
|
||||
const m = line.match(/(\d+)\s+(clients|items|values|responsabili|analisi)/i);
|
||||
if (m) return m[1] + ' elementi';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
startedAt = Date.now();
|
||||
timerId = setInterval(function() {
|
||||
const s = Math.round((Date.now() - startedAt) / 1000);
|
||||
progressElapsed.textContent = s + 's';
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timerId) clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
|
||||
function resetUI() {
|
||||
buildSteps();
|
||||
currentStepIndex = -1;
|
||||
setProgress(0);
|
||||
logPre.textContent = '';
|
||||
progressTitle.textContent = 'Aggiornamento in corso…';
|
||||
progressElapsed.textContent = '0s';
|
||||
resultBox.classList.remove('is-visible');
|
||||
progressWrap.classList.add('is-visible');
|
||||
}
|
||||
|
||||
function showResult(type, message) {
|
||||
stopTimer();
|
||||
progressBar.classList.remove('progress-bar-animated', 'progress-bar-striped');
|
||||
|
||||
resultAlert.className = 'alert d-flex align-items-center';
|
||||
if (type === 'success') {
|
||||
resultAlert.classList.add('alert-success');
|
||||
resultIcon.className = 'bx bx-check-circle me-2 fs-4';
|
||||
progressBar.classList.add('bg-success');
|
||||
} else if (type === 'warning') {
|
||||
resultAlert.classList.add('alert-warning');
|
||||
resultIcon.className = 'bx bx-error me-2 fs-4';
|
||||
progressBar.classList.add('bg-warning');
|
||||
} else {
|
||||
resultAlert.classList.add('alert-danger');
|
||||
resultIcon.className = 'bx bx-x-circle me-2 fs-4';
|
||||
progressBar.classList.add('bg-danger');
|
||||
}
|
||||
resultText.textContent = message;
|
||||
resultBox.classList.add('is-visible');
|
||||
btnStart.disabled = false;
|
||||
includeAnalisi.disabled = false;
|
||||
}
|
||||
|
||||
function run() {
|
||||
btnStart.disabled = true;
|
||||
includeAnalisi.disabled = true;
|
||||
idleHint.style.display = 'none';
|
||||
resetUI();
|
||||
startTimer();
|
||||
|
||||
const analisiParam = includeAnalisi.checked ? '1' : '0';
|
||||
const es = new EventSource(WARM_ENDPOINT + '?stream=1&analisi=' + analisiParam);
|
||||
|
||||
es.onmessage = function(e) {
|
||||
const line = e.data;
|
||||
logPre.textContent += line + '\n';
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
|
||||
const idx = matchStep(line);
|
||||
if (idx !== -1) {
|
||||
advanceTo(idx, extractDetail(line));
|
||||
}
|
||||
};
|
||||
|
||||
es.addEventListener('busy', function(e) {
|
||||
es.close();
|
||||
showResult('warning', e.data ||
|
||||
'Un aggiornamento è già in corso. Attendi qualche istante e riprova.');
|
||||
});
|
||||
|
||||
es.addEventListener('done', function(e) {
|
||||
es.close();
|
||||
// completa tutti i passi
|
||||
for (let i = 0; i < activeSteps.length; i++) markStep(i, 'done');
|
||||
setProgress(100);
|
||||
let elapsed = '';
|
||||
try {
|
||||
elapsed = JSON.parse(e.data).elapsed;
|
||||
} catch (err) {}
|
||||
|
||||
// se nel log è comparsa la parola ERRORE, segnala come warning
|
||||
if (logPre.textContent.indexOf('ERRORE') !== -1) {
|
||||
showResult('warning',
|
||||
'Aggiornamento terminato, ma alcuni dati potrebbero non essere stati caricati. ' +
|
||||
'Controlla il dettaglio tecnico.');
|
||||
} else {
|
||||
progressTitle.textContent = 'Completato';
|
||||
showResult('success',
|
||||
'Dati aggiornati correttamente' +
|
||||
(elapsed ? ' in ' + elapsed + ' secondi.' : '.') +
|
||||
' Le nuove informazioni sono ora disponibili.');
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = function() {
|
||||
// EventSource tenta il reconnect da solo: lo blocchiamo.
|
||||
es.close();
|
||||
stopTimer();
|
||||
// Se avevamo già finito, il done è già scattato: non sovrascrivere.
|
||||
if (!resultBox.classList.contains('is-visible')) {
|
||||
showResult('error',
|
||||
'Connessione interrotta durante l\'aggiornamento. ' +
|
||||
'Verifica la rete e riprova.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
btnStart.addEventListener('click', run);
|
||||
btnAgain.addEventListener('click', run);
|
||||
|
||||
toggleLog.addEventListener('click', function() {
|
||||
logBox.classList.toggle('is-visible');
|
||||
toggleLog.innerHTML = logBox.classList.contains('is-visible') ?
|
||||
'<i class="bx bx-hide"></i> Nascondi dettaglio tecnico' :
|
||||
'<i class="bx bx-code-alt"></i> Mostra dettaglio tecnico';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user