diff --git a/.gitignore b/.gitignore index abc1720..d3a29f3 100644 --- a/.gitignore +++ b/.gitignore @@ -69,4 +69,9 @@ public/userarea/logsapi/commessaweb_invia_763.json public/userarea/logsapi/last_auth_url.txt # User uploaded files -/public/userarea/files/ \ No newline at end of file +/public/userarea/files/ + +# Documenti e allegati caricati dagli utenti (mescole, dipendenti, formazione, DPI) +/public/userarea/uploads/ +!/public/userarea/uploads/.gitkeep + diff --git a/public/userarea/mescole.php b/public/userarea/mescole.php index 3a97411..b8d373d 100644 --- a/public/userarea/mescole.php +++ b/public/userarea/mescole.php @@ -131,16 +131,23 @@ max-width: 260px; } - /* Stato */ + /* Certificati */ #tabellaMescole th:nth-child(5), #tabellaMescole td:nth-child(5) { - width: 140px; - max-width: 140px; + width: 130px; + max-width: 130px; + } + + /* Stato */ + #tabellaMescole th:nth-child(6), + #tabellaMescole td:nth-child(6) { + width: 130px; + max-width: 130px; } /* Azioni */ - #tabellaMescole th:nth-child(6), - #tabellaMescole td:nth-child(6) { + #tabellaMescole th:nth-child(7), + #tabellaMescole td:nth-child(7) { width: 190px; max-width: 190px; white-space: normal; @@ -163,6 +170,193 @@ font-size: 1rem; line-height: 1; } + + /* ---------- DROP ZONE FILE ---------- */ + .dropzone { + border: 2px dashed #bcd4f4; + background: #f8fbff; + border-radius: 14px; + padding: 28px 20px; + text-align: center; + cursor: pointer; + transition: all .18s ease-in-out; + user-select: none; + } + + .dropzone:hover { + background: #eef5ff; + border-color: #0d6efd; + } + + .dropzone.dragover { + background: #dbeafe; + border-color: #0d6efd; + border-style: solid; + transform: scale(1.01); + } + + .dropzone-icon { + font-size: 2.4rem; + line-height: 1; + margin-bottom: 6px; + } + + .dropzone-main { + font-weight: 600; + color: #1f2d3d; + font-size: 1.05rem; + } + + .dropzone-sub { + color: #64748b; + font-size: .87rem; + margin-top: 4px; + } + + .dz-queue { + margin-top: 14px; + } + + .dz-item { + display: flex; + align-items: center; + gap: 10px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 8px 12px; + margin-bottom: 8px; + font-size: .92rem; + } + + .dz-item-name { + flex: 1; + min-width: 0; + text-align: left; + word-break: break-word; + overflow-wrap: anywhere; + line-height: 1.3; + } + + .dz-item-state { + font-size: .85rem; + font-weight: 600; + white-space: nowrap; + } + + .dz-ok { + color: #166534; + } + + .dz-err { + color: #991b1b; + } + + .dz-progress { + width: 110px; + height: 6px; + background: #e2e8f0; + border-radius: 999px; + overflow: hidden; + } + + .dz-progress-bar { + height: 100%; + width: 0; + background: #0d6efd; + transition: width .15s; + } + + /* ---------- TABELLA FILE NELLA MODALE ---------- */ + #fTable { + table-layout: fixed; + width: 100%; + } + + #fTable th, + #fTable td { + vertical-align: middle; + word-break: break-word; + overflow-wrap: anywhere; + white-space: normal; + } + + /* Categoria */ + #fTable th:nth-child(1), + #fTable td:nth-child(1) { + width: 150px; + } + + /* Titolo */ + #fTable th:nth-child(2), + #fTable td:nth-child(2) { + width: auto; + } + + /* Nome File */ + #fTable th:nth-child(3), + #fTable td:nth-child(3) { + width: 30%; + } + + /* Caricato il */ + #fTable th:nth-child(4), + #fTable td:nth-child(4) { + width: 140px; + } + + /* Azioni */ + #fTable th:nth-child(5), + #fTable td:nth-child(5) { + width: 110px; + white-space: nowrap; + } + + #fTable td a { + color: #0d6efd; + text-decoration: none; + } + + #fTable td a:hover { + text-decoration: underline; + } + + .file-title-cell { + cursor: text; + text-align: left; + line-height: 1.35; + } + + .file-title-cell:hover { + background: #f1f5f9; + } + + .file-title-input { + width: 100%; + border: 1px solid #0d6efd; + border-radius: 6px; + padding: 3px 6px; + font-size: .95rem; + } + + .badge-files { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: .8rem; + font-weight: 600; + } + + .badge-files-ok { + background: #dcfce7; + color: #166534; + } + + .badge-files-none { + background: #fee2e2; + color: #991b1b; + } + @@ -205,6 +399,7 @@ Nome Uscita Q.tà Totale Linee Associate + Certificati Stato Azioni @@ -236,6 +431,8 @@ m.nomeuscita, m.is_active, IFNULL(q.qty_totale, 0) AS qty_totale, + (SELECT COUNT(*) FROM mescole_files mf + WHERE mf.idmescola = m.id AND mf.categoria = 'certificati_analisi') AS n_certificati, GROUP_CONCAT(DISTINCT pl.name SEPARATOR ', ') AS linee FROM mescole m LEFT JOIN ( @@ -262,6 +459,7 @@ - - - + - "; } else { while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { @@ -278,6 +476,11 @@ $nomeUscitaSafe = htmlspecialchars($row['nomeuscita']); + $nCert = (int)($row['n_certificati'] ?? 0); + $certBadge = $nCert > 0 + ? "📄 {$nCert}" + : "— nessuno"; + echo " {$row['id']} @@ -287,6 +490,7 @@ {$qtyTot} {$linee} + {$certBadge} {$badge} - +
+ +
-
- - - - - - - - - - - -
CategoriaTitoloNome FileCaricato ilAzioni
+
+
📥
+
Trascina qui i file dalla mail
+
+ oppure clicca per selezionarli · Ctrl+V per incollare
+ Il titolo viene creato dal nome del file — PDF, Word, Excel, immagini · max 15MB +
+ +
+ +
>
+ +
+ + + + + + + + + + + +
CategoriaTitoloNome FileCaricato ilAzioni
+
+ + @@ -997,45 +1203,293 @@ // FILE / REPOSITORY // ============================ + function fEscape(s) { + return String(s == null ? '' : s).replace(/[<>&"]/g, function(c) { + return ({ + '<': '<', + '>': '>', + '&': '&', + '"': '"' + })[c]; + }); + } + + function fFmtDate(s) { + if (!s) return '—'; + var d = new Date(String(s).replace(' ', 'T')); + if (isNaN(d.getTime())) return s; + var p = function(n) { + return String(n).padStart(2, '0'); + }; + return p(d.getDate()) + '/' + p(d.getMonth() + 1) + '/' + d.getFullYear() + + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); + } + + function fRenderRow(f) { + return ` + + ${fEscape(f.categoria_label)} + ${fEscape(f.titolo)} + ${fEscape(f.original_filename)} + ${fFmtDate(f.uploaded_at)} + + ⬇️ + + + `; + } + function fLoadRows(idMescola) { fetch("get_mescola_files.php?id=" + encodeURIComponent(idMescola)) .then(r => r.json()) .then(data => { - // popola dropdown categorie (solo la prima volta che arrivano) + // popola dropdown categorie (solo la prima volta) const sel = $("#fCategoria"); if (sel.children().length === 0 && data.categorie) { Object.keys(data.categorie).forEach(slug => { sel.append(``); }); + // preseleziona i certificati di analisi: è il caso d'uso più frequente + if (data.categorie['certificati_analisi']) { + sel.val('certificati_analisi'); + } } const tbody = $("#fTable tbody"); tbody.empty(); if (!data.success || !Array.isArray(data.rows) || data.rows.length === 0) { - tbody.append(` + tbody.append(` Nessun file caricato `); return; } - data.rows.forEach(f => { - tbody.append(` - - ${f.categoria_label} - ${f.titolo} - ${f.original_filename} - ${f.uploaded_at} - - ⬇️ - - - - `); - }); + data.rows.forEach(f => tbody.append(fRenderRow(f))); }); } + // ---------- UPLOAD CON CODA E PROGRESSO ---------- + function fUploadOne(file, idMescola, categoria) { + return new Promise(function(resolve) { + const $item = $(` +
+ 📄 + ${fEscape(file.name)} +
+ 0% +
`); + $("#fQueue").append($item); + + const $bar = $item.find('.dz-progress-bar'); + const $state = $item.find('.dz-item-state'); + + const fd = new FormData(); + fd.append("idmescola", idMescola); + fd.append("categoria", categoria); + fd.append("titolo", ""); // il server genera il titolo dal nome file + fd.append("file", file); + + const xhr = new XMLHttpRequest(); + xhr.open("POST", "save_mescola_file.php", true); + + xhr.upload.onprogress = function(e) { + if (!e.lengthComputable) return; + const pct = Math.round((e.loaded / e.total) * 100); + $bar.css('width', pct + '%'); + $state.text(pct + '%'); + }; + + xhr.onload = function() { + let data = null; + try { + data = JSON.parse(xhr.responseText); + } catch (err) { + data = null; + } + + if (data && data.success) { + $bar.css('width', '100%'); + $state.removeClass().addClass('dz-item-state dz-ok').text('✓ Caricato'); + if (data.row) { + $("#fTable tbody .f-empty-row").remove(); + $("#fTable tbody").prepend(fRenderRow(data.row)); + } + setTimeout(function() { + $item.fadeOut(300, function() { + $(this).remove(); + }); + }, 2500); + } else { + $item.find('.dz-progress').remove(); + $state.removeClass().addClass('dz-item-state dz-err') + .text('✗ ' + ((data && data.message) ? data.message : 'Errore')); + } + resolve(); + }; + + xhr.onerror = function() { + $item.find('.dz-progress').remove(); + $state.removeClass().addClass('dz-item-state dz-err').text('✗ Errore di rete'); + resolve(); + }; + + xhr.send(fd); + }); + } + + function fHandleFiles(fileList) { + const idMescola = $("#fIdMescola").val(); + const categoria = $("#fCategoria").val(); + + if (!idMescola) return; + if (!categoria) { + Swal.fire({ + icon: "warning", + title: "Attenzione", + text: "Seleziona prima una categoria" + }); + return; + } + + const files = Array.from(fileList || []); + if (files.length === 0) return; + + // Caricamento sequenziale: più gentile col server e progresso leggibile + files.reduce(function(chain, file) { + return chain.then(function() { + return fUploadOne(file, idMescola, categoria); + }); + }, Promise.resolve()); + } + + // Click sulla dropzone → apre il selettore file + $(document).on("click", "#fDropzone", function() { + document.getElementById("fFileInput").click(); + }); + + $(document).on("change", "#fFileInput", function() { + fHandleFiles(this.files); + this.value = ""; // permette di ricaricare lo stesso file + }); + + // Drag & drop + ['dragenter', 'dragover'].forEach(function(evt) { + document.addEventListener(evt, function(e) { + const dz = e.target.closest && e.target.closest('#fDropzone'); + if (!dz) return; + e.preventDefault(); + e.stopPropagation(); + dz.classList.add('dragover'); + }); + }); + + ['dragleave', 'drop'].forEach(function(evt) { + document.addEventListener(evt, function(e) { + const dz = e.target.closest && e.target.closest('#fDropzone'); + if (!dz) return; + e.preventDefault(); + e.stopPropagation(); + dz.classList.remove('dragover'); + }); + }); + + document.addEventListener('drop', function(e) { + const dz = e.target.closest && e.target.closest('#fDropzone'); + if (!dz) return; + e.preventDefault(); + if (e.dataTransfer && e.dataTransfer.files) { + fHandleFiles(e.dataTransfer.files); + } + }); + + // Impedisce al browser di aprire il file se cade fuori dalla dropzone + ['dragover', 'drop'].forEach(function(evt) { + document.addEventListener(evt, function(e) { + if (!$('#associaFileModal').hasClass('show')) return; + if (e.target.closest && e.target.closest('#fDropzone')) return; + e.preventDefault(); + }); + }); + + // Ctrl+V dentro la modale + document.addEventListener('paste', function(e) { + if (!$('#associaFileModal').hasClass('show')) return; + if (!e.clipboardData || !e.clipboardData.items) return; + + const files = []; + Array.from(e.clipboardData.items).forEach(function(item) { + if (item.kind === 'file') { + const f = item.getAsFile(); + if (f) files.push(f); + } + }); + + if (files.length > 0) { + e.preventDefault(); + fHandleFiles(files); + } + }); + + // Rinomina il titolo con doppio click + $(document).on("dblclick", ".file-title-cell", function() { + const $td = $(this); + if ($td.find('input').length) return; + + const oldVal = $td.text().trim(); + const fileId = $td.closest('tr').data('file-id'); + + $td.html(``); + const $input = $td.find('input'); + $input.trigger('focus').trigger('select'); + + function commit(save) { + const newVal = $input.val().trim(); + if (!save || newVal === '' || newVal === oldVal) { + $td.text(oldVal); + return; + } + $td.text(newVal); + const p = new URLSearchParams(); + p.append('id', fileId); + p.append('titolo', newVal); + fetch("rename_mescola_file.php", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: p.toString() + }) + .then(r => r.json()) + .then(data => { + if (!data.success) { + $td.text(oldVal); + Swal.fire({ + icon: "error", + title: "Errore", + text: data.message || "Impossibile rinominare" + }); + } + }) + .catch(function() { + $td.text(oldVal); + }); + } + + $input.on('blur', function() { + commit(true); + }); + $input.on('keydown', function(ev) { + if (ev.key === 'Enter') { + ev.preventDefault(); + $input.trigger('blur'); + } else if (ev.key === 'Escape') { + $input.off('blur'); + commit(false); + } + }); + }); + // Open modal $(document).on("click", ".associa-file", function() { const idMescola = $(this).data("id"); @@ -1043,65 +1497,13 @@ $("#fIdMescola").val(idMescola); $("#fNomeUscita").text(nomeUscita); - $("#fTitolo").val(""); + $("#fQueue").empty(); $("#fFileInput").val(""); $("#associaFileModal").modal("show"); fLoadRows(idMescola); }); - // Upload file - $("#fSaveBtn").on("click", function(e) { - e.preventDefault(); - - const idMescola = $("#fIdMescola").val(); - const categoria = $("#fCategoria").val(); - const titolo = $("#fTitolo").val().trim(); - const fileInput = document.getElementById("fFileInput"); - - if (!titolo) { - Swal.fire({ - icon: "warning", - title: "Attenzione", - text: "Il titolo del file è obbligatorio" - }); - return; - } - if (!fileInput.files.length) { - Swal.fire({ - icon: "warning", - title: "Attenzione", - text: "Seleziona un file da caricare" - }); - return; - } - - const formData = new FormData(); - formData.append("idmescola", idMescola); - formData.append("categoria", categoria); - formData.append("titolo", titolo); - formData.append("file", fileInput.files[0]); - - fetch("save_mescola_file.php", { - method: "POST", - body: formData - }) - .then(r => r.json()) - .then(data => { - if (data.success) { - $("#fTitolo").val(""); - $("#fFileInput").val(""); - fLoadRows(idMescola); - } else { - Swal.fire({ - icon: "error", - title: "Errore", - text: data.message || "Upload non riuscito" - }); - } - }); - }); - // Delete file $(document).on("click", ".f-del", function() { const id = $(this).data("id"); diff --git a/public/userarea/rename_mescola_file.php b/public/userarea/rename_mescola_file.php new file mode 100644 index 0000000..c2da0eb --- /dev/null +++ b/public/userarea/rename_mescola_file.php @@ -0,0 +1,28 @@ + false, 'message' => 'ID non valido']); + exit; +} +if ($titolo === '') { + echo json_encode(['success' => false, 'message' => 'Il titolo non può essere vuoto']); + exit; +} + +$titolo = mb_substr($titolo, 0, 255); + +try { + $pdo = DBHandlerSelect::getInstance()->getConnection(); + + $stmt = $pdo->prepare("UPDATE mescole_files SET titolo = ? WHERE id = ?"); + $stmt->execute([$titolo, $id]); + + echo json_encode(['success' => true, 'titolo' => $titolo]); +} catch (Throwable $e) { + echo json_encode(['success' => false, 'message' => 'Errore database: ' . $e->getMessage()]); +} diff --git a/public/userarea/save_mescola_file.php b/public/userarea/save_mescola_file.php index 687a882..edeb22b 100644 --- a/public/userarea/save_mescola_file.php +++ b/public/userarea/save_mescola_file.php @@ -16,10 +16,6 @@ if (!array_key_exists($categoria, $categorie)) { echo json_encode(['success' => false, 'message' => 'Categoria non valida']); exit; } -if ($titolo === '') { - echo json_encode(['success' => false, 'message' => 'Il titolo del file è obbligatorio']); - exit; -} if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { echo json_encode(['success' => false, 'message' => 'File mancante o upload non riuscito']); exit; @@ -34,6 +30,16 @@ $tmpPath = $_FILES['file']['tmp_name']; $size = (int)$_FILES['file']['size']; $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); +// Titolo automatico dal nome file se non fornito +if ($titolo === '') { + $titolo = pathinfo($originalName, PATHINFO_FILENAME); + $titolo = trim(preg_replace('/[_\-]+/', ' ', $titolo)); + if ($titolo === '') { + $titolo = 'Documento del ' . date('d/m/Y'); + } +} +$titolo = mb_substr($titolo, 0, 255); + if (!in_array($ext, $allowedExt, true)) { echo json_encode(['success' => false, 'message' => 'Estensione file non consentita (' . $ext . ')']); exit; @@ -72,7 +78,25 @@ try { "); $stmt->execute([$idmescola, $categoria, $titolo, $storedName, $originalName, $mimeType, $size]); - echo json_encode(['success' => true, 'id' => $pdo->lastInsertId()]); + $newId = (int)$pdo->lastInsertId(); + + echo json_encode([ + 'success' => true, + 'id' => $newId, + 'row' => [ + 'id' => $newId, + 'idmescola' => $idmescola, + 'categoria' => $categoria, + 'categoria_label' => $categorie[$categoria], + 'titolo' => $titolo, + 'filename' => $storedName, + 'original_filename' => $originalName, + 'mime_type' => $mimeType, + 'filesize' => $size, + 'uploaded_at' => date('Y-m-d H:i:s'), + 'url' => 'uploads/mescole/' . $idmescola . '/' . $storedName, + ], + ]); } catch (Throwable $e) { // rollback file se il DB fallisce @unlink($destPath);