diff --git a/composer.json b/composer.json
index 44057e75..1d5abb34 100644
--- a/composer.json
+++ b/composer.json
@@ -41,7 +41,7 @@
"laravel/socialite": "^5.16",
"laravel/tinker": "^2.7",
"laravel/ui": "^4.0",
- "phpmailer/phpmailer": "^6.9",
+ "phpmailer/phpmailer": "^7.1",
"phpoffice/phpspreadsheet": "^4.1",
"proengsoft/laravel-jsvalidation": "^4.0.0",
"robmorgan/phinx": "^0.16.12",
diff --git a/composer.lock b/composer.lock
index 4d3ae474..64acd6fe 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "557df76fdde549210349be369d479f5d",
+ "content-hash": "e54cf3e0bbc04b94b563b01620ac3416",
"packages": [
{
"name": "akaunting/laravel-setting",
@@ -4166,16 +4166,16 @@
},
{
"name": "phpmailer/phpmailer",
- "version": "v6.9.3",
+ "version": "v7.1.1",
"source": {
"type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git",
- "reference": "2f5c94fe7493efc213f643c23b1b1c249d40f47e"
+ "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/2f5c94fe7493efc213f643c23b1b1c249d40f47e",
- "reference": "2f5c94fe7493efc213f643c23b1b1c249d40f47e",
+ "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
+ "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
"shasum": ""
},
"require": {
@@ -4189,13 +4189,14 @@
"doctrine/annotations": "^1.2.6 || ^1.13.3",
"php-parallel-lint/php-console-highlighter": "^1.0.0",
"php-parallel-lint/php-parallel-lint": "^1.3.2",
- "phpcompatibility/php-compatibility": "^9.3.5",
- "roave/security-advisories": "dev-latest",
- "squizlabs/php_codesniffer": "^3.7.2",
+ "phpcompatibility/php-compatibility": "^10.0.0@dev",
+ "squizlabs/php_codesniffer": "^3.13.5",
"yoast/phpunit-polyfills": "^1.0.4"
},
"suggest": {
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
+ "directorytree/imapengine": "For uploading sent messages via IMAP, see gmail example",
+ "ext-imap": "Needed to support advanced email address parsing according to RFC822",
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
@@ -4235,7 +4236,7 @@
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"support": {
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
- "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.9.3"
+ "source": "https://github.com/PHPMailer/PHPMailer/tree/v7.1.1"
},
"funding": [
{
@@ -4243,7 +4244,7 @@
"type": "github"
}
],
- "time": "2024-11-24T18:04:13+00:00"
+ "time": "2026-05-18T08:06:14+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
diff --git a/db/migrations/20260923062342_add_notify_export_email_to_auth_users.php b/db/migrations/20260923062342_add_notify_export_email_to_auth_users.php
new file mode 100644
index 00000000..2521b84e
--- /dev/null
+++ b/db/migrations/20260923062342_add_notify_export_email_to_auth_users.php
@@ -0,0 +1,20 @@
+table('auth_users');
+ if (!$table->hasColumn('notify_export_email')) {
+ $table->addColumn('notify_export_email', 'boolean', [
+ 'default' => true, // di default ricevono la mail
+ 'null' => false,
+ 'after' => 'lims_global_user_id',
+ ])->update();
+ }
+ }
+}
diff --git a/db/migrations/20260923065449_add_analysis_price_to_part_analyses.php b/db/migrations/20260923065449_add_analysis_price_to_part_analyses.php
new file mode 100644
index 00000000..d31ffb7c
--- /dev/null
+++ b/db/migrations/20260923065449_add_analysis_price_to_part_analyses.php
@@ -0,0 +1,21 @@
+table('identification_parts_analyses')
+ ->addColumn('analysis_price', 'decimal', [
+ 'precision' => 10,
+ 'scale' => 2,
+ 'null' => true,
+ 'default' => null,
+ 'after' => 'analysis_level',
+ ])
+ ->update();
+ }
+}
diff --git a/public/userarea/analysisModal.js b/public/userarea/analysisModal.js
index 77d9c400..4bf9059f 100644
--- a/public/userarea/analysisModal.js
+++ b/public/userarea/analysisModal.js
@@ -166,6 +166,7 @@
is_web_selectable: rowEl.getAttribute("data-web") === "1" ? 1 : 0,
is_accredited:
rowEl.getAttribute("data-accredited") === "1" ? 1 : 0,
+ analysis_price: rowEl.getAttribute("data-price") || "0",
};
}
@@ -196,6 +197,10 @@
: null,
is_web_selectable: payload.is_web_selectable,
is_accredited: payload.is_accredited,
+ analysis_price:
+ payload.analysis_price !== ""
+ ? parseFloat(payload.analysis_price)
+ : null,
});
}
}
@@ -247,6 +252,7 @@
analysis_level: payload.analysis_level,
is_web_selectable: payload.is_web_selectable,
is_accredited: payload.is_accredited,
+ analysis_price: payload.analysis_price,
},
})
.done(function (resp) {
@@ -389,6 +395,17 @@
const selectable = item.SelezionabileSuWeb === true;
const accredited = item.Accreditato === true;
const level = item.Livello ?? "";
+
+ const rawPrice = item.Prezzo;
+ const priceNum =
+ rawPrice === null ||
+ rawPrice === undefined ||
+ rawPrice === ""
+ ? 0
+ : parseFloat(String(rawPrice).replace(",", "."));
+ const price = isNaN(priceNum) ? 0 : priceNum;
+ const hasPrice = price > 0;
+
const searchText = (
analysisName +
" " +
@@ -414,6 +431,10 @@
"";
}
+ if (!hasPrice) {
+ badges += 'No price';
+ }
+
return `
${escapeHtml(analysisName)}
@@ -440,7 +462,6 @@
filterAnalysisList();
syncSelectedAnalysisRows();
}
-
function filterAnalysisList() {
const modal = document.getElementById("analysisModal");
if (!modal) return;
diff --git a/public/userarea/class/Mailer.class.php b/public/userarea/class/Mailer.class.php
new file mode 100644
index 00000000..6e2fc819
--- /dev/null
+++ b/public/userarea/class/Mailer.class.php
@@ -0,0 +1,50 @@
+isSMTP();
+ $mail->Host = env('MAIL_HOST');
+ $mail->SMTPAuth = true;
+ $mail->Username = env('MAIL_USERNAME');
+ $mail->Password = env('MAIL_PASSWORD');
+ $mail->Port = (int) env('MAIL_PORT', 465);
+ $enc = env('MAIL_ENCRYPTION', 'ssl');
+ $mail->SMTPSecure = ($enc === 'tls')
+ ? PHPMailer::ENCRYPTION_STARTTLS
+ : PHPMailer::ENCRYPTION_SMTPS;
+ $mail->CharSet = 'UTF-8';
+
+ $mail->setFrom(env('MAIL_FROM_ADDRESS'), env('MAIL_FROM_NAME', 'SmartTRF'));
+
+ foreach ($to as $addr) {
+ $addr = trim((string)$addr);
+ if ($addr !== '' && filter_var($addr, FILTER_VALIDATE_EMAIL)) {
+ $mail->addAddress($addr);
+ }
+ }
+ if (!$mail->getToAddresses()) {
+ return ['ok' => false, 'error' => 'Nessun destinatario valido'];
+ }
+
+ $mail->isHTML(true);
+ $mail->Subject = $subject;
+ $mail->Body = $htmlBody;
+ $mail->AltBody = $altBody ?: strip_tags($htmlBody);
+
+ $mail->send();
+ return ['ok' => true, 'error' => null];
+ } catch (PHPMailerException $e) {
+ error_log('Mailer error: ' . $mail->ErrorInfo);
+ return ['ok' => false, 'error' => $mail->ErrorInfo];
+ }
+ }
+}
diff --git a/public/userarea/exportLims_gridData.js b/public/userarea/exportLims_gridData.js
index 44bb57ad..dbb85d0d 100644
--- a/public/userarea/exportLims_gridData.js
+++ b/public/userarea/exportLims_gridData.js
@@ -24,6 +24,24 @@
if (overlay) overlay.style.display = "none";
}
+ function escHtml(v) {
+ return String(v ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
+ // Badge "No price" con tooltip dei nomi analisi (vuoto se count = 0)
+ function noPriceBadgeHTML(count, names) {
+ const n = parseInt(count, 10) || 0;
+ if (n <= 0) return "";
+ const title = (names || []).join("\n");
+ return `
No price (${n})`;
+ }
+ window.noPriceBadgeHTML = noPriceBadgeHTML;
+
function getGridRow(iddatadb) {
return document.querySelector(`.grid-row[data-id="${iddatadb}"]`);
}
@@ -34,6 +52,29 @@
);
}
+ // Invia la mail di riepilogo per gli iddatadb processati (fire-and-forget).
+ // Una sola chiamata a fine export (singolo o batch).
+ function sendExportSummaryMail(iddatadbList) {
+ const ids = (iddatadbList || [])
+ .map((v) => parseInt(v, 10))
+ .filter(Boolean);
+ if (!ids.length) return;
+
+ const body = new URLSearchParams();
+ ids.forEach((id) => body.append("ids[]", id));
+ const tid = window.gridMeta && window.gridMeta.templateId;
+ if (tid) body.append("template_id", tid);
+
+ fetch("send_export_summary.php", {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: body.toString(),
+ })
+ .then((r) => r.json())
+ .then((res) => console.log("Summary mail:", res))
+ .catch((err) => console.warn("Summary mail failed:", err));
+ }
+
// ── Validation ──────────────────────────────────────────────────────
async function validateRows(rowsToValidate) {
@@ -142,6 +183,9 @@
if (idx >= 0) {
window.gridData[idx].status = "l";
window.gridData[idx].commessaweb = data.commessaweb;
+ window.gridData[idx].noPriceCount = data.noPriceCount || 0;
+ window.gridData[idx].noPriceAnalyses =
+ data.noPriceAnalyses || [];
}
// Update visible DOM row
@@ -168,6 +212,11 @@
statusCell.appendChild(cwSpan);
}
cwSpan.textContent = data.commessaweb;
+ const npHtml = noPriceBadgeHTML(
+ data.noPriceCount,
+ data.noPriceAnalyses,
+ );
+ if (npHtml) cwSpan.insertAdjacentHTML("beforeend", npHtml);
}
const exportBtn = gridRow.querySelector(".export-lims-btn");
if (exportBtn) {
@@ -195,8 +244,17 @@
msg.innerHTML =
`${data.message.replace(/\n/g, "
")}` +
`
ID CommessaWeb: ${data.idcommessaweb}` +
- `
Codice CommessaWeb: ${data.commessaweb}` +
- (data.totalPhotos > 0 ? `
Foto: ${data.totalPhotos}` : "");
+ `
Codice CommessaWeb: ${escHtml(data.commessaweb)}` +
+ noPriceBadgeHTML(data.noPriceCount, data.noPriceAnalyses) +
+ (data.totalPhotos > 0 ? `
Foto: ${data.totalPhotos}` : "") +
+ (data.noPriceCount > 0
+ ? `
Analisi senza prezzo:
` +
+ `
` +
+ (data.noPriceAnalyses || [])
+ .map((n) => `- ${escHtml(n)}
`)
+ .join("") +
+ `
`
+ : "");
label.textContent = "Esportazione Completata";
} else {
msg.textContent = `Errore: ${data.message}`;
@@ -386,6 +444,8 @@
data.message || "Unknown error",
);
showExportResult(data);
+ // Mail di riepilogo per la singola commessa
+ sendExportSummaryMail([iddatadb]);
} catch (error) {
if (gridRow) {
setRowExporting(gridRow, false);
@@ -633,6 +693,8 @@
let processed = 0,
succeeded = 0,
failed = 0;
+ const exportedList = []; // { commessaweb, noPriceCount, noPriceAnalyses }
+ const processedIds = []; // iddatadb processati (ok o errore) per la mail
disableAllRowButtons();
@@ -666,11 +728,17 @@
const gridRow = row || getGridRow(iddatadb);
if (gridRow) setRowExporting(gridRow, true);
+ processedIds.push(iddatadb);
try {
const data = await sendExport(iddatadb, batchUuid);
processed++;
if (data.success) {
succeeded++;
+ exportedList.push({
+ commessaweb: data.commessaweb,
+ noPriceCount: data.noPriceCount || 0,
+ noPriceAnalyses: data.noPriceAnalyses || [],
+ });
} else {
failed++;
const errIdx = getRowIndexByIddatadb(iddatadb);
@@ -703,6 +771,9 @@
enableAllRowButtons();
if (bar) bar.style.display = "none";
+ // Mail di riepilogo unica per tutto il batch processato
+ sendExportSummaryMail(processedIds);
+
// Re-render to reflect status changes, then restore error indicators
const gr = window.gridRenderer;
if (gr) gr.renderVisibleRows();
@@ -730,6 +801,31 @@
msgEl.innerHTML = `Exported:
${succeeded}Errors:
${failed}`;
}
+ if (exportedList.length) {
+ const npCommesse = exportedList.filter(
+ (x) => x.noPriceCount > 0,
+ ).length;
+ msgEl.innerHTML +=
+ `
` +
+ (npCommesse > 0
+ ? `
${npCommesse} commesse con analisi senza prezzo
`
+ : "") +
+ `
Commesse esportate:
` +
+ `
` +
+ exportedList
+ .map(
+ (x) =>
+ `- ${escHtml(x.commessaweb || "—")}` +
+ noPriceBadgeHTML(
+ x.noPriceCount,
+ x.noPriceAnalyses,
+ ) +
+ `
`,
+ )
+ .join("") +
+ `
`;
+ }
+
const modalEl = document.getElementById("exportResponseModal");
new bootstrap.Modal(modalEl, { keyboard: false }).show();
modalEl.addEventListener("hidden.bs.modal", cleanupBackdrop, {
diff --git a/public/userarea/export_to_lims.php b/public/userarea/export_to_lims.php
index 1d8e2752..bbd3b7e0 100644
--- a/public/userarea/export_to_lims.php
+++ b/public/userarea/export_to_lims.php
@@ -585,7 +585,7 @@ try {
// 🔹 STEP 6.3: Add Analyses (AnalisiCampione) via Campione({id})/AddAnalisi bound action
$stmt = $pdo->prepare("
- SELECT part_id, analysis_recordkey, analysis_name, analysis_method
+ SELECT part_id, analysis_recordkey, analysis_name, analysis_method, analysis_price
FROM identification_parts_analyses
WHERE iddatadb = :iddatadb
ORDER BY part_id, id
@@ -593,6 +593,17 @@ try {
$stmt->execute(['iddatadb' => $iddatadb]);
$analysesRows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+ // Analisi senza prezzo (NULL o 0), una sola volta per recordkey
+ $noPriceAnalyses = [];
+ foreach ($analysesRows as $a) {
+ $price = $a['analysis_price'];
+ if ($price === null || (float)$price <= 0) {
+ $rk = (string)($a['analysis_recordkey'] ?? '');
+ $noPriceAnalyses[$rk] = $a['analysis_name'] ?: 'Unnamed analysis';
+ }
+ }
+ $noPriceAnalyses = array_values($noPriceAnalyses);
+
$partIdToIndex = [];
foreach ($parts as $idx => $part) {
$partIdToIndex[(int)$part['part_id']] = $idx;
@@ -810,6 +821,8 @@ try {
"totalAnalyses" => $totalAnalyses,
"addedAnalyses" => $addedAnalyses,
"failedAnalyses" => $failedAnalyses,
+ "noPriceCount" => count($noPriceAnalyses),
+ "noPriceAnalyses" => $noPriceAnalyses,
"message" => "Export successful",
"logFiles" => [
"step5_create" => $logFileStep5,
diff --git a/public/userarea/gridRenderer.js b/public/userarea/gridRenderer.js
index 288dbc59..d08eb4a2 100644
--- a/public/userarea/gridRenderer.js
+++ b/public/userarea/gridRenderer.js
@@ -588,7 +588,14 @@
: "To LIMS";
let html = `
${label}`;
if (row.commessaweb) {
- html += `
${esc(row.commessaweb)}`;
+ const npBadge =
+ typeof window.noPriceBadgeHTML === "function"
+ ? window.noPriceBadgeHTML(
+ row.noPriceCount,
+ row.noPriceAnalyses,
+ )
+ : "";
+ html += `
${esc(row.commessaweb)}${npBadge}`;
}
div.innerHTML = html;
div.classList.remove("editable-cell");
diff --git a/public/userarea/include/topbar.php b/public/userarea/include/topbar.php
index f37e0d9d..3c505d87 100644
--- a/public/userarea/include/topbar.php
+++ b/public/userarea/include/topbar.php
@@ -108,6 +108,8 @@