<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TMS Logistica - Demo (LocalStorage)</title>
<style>
:root{
--bg:#0b0f17;
--panel:#101828;
--panel2:#0f172a;
--text:#e5e7eb;
--muted:#9ca3af;
--line:#1f2937;
--brand:#22c55e;
--warn:#f59e0b;
--bad:#ef4444;
--info:#60a5fa;
--chip:#111827;
--shadow: 0 10px 30px rgba(0,0,0,.35);
--radius: 16px;
--radius2: 12px;
}
*{box-sizing:border-box}
body{
margin:0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Apple Color Emoji","Segoe UI Emoji";
background: radial-gradient(1200px 700px at 20% -10%, rgba(34,197,94,.18), transparent 60%),
radial-gradient(900px 600px at 110% 10%, rgba(96,165,250,.12), transparent 50%),
var(--bg);
color:var(--text);
min-height:100vh;
}
a{color:inherit}
.wrap{
max-width: 1100px;
margin: 0 auto;
padding: 18px;
}
.topbar{
display:flex;
gap:12px;
align-items:center;
justify-content:space-between;
position:sticky;
top:0;
padding: 14px 18px;
background: rgba(11,15,23,.65);
backdrop-filter: blur(10px);
border-bottom:1px solid rgba(31,41,55,.6);
z-index: 5;
}
.brand{
display:flex; align-items:center; gap:10px;
font-weight:700;
letter-spacing:.2px;
}
.dot{
width:12px;height:12px;border-radius:999px;background:var(--brand);
box-shadow: 0 0 0 6px rgba(34,197,94,.12);
}
.pill{
display:inline-flex; align-items:center; gap:8px;
padding: 8px 10px;
border:1px solid rgba(31,41,55,.9);
background: rgba(16,24,40,.75);
border-radius:999px;
color: var(--muted);
font-size: 13px;
}
.btn{
border:1px solid rgba(31,41,55,.9);
background: rgba(16,24,40,.85);
color: var(--text);
padding: 10px 12px;
border-radius: 12px;
cursor:pointer;
transition: transform .06s ease, background .2s ease, border-color .2s ease;
font-weight: 600;
font-size: 14px;
}
.btn:hover{background: rgba(16,24,40,.98); border-color: rgba(55,65,81,1)}
.btn:active{transform: translateY(1px)}
.btn.primary{
background: linear-gradient(180deg, rgba(34,197,94,.95), rgba(34,197,94,.75));
border-color: rgba(34,197,94,.35);
color:#052e16;
}
.btn.danger{
background: rgba(239,68,68,.12);
border-color: rgba(239,68,68,.35);
color: #fecaca;
}
.btn.ghost{
background: transparent;
}
.grid{
display:grid;
gap: 14px;
}
.cards{
grid-template-columns: repeat(12, 1fr);
}
.card{
background: rgba(16,24,40,.85);
border: 1px solid rgba(31,41,55,.85);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow:hidden;
}
.card .hd{
padding: 14px 14px 10px;
border-bottom:1px solid rgba(31,41,55,.7);
display:flex;
align-items:flex-start;
justify-content:space-between;
gap: 12px;
}
.card .hd h2{
margin:0;
font-size: 16px;
letter-spacing:.2px;
}
.card .hd p{
margin:6px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.35;
}
.card .bd{
padding: 14px;
}
.kpis{
display:grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.kpi{
background: rgba(15,23,42,.8);
border:1px solid rgba(31,41,55,.8);
border-radius: var(--radius2);
padding: 12px;
min-height: 78px;
display:flex;
flex-direction:column;
justify-content:space-between;
}
.kpi .lab{
color: var(--muted);
font-size: 12px;
}
.kpi .val{
font-size: 22px;
font-weight: 800;
}
.kpi .sub{
color: var(--muted);
font-size: 12px;
}
.row{
display:flex;
gap: 10px;
flex-wrap: wrap;
align-items:center;
}
.split{
display:flex;
gap: 14px;
flex-wrap: wrap;
}
.split > *{flex: 1}
label{
display:block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
input, select, textarea{
width:100%;
padding: 10px 11px;
border-radius: 12px;
border: 1px solid rgba(31,41,55,.95);
background: rgba(3,7,18,.35);
color: var(--text);
outline: none;
}
textarea{min-height: 88px; resize: vertical}
input:focus, select:focus, textarea:focus{
border-color: rgba(96,165,250,.7);
box-shadow: 0 0 0 4px rgba(96,165,250,.12);
}
.table{
width:100%;
border-collapse: collapse;
overflow:hidden;
border-radius: 12px;
border: 1px solid rgba(31,41,55,.85);
}
.table th, .table td{
padding: 10px 10px;
border-bottom: 1px solid rgba(31,41,55,.65);
text-align:left;
font-size: 13px;
vertical-align: top;
}
.table th{
color: var(--muted);
font-weight: 700;
background: rgba(15,23,42,.7);
position: sticky;
top: 0;
z-index: 1;
}
.table tr:hover td{background: rgba(15,23,42,.35)}
.chip{
display:inline-flex;
align-items:center;
gap: 6px;
padding: 6px 10px;
border-radius:999px;
background: rgba(17,24,39,.9);
border:1px solid rgba(31,41,55,.85);
font-size: 12px;
color: var(--muted);
white-space:nowrap;
}
.chip.good{border-color: rgba(34,197,94,.35); color: rgba(187,247,208,.95); background: rgba(34,197,94,.10)}
.chip.warn{border-color: rgba(245,158,11,.35); color: rgba(254,243,199,.95); background: rgba(245,158,11,.10)}
.chip.bad{border-color: rgba(239,68,68,.35); color: rgba(254,202,202,.95); background: rgba(239,68,68,.10)}
.chip.info{border-color: rgba(96,165,250,.35); color: rgba(191,219,254,.95); background: rgba(96,165,250,.10)}
.muted{color: var(--muted)}
.tabs{
display:flex;
gap: 8px;
flex-wrap: wrap;
}
.tab{
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(31,41,55,.85);
background: rgba(16,24,40,.70);
cursor:pointer;
font-weight: 700;
color: var(--muted);
font-size: 13px;
}
.tab.active{
background: rgba(34,197,94,.14);
border-color: rgba(34,197,94,.35);
color: rgba(187,247,208,.95);
}
.two{
display:grid;
grid-template-columns: 1.1fr .9fr;
gap: 14px;
}
.hint{
font-size: 12px;
color: var(--muted);
line-height: 1.35;
}
.hr{
height:1px;background: rgba(31,41,55,.8); margin: 10px 0;
}
.toast{
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: 16px;
padding: 10px 12px;
border-radius: 999px;
background: rgba(16,24,40,.92);
border: 1px solid rgba(31,41,55,.85);
color: var(--text);
box-shadow: var(--shadow);
font-size: 13px;
display:none;
z-index: 10;
max-width: calc(100vw - 24px);
}
.modal{
position: fixed;
inset: 0;
display:none;
background: rgba(0,0,0,.55);
z-index: 20;
padding: 18px;
}
.modal .box{
max-width: 920px;
margin: 0 auto;
background: rgba(16,24,40,.96);
border:1px solid rgba(31,41,55,.9);
border-radius: 18px;
overflow:hidden;
box-shadow: var(--shadow);
}
.modal .box .hd{
padding: 14px 14px 10px;
border-bottom: 1px solid rgba(31,41,55,.7);
display:flex;
align-items:center;
justify-content:space-between;
gap: 10px;
}
.modal .box .hd h3{margin:0; font-size: 15px}
.modal .box .bd{padding: 14px}
.sigbox{
border: 1px dashed rgba(55,65,81,1);
border-radius: 12px;
overflow:hidden;
background: rgba(3,7,18,.25);
}
canvas{display:block; width:100%; height: 180px;}
.imgPreview{
width:100%;
border-radius: 12px;
border:1px solid rgba(31,41,55,.85);
background: rgba(3,7,18,.25);
overflow:hidden;
}
.imgPreview img{display:block; width:100%; height:auto}
.footer-note{
margin-top: 12px;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
/* Responsive */
@media (max-width: 980px){
.two{grid-template-columns: 1fr}
.kpis{grid-template-columns: repeat(2, 1fr)}
}
@media (max-width: 520px){
.wrap{padding: 12px}
.topbar{padding: 12px}
.kpis{grid-template-columns: 1fr}
.btn{width:auto}
.table th:nth-child(3),
.table td:nth-child(3){display:none} /* nascondi una colonna su schermi piccoli */
}
</style>
</head>
<body>
<div class="topbar">
<div class="brand">
<span class="dot"></span>
<div>
<div style="font-size:14px;">TMS Logistica</div>
<div class="muted" style="font-size:12px;">Demo LocalStorage (Netlify-ready)</div>
</div>
</div>
<div class="row" id="topRight">
<!-- popolato via JS -->
</div>
</div>
<div class="wrap" id="app"></div>
<div class="toast" id="toast"></div>
<!-- MODAL POD / FIRMA -->
<div class="modal" id="modalPOD" role="dialog" aria-modal="true">
<div class="box">
<div class="hd">
<div>
<h3 id="podTitle">POD - Prova di consegna</h3>
<div class="hint" id="podSubtitle"></div>
</div>
<div class="row">
<button class="btn ghost" id="podCloseBtn">Chiudi</button>
</div>
</div>
<div class="bd">
<div class="two">
<div>
<div class="hint">Firma (mock): disegna nel riquadro. Poi salva.</div>
<div class="sigbox" style="margin-top:8px;">
<canvas id="sigCanvas" width="900" height="360"></canvas>
</div>
<div class="row" style="margin-top:10px;">
<button class="btn" id="sigClearBtn">Pulisci firma</button>
<button class="btn primary" id="sigSaveBtn">Salva firma</button>
</div>
</div>
<div>
<div class="hint">Foto POD (mock): carica un'immagine (salvata in LocalStorage).</div>
<input type="file" id="podPhotoInput" accept="image/*" style="margin-top:8px;">
<div class="row" style="margin-top:10px;">
<button class="btn primary" id="podPhotoSaveBtn">Salva foto</button>
<button class="btn danger" id="podPhotoDelBtn">Rimuovi foto</button>
</div>
<div style="margin-top:10px;" class="imgPreview" id="podPhotoPreview" aria-label="Anteprima foto"></div>
<div class="footer-note">Nota: essendo una demo, firma e foto restano nel browser (LocalStorage). Se cambi dispositivo non le vedrai.</div>
</div>
</div>
</div>
</div>
</div>
<script>
/**
* TMS Logistica - Single File App (LocalStorage)
* - Login mock con ruoli
* - CRUD Clienti / Autisti / Spedizioni
* - Vista Autista con stati + POD mock (firma+foto)
* Nessuna dipendenza esterna.
*/
/* ------------------ Storage Keys ------------------ */
const LS = {
DB: "tms_db_v1",
SESSION: "tms_session_v1"
};
/* ------------------ Helpers ------------------ */
const $ = (sel, root=document) => root.querySelector(sel);
const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
function uid(prefix="id"){
return prefix + "_" + Math.random().toString(16).slice(2) + "_" + Date.now().toString(16);
}
function todayISO(){
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth()+1).padStart(2,"0");
const day = String(d.getDate()).padStart(2,"0");
return `${y}-${m}-${day}`;
}
function formatDate(iso){
if(!iso) return "";
const [y,m,d] = iso.split("-");
return `${d}/${m}/${y}`;
}
function toast(msg){
const t = $("#toast");
t.textContent = msg;
t.style.display = "block";
clearTimeout(toast._timer);
toast._timer = setTimeout(()=> t.style.display="none", 2200);
}
function escapeHtml(s){
return String(s ?? "")
.replaceAll("&","&")
.replaceAll("<","<")
.replaceAll(">",">")
.replaceAll('"',""")
.replaceAll("'","'");
}
function statusChip(st){
const map = {
"da_caricare": ["Da caricare","warn"],
"in_transito": ["In transito","info"],
"consegnato": ["Consegnato","good"],
"problema": ["Problema","bad"],
"partito": ["Partito","info"],
"arrivato": ["Arrivato","info"]
};
const [lab, cls] = map[st] || [st,""];
return `<span class="chip ${cls}">${lab}</span>`;
}
function roleChip(role){
const r = role === "admin" ? ["Admin/Ufficio","good"] : ["Autista","info"];
return `<span class="chip ${r[1]}">${r[0]}</span>`;
}
/* ------------------ DB ------------------ */
function loadDB(){
const raw = localStorage.getItem(LS.DB);
if(raw){
try { return JSON.parse(raw); } catch(e){}
}
// Seed iniziale
const seed = {
clients: [
{ id: uid("cli"), ragione_sociale:"Alfa Srl", partita_iva:"01234567890", indirizzo:"Via Roma 10", citta:"Milano", cap:"20100", referente:"Marco Bianchi", telefono:"+39 02 123456", email:"logistica@alfasrl.it", note:"Consegne mattina" },
{ id: uid("cli"), ragione_sociale:"Beta Spa", partita_iva:"09876543210", indirizzo:"Viale Europa 5", citta:"Bologna", cap:"40100", referente:"Sara Rossi", telefono:"+39 051 987654", email:"spedizioni@betaspa.it", note:"Richiede POD foto" }
],
drivers: [
{ id: uid("drv"), nome:"Luca Verdi", telefono:"+39 333 111222", email:"l.verdi@trasporti.it", targa_mezzo_preferito:"AB123CD" },
{ id: uid("drv"), nome:"Gianni Neri", telefono:"+39 333 444555", email:"g.neri@trasporti.it", targa_mezzo_preferito:"EF456GH" }
],
shipments: []
};
// Seed spedizioni
seed.shipments.push({
id: uid("shp"),
cliente_id: seed.clients[0].id,
autista_id: seed.drivers[0].id,
data_ritiro: todayISO(),
indirizzo_ritiro: "Via Roma 10, Milano (MI)",
data_consegna: todayISO(),
indirizzo_consegna: "Via Emilia 20, Bologna (BO)",
colli: 6,
peso_kg: 420,
volume_mc: 1.2,
targa_mezzo: "AB123CD",
stato: "da_caricare",
note: "Fragile",
pod: { signatureDataUrl: null, photoDataUrl: null }
});
seed.shipments.push({
id: uid("shp"),
cliente_id: seed.clients[1].id,
autista_id: seed.drivers[1].id,
data_ritiro: todayISO(),
indirizzo_ritiro: "Viale Europa 5, Bologna (BO)",
data_consegna: todayISO(),
indirizzo_consegna: "Via Garibaldi 7, Firenze (FI)",
colli: 2,
peso_kg: 80,
volume_mc: null,
targa_mezzo: "EF456GH",
stato: "in_transito",
note: "",
pod: { signatureDataUrl: null, photoDataUrl: null }
});
saveDB(seed);
return seed;
}
function saveDB(db){
localStorage.setItem(LS.DB, JSON.stringify(db));
}
function getSession(){
const raw = localStorage.getItem(LS.SESSION);
if(!raw) return null;
try { return JSON.parse(raw); } catch(e){ return null; }
}
function setSession(sess){
localStorage.setItem(LS.SESSION, JSON.stringify(sess));
}
function clearSession(){
localStorage.removeItem(LS.SESSION);
}
/* ------------------ App State ------------------ */
let db = loadDB();
let view = "dashboard"; // dashboard | clients | drivers | shipments
let editing = { clientId:null, driverId:null, shipmentId:null };
let podEditingShipmentId = null;
function currentUser(){
return getSession();
}
/* ------------------ WordPress + Netlify “collegamento” ------------------
Questo index.html è pensato per Netlify. Su WordPress lo "colleghi" via iframe.
-------------------------------------------------------------------------- */
/* ------------------ Render Top Right ------------------ */
function renderTopRight(){
const u = currentUser();
const el = $("#topRight");
if(!u){
el.innerHTML = `<span class="pill">Non loggato</span>`;
return;
}
const name = u.role === "admin" ? "Admin/Ufficio" : (u.driverName || "Autista");
el.innerHTML = `
<span class="pill">${roleChip(u.role)} <span class="muted">|</span> ${escapeHtml(name)}</span>
<button class="btn" id="btnLogout">Logout</button>
`;
$("#btnLogout").onclick = () => {
clearSession();
view = "dashboard";
editing = { clientId:null, driverId:null, shipmentId:null };
toast("Logout effettuato");
render();
};
}
/* ------------------ Login Screen ------------------ */
function renderLogin(){
const app = $("#app");
app.innerHTML = `
<div class="grid" style="gap:14px;">
<div class="card">
<div class="hd">
<div>
<h2>Login (mock)</h2>
<p>Questa è una demo: niente backend. I dati restano nel tuo browser (LocalStorage).</p>
</div>
<div class="pill">Suggerimento: prova <b>Admin</b> per gestire tutto, oppure <b>Autista</b> per vedere solo le spedizioni assegnate.</div>
</div>
<div class="bd">
<div class="two">
<div>
<h3 style="margin:0 0 8px;font-size:14px;">Accesso Admin/Ufficio</h3>
<div class="hint">Clicca per entrare come Admin (mock).</div>
<div class="row" style="margin-top:10px;">
<button class="btn primary" id="loginAdmin">Entra come Admin</button>
</div>
<div class="hr"></div>
<h3 style="margin:0 0 8px;font-size:14px;">Accesso Autista</h3>
<div class="hint">Seleziona un autista esistente e accedi. In vista autista vedrai solo le spedizioni assegnate.</div>
<div style="margin-top:10px;">
<label>Autista</label>
<select id="loginDriverSel"></select>
</div>
<div class="row" style="margin-top:10px;">
<button class="btn primary" id="loginDriver">Entra come Autista</button>
</div>
<div class="footer-note">
Se vuoi cambiare “utenti”, gestisci l’elenco Autisti dalla vista Admin.
</div>
</div>
<div>
<h3 style="margin:0 0 8px;font-size:14px;">Come collegarla a WordPress</h3>
<div class="hint">
1) Pubblica questo file su Netlify.<br>
2) In WordPress crea una pagina “Gestionale”.<br>
3) Inserisci un iframe con l’URL Netlify dell’app.
</div>
<div class="hr"></div>
<div class="hint"><b>Nota importante:</b> usando LocalStorage, ogni dispositivo tiene i suoi dati. Per un gestionale reale (multi-dispositivo), serve un backend + database.</div>
</div>
</div>
</div>
</div>
</div>
`;
// Populate driver selector
const sel = $("#loginDriverSel");
sel.innerHTML = db.drivers.map(d => `<option value="${d.id}">${escapeHtml(d.nome)} (${escapeHtml(d.targa_mezzo_preferito || "—")})</option>`).join("");
$("#loginAdmin").onclick = () => {
setSession({ role:"admin" });
toast("Accesso Admin effettuato");
render();
};
$("#loginDriver").onclick = () => {
const id = sel.value;
const d = db.drivers.find(x => x.id === id);
if(!d){ toast("Seleziona un autista"); return; }
setSession({ role:"driver", driverId: d.id, driverName: d.nome });
toast("Accesso Autista effettuato");
view = "shipments";
render();
};
}
/* ------------------ Navigation Tabs ------------------ */
function renderTabs(){
const u = currentUser();
if(!u) return "";
const isAdmin = u.role === "admin";
const tabs = [];
tabs.push({ key:"dashboard", label:"Dashboard" });
if(isAdmin){
tabs.push({ key:"clients", label:"Clienti" });
tabs.push({ key:"drivers", label:"Autisti" });
tabs.push({ key:"shipments", label:"Spedizioni" });
}else{
tabs.push({ key:"shipments", label:"Le mie spedizioni" });
}
return `
<div class="card" style="margin-bottom:14px;">
<div class="bd">
<div class="tabs">
${tabs.map(t => `
<div class="tab ${view===t.key ? "active":""}" data-tab="${t.key}">${t.label}</div>
`).join("")}
</div>
</div>
</div>
`;
}
function bindTabs(){
$$(".tab").forEach(t => {
t.onclick = () => {
view = t.getAttribute("data-tab");
editing = { clientId:null, driverId:null, shipmentId:null };
render();
};
});
}
/* ------------------ Dashboard ------------------ */
function computeDashboard(){
const u = currentUser();
const isAdmin = u?.role === "admin";
const shipments = isAdmin
? db.shipments
: db.shipments.filter(s => s.autista_id === u.driverId);
const today = todayISO();
const todayCount = shipments.filter(s => s.data_ritiro === today || s.data_consegna === today).length;
const inTransit = shipments.filter(s => s.stato === "in_transito" || s.stato === "partito" || s.stato === "arrivato").length;
const delivered = shipments.filter(s => s.stato === "consegnato").length;
const problems = shipments.filter(s => s.stato === "problema").length;
return { todayCount, inTransit, delivered, problems };
}
function renderDashboard(){
const u = currentUser();
const isAdmin = u.role === "admin";
const k = computeDashboard();
const shipments = isAdmin
? db.shipments
: db.shipments.filter(s => s.autista_id === u.driverId);
// Recent list (max 6)
const recent = [...shipments].sort((a,b) => (b.data_ritiro||"").localeCompare(a.data_ritiro||"")).slice(0,6);
const app = $("#app");
app.innerHTML = `
${renderTabs()}
<div class="grid" style="gap:14px;">
<div class="card">
<div class="hd">
<div>
<h2>Dashboard</h2>
<p>${isAdmin ? "Riepilogo generale operatività." : "Riepilogo delle tue spedizioni assegnate."}</p>
</div>
<div class="row">
${isAdmin ? `<button class="btn primary" id="quickNewShipment">+ Nuova spedizione</button>` : ""}
</div>
</div>
<div class="bd">
<div class="kpis">
<div class="kpi">
<div class="lab">Spedizioni oggi</div>
<div class="val">${k.todayCount}</div>
<div class="sub">ritiro o consegna in data odierna</div>
</div>
<div class="kpi">
<div class="lab">In transito</div>
<div class="val">${k.inTransit}</div>
<div class="sub">partito/arrivato/in transito</div>
</div>
<div class="kpi">
<div class="lab">Consegnate</div>
<div class="val">${k.delivered}</div>
<div class="sub">completate</div>
</div>
<div class="kpi">
<div class="lab">Problemi</div>
<div class="val">${k.problems}</div>
<div class="sub">da gestire</div>
</div>
</div>
<div class="hr"></div>
<div class="row" style="justify-content:space-between;">
<div>
<div style="font-weight:800; margin-bottom:4px;">Ultime spedizioni</div>
<div class="hint">Clicca “Apri” per dettagli (e POD in vista autista).</div>
</div>
<button class="btn" id="goShipments">Vai a Spedizioni</button>
</div>
<div style="margin-top:10px; overflow:auto; max-height: 360px;">
<table class="table">
<thead>
<tr>
<th>Stato</th>
<th>Cliente</th>
<th>Ritiro</th>
<th>Consegna</th>
<th>Autista</th>
<th></th>
</tr>
</thead>
<tbody>
${recent.length ? recent.map(s => {
const c = db.clients.find(x => x.id === s.cliente_id);
const d = db.drivers.find(x => x.id === s.autista_id);
return `
<tr>
<td>${statusChip(s.stato)}</td>
<td>${escapeHtml(c?.ragione_sociale || "—")}</td>
<td>${formatDate(s.data_ritiro)}<div class="muted">${escapeHtml(s.indirizzo_ritiro||"")}</div></td>
<td>${formatDate(s.data_consegna)}<div class="muted">${escapeHtml(s.indirizzo_consegna||"")}</div></td>
<td>${escapeHtml(d?.nome || "—")}</td>
<td><button class="btn" data-open-shp="${s.id}">Apri</button></td>
</tr>
`;
}).join("") : `<tr><td colspan="6" class="muted">Nessuna spedizione.</td></tr>`}
</tbody>
</table>
</div>
</div>
</div>
</div>
`;
if(isAdmin){
$("#quickNewShipment").onclick = () => { view="shipments"; editing.shipmentId=null; render(); setTimeout(()=> $("#shpForm")?.scrollIntoView({behavior:"smooth"}), 80); };
}
$("#goShipments").onclick = () => { view="shipments"; render(); };
$$("[data-open-shp]").forEach(b => b.onclick = () => {
const id = b.getAttribute("data-open-shp");
view="shipments"; editing.shipmentId = id;
render();
setTimeout(()=> $("#shpForm")?.scrollIntoView({behavior:"smooth"}), 80);
});
bindTabs();
}
/* ------------------ Clients ------------------ */
function renderClients(){
const app = $("#app");
const q = (renderClients._q || "").toLowerCase();
const list = db.clients.filter(c => {
const blob = `${c.ragione_sociale} ${c.partita_iva} ${c.citta} ${c.referente} ${c.email}`.toLowerCase();
return blob.includes(q);
});
const editingClient = editing.clientId ? db.clients.find(c => c.id === editing.clientId) : null;
app.innerHTML = `
${renderTabs()}
<div class="grid" style="gap:14px;">
<div class="card">
<div class="hd">
<div>
<h2>Clienti</h2>
<p>Gestisci anagrafiche clienti. CRUD completo su LocalStorage.</p>
</div>
<div class="row">
<input id="clientSearch" placeholder="Cerca per ragione sociale, P.IVA, città, referente..." value="${escapeHtml(renderClients._q||"")}" style="min-width:260px;">
<button class="btn primary" id="newClientBtn">+ Nuovo cliente</button>
</div>
</div>
<div class="bd">
<div style="overflow:auto; max-height: 420px;">
<table class="table">
<thead>
<tr>
<th>Ragione sociale</th>
<th>P.IVA</th>
<th>Città</th>
<th>Referente</th>
<th></th>
</tr>
</thead>
<tbody>
${list.length ? list.map(c => `
<tr>
<td><b>${escapeHtml(c.ragione_sociale)}</b><div class="muted">${escapeHtml(c.email||"")}</div></td>
<td>${escapeHtml(c.partita_iva||"")}</td>
<td>${escapeHtml(c.citta||"")}</td>
<td>${escapeHtml(c.referente||"")}</td>
<td class="row" style="justify-content:flex-end;">
<button class="btn" data-edit-client="${c.id}">Modifica</button>
<button class="btn danger" data-del-client="${c.id}">Elimina</button>
</td>
</tr>
`).join("") : `<tr><td colspan="5" class="muted">Nessun cliente.</td></tr>`}
</tbody>
</table>
</div>
<div class="hr"></div>
<div id="clientFormWrap">
<div class="row" style="justify-content:space-between;">
<div>
<div style="font-weight:800; margin-bottom:4px;">${editingClient ? "Modifica cliente" : "Nuovo cliente"}</div>
<div class="hint">Compila i campi e salva.</div>
</div>
${editingClient ? `<button class="btn" id="cancelEditClient">Annulla modifica</button>` : ""}
</div>
<form id="clientForm" style="margin-top:10px;">
<div class="split">
<div>
<label>Ragione sociale *</label>
<input required name="ragione_sociale" value="${escapeHtml(editingClient?.ragione_sociale||"")}">
</div>
<div>
<label>Partita IVA</label>
<input name="partita_iva" value="${escapeHtml(editingClient?.partita_iva||"")}">
</div>
</div>
<div class="split" style="margin-top:10px;">
<div>
<label>Indirizzo</label>
<input name="indirizzo" value="${escapeHtml(editingClient?.indirizzo||"")}">
</div>
<div>
<label>Città</label>
<input name="citta" value="${escapeHtml(editingClient?.citta||"")}">
</div>
<div>
<label>CAP</label>
<input name="cap" value="${escapeHtml(editingClient?.cap||"")}">
</div>
</div>
<div class="split" style="margin-top:10px;">
<div>
<label>Referente</label>
<input name="referente" value="${escapeHtml(editingClient?.referente||"")}">
</div>
<div>
<label>Telefono</label>
<input name="telefono" value="${escapeHtml(editingClient?.telefono||"")}">
</div>
<div>
<label>Email</label>
<input type="email" name="email" value="${escapeHtml(editingClient?.email||"")}">
</div>
</div>
<div style="margin-top:10px;">
<label>Note</label>
<textarea name="note">${escapeHtml(editingClient?.note||"")}</textarea>
</div>
<div class="row" style="margin-top:10px;">
<button class="btn primary" type="submit">${editingClient ? "Salva modifiche" : "Crea cliente"}</button>
</div>
</form>
</div>
</div>
</div>
</div>
`;
$("#clientSearch").oninput = (e) => {
renderClients._q = e.target.value || "";
renderClients();
};
$("#newClientBtn").onclick = () => {
editing.clientId = null;
renderClients._q = renderClients._q || "";
renderClients();
setTimeout(()=> $("#clientFormWrap")?.scrollIntoView({behavior:"smooth"}), 80);
};
if(editingClient){
$("#cancelEditClient").onclick = () => {
editing.clientId = null;
renderClients();
};
}
$$("[data-edit-client]").forEach(b => b.onclick = () => {
editing.clientId = b.getAttribute("data-edit-client");
renderClients();
setTimeout(()=> $("#clientFormWrap")?.scrollIntoView({behavior:"smooth"}), 80);
});
$$("[data-del-client]").forEach(b => b.onclick = () => {
const id = b.getAttribute("data-del-client");
// impedisci delete se usato in spedizioni
const used = db.shipments.some(s => s.cliente_id === id);
if(used){
toast("Impossibile eliminare: cliente usato in una spedizione.");
return;
}
db.clients = db.clients.filter(c => c.id !== id);
saveDB(db);
toast("Cliente eliminato");
if(editing.clientId === id) editing.clientId = null;
renderClients();
});
$("#clientForm").onsubmit = (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const obj = Object.fromEntries(fd.entries());
if(!obj.ragione_sociale.trim()){
toast("Ragione sociale obbligatoria");
return;
}
if(editingClient){
Object.assign(editingClient, obj);
toast("Cliente aggiornato");
}else{
db.clients.push({ id: uid("cli"), ...obj });
toast("Cliente creato");
e.target.reset();
}
saveDB(db);
renderClients();
};
bindTabs();
}
/* ------------------ Drivers ------------------ */
function renderDrivers(){
const app = $("#app");
const editingDriver = editing.driverId ? db.drivers.find(d => d.id === editing.driverId) : null;
app.innerHTML = `
${renderTabs()}
<div class="grid" style="gap:14px;">
<div class="card">
<div class="hd">
<div>
<h2>Autisti</h2>
<p>Gestisci anagrafiche autisti. Gli autisti servono anche per il login mock.</p>
</div>
<div class="row">
<button class="btn primary" id="newDriverBtn">+ Nuovo autista</button>
</div>
</div>
<div class="bd">
<div style="overflow:auto; max-height: 420px;">
<table class="table">
<thead>
<tr>
<th>Nome</th>
<th>Telefono</th>
<th>Email</th>
<th>Targa preferita</th>
<th></th>
</tr>
</thead>
<tbody>
${db.drivers.length ? db.drivers.map(d => `
<tr>
<td><b>${escapeHtml(d.nome)}</b></td>
<td>${escapeHtml(d.telefono||"")}</td>
<td>${escapeHtml(d.email||"")}</td>
<td>${escapeHtml(d.targa_mezzo_preferito||"")}</td>
<td class="row" style="justify-content:flex-end;">
<button class="btn" data-edit-driver="${d.id}">Modifica</button>
<button class="btn danger" data-del-driver="${d.id}">Elimina</button>
</td>
</tr>
`).join("") : `<tr><td colspan="5" class="muted">Nessun autista.</td></tr>`}
</tbody>
</table>
</div>
<div class="hr"></div>
<div id="driverFormWrap">
<div class="row" style="justify-content:space-between;">
<div>
<div style="font-weight:800; margin-bottom:4px;">${editingDriver ? "Modifica autista" : "Nuovo autista"}</div>
<div class="hint">Compila i campi e salva.</div>
</div>
${editingDriver ? `<button class="btn" id="cancelEditDriver">Annulla modifica</button>` : ""}
</div>
<form id="driverForm" style="margin-top:10px;">
<div class="split">
<div>
<label>Nome *</label>
<input required name="nome" value="${escapeHtml(editingDriver?.nome||"")}">
</div>
<div>
<label>Telefono</label>
<input name="telefono" value="${escapeHtml(editingDriver?.telefono||"")}">
</div>
</div>
<div class="split" style="margin-top:10px;">
<div>
<label>Email</label>
<input type="email" name="email" value="${escapeHtml(editingDriver?.email||"")}">
</div>
<div>
<label>Targa mezzo preferito (opzionale)</label>
<input name="targa_mezzo_preferito" value="${escapeHtml(editingDriver?.targa_mezzo_preferito||"")}">
</div>
</div>
<div class="row" style="margin-top:10px;">
<button class="btn primary" type="submit">${editingDriver ? "Salva modifiche" : "Crea autista"}</button>
</div>
</form>
</div>
</div>
</div>
</div>
`;
$("#newDriverBtn").onclick = () => {
editing.driverId = null;
renderDrivers();
setTimeout(()=> $("#driverFormWrap")?.scrollIntoView({behavior:"smooth"}), 80);
};
if(editingDriver){
$("#cancelEditDriver").onclick = () => {
editing.driverId = null;
renderDrivers();
};
}
$$("[data-edit-driver]").forEach(b => b.onclick = () => {
editing.driverId = b.getAttribute("data-edit-driver");
renderDrivers();
setTimeout(()=> $("#driverFormWrap")?.scrollIntoView({behavior:"smooth"}), 80);
});
$$("[data-del-driver]").forEach(b => b.onclick = () => {
const id = b.getAttribute("data-del-driver");
const used = db.shipments.some(s => s.autista_id === id);
if(used){
toast("Impossibile eliminare: autista usato in una spedizione.");
return;
}
db.drivers = db.drivers.filter(d => d.id !== id);
saveDB(db);
toast("Autista eliminato");
if(editing.driverId === id) editing.driverId = null;
renderDrivers();
});
$("#driverForm").onsubmit = (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const obj = Object.fromEntries(fd.entries());
if(!obj.nome.trim()){
toast("Nome obbligatorio");
return;
}
if(editingDriver){
Object.assign(editingDriver, obj);
toast("Autista aggiornato");
}else{
db.drivers.push({ id: uid("drv"), ...obj });
toast("Autista creato");
e.target.reset();
}
saveDB(db);
renderDrivers();
};
bindTabs();
}
/* ------------------ Shipments ------------------ */
function renderShipments(){
const u = currentUser();
const isAdmin = u.role === "admin";
const app = $("#app");
// filters
const f = renderShipments._f || { q:"", status:"", date:"" };
const base = isAdmin ? db.shipments : db.shipments.filter(s => s.autista_id === u.driverId);
const list = base.filter(s => {
const c = db.clients.find(x => x.id === s.cliente_id);
const d = db.drivers.find(x => x.id === s.autista_id);
const blob = `${c?.ragione_sociale||""} ${s.indirizzo_ritiro||""} ${s.indirizzo_consegna||""} ${d?.nome||""} ${s.targa_mezzo||""}`.toLowerCase();
const qOk = !f.q || blob.includes(f.q.toLowerCase());
const stOk = !f.status || s.stato === f.status;
const dateOk = !f.date || s.data_ritiro === f.date || s.data_consegna === f.date;
return qOk && stOk && dateOk;
});
const editingShipment = editing.shipmentId ? db.shipments.find(s => s.id === editing.shipmentId) : null;
app.innerHTML = `
${renderTabs()}
<div class="grid" style="gap:14px;">
<div class="card">
<div class="hd">
<div>
<h2>${isAdmin ? "Spedizioni" : "Le mie spedizioni"}</h2>
<p>${isAdmin ? "Crea e assegna carichi agli autisti." : "Aggiorna stati e allega POD (firma/foto) a consegna avvenuta."}</p>
</div>
<div class="row">
<input id="shpSearch" placeholder="Cerca (cliente, indirizzi, targa, autista...)" value="${escapeHtml(f.q||"")}" style="min-width:260px;">
<select id="shpStatusFilter" style="max-width:180px;">
<option value="">Tutti gli stati</option>
<option value="da_caricare" ${f.status==="da_caricare"?"selected":""}>Da caricare</option>
<option value="in_transito" ${f.status==="in_transito"?"selected":""}>In transito</option>
<option value="consegnato" ${f.status==="consegnato"?"selected":""}>Consegnato</option>
<option value="problema" ${f.status==="problema"?"selected":""}>Problema</option>
<option value="partito" ${f.status==="partito"?"selected":""}>Partito</option>
<option value="arrivato" ${f.status==="arrivato"?"selected":""}>Arrivato</option>
</select>
<input id="shpDateFilter" type="date" value="${escapeHtml(f.date||"")}" style="max-width:170px;">
${isAdmin ? `<button class="btn primary" id="newShipmentBtn">+ Nuova spedizione</button>` : ""}
</div>
</div>
<div class="bd">
<div style="overflow:auto; max-height: 440px;">
<table class="table">
<thead>
<tr>
<th>Stato</th>
<th>Cliente</th>
<th>Ritiro</th>
<th>Consegna</th>
<th>Autista</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
${list.length ? list.map(s => {
const c = db.clients.find(x => x.id === s.cliente_id);
const d = db.drivers.find(x => x.id === s.autista_id);
const podOk = (s.pod?.signatureDataUrl || s.pod?.photoDataUrl) ? `<span class="chip good">POD</span>` : `<span class="chip">—</span>`;
return `
<tr>
<td>${statusChip(s.stato)} ${podOk}</td>
<td><b>${escapeHtml(c?.ragione_sociale || "—")}</b><div class="muted">${escapeHtml(s.note||"")}</div></td>
<td>${formatDate(s.data_ritiro)}<div class="muted">${escapeHtml(s.indirizzo_ritiro||"")}</div></td>
<td>${formatDate(s.data_consegna)}<div class="muted">${escapeHtml(s.indirizzo_consegna||"")}</div></td>
<td>${escapeHtml(d?.nome || "—")}</td>
<td>
<div class="row">
<button class="btn" data-open-shp="${s.id}">Apri</button>
${isAdmin ? `<button class="btn danger" data-del-shp="${s.id}">Elimina</button>` : ""}
${!isAdmin ? `<button class="btn primary" data-pod-shp="${s.id}">POD</button>` : ""}
</div>
</td>
</tr>
`;
}).join("") : `<tr><td colspan="6" class="muted">Nessuna spedizione trovata.</td></tr>`}
</tbody>
</table>
</div>
<div class="hr"></div>
<div id="shpForm">
${isAdmin ? renderShipmentFormAdmin(editingShipment) : renderShipmentDriverPanel(editingShipment, u.driverId)}
</div>
</div>
</div>
</div>
`;
// bind filters
$("#shpSearch").oninput = (e)=>{ f.q = e.target.value || ""; renderShipments._f = f; renderShipments(); };
$("#shpStatusFilter").onchange = (e)=>{ f.status = e.target.value || ""; renderShipments._f = f; renderShipments(); };
$("#shpDateFilter").onchange = (e)=>{ f.date = e.target.value || ""; renderShipments._f = f; renderShipments(); };
if(isAdmin){
$("#newShipmentBtn").onclick = () => {
editing.shipmentId = null;
renderShipments();
setTimeout(()=> $("#shpForm")?.scrollIntoView({behavior:"smooth"}), 80);
};
}
$$("[data-open-shp]").forEach(b => b.onclick = () => {
editing.shipmentId = b.getAttribute("data-open-shp");
renderShipments();
setTimeout(()=> $("#shpForm")?.scrollIntoView({behavior:"smooth"}), 80);
});
$$("[data-del-shp]").forEach(b => b.onclick = () => {
const id = b.getAttribute("data-del-shp");
db.shipments = db.shipments.filter(s => s.id !== id);
saveDB(db);
toast("Spedizione eliminata");
if(editing.shipmentId === id) editing.shipmentId = null;
renderShipments();
});
$$("[data-pod-shp]").forEach(b => b.onclick = () => {
const id = b.getAttribute("data-pod-shp");
openPODModal(id);
});
// bind admin form submit
const form = $("#shipmentFormAdmin");
if(form){
form.onsubmit = (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const obj = Object.fromEntries(fd.entries());
// normalize numeric
obj.colli = obj.colli ? Number(obj.colli) : 0;
obj.peso_kg = obj.peso_kg ? Number(obj.peso_kg) : 0;
obj.volume_mc = obj.volume_mc ? Number(obj.volume_mc) : null;
if(!obj.cliente_id){ toast("Seleziona un cliente"); return; }
if(!obj.autista_id){ toast("Seleziona un autista"); return; }
if(!obj.data_ritiro){ toast("Inserisci data ritiro"); return; }
if(!obj.indirizzo_ritiro.trim()){ toast("Inserisci luogo ritiro"); return; }
if(!obj.data_consegna){ toast("Inserisci data consegna"); return; }
if(!obj.indirizzo_consegna.trim()){ toast("Inserisci luogo consegna"); return; }
if(!obj.stato){ toast("Seleziona stato"); return; }
if(editingShipment){
Object.assign(editingShipment, obj);
// garantisci pod struttura
editingShipment.pod = editingShipment.pod || { signatureDataUrl:null, photoDataUrl:null };
toast("Spedizione aggiornata");
}else{
db.shipments.push({
id: uid("shp"),
...obj,
pod: { signatureDataUrl:null, photoDataUrl:null }
});
toast("Spedizione creata");
e.target.reset();
}
saveDB(db);
renderShipments();
};
}
// bind driver quick status buttons + pod
const panel = $("#driverPanel");
if(panel){
const sid = panel.getAttribute("data-sid");
if(sid){
$("#btnPartito")?.addEventListener("click", ()=> driverSetStatus(sid, "partito"));
$("#btnArrivato")?.addEventListener("click", ()=> driverSetStatus(sid, "arrivato"));
$("#btnConsegnato")?.addEventListener("click", ()=> driverSetStatus(sid, "consegnato"));
$("#btnProblema")?.addEventListener("click", ()=> driverSetStatus(sid, "problema"));
$("#btnPOD")?.addEventListener("click", ()=> openPODModal(sid));
}
}
bindTabs();
}
function renderShipmentFormAdmin(editingShipment){
const cOptions = db.clients.map(c => `<option value="${c.id}" ${editingShipment?.cliente_id===c.id?"selected":""}>${escapeHtml(c.ragione_sociale)}</option>`).join("");
const dOptions = db.drivers.map(d => `<option value="${d.id}" ${editingShipment?.autista_id===d.id?"selected":""}>${escapeHtml(d.nome)}</option>`).join("");
return `
<div class="row" style="justify-content:space-between;">
<div>
<div style="font-weight:800; margin-bottom:4px;">${editingShipment ? "Dettaglio / Modifica spedizione" : "Nuova spedizione"}</div>
<div class="hint">Crea o aggiorna una scheda carico/spedizione.</div>
</div>
${editingShipment ? `<button class="btn" id="cancelEditShipment">Annulla modifica</button>` : ""}
</div>
<form id="shipmentFormAdmin" style="margin-top:10px;">
<div class="split">
<div>
<label>Cliente *</label>
<select name="cliente_id" required>
<option value="">Seleziona cliente</option>
${cOptions}
</select>
</div>
<div>
<label>Autista assegnato *</label>
<select name="autista_id" required>
<option value="">Seleziona autista</option>
${dOptions}
</select>
</div>
</div>
<div class="split" style="margin-top:10px;">
<div>
<label>Data ritiro *</label>
<input type="date" name="data_ritiro" value="${escapeHtml(editingShipment?.data_ritiro||todayISO())}" required>
</div>
<div>
<label>Luogo ritiro (indirizzo completo) *</label>
<input name="indirizzo_ritiro" value="${escapeHtml(editingShipment?.indirizzo_ritiro||"")}" required>
</div>
</div>
<div class="split" style="margin-top:10px;">
<div>
<label>Data consegna *</label>
<input type="date" name="data_consegna" value="${escapeHtml(editingShipment?.data_consegna||todayISO())}" required>
</div>
<div>
<label>Luogo consegna (indirizzo completo) *</label>
<input name="indirizzo_consegna" value="${escapeHtml(editingShipment?.indirizzo_consegna||"")}" required>
</div>
</div>
<div class="split" style="margin-top:10px;">
<div>
<label>Numero colli</label>
<input type="number" min="0" name="colli" value="${escapeHtml(editingShipment?.colli ?? "")}">
</div>
<div>
<label>Peso (kg)</label>
<input type="number" min="0" step="0.1" name="peso_kg" value="${escapeHtml(editingShipment?.peso_kg ?? "")}">
</div>
<div>
<label>Volume (mc) (opzionale)</label>
<input type="number" min="0" step="0.01" name="volume_mc" value="${escapeHtml(editingShipment?.volume_mc ?? "")}">
</div>
</div>
<div class="split" style="margin-top:10px;">
<div>
<label>Mezzo / Targa</label>
<input name="targa_mezzo" value="${escapeHtml(editingShipment?.targa_mezzo||"")}">
</div>
<div>
<label>Stato spedizione *</label>
<select name="stato" required>
<option value="da_caricare" ${editingShipment?.stato==="da_caricare"?"selected":""}>Da caricare</option>
<option value="in_transito" ${editingShipment?.stato==="in_transito"?"selected":""}>In transito</option>
<option value="consegnato" ${editingShipment?.stato==="consegnato"?"selected":""}>Consegnato</option>
<option value="problema" ${editingShipment?.stato==="problema"?"selected":""}>Problema</option>
<option value="partito" ${editingShipment?.stato==="partito"?"selected":""}>Partito</option>
<option value="arrivato" ${editingShipment?.stato==="arrivato"?"selected":""}>Arrivato</option>
</select>
</div>
</div>
<div style="margin-top:10px;">
<label>Note</label>
<textarea name="note">${escapeHtml(editingShipment?.note||"")}</textarea>
</div>
<div class="row" style="margin-top:10px;">
<button class="btn primary" type="submit">${editingShipment ? "Salva modifiche" : "Crea spedizione"}</button>
</div>
</form>
`;
}
function renderShipmentDriverPanel(editingShipment, driverId){
// se non c'è selezione, prendi la prima del driver
const my = db.shipments.filter(s => s.autista_id === driverId);
const current = editingShipment && editingShipment.autista_id === driverId ? editingShipment : (my[0] || null);
if(!current){
return `<div class="hint">Nessuna spedizione assegnata.</div>`;
}
const c = db.clients.find(x => x.id === current.cliente_id);
const hasPOD = !!(current.pod?.signatureDataUrl || current.pod?.photoDataUrl);
// mostra detail + quick actions
return `
<div id="driverPanel" data-sid="${current.id}">
<div class="row" style="justify-content:space-between;">
<div>
<div style="font-weight:800; margin-bottom:4px;">Dettaglio spedizione</div>
<div class="hint">Cliente: <b>${escapeHtml(c?.ragione_sociale||"—")}</b> — ${statusChip(current.stato)} ${hasPOD ? `<span class="chip good">POD salvato</span>` : `<span class="chip">POD mancante</span>`}</div>
</div>
<button class="btn primary" id="btnPOD">Apri POD</button>
</div>
<div class="card" style="margin-top:10px; background: rgba(15,23,42,.55); box-shadow:none;">
<div class="bd">
<div class="split">
<div>
<div class="muted" style="font-size:12px;">Ritiro</div>
<div><b>${formatDate(current.data_ritiro)}</b></div>
<div class="muted">${escapeHtml(current.indirizzo_ritiro||"")}</div>
</div>
<div>
<div class="muted" style="font-size:12px;">Consegna</div>
<div><b>${formatDate(current.data_consegna)}</b></div>
<div class="muted">${escapeHtml(current.indirizzo_consegna||"")}</div>
</div>
</div>
<div class="hr"></div>
<div class="split">
<div><span class="chip">Colli: <b>${Number(current.colli||0)}</b></span></div>
<div><span class="chip">Peso: <b>${Number(current.peso_kg||0)}</b> kg</span></div>
<div><span class="chip">Targa: <b>${escapeHtml(current.targa_mezzo||"—")}</b></span></div>
</div>
<div class="hr"></div>
<div class="hint">${escapeHtml(current.note||"")}</div>
</div>
</div>
<div class="hr"></div>
<div>
<div style="font-weight:800; margin-bottom:6px;">Aggiorna stato</div>
<div class="row">
<button class="btn" id="btnPartito">Partito</button>
<button class="btn" id="btnArrivato">Arrivato</button>
<button class="btn primary" id="btnConsegnato">Consegnato</button>
<button class="btn danger" id="btnProblema">Problema</button>
</div>
<div class="footer-note">Consiglio: a “Consegnato” apri POD e salva almeno firma o foto.</div>
</div>
</div>
`;
}
function driverSetStatus(shipmentId, newStatus){
const s = db.shipments.find(x => x.id === shipmentId);
if(!s) return;
s.stato = newStatus;
saveDB(db);
toast("Stato aggiornato: " + newStatus.replaceAll("_"," "));
renderShipments();
}
/* ------------------ POD Modal (Signature + Photo) ------------------ */
const modal = $("#modalPOD");
const sigCanvas = $("#sigCanvas");
const sigCtx = sigCanvas.getContext("2d");
let sigDrawing = false;
let sigHasInk = false;
let pendingPhotoDataUrl = null;
function openPODModal(shipmentId){
podEditingShipmentId = shipmentId;
const s = db.shipments.find(x => x.id === shipmentId);
if(!s) return;
const c = db.clients.find(x => x.id === s.cliente_id);
$("#podTitle").textContent = "POD - Prova di consegna";
$("#podSubtitle").textContent = `Spedizione: ${c?.ragione_sociale || "—"} • Stato: ${s.stato.replaceAll("_"," ")}`;
// reset canvas & load existing signature
clearSignature();
if(s.pod?.signatureDataUrl){
const img = new Image();
img.onload = () => {
// draw to canvas with proper scaling
sigCtx.clearRect(0,0,sigCanvas.width,sigCanvas.height);
sigCtx.drawImage(img, 0, 0, sigCanvas.width, sigCanvas.height);
sigHasInk = true;
};
img.src = s.pod.signatureDataUrl;
}
// load photo preview
pendingPhotoDataUrl = null;
$("#podPhotoInput").value = "";
renderPhotoPreview(s.pod?.photoDataUrl || null);
modal.style.display = "block";
}
function closePODModal(){
modal.style.display = "none";
podEditingShipmentId = null;
pendingPhotoDataUrl = null;
}
function clearSignature(){
sigCtx.clearRect(0,0,sigCanvas.width,sigCanvas.height);
// background subtle
sigCtx.fillStyle = "rgba(3,7,18,.15)";
sigCtx.fillRect(0,0,sigCanvas.width,sigCanvas.height);
sigCtx.fillStyle = "rgba(229,231,235,1)";
sigHasInk = false;
}
function renderPhotoPreview(dataUrl){
const box = $("#podPhotoPreview");
if(!dataUrl){
box.innerHTML = `<div class="hint" style="padding:12px;">Nessuna foto salvata.</div>`;
return;
}
box.innerHTML = `<img alt="Foto POD" src="${dataUrl}">`;
}
// Signature drawing (mouse + touch)
function getCanvasPos(e){
const rect = sigCanvas.getBoundingClientRect();
const touch = e.touches && e.touches[0];
const clientX = touch ? touch.clientX : e.clientX;
const clientY = touch ? touch.clientY : e.clientY;
const x = (clientX - rect.left) * (sigCanvas.width / rect.width);
const y = (clientY - rect.top) * (sigCanvas.height / rect.height);
return {x,y};
}
function sigStart(e){
e.preventDefault();
sigDrawing = true;
const p = getCanvasPos(e);
sigCtx.beginPath();
sigCtx.moveTo(p.x, p.y);
}
function sigMove(e){
if(!sigDrawing) return;
e.preventDefault();
const p = getCanvasPos(e);
sigCtx.lineWidth = 4;
sigCtx.lineCap = "round";
sigCtx.strokeStyle = "rgba(229,231,235,.95)";
sigCtx.lineTo(p.x, p.y);
sigCtx.stroke();
sigHasInk = true;
}
function sigEnd(e){
if(!sigDrawing) return;
e.preventDefault();
sigDrawing = false;
sigCtx.closePath();
}
sigCanvas.addEventListener("mousedown", sigStart);
sigCanvas.addEventListener("mousemove", sigMove);
window.addEventListener("mouseup", sigEnd);
sigCanvas.addEventListener("touchstart", sigStart, {passive:false});
sigCanvas.addEventListener("touchmove", sigMove, {passive:false});
sigCanvas.addEventListener("touchend", sigEnd, {passive:false});
$("#podCloseBtn").onclick = closePODModal;
modal.addEventListener("click", (e)=>{ if(e.target === modal) closePODModal(); });
$("#sigClearBtn").onclick = () => {
clearSignature();
toast("Firma pulita");
};
$("#sigSaveBtn").onclick = () => {
if(!podEditingShipmentId) return;
const s = db.shipments.find(x => x.id === podEditingShipmentId);
if(!s) return;
if(!sigHasInk){
toast("Disegna una firma prima di salvare");
return;
}
s.pod = s.pod || { signatureDataUrl:null, photoDataUrl:null };
s.pod.signatureDataUrl = sigCanvas.toDataURL("image/png");
saveDB(db);
toast("Firma POD salvata");
// refresh table badges
render();
};
$("#podPhotoInput").addEventListener("change", async (e)=>{
const file = e.target.files && e.target.files[0];
if(!file) return;
// convert to base64 dataURL
const reader = new FileReader();
reader.onload = () => {
pendingPhotoDataUrl = reader.result;
renderPhotoPreview(pendingPhotoDataUrl);
};
reader.readAsDataURL(file);
});
$("#podPhotoSaveBtn").onclick = () => {
if(!podEditingShipmentId) return;
const s = db.shipments.find(x => x.id === podEditingShipmentId);
if(!s) return;
if(!pendingPhotoDataUrl){
toast("Carica una foto prima di salvare");
return;
}
s.pod = s.pod || { signatureDataUrl:null, photoDataUrl:null };
s.pod.photoDataUrl = pendingPhotoDataUrl;
saveDB(db);
toast("Foto POD salvata");
render();
};
$("#podPhotoDelBtn").onclick = () => {
if(!podEditingShipmentId) return;
const s = db.shipments.find(x => x.id === podEditingShipmentId);
if(!s) return;
s.pod = s.pod || { signatureDataUrl:null, photoDataUrl:null };
s.pod.photoDataUrl = null;
pendingPhotoDataUrl = null;
saveDB(db);
renderPhotoPreview(null);
toast("Foto rimossa");
render();
};
// init signature background
clearSignature();
/* ------------------ Main Render ------------------ */
function render(){
db = loadDB(); // reload (safe)
renderTopRight();
const u = currentUser();
if(!u){
renderLogin();
return;
}
// route
if(view === "dashboard") renderDashboard();
else if(view === "clients") renderClients();
else if(view === "drivers") renderDrivers();
else if(view === "shipments") renderShipments();
else renderDashboard();
// bind cancel buttons (admin forms)
$("#cancelEditShipment")?.addEventListener("click", ()=>{
editing.shipmentId = null;
renderShipments();
});
}
// boot
render();
</script>
</body>
</html>