Dashboard

Il lavoro di oggi, senza fogli sparsi

▣

Buongiorno 👋

Qui vedi cosa richiede attenzione prima di aprire WhatsApp, Excel o il calendario.

Lead aperti
0
Da contattare o quotare
Preventivi aperti
0
In attesa di decisione
Pipeline €
€0
Valore offerte non perse
Commesse attive
0
Ordine, arrivo o posa
Margine medio
0%
Preventivi presenti

Flusso commerciale

Quante pratiche hai in ogni fase

Da fare oggi

Priorità generate dallo stato delle pratiche

Commesse in avanzamento

Valore, fase e prossima azione

CRM commerciale

Ogni card è una pratica reale: spostarla aggiorna stato, timeline e prossima azione.

Opportunità aperte
0
Prima della conferma
Valore pipeline
€0
Opportunità commerciali aperte
Follow-up scaduti
0
Da gestire subito
Tasso chiusura
0%
Vinti / pratiche chiuse
Ciclo medio
—
Creazione → conferma

Richiede attenzione

Problemi commerciali rilevati automaticamente

Performance per origine

Volumi, valore e chiusure

Opportunità perse

Motivo di perdita registrato

Pratiche

Un unico contenitore dalla richiesta iniziale alla chiusura della commessa

Seleziona una pratica oppure creane una nuova.

Clienti & lead

Un'anagrafica unica dalla prima richiesta al post-vendita

ClienteContattiCittàStatoProssima azione

Costi & marginalità

Distinta tecnica → costo reale → prezzo obiettivo → prezzo minimo → margine dopo sconto.

Configurazioni valorizzate
0
Con sistema e distinta
Costo tecnico totale
€0
Configurazioni correnti
Vendita obiettivo
€0
Alla marginalità target
Margine target
0%
Policy aziendale
Sotto soglia
0
Preventivi da controllare

Configurazioni valorizzate

Apri una configurazione per vedere costo, prezzo minimo e simulatore sconti.

Pratica / serramentoSistemaCostoPrezzo targetMinimoStato

Costi componenti

Listino acquisto, sconto fornitore, sfrido e tempo interno alimentano il motore P7.

Componente€/unitàSconto %Sfrido %Minuti

Preventivi

Preventivo 1 è ultra rapido; Preventivo 2 e 3 aggiungono progressivamente dettagli commerciali e tecnici.

Profilo attivo: Preventivo 1
Preventivo 1 · essenziale e veloce.

Nuovo preventivo

Compila intestazione e aggiungi le configurazioni.

BOZZAv1

Righe del preventivo

Ogni riga mantiene configurazione tecnica, costo e margine P7.

0 righe

Preventivo 1 · Ultra Rapido

Scegli il serramento, inserisci misure e quantità, poi aggiungilo. Tutti gli altri valori usano i default del profilo.

dati locali
Prezzo indicativo della riga€0calcolo dai costi e dal margine impostato
DA CALCOLARE

Serramenti inseriti

Puoi correggere o duplicare le righe dalla modalità Commerciale/Completa.

0
TOTALE PREVENTIVO IVA INCLUSA€0

Aggiungi serramento / optional

Puoi arrivare dal Configuratore 2D oppure compilare una riga manualmente.

Modalità rapida: scegli un preset o compila tipo, quantità, dimensioni, materiale e apertura. Vetro, colore, posa, IVA e margine usano i default aziendali ma restano modificabili nelle modalità superiori.

Preventivi salvati

Storico versioni e stato commerciale.

DocumentoClienteRigheTotaleMargineStatoAzioni
TOTALE PREVENTIVO€0
\n

Commesse

Dalla conferma del preventivo alla posa completata

Consuntivo economico reale

Preventivato vs reale: acquisti, ore, viaggio, imprevisti e margine finale.

P14 · Actual margin
Ricavi commesse
€0
Preventivi accettati
Costo previsto
€0
P7 / preventivo
Costo reale
€0
Acquisti + lavoro + extra
Margine reale
0%
Sulle commesse valorizzate
Fuori budget
0
Costo reale > previsto

Commesse

Apri una commessa per capire dove cambia il margine.

Costi extra / imprevisti

Inserisci soltanto costi non già presenti in ordini e ore effettive.

Acquisti & ordini fornitori

Distinta tecnica → fornitore → ordine → consegna parziale → ricezione completa.

P10 · Procurement
Ordini aperti
0
Non ancora ricevuti
Valore ordinato
€0
Ordini attivi
In ritardo
0
Consegna prevista superata
In arrivo 7 gg
0
Consegne pianificate
Ricevuto
0%
Quantità complessive

Genera ordini dalla commessa

Le righe vengono raggruppate automaticamente in base al fornitore assegnato ai componenti.

Le commesse legacy senza configurazioni tecniche vengono trasformate in una riga generica da assegnare al fornitore.

Ordini di acquisto

Stato operativo, ritardi, consegne e problemi.

Fornitori

Lead time e associazione con produttori/componenti.

Rilievo digitale avanzato

Un solo sopralluogo, tutti i vani, dati tecnici e foto legati al singolo serramento

Disponibile offline nel prototipo
Vani totali0
Rilievi completi0
Da completare0
Avanzamento0%
Seleziona una pratica e un vano per iniziare il rilievo.

Storico rilievi

Ogni voce resta collegata a immobile, ambiente, vano e serramento

Configuratore tecnico 2D

Dal vano rilevato al serramento configurato: geometria, apertura, finiture e distinta preliminare nello stesso oggetto.

Seleziona un serramentoRev. —
Geometria e apertura—
Prodottobase per catalogo P6
Rule Enginegeometria + compatibilità catalogo
Distinta tecnica risoltacomponenti compatibili del sistema
Economia configurazioneP7 · costo distinto e prezzo consigliato
Seleziona un sistema tecnico.
Costo reale stimato€0
Prezzo obiettivo€0
Prezzo minimo€0
Revisionistorico tecnico

Serramento

Seleziona un serramento dalla pratica

Da configurare
Vista interna
Dimensione—
Superficie—
Campiture—
Ante mobili—

Il disegno è parametrico e rappresenta la configurazione tecnica salvata sul singolo serramento. Limiti dimensionali, compatibilità di sistema e componenti vengono verificati dal Rule Engine P6 prima del salvataggio.

Magazzino & Material Readiness

Ricevuto, danneggiato, ubicazione e gate automatico prima della posa.

P11 · Readiness Engine
Commesse pronte
0
Posa sbloccata
Commesse bloccate
0
Materiale incompleto
Con problemi
0
Danneggiato / sostituzione
Lotti a stock
0
Ricezioni fisiche
Stock verificato
0%
Lotti buoni verificati

Readiness commesse

Il motore usa ordini, ricezioni, danneggiati e sostituzioni aperte.

Stock fisico

Ogni ricezione genera un lotto associato alla commessa e a un'ubicazione.

Ubicazioni

Area ricevimento, scaffali, zona commesse e quarantena.

Planning operativo

Squadre, capacità, readiness, assenze e conflitti in un'unica vista settimanale.

Attività settimana
0
Pose + sopralluoghi
Pose pronte
0
Readiness P11 OK
Conflitti
0
Sovrapposizioni / assenze
Carico medio
0%
Capacità squadre utilizzata
Backlog posa
0
Commesse da pianificare
Settimana
🟢 Posa pronta🟠 Override🔵 Sopralluogo🔴 Bloccata

Interventi pianificati

Elenco operativo con readiness e stato esecuzione.

Data / oraClienteCommessa / praticaSquadraDurataReadinessStato

App tecnico & posatore

Agenda mobile, timer, checklist, foto, problemi, firma e verbale anche offline.

● Online
Modalità standalone HTML: dati, timer, checklist, foto e coda operativa vengono salvati direttamente nel browser anche aprendo questo singolo file.
Oggi
0
Attività pianificate
In corso
0
Sessioni aperte
Problemi aperti
0
Da gestire
Coda offline
0
Eventi da inviare al backend
Seleziona un'attività dall'agenda.

Impostazioni

Configura SerramentiOS senza entrare nelle schermate tecniche. Le modifiche sono salvate nel workspace corrente.

v1.7 · Settings 2.0

Dati aziendali

Vengono utilizzati anche nei documenti cliente.

Nessun logo

Preventivi

Numerazione, validità, condizioni standard e i tre profili Preventivo 1 / 2 / 3.

Commerciale & CRM

Tempi di follow-up e soglie che generano avvisi nel CRM.

Default tecnici

Valori suggeriti nelle nuove configurazioni e nei rilievi.

Questi sono suggerimenti iniziali: il configuratore e i profili preventivo possono comunque sovrascriverli quando serve.

Costi & marginalità

I valori alimentano il motore economico P7 e il consuntivo P14.

Planning

Orari e valori predefiniti quando si pianifica una nuova attività.

App tecnico & posatore

Definisci cosa è obbligatorio prima di chiudere un intervento.

Le regole vengono controllate direttamente quando il tecnico preme “Fine lavoro”.

Utenti, ruoli e permessi

La gestione dettagliata resta in una sola area per evitare duplicazioni.

Backup & dati

Il software è un singolo HTML, ma i dati operativi sono salvati nel browser: fai backup periodici.

Zona pericolosa

Il reset cancella i dati locali e ripristina il dataset demo.

Platform Core

Workspace, sessioni, ruoli, permessi, audit, backup e contratti di integrazione.

P9 · SaaS foundation
⚠
Prototipo locale, non sicurezza production.Workspace e permessi sono realmente simulati nel file, ma isolamento dati, autenticazione e autorizzazione sicuri dovranno essere applicati server-side con PostgreSQL/API. Questa schermata definisce e testa il comportamento prima della migrazione.
Workspace
1
Aziende configurate
Utenti attivi
0
Nel workspace corrente
Ruoli
0
Profili permessi
Audit events
0
Tracciati localmente
Dati workspace
0 KB
JSON locale stimato

Workspace & isolamento dati

Ogni azienda mantiene un dataset separato nella simulazione P9.

Utenti & sessione

Cambia utente per verificare cosa vede ogni ruolo.

Matrice permessi

I permessi governano menu e azioni nella demo; in produzione verranno verificati anche dall'API.

Audit log

Chi ha modificato cosa e quando.

Backup & portabilità

Nessun lock-in sui dati del prototipo.

Utilizzo locale stimato0 KB
Il limite reale del browser varia. Foto e documenti production andranno su Object Storage.

API & webhook contract

Registry locale per progettare integrazioni prima del backend.

API KEY DEMO — non è una credenziale production
Nessuna chiave

Architettura target

Stato del passaggio da HTML locale a SaaS reale.

FrontendNext.js + TypeScriptSPEC PRONTA
DatabasePostgreSQL + workspace_idSCHEMA P9
AuthSessioni + membership + RBACBACKEND
File storageS3-compatible + signed URLBACKEND
APIREST / webhook idempotentiCONTRATTO
AuditEvent log immutabilePROTOTIPO

Gate per il prossimo blocco

Cosa deve essere vero prima di P10 — Ordini fornitori.

Workspace isolation
Ogni entità production dovrà avere workspace_id e policy RLS.
Definito
RBAC
Ruoli e permessi formalizzati.
Definito
Audit trail
Modifiche attribuite a utente/workspace.
Prototipo
PostgreSQL
Schema allegato pronto per la migrazione applicativa.
Da collegare
`);w.document.close() } function renderFieldDetail(){ const el=document.getElementById("fieldActivityDetail"),e=fieldEventByKey(selectedFieldEventKey);if(!el)return;if(!e){el.innerHTML='
Seleziona un’attività dall’agenda.
';return} const info=fieldTargetInfo(e),s=fieldSessionForEvent(e.kind,e.id),r=e.type==="Posa"?calculateJobReadiness(e.jobId):null,stats=s?fieldChecklistStats(s):{percent:0,done:0,total:(FIELD_CHECKLISTS[e.type]||FIELD_CHECKLISTS.Sopralluogo).filter(x=>x[2]).length},check=s?fieldCompletionCheck(s,e):null,addr=encodeURIComponent(info.address||""); el.innerHTML=`

${esc(e.type)} · ${esc(info.client)}

${esc(info.code)} · ${fmtDate(e.date)} ${esc(e.startTime||"")} · ${Number(e.durationHours||0).toFixed(1)}h pianificate

${fieldSessionStatusBadge(s)}
${esc(info.address||"Indirizzo non disponibile")} ${info.address?`· Naviga ↗`:""}
${r?`
Materiali ${r.percent}% · ${readinessLabel(r)}
`:""}
${formatFieldTimer(s?fieldElapsedMs(s):0)}TEMPO EFFETTIVO
${!s||s.status==="Non iniziata"?'':s.status==="In corso"?'':s.status==="In pausa"?'':""}${s&&s.status!=="Completata"?'':""}${s&&s.status==="Completata"?``:""}
${s?`

Checklist operativa

${stats.percent}%
${s.checklist.map(i=>`
${i.required?"OBBLIGATORIO":"FACOLTATIVO"}
`).join("")}

Foto e documentazione

Problemi dal cantiere

${s.issues.length?s.issues.map(i=>`
${esc(i.category)} · ${esc(i.severity)}
${esc(i.status)}

${esc(i.description)}

${i.status!=="Risolto"?``:""}
`).join(""):'
Nessun problema.
'}
${(e.type==="Posa"||e.type==="Collaudo")?`

Firma cliente

${s.signatureData?`

Firmato da ${esc(s.customerSigner)}

`:`
`}
`:""}
x.includes("critico"))?"block":"warn"}">${check?.ok?"✓ Attività pronta per essere chiusa":`Da completare: ${esc((check?.issues||[]).join(" · ")||"avvia attività")}`}
Offline-first: checklist, timer e segnalazioni sono salvati localmente. Foto e coda di sincronizzazione usano IndexedDB; il backend P9 potrà consumare la coda quando verrà collegato.
`:`
Premi Inizia lavoro per attivare checklist, timer, foto e verbale.
`}`; if(s){setTimeout(()=>{renderFieldMedia(s);initFieldSignatureCanvas()},0)} } async function refreshFieldNetworkUI(){ const online=navigator.onLine,b=document.getElementById("fieldNetworkBadge");if(b){b.className=`field-net ${online?"online":"offline"}`;b.textContent=online?"● Online":"● Offline"}let count=0;try{count=(await fieldDbAll("outbox")).filter(x=>x.status==="pending").length}catch(e){}if(document.getElementById("fieldKSync"))fieldKSync.textContent=count } async function fieldSyncNow(){ const all=await fieldDbAll("outbox"),pending=all.filter(x=>x.status==="pending");if(!pending.length){toast("Coda offline vuota");return}if(!navigator.onLine){toast("Sei offline: coda conservata");return}toast(`Backend non ancora collegato: ${pending.length} eventi restano in coda`);refreshFieldNetworkUI() } function installFieldPWA(){if(!pwaInstallPrompt){toast("Installazione non disponibile in questo contesto");return}pwaInstallPrompt.prompt();pwaInstallPrompt.userChoice.finally(()=>{pwaInstallPrompt=null;fieldInstallPwaBtn.style.display="none"})} function registerFieldPWA(){ refreshFieldNetworkUI(); const h=document.getElementById("fieldPwaHint"); if(h)h.textContent="Modalità standalone HTML attiva: nessun file esterno richiesto. Dati operativi e foto restano salvati localmente nel browser."; } function renderFieldApp(reset=false){ if(!document.getElementById("fieldActivityList"))return;const current=fieldTeamFilter.value;fieldTeamFilter.innerHTML=''+state.planningTeams.filter(t=>t.active!==false).map(t=>``).join("");if([...fieldTeamFilter.options].some(o=>o.value===current))fieldTeamFilter.value=current; const events=fieldFilteredEvents();if(reset||!events.some(e=>fieldEventKey(e)===selectedFieldEventKey))selectedFieldEventKey=events[0]?fieldEventKey(events[0]):null; const today=new Date().toISOString().slice(0,10),todayEvents=allPlanningEvents().filter(e=>fieldRoleAllowsEvent(e)&&e.date===today),running=state.fieldSessions.filter(s=>["In corso","In pausa"].includes(s.status)),issues=state.fieldSessions.reduce((a,s)=>a+s.issues.filter(i=>i.status!=="Risolto").length,0);fieldKToday.textContent=todayEvents.length;fieldKRunning.textContent=running.length;fieldKIssues.textContent=issues; fieldActivityList.innerHTML=events.length?events.map(e=>{const info=fieldTargetInfo(e),s=fieldSessionForEvent(e.kind,e.id),r=e.type==="Posa"?calculateJobReadiness(e.jobId):null;return`

${esc(e.startTime||"")} · ${esc(e.type)}

${fieldSessionStatusBadge(s)}

${esc(info.client)} · ${esc(info.code)}
${esc(info.address||"")} · ${fmtDate(e.date)}${r?`
Materiali ${r.percent}%`:""}

`}).join(""):'
Nessuna attività nel periodo selezionato.
'; renderFieldDetail();refreshFieldNetworkUI();if(!fieldTimerHandle)fieldTimerHandle=setInterval(updateFieldTimerDisplay,1000) } function updateFieldTimerDisplay(){const e=fieldEventByKey(selectedFieldEventKey),s=e&&fieldSessionForEvent(e.kind,e.id),el=document.getElementById("fieldTimerDisplay");if(el&&s)el.innerHTML=`${formatFieldTimer(fieldElapsedMs(s))}TEMPO EFFETTIVO`} function normalizeActualsP14(base){ if(!Array.isArray(base.jobExpenses))base.jobExpenses=[]; base.jobExpenses.forEach(x=>{x.amount=Number(x.amount)||0;x.date=x.date||new Date().toISOString().slice(0,10);x.category=x.category||"Altro";x.note=x.note||""}); } function acceptedQuoteForJob(job){return state.quotes.find(q=>Number(q.id)===Number(job?.quoteId))||[...state.quotes].filter(q=>Number(q.practiceId)===Number(job?.practiceId)&&q.status==="Accettato").sort((a,b)=>Number(b.version||1)-Number(a.version||1))[0]||null} function expectedJobEconomics(job){ const q=acceptedQuoteForJob(job),revenue=Number(q?.net||job?.value||0),cost=Number(q?.cost||0),margin=revenue?((revenue-cost)/revenue)*100:0; return{quote:q,revenue,cost,margin} } function actualJobEconomics(job){ const expected=expectedJobEconomics(job),orders=state.purchaseOrders.filter(po=>Number(po.jobId)===Number(job.id)&&po.status!=="Annullato"),material=orders.reduce((a,po)=>a+purchaseOrderValue(po),0); const sessions=state.fieldSessions.filter(s=>Number(s.jobId)===Number(job.id)&&s.status==="Completata"),actualMinutes=sessions.reduce((a,s)=>a+Number(s.actualMinutes||0),0),labor=actualMinutes/60*p7Policy().laborHourlyCost; const eventIds=new Set(sessions.map(s=>`${s.eventKind}:${s.eventId}`)),travelMinutes=state.installs.filter(i=>Number(i.jobId)===Number(job.id)&&(eventIds.has(`install:${i.id}`)||i.status==="Completata")).reduce((a,i)=>a+Number(i.travelMinutes||0),0),travel=travelMinutes/60*p7Policy().laborHourlyCost; const expenses=state.jobExpenses.filter(x=>Number(x.jobId)===Number(job.id)),extras=expenses.reduce((a,x)=>a+Number(x.amount||0),0),direct=material+labor+travel+extras,overhead=direct*(p7Policy().overheadPercent/100),actualCost=direct+overhead,revenue=expected.revenue,actualMargin=revenue?((revenue-actualCost)/revenue)*100:0,variance=actualCost-expected.cost; const plannedHours=state.installs.filter(i=>Number(i.jobId)===Number(job.id)).reduce((a,i)=>a+Number(i.durationHours||0),0),actualHours=actualMinutes/60; return{...expected,material,labor,travel,extras,overhead,actualCost,actualMargin,variance,plannedHours,actualHours,travelMinutes,expenses} } function jobMarginHealth(e){if(e.actualMargin<0)return"bad";if(e.actualMarginSeleziona una commessa.';return} const e=actualJobEconomics(job),health=jobMarginHealth(e),p=practiceById(job.practiceId),marginDelta=e.actualMargin-e.margin; el.innerHTML=`

${esc(job.code)} · ${esc(clientName(job.clientId))}

${esc(p?.site||"")} · ${esc(job.status)}

${e.actualMargin.toFixed(1)}%Margine reale · preventivato ${e.margin.toFixed(1)}% · variazione ${marginDelta>=0?"+":""}${marginDelta.toFixed(1)} pt
Ricavo netto${money(e.revenue)}
Costo previsto${money(e.cost)}
Materiali acquistati${money(e.material)}
Ore effettive${e.actualHours.toFixed(1)}h · ${money(e.labor)}
Viaggio${Math.round(e.travelMinutes)} min · ${money(e.travel)}
Extra / imprevisti${money(e.extras)}
Costi generali${money(e.overhead)}
Costo reale${money(e.actualCost)}
Scostamento costo${e.variance>=0?"+":""}${money(e.variance)}

Ore: previste vs reali

Previste planning${e.plannedHours.toFixed(1)}h
Effettive Field App${e.actualHours.toFixed(1)}h
Scostamento${(e.actualHours-e.plannedHours)>=0?"+":""}${(e.actualHours-e.plannedHours).toFixed(1)}h

Driver costo reale

${actualDrivers(e).map(d=>`
${esc(d.name)}
${d.expected===null?"—":money(d.expected)}previsto
${money(d.actual)}reale
`).join("")}
`; } function saveJobExpense(){ if(!selectedActualJobId){toast("Seleziona una commessa");return}const amount=Number(aeAmount.value)||0,note=aeNote.value.trim();if(amount<=0){toast("Inserisci un importo");return} state.jobExpenses.unshift({id:Date.now(),jobId:Number(selectedActualJobId),category:aeCategory.value,amount,note,date:new Date().toISOString().slice(0,10),createdAt:new Date().toISOString()});aeAmount.value="";aeNote.value="";saveState("Costo consuntivo","Consuntivo",`${state.jobs.find(j=>j.id===selectedActualJobId)?.code||""} · ${money(amount)}`);renderActuals(false) } function deleteJobExpense(id){const x=state.jobExpenses.find(e=>e.id===id);state.jobExpenses=state.jobExpenses.filter(e=>e.id!==id);saveState("Rimozione costo","Consuntivo",x?.note||String(id));renderActuals(false)} function renderActualExpenses(){ const el=document.getElementById("actualExpenseRows");if(!el)return;const rows=state.jobExpenses.filter(x=>Number(x.jobId)===Number(selectedActualJobId));el.innerHTML=rows.length?rows.map(x=>`
${esc(x.category)}${esc(x.note||"")} · ${fmtDate(x.date)}
${money(x.amount)}
${esc(state.jobs.find(j=>j.id===x.jobId)?.code||"")}
`).join(""):'
Nessun costo extra per la commessa.
' } function renderActuals(reset=false){ if(!document.getElementById("actualJobRows"))return;const q=(actualSearch?.value||"").toLowerCase(),jobs=state.jobs.filter(j=>!q||[j.code,clientName(j.clientId)].join(" ").toLowerCase().includes(q)); if(reset||!jobs.some(j=>Number(j.id)===Number(selectedActualJobId)))selectedActualJobId=jobs[0]?.id||null; const all=jobs.map(j=>actualJobEconomics(j)),rev=all.reduce((a,e)=>a+e.revenue,0),exp=all.reduce((a,e)=>a+e.cost,0),act=all.reduce((a,e)=>a+e.actualCost,0),margin=rev?((rev-act)/rev)*100:0; acKRevenue.textContent=money(rev);acKExpected.textContent=money(exp);acKActual.textContent=money(act);acKMargin.textContent=margin.toFixed(1)+"%";acKOver.textContent=all.filter(e=>e.actualCost>e.cost&&e.cost>0).length; actualJobRows.innerHTML=jobs.length?jobs.map(j=>{const e=actualJobEconomics(j),health=jobMarginHealth(e),delta=e.actualMargin-e.margin;return`

${esc(j.code)} · ${esc(clientName(j.clientId))}

${e.actualMargin.toFixed(1)}%

Previsto ${e.margin.toFixed(1)}% → reale ${e.actualMargin.toFixed(1)}% · costo ${money(e.cost)} → ${money(e.actualCost)} · ${delta>=0?"+":""}${delta.toFixed(1)} pt

`}).join(""):'
Nessuna commessa.
'; renderActualDetail(jobs.find(j=>Number(j.id)===Number(selectedActualJobId))||null);renderActualExpenses() } function setQuoteMode(mode){ quoteMode=["rapida","guidata","completa"].includes(mode)?mode:"rapida";const card=document.getElementById("quoteDocCard");if(card)card.className=`card quote2-doc qmode-${quoteMode}`;document.querySelectorAll("[data-qmode]").forEach(b=>b.classList.toggle("active",b.dataset.qmode===quoteMode)); const help={rapida:"Solo i campi essenziali. Il resto usa i default aziendali.",guidata:"Mostra prezzo, posa, vetro, colore e sconti senza sovraccaricare la schermata.",completa:"Tutti i campi commerciali, note, optional, varianti e controlli."};if(document.getElementById("quoteModeHelp"))quoteModeHelp.textContent=help[quoteMode];localStorage.setItem("serramentios_quote_mode",quoteMode) } function applyQuotePreset(name){ const presets={ finestra:{type:"Finestra",qty:1,w:1200,h:1400,material:"PVC",opening:"Battente 2 ante",glass:"Doppio vetro",color:"Bianco",install:"standard"}, portafinestra:{type:"Portafinestra",qty:1,w:1200,h:2400,material:"PVC",opening:"Battente 2 ante",glass:"Doppio vetro",color:"Bianco",install:"standard"}, scorrevole:{type:"Scorrevole",qty:1,w:2400,h:2200,material:"Alluminio",opening:"Scorrevole",glass:"Doppio vetro",color:"Antracite",install:"standard"}, solo_fornitura:{type:"Finestra",qty:1,w:1200,h:1400,material:"PVC",opening:"Battente 2 ante",glass:"Doppio vetro",color:"Bianco",install:"none"} },p=presets[name];if(!p)return;qType.value=p.type;qQty.value=p.qty;qW.value=p.w;qH.value=p.h;qMaterial.value=p.material;qOpening.value=p.opening;qGlass.value=p.glass;qColor.value=p.color;qInstall.value=p.install;qMarginTarget.value=p7Policy().targetMargin;calcQuote();toast("Preset applicato") } function duplicateLastQuoteLine(){ const q=ensureQuoteDraft();if(!q.lines.length){toast("Non ci sono righe da duplicare");return}const l=clone(q.lines[q.lines.length-1]);l.id=Date.now()+Math.floor(Math.random()*999);l.code=(l.code||"R")+"-COPIA";q.lines.push(l);renderQuoteDraft();toast("Ultima riga duplicata") } function initQuoteMode(){quoteMode="completa"} function setUiMode(mode){ const m=mode==="full"?"full":"essential";document.body.classList.toggle("ui-essential",m==="essential");localStorage.setItem("serramentios_ui_mode",m);if(state?.settings?.ui)state.settings.ui.mode=m; const a=document.getElementById("uiEssentialBtn"),b=document.getElementById("uiFullBtn");if(a)a.classList.toggle("active",m==="essential");if(b)b.classList.toggle("active",m==="full") } function initUiMode(){setUiMode(state?.settings?.ui?.mode||localStorage.getItem("serramentios_ui_mode")||"essential")} function updateMobileNav(view){document.querySelectorAll("[data-mobile-view]").forEach(b=>b.classList.toggle("active",b.dataset.mobileView===view))} /* DEFAULT_QUOTE_PROFILES inizializzata prima di loadState */ function normalizeQuoteProfilesV16(base){ if(!Array.isArray(base.quoteProfiles)||base.quoteProfiles.length!==3)base.quoteProfiles=clone(DEFAULT_QUOTE_PROFILES); base.quoteProfiles=base.quoteProfiles.map((p,i)=>({...clone(DEFAULT_QUOTE_PROFILES[i]),...p,id:p.id||DEFAULT_QUOTE_PROFILES[i].id})); if(!base.activeQuoteProfileId||!base.quoteProfiles.some(p=>p.id===base.activeQuoteProfileId))base.activeQuoteProfileId="q1" } function activeQuoteProfile(){return state.quoteProfiles?.find(p=>p.id===state.activeQuoteProfileId)||DEFAULT_QUOTE_PROFILES[0]} function quoteProfileById(id){return state.quoteProfiles?.find(p=>p.id===id)||null} function profileFeatureVisible(profile,feature){ const map={product:profile.showProduct,discounts:profile.showDiscounts,optional:profile.showOptional,notes:profile.showNotes,terms:profile.showTerms,margin:profile.showMargin,status:profile.showStatus,title:profile.level!=="minimal"}; return map[feature]!==false } function renderQuoteProfiles(){ const strip=document.getElementById("quoteProfileStrip");if(!strip)return;const p=activeQuoteProfile(); strip.innerHTML=state.quoteProfiles.map(x=>``).join(""); quoteProfileCurrentLabel.textContent=p.name;quoteProfileSummary.textContent=`${p.name} · ${p.description}`; document.querySelectorAll("[data-q-feature]").forEach(el=>{el.style.display=profileFeatureVisible(p,el.dataset.qFeature)?"":"none"}); const presets=document.getElementById("quoteQuickPresets"),hint=document.getElementById("quoteFastHint");if(presets)presets.style.display=p.usePresets?"":"none";if(hint)hint.style.display=p.usePresets?"":"none"; renderQuickQuoteUI(); } function applyActiveQuoteProfileDefaults(){ const p=activeQuoteProfile();if(document.getElementById("qVat"))qVat.value=String(p.vat);if(document.getElementById("qMarginTarget"))qMarginTarget.value=p.margin;if(document.getElementById("qGlass"))qGlass.value=p.glass;if(document.getElementById("qInstall"))qInstall.value=p.install } function selectQuoteProfile(id){ if(!quoteProfileById(id))return;state.activeQuoteProfileId=id;if(quoteDraft)quoteDraft.profileId=id;rawPersist();persistActiveWorkspaceSnapshot();applyActiveQuoteProfileDefaults();renderQuoteProfiles();calcQuote();renderQuoteDraft();renderQuickQuoteUI();toast(`${activeQuoteProfile().name} attivo`) } function openQuoteProfileSettings(){ qpsProfile.innerHTML=state.quoteProfiles.map(p=>``).join("");qpsProfile.value=state.activeQuoteProfileId;loadQuoteProfileEditor();openModal("quoteProfileSettingsModal") } function loadQuoteProfileEditor(){ const p=quoteProfileById(qpsProfile.value)||activeQuoteProfile();qpsName.value=p.name;qpsDescription.value=p.description;qpsLevel.value=p.level;qpsVat.value=String(p.vat);qpsMargin.value=p.margin;qpsGlass.value=p.glass;qpsInstall.value=p.install; qpsProduct.checked=!!p.showProduct;qpsDiscounts.checked=!!p.showDiscounts;qpsOptional.checked=!!p.showOptional;qpsNotes.checked=!!p.showNotes;qpsTerms.checked=!!p.showTerms;qpsMarginField.checked=!!p.showMargin;qpsStatus.checked=!!p.showStatus;qpsPresets.checked=!!p.usePresets; qpsPrintDrawing.checked=!!p.printDrawing;qpsPrintTechnical.checked=!!p.printTechnical;qpsPrintLinePrice.checked=!!p.printLinePrice;qpsPrintOptional.checked=!!p.printOptional;qpsPrintNotes.checked=!!p.printNotes;qpsPrintTerms.checked=!!p.printTerms; qpsPreviewText.textContent=`Cliente: ${p.printDrawing?"disegno 2D · ":""}${p.printTechnical?"dati tecnici · ":""}${p.printLinePrice?"prezzi riga · ":"solo totale · "}${p.printOptional?"optional · ":""}${p.printTerms?"IVA/validità":""}` } function saveQuoteProfileEditor(){ const p=quoteProfileById(qpsProfile.value);if(!p)return;const name=qpsName.value.trim();if(!name){toast("Inserisci il nome");return} Object.assign(p,{name,description:qpsDescription.value.trim(),level:qpsLevel.value,vat:Number(qpsVat.value)||10,margin:Number(qpsMargin.value)||35,glass:qpsGlass.value,install:qpsInstall.value,showProduct:qpsProduct.checked,showDiscounts:qpsDiscounts.checked,showOptional:qpsOptional.checked,showNotes:qpsNotes.checked,showTerms:qpsTerms.checked,showMargin:qpsMarginField.checked,showStatus:qpsStatus.checked,usePresets:qpsPresets.checked,printDrawing:qpsPrintDrawing.checked,printTechnical:qpsPrintTechnical.checked,printLinePrice:qpsPrintLinePrice.checked,printOptional:qpsPrintOptional.checked,printNotes:qpsPrintNotes.checked,printTerms:qpsPrintTerms.checked}); saveState("Profilo preventivo","Preventivi",name);renderQuoteProfiles();loadQuoteProfileEditor();toast("Impostazioni salvate") } function resetQuoteProfiles(){ if(!confirm("Ripristinare Preventivo 1, 2 e 3?"))return;state.quoteProfiles=clone(DEFAULT_QUOTE_PROFILES);state.activeQuoteProfileId="q1";saveState("Reset profili preventivo","Preventivi","Preventivo 1/2/3");renderQuoteProfiles();loadQuoteProfileEditor() } function profileForQuote(q){return quoteProfileById(q?.profileId)||activeQuoteProfile()} function serramentiOSDiagnostic(){ return{ ready:window.SERRAMENTIOS_READY===true, storageKey:STORAGE_KEY, clients:state?.clients?.length||0, practices:state?.practices?.length||0, quoteProfiles:state?.quoteProfiles?.map(x=>x.name)||[], activeWorkspace:activeWorkspace()?.name||null, activeUser:currentUser()?.name||null } } const DEFAULT_APP_SETTINGS={ company:{name:"Serramenti Demo SRL",vatNumber:"",taxCode:"",pec:"",address:"",zip:"",city:"",province:"",phone:"",email:"",website:"",footer:"",logoData:""}, quotes:{prefix:"PRE",digits:3,validityDays:30,depositPct:30,defaultTitle:"Fornitura e posa serramenti",paymentTerms:"30% acconto alla conferma, saldo secondo accordi.",deliveryText:"Tempi da confermare in fase d'ordine.",warrantyMonths:24,defaultCustomerNote:""}, commercial:{staleDays:4,quoteStaleDays:5,followUp:{Nuovo:1,Contattato:2,Sopralluogo:2,Preventivo:3,Trattativa:2},sources:["Google","Meta Ads","Passaparola","Showroom","Partner","Altro"],lostReasons:["Prezzo","Scelto concorrente","Nessuna risposta","Tempistiche","Progetto rinviato","Fuori target","Problema tecnico","Altro"]}, technical:{unit:"mm",toleranceMm:5,defaultMaterial:"PVC",defaultGlass:"Doppio vetro",defaultInstall:"standard"}, purchasing:{prefix:"PO",digits:3,defaultLeadDays:14,receivingLocationCode:"RICEV"}, planning:{workdayStart:"08:00",workdayEnd:"17:00",defaultInstallHours:4,defaultTravelMinutes:30,workingDays:[1,2,3,4,5,6]}, field:{requireChecklist:true,requireSignature:true,requireAfterPhoto:false,blockCriticalIssues:true}, ui:{mode:"essential"} }; function normalizeSettingsV17(base){ const raw=base.settings&&typeof base.settings==="object"?base.settings:{}; base.settings={ company:{...DEFAULT_APP_SETTINGS.company,...(raw.company||{})}, quotes:{...DEFAULT_APP_SETTINGS.quotes,...(raw.quotes||{})}, commercial:{...DEFAULT_APP_SETTINGS.commercial,...(raw.commercial||{}),followUp:{...DEFAULT_APP_SETTINGS.commercial.followUp,...(raw.commercial?.followUp||{})}}, technical:{...DEFAULT_APP_SETTINGS.technical,...(raw.technical||{})}, purchasing:{...DEFAULT_APP_SETTINGS.purchasing,...(raw.purchasing||{})}, planning:{...DEFAULT_APP_SETTINGS.planning,...(raw.planning||{})}, field:{...DEFAULT_APP_SETTINGS.field,...(raw.field||{})}, ui:{...DEFAULT_APP_SETTINGS.ui,...(raw.ui||{})} }; if(!Array.isArray(base.settings.commercial.sources)||!base.settings.commercial.sources.length)base.settings.commercial.sources=clone(DEFAULT_APP_SETTINGS.commercial.sources); if(!Array.isArray(base.settings.commercial.lostReasons)||!base.settings.commercial.lostReasons.length)base.settings.commercial.lostReasons=clone(DEFAULT_APP_SETTINGS.commercial.lostReasons); if(!Array.isArray(base.settings.planning.workingDays))base.settings.planning.workingDays=clone(DEFAULT_APP_SETTINGS.planning.workingDays); return base.settings } function appSettings(){if(!state.settings)normalizeSettingsV17(state);return state.settings} function setSettingsTab(tab){ document.querySelectorAll("[data-settings-tab]").forEach(b=>b.classList.toggle("active",b.dataset.settingsTab===tab)); document.querySelectorAll("[data-settings-panel]").forEach(p=>p.classList.toggle("active",p.dataset.settingsPanel===tab)); } function settingsLines(v){return String(v||"").split(/\r?\n|,/).map(x=>x.trim()).filter(Boolean)} function fillSettingsSimpleSelect(id,values){ const el=document.getElementById(id);if(!el)return;const current=el.value;el.innerHTML=values.map(v=>``).join("");if(values.includes(current))el.value=current } function renderSettings(){ if(!document.getElementById("settings"))return;const s=appSettings(),c=s.company,q=s.quotes,crm=s.commercial,t=s.technical,p=s.planning,f=s.field,pc=p7Policy(); stCompanyName.value=c.name;stCompanyVat.value=c.vatNumber;stCompanyTax.value=c.taxCode;stCompanyPec.value=c.pec;stCompanyAddress.value=c.address;stCompanyZip.value=c.zip;stCompanyCity.value=c.city;stCompanyProvince.value=c.province;stCompanyPhone.value=c.phone;stCompanyEmail.value=c.email;stCompanyWebsite.value=c.website;stCompanyFooter.value=c.footer; stCompanyLogoPreview.innerHTML=c.logoData?`Logo`:"Nessun logo"; stQuotePrefix.value=q.prefix;stQuoteDigits.value=q.digits;stQuoteValidity.value=q.validityDays;stQuoteDeposit.value=q.depositPct;stQuoteTitle.value=q.defaultTitle;stQuotePayment.value=q.paymentTerms;stQuoteDelivery.value=q.deliveryText;stQuoteWarranty.value=q.warrantyMonths;stQuoteCustomerNote.value=q.defaultCustomerNote; stCrmStale.value=crm.staleDays;stCrmQuoteStale.value=crm.quoteStaleDays;stFuNuovo.value=crm.followUp.Nuovo;stFuContattato.value=crm.followUp.Contattato;stFuSopralluogo.value=crm.followUp.Sopralluogo;stFuPreventivo.value=crm.followUp.Preventivo;stFuTrattativa.value=crm.followUp.Trattativa;stCrmSources.value=crm.sources.join("\n");stCrmLostReasons.value=crm.lostReasons.join("\n"); stTechUnit.value=t.unit;stTechTolerance.value=t.toleranceMm;stTechMaterial.value=t.defaultMaterial;stTechGlass.value=t.defaultGlass;stTechInstall.value=t.defaultInstall; stCostTarget.value=pc.targetMargin;stCostMin.value=pc.minMargin;stCostFloor.value=pc.hardFloorMargin;stCostOverhead.value=pc.overheadPercent;stCostHourly.value=pc.laborHourlyCost;stCostTransport.value=pc.transportBase;stCostInstall.value=pc.installBasePerPiece;stCostCertified.value=pc.certifiedInstallExtra; stPlanStart.value=p.workdayStart;stPlanEnd.value=p.workdayEnd;stPlanDuration.value=p.defaultInstallHours;stPlanTravel.value=p.defaultTravelMinutes;document.querySelectorAll("#stWorkingDays input").forEach(x=>x.checked=p.workingDays.includes(Number(x.value))); stFieldChecklist.checked=f.requireChecklist;stFieldSignature.checked=f.requireSignature;stFieldAfterPhoto.checked=f.requireAfterPhoto;stFieldCritical.checked=f.blockCriticalIssues; const users=state.platform?.users?.filter(u=>Number(u.workspaceId)===Number(state.platform.activeWorkspaceId))||[],active=users.filter(u=>u.active!==false); settingsUserSummary.innerHTML=`
Utenti
${users.length}
nel workspace
Attivi
${active.length}
accesso abilitato
Ruoli
${state.platform?.roles?.length||0}
profili permesso
` } function saveSettingsCompany(){ const c=appSettings().company;Object.assign(c,{name:stCompanyName.value.trim()||"SerramentiOS",vatNumber:stCompanyVat.value.trim(),taxCode:stCompanyTax.value.trim(),pec:stCompanyPec.value.trim(),address:stCompanyAddress.value.trim(),zip:stCompanyZip.value.trim(),city:stCompanyCity.value.trim(),province:stCompanyProvince.value.trim().toUpperCase(),phone:stCompanyPhone.value.trim(),email:stCompanyEmail.value.trim(),website:stCompanyWebsite.value.trim(),footer:stCompanyFooter.value.trim()});saveState("Impostazioni azienda","Impostazioni",c.name);toast("Dati aziendali salvati") } function saveSettingsCompanyLogo(input){ const file=input.files?.[0];if(!file)return;const reader=new FileReader();reader.onload=()=>{const img=new Image();img.onload=()=>{const maxW=420,maxH=180,ratio=Math.min(maxW/img.width,maxH/img.height,1),canvas=document.createElement("canvas");canvas.width=Math.max(1,Math.round(img.width*ratio));canvas.height=Math.max(1,Math.round(img.height*ratio));canvas.getContext("2d").drawImage(img,0,0,canvas.width,canvas.height);appSettings().company.logoData=canvas.toDataURL("image/jpeg",.82);input.value="";saveState("Logo azienda","Impostazioni","Logo aggiornato");renderSettings();toast("Logo salvato")};img.src=reader.result};reader.readAsDataURL(file) } function saveSettingsQuotes(){ const q=appSettings().quotes;Object.assign(q,{prefix:(stQuotePrefix.value.trim()||"PRE").replace(/[^A-Za-z0-9_-]/g,"").toUpperCase(),digits:Math.max(2,Math.min(8,Number(stQuoteDigits.value)||3)),validityDays:Math.max(1,Number(stQuoteValidity.value)||30),depositPct:Math.max(0,Math.min(100,Number(stQuoteDeposit.value)||0)),defaultTitle:stQuoteTitle.value.trim()||"Fornitura e posa serramenti",paymentTerms:stQuotePayment.value.trim(),deliveryText:stQuoteDelivery.value.trim(),warrantyMonths:Math.max(0,Number(stQuoteWarranty.value)||0),defaultCustomerNote:stQuoteCustomerNote.value.trim()});saveState("Impostazioni preventivi","Impostazioni",`${q.prefix} · ${q.validityDays}gg`);toast("Impostazioni preventivi salvate") } function saveSettingsCommercial(){ const c=appSettings().commercial;c.staleDays=Math.max(1,Number(stCrmStale.value)||4);c.quoteStaleDays=Math.max(1,Number(stCrmQuoteStale.value)||5);c.followUp={Nuovo:Number(stFuNuovo.value)||0,Contattato:Number(stFuContattato.value)||0,Sopralluogo:Number(stFuSopralluogo.value)||0,Preventivo:Number(stFuPreventivo.value)||0,Trattativa:Number(stFuTrattativa.value)||0};c.sources=settingsLines(stCrmSources.value);c.lostReasons=settingsLines(stCrmLostReasons.value);saveState("Impostazioni CRM","Impostazioni",`${c.staleDays}/${c.quoteStaleDays} giorni`);renderSelects();toast("Impostazioni commerciali salvate") } function saveSettingsTechnical(){ const t=appSettings().technical;Object.assign(t,{unit:stTechUnit.value,toleranceMm:Math.max(0,Number(stTechTolerance.value)||0),defaultMaterial:stTechMaterial.value,defaultGlass:stTechGlass.value,defaultInstall:stTechInstall.value});saveState("Impostazioni tecniche","Impostazioni",`${t.defaultMaterial} · ${t.defaultGlass}`);toast("Default tecnici salvati") } function saveSettingsCosts(){ state.pricingPolicy={...p7Policy(),targetMargin:Number(stCostTarget.value)||35,minMargin:Number(stCostMin.value)||25,hardFloorMargin:Number(stCostFloor.value)||18,overheadPercent:Number(stCostOverhead.value)||0,laborHourlyCost:Number(stCostHourly.value)||0,transportBase:Number(stCostTransport.value)||0,installBasePerPiece:Number(stCostInstall.value)||0,certifiedInstallExtra:Number(stCostCertified.value)||0};saveState("Impostazioni costi","Impostazioni",`Target ${state.pricingPolicy.targetMargin}%`);toast("Costi e marginalità salvati") } function saveSettingsPlanning(){ const p=appSettings().planning;Object.assign(p,{workdayStart:stPlanStart.value||"08:00",workdayEnd:stPlanEnd.value||"17:00",defaultInstallHours:Math.max(.5,Number(stPlanDuration.value)||4),defaultTravelMinutes:Math.max(0,Number(stPlanTravel.value)||0),workingDays:[...document.querySelectorAll("#stWorkingDays input:checked")].map(x=>Number(x.value))});saveState("Impostazioni planning","Impostazioni",`${p.workdayStart}-${p.workdayEnd}`);toast("Planning aggiornato") } function saveSettingsField(){ const f=appSettings().field;Object.assign(f,{requireChecklist:stFieldChecklist.checked,requireSignature:stFieldSignature.checked,requireAfterPhoto:stFieldAfterPhoto.checked,blockCriticalIssues:stFieldCritical.checked});saveState("Impostazioni App tecnico","Impostazioni","Regole chiusura aggiornate");toast("App tecnico aggiornata") } let quickQuoteType="Finestra"; function quickQuoteDefaults(type){ const map={ "Finestra":{w:1200,h:1400,material:"PVC",opening:"Battente 2 ante"}, "Portafinestra":{w:1200,h:2400,material:"PVC",opening:"Battente 2 ante"}, "Scorrevole":{w:2400,h:2200,material:"Alluminio",opening:"Scorrevole"}, "Portoncino":{w:1000,h:2200,material:"Alluminio",opening:"Battente 1 anta"}, "Zanzariera":{w:1200,h:1400,material:"PVC",opening:"Fisso"} };return map[type]||map.Finestra } function selectQuickQuoteType(type,applyDefaults=true){ quickQuoteType=type; document.querySelectorAll("[data-q1type]").forEach(b=>b.classList.toggle("active",b.dataset.q1type===type)); if(applyDefaults){const d=quickQuoteDefaults(type);if(q1W)q1W.value=d.w;if(q1H)q1H.value=d.h;if(q1Material)q1Material.value=d.material;if(q1Opening)q1Opening.value=d.opening} syncQuickQuoteFields() } function syncQuickQuoteFields(){ if(!document.getElementById("q1QuickComposer"))return; const p=activeQuoteProfile(),d=quickQuoteDefaults(quickQuoteType); if(document.getElementById("qType"))qType.value=quickQuoteType; if(document.getElementById("qQty"))qQty.value=Math.max(1,Number(q1Qty?.value)||1); if(document.getElementById("qW"))qW.value=Math.max(1,Number(q1W?.value)||d.w); if(document.getElementById("qH"))qH.value=Math.max(1,Number(q1H?.value)||d.h); if(document.getElementById("qMaterial"))qMaterial.value=q1Material?.value||d.material; if(document.getElementById("qOpening"))qOpening.value=q1Opening?.value||d.opening; if(document.getElementById("qGlass"))qGlass.value=p.glass||appSettings().technical.defaultGlass||"Doppio vetro"; if(document.getElementById("qInstall"))qInstall.value=p.install||appSettings().technical.defaultInstall||"standard"; if(document.getElementById("qMarginTarget"))qMarginTarget.value=Number(p.margin)||p7Policy().targetMargin; if(document.getElementById("qDiscount"))qDiscount.value=0; if(document.getElementById("qVariant"))qVariant.value=""; if(document.getElementById("qOptional"))qOptional.value="no"; if(document.getElementById("qNotes"))qNotes.value=""; renderQuickQuoteEstimate() } function renderQuickQuoteEstimate(){ const price=document.getElementById("q1QuickPrice"),health=document.getElementById("q1QuickHealth"),meta=document.getElementById("q1QuickPriceMeta");if(!price)return; try{ const c=calcQuote();price.textContent=money(c.net); health.className=`field-badge ${c.priceHealth==="safe"?"ready":c.priceHealth==="warn"?"warn":"danger"}`; health.textContent=c.priceHealth==="safe"?"MARGINE OK":c.priceHealth==="warn"?"ATTENZIONE":"SOTTO FLOOR"; meta.textContent=`costo ${money(c.cost)} · margine ${Number(c.margin||0).toFixed(1)}%` }catch(err){price.textContent="€0";health.className="field-badge gray";health.textContent="DA CALCOLARE";meta.textContent="completa le misure"} } function addQuickQuoteLine(){ syncQuickQuoteFields(); const before=ensureQuoteDraft().lines.length; addQuoteLine(); const after=ensureQuoteDraft().lines.length; if(after>before){q1Qty.value=1;renderQuickQuoteUI();setTimeout(()=>q1W?.focus(),0)} } function quickPreset(name){ const map={finestra:"Finestra",portafinestra:"Portafinestra",scorrevole:"Scorrevole"}; selectQuickQuoteType(map[name]||"Finestra",true) } function renderQuickQuoteUI(){ const box=document.getElementById("q1QuickComposer"),std=document.getElementById("qStandardComposer");if(!box||!std)return; const fast=activeQuoteProfile().id==="q1";box.classList.toggle("active",fast);std.classList.toggle("quick-hidden",fast); if(!fast)return; const q=ensureQuoteDraft(),t=quoteTotals(q); q1QuickCount.textContent=String(q.lines.length); q1QuickTotal.textContent=money(t.total); q1QuickLines.innerHTML=q.lines.length?q.lines.map(l=>`

${esc(l.code)} · ${esc(l.type||l.description)}

${Number(l.qty||1)} pz · ${l.w||"—"}×${l.h||"—"} mm · ${esc(l.material||"")} · ${esc(l.opening||"")}

${money(l.net)}
`).join(""):'
Nessun serramento inserito. Scegli un tipo e premi “Aggiungi”.
'; syncQuickQuoteFields() } function clone(v){return JSON.parse(JSON.stringify(v))} function latestContactFromActivities(p){const a=(p.activities||[]).filter(x=>/contatto|telefon|whatsapp|email|showroom/i.test((x.type||"")+" "+(x.text||""))).sort((x,y)=>String(y.date).localeCompare(String(x.date)))[0];return a?.date||""} function addDaysISOFrom(dateLike,days){const d=dateLike?new Date(dateLike):new Date();if(Number.isNaN(d.getTime()))return addDays(days);d.setDate(d.getDate()+days);return d.toISOString().slice(0,10)} function normalizeQuoteP8(q,base){ q.version=Number(q.version)||1;q.profileId=q.profileId||base.activeQuoteProfileId||"q1";q.title=q.title||"Fornitura e posa serramenti";q.globalDiscount=Number(q.globalDiscount)||0;q.vatRate=Number(q.vatRate)||((Number(q.net)>0&&Number(q.vat)>=0)?Math.round(Number(q.vat)/Number(q.net)*100):10)||10;q.validUntil=q.validUntil||addDaysISOFrom(q.date||new Date(),30);q.clientNote=q.clientNote||"";q.internalNote=q.internalNote||"";if(q.status==="Perso")q.status="Rifiutato";q.sentAt=q.sentAt||"";q.viewedAt=q.viewedAt||"";q.acceptedAt=q.acceptedAt||"";q.rejectedAt=q.rejectedAt||""; if(!Array.isArray(q.lines)||!q.lines.length){const gross=Number(q.net)||0,cost=Number(q.cost)||0;q.lines=[{id:Number(q.id)*10+1,configurationId:q.configurationId||null,code:"R01",description:q.desc||"Serramento",type:"Serramento",qty:1,grossNet:gross,lineDiscount:0,net:gross,cost,margin:Number(q.margin)||((gross-cost)/Math.max(gross,1)*100),optional:false,included:true,variantGroup:"",notes:q.notes||"",priceHealth:q.priceHealth||"safe",pricingSnapshot:q.pricingSnapshot||null}]} q.lines.forEach((l,i)=>{l.id=l.id||Number(q.id)*100+i+1;l.qty=Number(l.qty)||1;l.grossNet=Number(l.grossNet??l.net)||0;l.lineDiscount=Number(l.lineDiscount)||0;l.net=Number(l.net??l.grossNet*(1-l.lineDiscount/100))||0;l.cost=Number(l.cost)||0;l.margin=Number(l.margin)||((l.net-l.cost)/Math.max(l.net,1)*100);l.optional=!!l.optional;l.included=l.included!==false;l.variantGroup=l.variantGroup||"";l.code=l.code||`R${String(i+1).padStart(2,"0")}`;l.description=l.description||"Serramento"});return q } function snapshotWorkspaceData(source){const out={};P9_DATA_ARRAY_KEYS.forEach(k=>out[k]=clone(source[k]||[]));P9_DATA_OBJECT_KEYS.forEach(k=>out[k]=clone(source[k]||{}));return out} function createEmptyWorkspaceDataset(){const d={};P9_DATA_ARRAY_KEYS.forEach(k=>d[k]=[]);d.manufacturers=clone(demo.manufacturers||[]);d.systems=clone(demo.systems||[]);d.components=clone(demo.components||[]);d.technicalRules=clone(demo.technicalRules||[]);d.suppliers=clone(demo.suppliers||[]);d.purchaseOrders=[];d.prices=clone(demo.prices||{});d.pricingPolicy=clone(P7_DEFAULT_POLICY);normalizeTechnicalCatalogP6(d);normalizeProcurementP10(d);normalizeWarehouseP11(d);normalizePlanningP12(d);normalizeFieldP13(d);normalizeActualsP14(d);normalizeQuoteProfilesV16(d);normalizeSettingsV17(d);return d} function normalizePlatformP9(base){ if(!base.platform||typeof base.platform!=="object")base.platform={};const pf=base.platform;pf.version=1;pf.activeWorkspaceId=Number(pf.activeWorkspaceId)||1;pf.activeUserId=Number(pf.activeUserId)||1; if(!Array.isArray(pf.roles)||!pf.roles.length)pf.roles=clone(P9_DEFAULT_ROLES);else{P9_DEFAULT_ROLES.forEach(r=>{const existing=pf.roles.find(x=>x.id===r.id);if(!existing)pf.roles.push(clone(r));else if(!existing.permissions?.includes("*")){existing.permissions=Array.from(new Set([...(existing.permissions||[]),...(r.permissions||[])]));existing.name=existing.name||r.name;existing.className=existing.className||r.className}})} if(!Array.isArray(pf.workspaces)||!pf.workspaces.length)pf.workspaces=[{id:1,name:"Serramenti Demo SRL",slug:"serramenti-demo",plan:"Pro",active:true,createdAt:"2026-09-29T08:00:00"},{id:2,name:"Showroom Milano",slug:"showroom-milano",plan:"Starter",active:true,createdAt:"2026-09-29T08:10:00"}]; if(!Array.isArray(pf.users)||!pf.users.length)pf.users=[{id:1,workspaceId:1,name:"Admin Demo",email:"admin@serramentios.local",roleId:"owner",active:true},{id:2,workspaceId:1,name:"Commerciale Demo",email:"commerciale@serramentios.local",roleId:"sales",active:true},{id:3,workspaceId:1,name:"Tecnico Demo",email:"tecnico@serramentios.local",roleId:"tech",active:true},{id:4,workspaceId:1,name:"Posatore Demo",email:"posa@serramentios.local",roleId:"installer",active:true},{id:5,workspaceId:2,name:"Admin Showroom",email:"admin.milano@serramentios.local",roleId:"owner",active:true}]; if(!Array.isArray(pf.audit))pf.audit=[];if(!Array.isArray(pf.webhooks))pf.webhooks=[];if(!Array.isArray(pf.apiKeys))pf.apiKeys=[];if(!pf.workspaceData||typeof pf.workspaceData!=="object")pf.workspaceData={}; if(!pf.workspaces.some(w=>Number(w.id)===Number(pf.activeWorkspaceId)))pf.activeWorkspaceId=pf.workspaces[0].id; if(!pf.users.some(u=>Number(u.id)===Number(pf.activeUserId)&&Number(u.workspaceId)===Number(pf.activeWorkspaceId)))pf.activeUserId=pf.users.find(u=>Number(u.workspaceId)===Number(pf.activeWorkspaceId)&&u.active!==false)?.id||null; if(!pf.workspaceData[String(pf.activeWorkspaceId)])pf.workspaceData[String(pf.activeWorkspaceId)]=snapshotWorkspaceData(base); pf.workspaces.forEach(w=>{if(!pf.workspaceData[String(w.id)]&&Number(w.id)!==Number(pf.activeWorkspaceId))pf.workspaceData[String(w.id)]=createEmptyWorkspaceDataset()}); if(!pf.apiKeys.some(k=>Number(k.workspaceId)===Number(pf.activeWorkspaceId)))pf.apiKeys.push({id:Date.now(),workspaceId:pf.activeWorkspaceId,label:"Demo integration",key:"sos_demo_"+Math.random().toString(36).slice(2,14),createdAt:new Date().toISOString(),active:true}); return base } function normalizeState(raw){ const base=raw&&typeof raw==="object"?raw:clone(demo); ["clients","quotes","jobs","measures","installs","surveys"].forEach(k=>{if(!Array.isArray(base[k]))base[k]=clone(demo[k]||[])}); if(!base.prices)base.prices=clone(demo.prices);if(!base.pricingPolicy)base.pricingPolicy=clone(P7_DEFAULT_POLICY);base.pricingPolicy={...P7_DEFAULT_POLICY,...base.pricingPolicy}; if(!Array.isArray(base.practices)||!base.practices.length){base.practices=clone(demo.practices)} base.practices.forEach(p=>{p.tasks=Array.isArray(p.tasks)?p.tasks:[];p.appointments=Array.isArray(p.appointments)?p.appointments:[];p.documents=Array.isArray(p.documents)?p.documents:[];p.activities=Array.isArray(p.activities)?p.activities:[];p.crmContacts=Array.isArray(p.crmContacts)?p.crmContacts:[];p.owner=p.owner||"Titolare";p.priority=p.priority||"Media";p.nextAction=p.nextAction||"";p.value=Number(p.value)||0;p.stageChangedAt=p.stageChangedAt||p.updatedAt||p.createdAt||new Date().toISOString();p.lastContactAt=p.lastContactAt||latestContactFromActivities(p);p.followUpAt=p.followUpAt||"";p.lostReason=p.lostReason||"";p.lostDetail=p.lostDetail||"";p.wonAt=p.wonAt||(["Confermato","Ordinato","Materiale arrivato","Posa","Collaudo","Chiuso"].includes(p.status)?p.updatedAt||p.createdAt:"");}); ["properties","rooms","openings","serramenti"].forEach(k=>{if(!Array.isArray(base[k]))base[k]=[]}); if(!Array.isArray(base.configurations))base.configurations=[]; ["manufacturers","systems","components","technicalRules"].forEach(k=>{if(!Array.isArray(base[k])||!base[k].length)base[k]=clone(demo[k]||[])}); migrateDataModelV4(base); normalizeTechnicalCatalogP6(base); normalizeProcurementP10(base); normalizeWarehouseP11(base); normalizePlanningP12(base); normalizeFieldP13(base); normalizeActualsP14(base); normalizeQuoteProfilesV16(base); normalizeSettingsV17(base); normalizeConfigurationsP5(base); base.quotes.forEach(q=>{if(!q.practiceId){q.practiceId=base.practices.find(p=>p.clientId===q.clientId)?.id||null};normalizeQuoteP8(q,base)}); base.jobs.forEach(j=>{if(!j.practiceId){j.practiceId=base.practices.find(p=>p.clientId===j.clientId)?.id||null}}); base.measures.forEach(m=>{if(!m.practiceId){m.practiceId=base.practices.find(p=>p.clientId===m.clientId)?.id||null};linkLegacyMeasure(base,m);normalizeAdvancedMeasure(m)}); normalizePlatformP9(base); return base; } function migrateDataModelV4(base){ base.practices.forEach(p=>{ let prop=base.properties.find(x=>x.practiceId===p.id); if(!prop){prop={id:100000+Number(p.id),practiceId:p.id,name:"Immobile principale",address:p.site||"",type:"Abitazione",notes:"Creato automaticamente dalla pratica"};base.properties.push(prop)} }); base.measures.forEach((m,idx)=>linkLegacyMeasure(base,m,idx)); } function linkLegacyMeasure(base,m,idx=0){ const p=base.practices.find(x=>x.id===m.practiceId);if(!p)return; let prop=base.properties.find(x=>x.id===m.propertyId)||base.properties.find(x=>x.practiceId===p.id);if(!prop)return;m.propertyId=prop.id; if(m.roomId&&m.openingId&&m.serramentoId)return; const raw=String(m.room||"Ambiente");const parts=raw.split(/\s[-·]\s/);const roomName=(parts[0]||"Ambiente").trim(); let room=base.rooms.find(x=>x.propertyId===prop.id&&x.name.toLowerCase()===roomName.toLowerCase()); if(!room){room={id:2000000+Number(p.id)*10+idx,practiceId:p.id,propertyId:prop.id,name:roomName,floor:"",notes:"Migrato dal rilievo precedente"};base.rooms.push(room)}m.roomId=room.id; let opening=base.openings.find(x=>x.id===m.openingId);if(!opening){const code=(parts[1]||("V"+String(base.openings.filter(x=>x.roomId===room.id).length+1).padStart(2,"0"))).trim().replace(/\s+/g,"");opening={id:3000000+Number(p.id)*10+idx,practiceId:p.id,propertyId:prop.id,roomId:room.id,code,type:(m.h>2000?"Portafinestra":"Finestra"),w:Number(m.w)||0,h:Number(m.h)||0,notes:m.notes||""};base.openings.push(opening)}m.openingId=opening.id; let ser=base.serramenti.find(x=>x.id===m.serramentoId)||base.serramenti.find(x=>x.openingId===opening.id);if(!ser){ser={id:4000000+Number(p.id)*10+idx,practiceId:p.id,propertyId:prop.id,roomId:room.id,openingId:opening.id,code:"S-"+opening.code,type:opening.type,material:"Da definire",opening:"Da definire",status:"Rilevato",qty:1,notes:"Creato dalla migrazione del rilievo"};base.serramenti.push(ser)}m.serramentoId=ser.id;m.room=room.name+" · "+opening.code; } function normalizeAdvancedMeasure(m){ const w=Number(m.w)||0,h=Number(m.h)||0; m.wTop=Number(m.wTop)||w;m.wCenter=Number(m.wCenter)||w;m.wBottom=Number(m.wBottom)||w; m.hLeft=Number(m.hLeft)||h;m.hCenter=Number(m.hCenter)||h;m.hRight=Number(m.hRight)||h; m.diag1=Number(m.diag1)||0;m.diag2=Number(m.diag2)||0;m.depthLeft=Number(m.depthLeft)||Number(m.depth)||0;m.depthRight=Number(m.depthRight)||Number(m.depth)||0;m.sillDepth=Number(m.sillDepth)||Number(m.sill)||0; m.counterframe=m.counterframe||"Da verificare";m.box=m.box||"Da verificare";m.shutter=m.shutter||"Da verificare";m.motor=m.motor||"Da verificare";m.mosquito=m.mosquito||"Da verificare";m.externalShutter=m.externalShutter||"Da verificare";m.threshold=m.threshold||"Da verificare";m.demolition=m.demolition||"Da verificare";m.disposal=m.disposal||"Da verificare";m.access=m.access||"Da verificare";m.floor=m.floor||"";m.lifting=m.lifting||"Da verificare";m.parking=m.parking||"Da verificare";m.criticalities=Array.isArray(m.criticalities)?m.criticalities:[];m.photos=Array.isArray(m.photos)?m.photos:[];m.completeness=Number(m.completeness)||surveyCompletenessFromMeasure(m);m.updatedAt=m.updatedAt||new Date().toISOString(); } function normalizeTechnicalCatalogP6(base){ base.manufacturers.forEach(x=>{x.active=x.active!==false;x.material=x.material||"";x.notes=x.notes||""}); base.systems.forEach(x=>{x.active=x.active!==false;x.minW=Number(x.minW)||0;x.maxW=Number(x.maxW)||99999;x.minH=Number(x.minH)||0;x.maxH=Number(x.maxH)||99999;x.maxArea=Number(x.maxArea)||999;x.maxLeafW=Number(x.maxLeafW)||99999;x.maxLeafH=Number(x.maxLeafH)||99999;x.allowedSchemas=Array.isArray(x.allowedSchemas)?x.allowedSchemas:[];x.allowedGlasses=Array.isArray(x.allowedGlasses)?x.allowedGlasses:[];x.allowedFinishes=Array.isArray(x.allowedFinishes)?x.allowedFinishes:[];x.allowedHandles=Array.isArray(x.allowedHandles)?x.allowedHandles:[];x.allowedThresholds=Array.isArray(x.allowedThresholds)?x.allowedThresholds:[]}); base.components.forEach(x=>{x.active=x.active!==false;x.schemas=Array.isArray(x.schemas)&&x.schemas.length?x.schemas:["*"];x.formula=x.formula||"unit";const seed=P7_DEFAULT_COMPONENT_COSTS[x.code]||{};x.unitCost=Number(x.unitCost ?? seed.unitCost ?? 0);x.supplierDiscount=Number(x.supplierDiscount ?? seed.supplierDiscount ?? 0);x.wastePercent=Number(x.wastePercent ?? seed.wastePercent ?? 0);x.laborMinutes=Number(x.laborMinutes ?? seed.laborMinutes ?? 0)}); base.technicalRules.forEach(x=>{x.active=x.active!==false;x.severity=x.severity||"warning";x.operator=x.operator||"eq";x.message=x.message||x.name||"Regola tecnica"}); } function normalizeConfigurationsP5(base){ base.configurations.forEach(c=>{c.history=Array.isArray(c.history)?c.history:[];c.revision=Number(c.revision)||1;c.status=c.status||"Configurato";c.direction=c.direction||"Sinistra";c.qty=Number(c.qty)||1;c.transomH=Number(c.transomH)||400;c.fixedSide=Number(c.fixedSide)||400;c.material=c.material||"Da definire";c.glass=c.glass||"Da definire";c.finish=c.finish||"";c.handle=c.handle||"Da definire";c.threshold=c.threshold||"Standard";c.notes=c.notes||"";c.savedAt=c.savedAt||new Date().toISOString(); if(!c.systemId&&c.system){const sys=base.systems.find(x=>String(x.code).toLowerCase()===String(c.system).toLowerCase()||String(x.name).toLowerCase()===String(c.system).toLowerCase()||String(c.system).toLowerCase().includes(String(x.code).toLowerCase()));if(sys){c.systemId=sys.id;c.manufacturerId=sys.manufacturerId}} const sys=base.systems.find(x=>Number(x.id)===Number(c.systemId));if(sys){c.manufacturerId=sys.manufacturerId;c.system=`${sys.code} · ${sys.name}`} c.componentSnapshot=Array.isArray(c.componentSnapshot)?c.componentSnapshot:[];c.ruleSnapshot=Array.isArray(c.ruleSnapshot)?c.ruleSnapshot:[];c.pricingSnapshot=c.pricingSnapshot||null; }); base.serramenti.forEach(ser=>{const c=base.configurations.find(x=>Number(x.serramentoId)===Number(ser.id));if(c){ser.configurationId=c.id;if(["Da rilevare","Rilevato","Rilievo parziale"].includes(ser.status))ser.status="Configurato"}}) } function loadState(){try{const fresh=localStorage.getItem(STORAGE_KEY);if(fresh)return normalizeState(JSON.parse(fresh));for(const key of LEGACY_KEYS){const legacy=localStorage.getItem(key);if(legacy)return normalizeState(JSON.parse(legacy))}return normalizeState(clone(demo))}catch(e){return normalizeState(clone(demo))}} function rawPersist(){localStorage.setItem(STORAGE_KEY,JSON.stringify(state))} function activeWorkspace(){return state.platform?.workspaces?.find(w=>Number(w.id)===Number(state.platform.activeWorkspaceId))||null} function currentUser(){return state.platform?.users?.find(u=>Number(u.id)===Number(state.platform.activeUserId))||null} function currentRole(){const u=currentUser();return state.platform?.roles?.find(r=>r.id===u?.roleId)||null} function can(permission){const r=currentRole();return !!r&&(r.permissions?.includes("*")||r.permissions?.includes(permission))} function persistActiveWorkspaceSnapshot(){if(!state.platform)return;state.platform.workspaceData[String(state.platform.activeWorkspaceId)]=snapshotWorkspaceData(state)} function platformAudit(action,area="Sistema",detail=""){if(!state.platform)return;const now=new Date().toISOString(),u=currentUser(),w=activeWorkspace(),last=state.platform.audit[0];if(last&&last.userId===u?.id&&last.action===action&&last.area===area&&(Date.now()-new Date(last.date).getTime())<1500){last.date=now;last.detail=detail||last.detail;return}state.platform.audit.unshift({id:Date.now()+Math.floor(Math.random()*999),workspaceId:w?.id||null,userId:u?.id||null,user:u?.name||"Sistema",action,area,detail,date:now});if(state.platform.audit.length>600)state.platform.audit.length=600} function currentViewId(){return document.querySelector(".view.active")?.id||"dashboard"} function renderViewById(id,force=false){ safeRender("selects",()=>renderSelects()); const map={ dashboard:()=>renderDashboard(), crm:()=>renderCRM(), clienti:()=>renderClients(), pratiche:()=>renderPractices(), rilievi:()=>{renderMeasures();renderMeasureStructureSelects();renderSurveyWorkspace(!!force)}, configuratore:()=>renderConfigurator(!!force), preventivi:()=>{renderQuotes();renderQuoteProfiles();if(!quoteDraft){quoteDraft=emptyQuoteDraft();fillQuoteHeaderForm(quoteDraft)}renderQuoteDraft();calcQuote();renderQuickQuoteUI()}, commesse:()=>renderJobs(), consuntivo:()=>renderActuals(!!force), acquisti:()=>renderPurchasing(!!force), magazzino:()=>renderWarehouse(!!force), posa:()=>{renderInstalls();renderPlanning()}, fieldapp:()=>renderFieldApp(!!force), catalogo:()=>renderCatalog(), pricing:()=>{renderPrices();renderPricing()}, settings:()=>renderSettings(), platform:()=>renderPlatform() }; safeRender("view-"+id,()=>map[id]?.()); safeRender("platform-chrome",()=>updatePlatformChrome()); safeRender("permissions",()=>applyPermissions()); } function renderAfterSave(area="Applicazione"){ const view=currentViewId(); requestAnimationFrame(()=>renderViewById(view,false)); } function saveState(action="Aggiornamento dati",area="Applicazione",detail=""){platformAudit(action,area,detail);persistActiveWorkspaceSnapshot();rawPersist();renderAfterSave(area)} function money(n){return new Intl.NumberFormat("it-IT",{style:"currency",currency:"EUR",maximumFractionDigits:0}).format(Number(n)||0)} function clientName(id){return state.clients.find(c=>c.id==id)?.name||"Cliente"} function toast(msg){const t=document.getElementById("toast");t.textContent=msg;t.classList.add("show");setTimeout(()=>t.classList.remove("show"),1800)} function openModal(id){document.getElementById(id).classList.add("open")} function closeModal(id){document.getElementById(id).classList.remove("open")} function resetDemo(){if(confirm("Ripristinare tutti i dati demo e le impostazioni di SerramentiOS?")){state=normalizeState(clone(demo));selectedPracticeId=state.practices[0]?.id||null;platformAudit("Ripristino demo","Piattaforma","Dataset demo ripristinato");persistActiveWorkspaceSnapshot();rawPersist();renderAll();toast("Demo ripristinata")}} function showView(id){ const req=P9_VIEW_PERMISSION[id];if(req&&!can(req)){toast("Il ruolo corrente non ha accesso a questa area");return} document.querySelectorAll(".view").forEach(v=>v.classList.toggle("active",v.id===id)); document.querySelectorAll("#nav button").forEach(b=>b.classList.toggle("active",b.dataset.view===id)); const map={dashboard:"Dashboard",crm:"CRM commerciale",clienti:"Clienti & lead",pratiche:"Pratiche",rilievi:"Rilievi & misure",configuratore:"Configuratore 2D",preventivi:"Preventivi",commesse:"Commesse",consuntivo:"Consuntivo economico",acquisti:"Acquisti & fornitori",magazzino:"Magazzino & readiness",posa:"Planning & posa",fieldapp:"App tecnico",catalogo:"Catalogo tecnico",pricing:"Costi & margini",settings:"Impostazioni",platform:"Avanzate"}; document.getElementById("topTitle").textContent=map[id]||id; document.getElementById("sidebar").classList.remove("open"); updateMobileNav(id); requestAnimationFrame(()=>renderViewById(id,true)); } document.querySelectorAll("#nav button").forEach(b=>b.addEventListener("click",()=>showView(b.dataset.view))); function safeRender(name,fn){try{fn()}catch(err){console.error("[SerramentiOS render] "+name,err)}} function renderAll(){ safeRender("selects",()=>renderSelects()); safeRender("dashboard",()=>renderDashboard()); safeRender("crm",()=>renderCRM()); safeRender("clients",()=>renderClients()); safeRender("practices",()=>renderPractices()); safeRender("quotes",()=>renderQuotes()); safeRender("jobs",()=>renderJobs()); safeRender("measures",()=>renderMeasures()); safeRender("installs",()=>renderInstalls()); safeRender("prices",()=>renderPrices()); safeRender("measure-structure",()=>renderMeasureStructureSelects()); safeRender("survey",()=>renderSurveyWorkspace(false)); safeRender("configurator",()=>renderConfigurator(false)); safeRender("catalog",()=>renderCatalog()); safeRender("pricing",()=>renderPricing()); safeRender("purchasing",()=>renderPurchasing(false)); safeRender("warehouse",()=>renderWarehouse(false)); safeRender("planning",()=>renderPlanning()); safeRender("fieldapp",()=>renderFieldApp(false)); safeRender("actuals",()=>renderActuals(false)); safeRender("quote-profiles",()=>renderQuoteProfiles()); safeRender("quote-calc",()=>calcQuote()); safeRender("quick-quote",()=>renderQuickQuoteUI()); safeRender("settings",()=>renderSettings()); safeRender("platform",()=>renderPlatform()); safeRender("platform-chrome",()=>updatePlatformChrome()); safeRender("permissions",()=>applyPermissions()); } function renderSelects(){ const opts=state.clients.map(c=>``).join(""); ["qClient","mClient","prClient"].forEach(id=>{const el=document.getElementById(id);if(!el)return;const val=el.value;el.innerHTML=''+opts;if([...el.options].some(o=>o.value===val))el.value=val}); const practiceOpts=state.practices.map(p=>``).join(""); ["qPractice","mPractice"].forEach(id=>{const el=document.getElementById(id);if(!el)return;const val=el.value;el.innerHTML=''+practiceOpts;if([...el.options].some(o=>o.value===val))el.value=val}); const crms=appSettings().commercial;["prSource","cSource"].forEach(id=>fillSettingsSimpleSelect(id,crms.sources));fillSettingsSimpleSelect("clReason",crms.lostReasons); if(document.getElementById("iJob")&&!document.getElementById("installModal").classList.contains("open")){renderPlanningModalSelects()} } function applyWorkspaceDataset(ds){P9_DATA_ARRAY_KEYS.forEach(k=>state[k]=clone(ds?.[k]||[]));P9_DATA_OBJECT_KEYS.forEach(k=>state[k]=clone(ds?.[k]||{}));state.activeQuoteProfileId=ds?.activeQuoteProfileId||"q1";if(!state.pricingPolicy)state.pricingPolicy=clone(P7_DEFAULT_POLICY);normalizeTechnicalCatalogP6(state);normalizeProcurementP10(state);normalizeWarehouseP11(state);normalizePlanningP12(state);normalizeFieldP13(state);normalizeActualsP14(state);normalizeQuoteProfilesV16(state);normalizeSettingsV17(state);normalizeConfigurationsP5(state);state.practices.forEach(p=>{p.tasks=p.tasks||[];p.activities=p.activities||[];p.appointments=p.appointments||[];p.documents=p.documents||[];p.crmContacts=p.crmContacts||[]});} function switchWorkspace(id){id=Number(id);if(id===Number(state.platform.activeWorkspaceId))return;const target=state.platform.workspaces.find(w=>Number(w.id)===id&&w.active!==false);if(!target){toast("Workspace non disponibile");return}persistActiveWorkspaceSnapshot();state.platform.activeWorkspaceId=id;state.platform.activeUserId=state.platform.users.find(u=>Number(u.workspaceId)===id&&u.active!==false)?.id||null;applyWorkspaceDataset(state.platform.workspaceData[String(id)]||createEmptyWorkspaceDataset());selectedPracticeId=state.practices[0]?.id||null;activeConfigSerramentoId=null;activeQuoteConfigId=null;quoteDraft=null;editingQuoteId=null;platformAudit("Cambio workspace","Piattaforma",target.name);persistActiveWorkspaceSnapshot();rawPersist();renderAll();showView("dashboard");toast(`Workspace: ${target.name}`)} function switchSessionUser(id){id=Number(id);const u=state.platform.users.find(x=>x.id===id&&Number(x.workspaceId)===Number(state.platform.activeWorkspaceId)&&x.active!==false);if(!u)return;state.platform.activeUserId=id;platformAudit("Cambio sessione","Piattaforma",u.name);rawPersist();updatePlatformChrome();applyPermissions();renderPlatform();const active=document.querySelector(".view.active")?.id;if(active&&P9_VIEW_PERMISSION[active]&&!can(P9_VIEW_PERMISSION[active]))showView("dashboard");toast(`Sessione: ${u.name}`)} function updatePlatformChrome(){if(!state.platform)return;const ws=document.getElementById("workspaceSwitcher");if(ws){ws.innerHTML=state.platform.workspaces.filter(w=>w.active!==false).map(w=>``).join("")}const u=currentUser(),r=currentRole();if(document.getElementById("sessionUserName"))sessionUserName.textContent=u?.name||"Nessun utente";if(document.getElementById("sessionRoleName"))sessionRoleName.textContent=r?.name||"Nessun ruolo";if(document.getElementById("sessionAvatar"))sessionAvatar.textContent=(u?.name||"NA").split(/\s+/).slice(0,2).map(x=>x[0]).join("").toUpperCase()} function applyPermissions(){document.querySelectorAll("#nav button[data-view]").forEach(b=>{const p=P9_VIEW_PERMISSION[b.dataset.view];b.style.display=(!p||can(p))?"":"none"});document.querySelectorAll("[data-requires]").forEach(el=>{el.style.display=can(el.dataset.requires)?"":"none"});document.querySelectorAll("[data-platform-admin]").forEach(el=>{el.style.display=(can("platform.manage")||can("users.manage"))?"":"none"})} function roleById(id){return state.platform.roles.find(r=>r.id===id)} function roleBadge(roleId){const r=roleById(roleId);return `${esc(r?.name||roleId)}`} function createWorkspace(){if(!can("platform.manage")){toast("Permesso insufficiente");return}const name=pfWorkspaceName.value.trim();if(!name){toast("Inserisci il nome azienda");return}const id=Math.max(0,...state.platform.workspaces.map(x=>Number(x.id)||0))+1,slug=name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g,"").replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");state.platform.workspaces.push({id,name,slug:slug||`workspace-${id}`,plan:"Starter",active:true,createdAt:new Date().toISOString()});state.platform.workspaceData[String(id)]=createEmptyWorkspaceDataset();const uid=Math.max(0,...state.platform.users.map(x=>Number(x.id)||0))+1;state.platform.users.push({id:uid,workspaceId:id,name:"Admin "+name,email:`admin@${slug||"workspace"}.local`,roleId:"owner",active:true});pfWorkspaceName.value="";saveState("Creazione workspace","Piattaforma",name);toast("Workspace creato")} function createPlatformUser(){if(!can("users.manage")&&!can("platform.manage")){toast("Permesso insufficiente");return}const name=pfUserName.value.trim(),email=pfUserEmail.value.trim().toLowerCase(),roleId=pfUserRole.value;if(!name||!email){toast("Inserisci nome ed email");return}if(state.platform.users.some(u=>u.email===email&&Number(u.workspaceId)===Number(state.platform.activeWorkspaceId))){toast("Email già presente nel workspace");return}const id=Math.max(0,...state.platform.users.map(x=>Number(x.id)||0))+1;state.platform.users.push({id,workspaceId:state.platform.activeWorkspaceId,name,email,roleId,active:true});pfUserName.value="";pfUserEmail.value="";saveState("Creazione utente","Utenti",`${name} · ${roleById(roleId)?.name||roleId}`);toast("Utente creato")} function updatePlatformUserRole(userId,roleId){if(!can("users.manage")&&!can("platform.manage")){toast("Permesso insufficiente");renderPlatform();return}const u=state.platform.users.find(x=>x.id===userId);if(!u)return;if(u.id===state.platform.activeUserId&&u.roleId==="owner"&&roleId!=="owner"&&state.platform.users.filter(x=>x.workspaceId===u.workspaceId&&x.active!==false&&x.roleId==="owner").length===1){toast("Deve restare almeno un Owner");renderPlatform();return}u.roleId=roleId;saveState("Cambio ruolo","Utenti",`${u.name} → ${roleById(roleId)?.name||roleId}`)} function togglePlatformUser(userId){if(!can("users.manage")&&!can("platform.manage")){toast("Permesso insufficiente");return}const u=state.platform.users.find(x=>x.id===userId);if(!u||u.id===state.platform.activeUserId){toast("Non puoi disattivare la sessione corrente");return}u.active=u.active===false;saveState(u.active?"Riattivazione utente":"Disattivazione utente","Utenti",u.name)} function toggleRolePermission(roleId,permission,checked){if(!can("platform.manage")){toast("Permesso insufficiente");renderPlatform();return}const r=roleById(roleId);if(!r||r.permissions.includes("*"))return;const s=new Set(r.permissions||[]);checked?s.add(permission):s.delete(permission);r.permissions=[...s];saveState("Aggiornamento permessi","Sicurezza",`${r.name} · ${permission}: ${checked?"ON":"OFF"}`)} function workspaceBytes(){try{return new Blob([JSON.stringify(snapshotWorkspaceData(state))]).size}catch(e){return JSON.stringify(snapshotWorkspaceData(state)).length}} function fmtBytes(n){if(n<1024)return n+" B";if(n<1024*1024)return (n/1024).toFixed(1)+" KB";return (n/1024/1024).toFixed(1)+" MB"} function downloadJson(name,data){const blob=new Blob([JSON.stringify(data,null,2)],{type:"application/json"}),url=URL.createObjectURL(blob),a=document.createElement("a");a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),500)} function exportWorkspaceBackup(){if(!can("data.export")&&!can("platform.manage")){toast("Permesso export insufficiente");return}persistActiveWorkspaceSnapshot();const w=activeWorkspace();downloadJson(`serramentios-${w?.slug||"workspace"}-backup.json`,{format:"SerramentiOS-P9-workspace",version:1,exportedAt:new Date().toISOString(),workspace:clone(w),dataset:snapshotWorkspaceData(state)});platformAudit("Export workspace","Backup",w?.name||"");rawPersist();renderPlatform()} function exportFullBackup(){if(!can("platform.manage")){toast("Solo Owner/Admin può esportare il backup completo");return}persistActiveWorkspaceSnapshot();downloadJson(`serramentios-full-backup-${new Date().toISOString().slice(0,10)}.json`,{format:"SerramentiOS-P9-full",version:1,exportedAt:new Date().toISOString(),state});platformAudit("Backup completo","Backup",`${state.platform.workspaces.length} workspace`);rawPersist();renderPlatform()} function importWorkspaceBackup(input){if(!can("platform.manage")){toast("Permesso insufficiente");input.value="";return}const f=input.files?.[0];if(!f)return;const r=new FileReader();r.onload=()=>{try{const data=JSON.parse(r.result);if(!data.dataset)throw new Error("dataset mancante");applyWorkspaceDataset(data.dataset);persistActiveWorkspaceSnapshot();platformAudit("Import workspace","Backup",f.name);rawPersist();selectedPracticeId=state.practices[0]?.id||null;renderAll();toast("Workspace importato")}catch(e){console.error(e);toast("Backup non valido")}input.value=""};r.readAsText(f)} function exportAuditLog(){if(!can("data.export")&&!can("platform.manage")){toast("Permesso export insufficiente");return}downloadJson(`serramentios-audit-${new Date().toISOString().slice(0,10)}.json`,state.platform.audit.filter(a=>Number(a.workspaceId)===Number(state.platform.activeWorkspaceId)));platformAudit("Export audit","Audit","");rawPersist();renderPlatform()} function generateDemoApiKey(){if(!can("platform.manage")){toast("Permesso insufficiente");return}let k=state.platform.apiKeys.find(x=>Number(x.workspaceId)===Number(state.platform.activeWorkspaceId));if(!k){k={id:Date.now(),workspaceId:state.platform.activeWorkspaceId,label:"Demo integration",active:true};state.platform.apiKeys.push(k)}k.key="sos_demo_"+Array.from(crypto.getRandomValues(new Uint8Array(18))).map(x=>x.toString(16).padStart(2,"0")).join("");k.createdAt=new Date().toISOString();saveState("Rigenerazione API key demo","Integrazioni","Chiave locale rigenerata")} function addWebhook(){if(!can("platform.manage")){toast("Permesso insufficiente");return}const url=pfWebhookUrl.value.trim(),event=pfWebhookEvent.value;if(!/^https?:\/\//i.test(url)){toast("Inserisci un URL http/https valido");return}state.platform.webhooks.push({id:Date.now(),workspaceId:state.platform.activeWorkspaceId,url,event,active:true,createdAt:new Date().toISOString()});pfWebhookUrl.value="";saveState("Creazione webhook","Integrazioni",`${event} → ${url}`)} function removeWebhook(id){if(!can("platform.manage"))return;const w=state.platform.webhooks.find(x=>x.id===id);state.platform.webhooks=state.platform.webhooks.filter(x=>x.id!==id);saveState("Rimozione webhook","Integrazioni",w?.url||"")} function renderAuditLog(){const el=document.getElementById("auditLog");if(!el)return;const q=(auditSearch?.value||"").toLowerCase(),area=auditAreaFilter?.value||"",rows=state.platform.audit.filter(a=>Number(a.workspaceId)===Number(state.platform.activeWorkspaceId)&&(!area||a.area===area)&&(!q||[a.user,a.action,a.area,a.detail].join(" ").toLowerCase().includes(q))).slice(0,100);el.innerHTML=rows.length?rows.map(a=>`
${esc(a.user||"Sistema")}${esc(roleById(state.platform.users.find(u=>u.id===a.userId)?.roleId)?.name||"")}
${esc(a.action)}${esc(a.area)}
${new Intl.DateTimeFormat("it-IT",{day:"2-digit",month:"2-digit",hour:"2-digit",minute:"2-digit"}).format(new Date(a.date))}
${esc(a.detail||"—")}
`).join(""):'
Nessun evento per questo filtro.
'} function renderPlatform(){const root=document.getElementById("platform");if(!root||!state.platform)return;const ws=activeWorkspace(),users=state.platform.users.filter(u=>Number(u.workspaceId)===Number(state.platform.activeWorkspaceId)),activeUsers=users.filter(u=>u.active!==false),bytes=workspaceBytes();pfKWorkspace.textContent=state.platform.workspaces.filter(w=>w.active!==false).length;pfKUsers.textContent=activeUsers.length;pfKRoles.textContent=state.platform.roles.length;pfKAudit.textContent=state.platform.audit.filter(a=>Number(a.workspaceId)===Number(state.platform.activeWorkspaceId)).length;pfKStorage.textContent=fmtBytes(bytes);pfStorageDetail.textContent=`${fmtBytes(bytes)} · ${state.clients.length} clienti · ${state.practices.length} pratiche · ${state.serramenti.length} serramenti`;pfStorageMeter.style.width=Math.min(100,bytes/(4.5*1024*1024)*100)+"%"; workspaceCards.innerHTML=state.platform.workspaces.filter(w=>w.active!==false).map(w=>`

${esc(w.name)}

${esc(w.slug)} · Piano ${esc(w.plan)} · ${state.platform.users.filter(u=>u.workspaceId===w.id&&u.active!==false).length} utenti

${Number(w.id)===Number(state.platform.activeWorkspaceId)?'ATTIVO':``}
`).join(""); pfSessionUser.innerHTML=activeUsers.map(u=>``).join("");pfCurrentRole.value=currentRole()?.name||"";pfUserRole.innerHTML=state.platform.roles.map(r=>``).join(""); platformUsers.innerHTML=users.map(u=>`
${esc(u.name)}${esc(u.email)}
${roleBadge(u.roleId)}
${u.active!==false?"Attivo":"Disattivo"}
${can("users.manage")||can("platform.manage")?``:""}
`).join(""); permissionHead.innerHTML='Ruolo'+P9_PERMISSIONS.map(p=>`${esc(p.label)}`).join("");permissionRows.innerHTML=state.platform.roles.map(r=>`${esc(r.name)}${P9_PERMISSIONS.map(p=>``).join("")}`).join(""); const areas=[...new Set(state.platform.audit.filter(a=>Number(a.workspaceId)===Number(state.platform.activeWorkspaceId)).map(a=>a.area))].sort(),oldArea=auditAreaFilter.value;auditAreaFilter.innerHTML=''+areas.map(a=>``).join("");renderAuditLog(); const key=state.platform.apiKeys.find(k=>Number(k.workspaceId)===Number(state.platform.activeWorkspaceId)&&k.active!==false);platformApiKey.textContent=key?.key||"Nessuna chiave demo";const hooks=state.platform.webhooks.filter(w=>Number(w.workspaceId)===Number(state.platform.activeWorkspaceId));platformWebhooks.innerHTML=hooks.length?hooks.map(w=>`
${esc(w.url)}
${esc(w.event)}
`).join(""):'
Nessun webhook registrato.
';updatePlatformChrome();applyPermissions()} function renderDashboard(){ const openLeads=state.practices.filter(p=>!["Confermato","Ordinato","Materiale arrivato","Posa","Collaudo","Chiuso","Perso"].includes(p.status)).length; const openQuotes=state.quotes.filter(q=>!["Accettato","Perso"].includes(q.status)).length; const pipeline=state.practices.filter(p=>!["Perso","Chiuso"].includes(p.status)).reduce((a,p)=>a+Number(p.value||0),0); const activeJobs=state.jobs.filter(j=>j.status!=="Completato").length; const avg=state.quotes.length?state.quotes.reduce((a,q)=>a+Number(q.margin||0),0)/state.quotes.length:0; kLead.textContent=openLeads;kQuote.textContent=openQuotes;kPipeline.textContent=money(pipeline);kJobs.textContent=activeJobs;kMargin.textContent=avg.toFixed(1)+"%"; const counts={ "Nuovi":state.practices.filter(p=>["Nuovo","Contattato"].includes(p.status)).length, "Sopralluoghi":state.practices.filter(p=>p.status==="Sopralluogo").length, "Preventivi":state.practices.filter(p=>["Preventivo","Trattativa"].includes(p.status)).length, "Ordini":state.practices.filter(p=>["Confermato","Ordinato","Materiale arrivato"].includes(p.status)).length, "Posa":state.practices.filter(p=>["Posa","Collaudo"].includes(p.status)).length }; pipelineStages.innerHTML=Object.entries(counts).map(([k,v])=>`
${k}${v}${v===1?"pratica":"pratiche"}
`).join(""); let tasks=[]; state.practices.filter(p=>p.status!=="Chiuso"&&p.status!=="Perso").forEach(p=>{ (p.tasks||[]).filter(t=>!t.done).forEach(t=>tasks.push({t:t.text,m:`${p.code} · ${clientName(p.clientId)}${t.due?" · "+fmtDate(t.due):""}`,b:"Task",cls:p.priority==="Alta"?"danger":"new",due:t.due||"9999-12-31"})); if(p.nextAction)tasks.push({t:p.nextAction,m:`${p.code} · ${clientName(p.clientId)}`,b:p.status,cls:"warn",due:"9999-12-30"}); }); tasks.sort((a,b)=>String(a.due).localeCompare(String(b.due))); todayTasks.innerHTML=tasks.length?tasks.slice(0,6).map(x=>`
${esc(x.t)}
${esc(x.m||"")}
${esc(x.b)}
`).join(""):'
Nessuna priorità aperta.
'; jobOverview.innerHTML=state.jobs.length?state.jobs.slice(0,4).map(j=>`
${j.code} · ${esc(clientName(j.clientId))}
${esc(j.next)} · Consegna ${fmtDate(j.due)}
${money(j.value)}
${j.progress}%
`).join(""):'
Nessuna commessa.
'; } function practiceById(id){return state.practices.find(p=>p.id==id)} function practiceProgress(status){const map={"Nuovo":1,"Contattato":1,"Sopralluogo":2,"Preventivo":3,"Trattativa":3,"Confermato":4,"Ordinato":4,"Materiale arrivato":4,"Posa":5,"Collaudo":5,"Chiuso":5,"Perso":1};return map[status]||1} function practicePriorityClass(p){return p==="Alta"?"priority-high":p==="Bassa"?"priority-low":"priority-medium"} function practiceStatusClass(s){if(["Chiuso","Confermato"].includes(s))return"ok";if(["Preventivo","Trattativa","Sopralluogo","Ordinato","Materiale arrivato","Posa","Collaudo"].includes(s))return"warn";if(s==="Perso")return"danger";return"new"} function openNewPractice(clientId){ renderSelects();prId.value="";prClient.value=clientId||"";prSite.value="";prStatus.value="Nuovo";prPriority.value="Media";prOwner.value="Titolare";prSource.value=clientId?(state.clients.find(c=>c.id==clientId)?.source||appSettings().commercial.sources[0]||"Altro"):(appSettings().commercial.sources[0]||"Altro");prValue.value="";prNext.value="";prNotes.value="";practiceModalTitle.textContent="Nuova pratica";openModal("practiceModal"); } function editPractice(id){const p=practiceById(id);if(!p)return;renderSelects();prId.value=p.id;prClient.value=p.clientId;prSite.value=p.site||"";prStatus.value=p.status;prPriority.value=p.priority;prOwner.value=p.owner||"";prSource.value=p.source||"Altro";prValue.value=p.value||"";prNext.value=p.nextAction||"";prNotes.value=p.notes||"";practiceModalTitle.textContent=`Modifica ${p.code}`;openModal("practiceModal")} function savePractice(){ if(!prClient.value){toast("Seleziona il cliente");return} const now=new Date().toISOString();const id=Number(prId.value);const requestedStatus=prStatus.value; if(id){ const p=practiceById(id),oldStatus=p.status,next=prNext.value.trim()||(activeCommercialStatuses.includes(requestedStatus)?defaultNextAction(requestedStatus):""); Object.assign(p,{clientId:Number(prClient.value),site:prSite.value.trim(),status:requestedStatus,priority:prPriority.value,owner:prOwner.value.trim()||"Titolare",source:prSource.value,value:Number(prValue.value)||0,nextAction:next,notes:prNotes.value.trim(),updatedAt:now}); if(oldStatus!==requestedStatus){p.stageChangedAt=now;if(activeCommercialStatuses.includes(requestedStatus)&&!p.followUpAt)p.followUpAt=defaultFollowUpForStage(requestedStatus);if(isWonPractice(p)){p.wonAt=p.wonAt||now;p.followUpAt=""}p.activities.unshift({id:Date.now()+1,type:"CRM",text:`Stato modificato: ${oldStatus} → ${requestedStatus}`,date:now})} p.activities.unshift({id:Date.now(),type:"Pratica",text:"Dati pratica aggiornati",date:now});syncClientStatusFromPractice(p);selectedPracticeId=p.id } else { const seq=String(state.practices.length+1).padStart(3,"0"),next=prNext.value.trim()||(activeCommercialStatuses.includes(requestedStatus)?defaultNextAction(requestedStatus):""); const p={id:Date.now(),code:"PRA-"+seq,clientId:Number(prClient.value),site:prSite.value.trim(),status:requestedStatus,priority:prPriority.value,owner:prOwner.value.trim()||"Titolare",source:prSource.value,value:Number(prValue.value)||0,nextAction:next,stageChangedAt:now,lastContactAt:"",followUpAt:activeCommercialStatuses.includes(requestedStatus)?defaultFollowUpForStage(requestedStatus):"",lostReason:"",lostDetail:"",crmContacts:[],createdAt:now,updatedAt:now,tasks:[],appointments:[],documents:[],activities:[{id:Date.now()+1,type:"Pratica",text:"Pratica creata",date:now}]}; if(isWonPractice(p))p.wonAt=now;state.practices.unshift(p);syncClientStatusFromPractice(p);selectedPracticeId=p.id } closeModal("practiceModal");saveState();showView("pratiche");toast("Pratica salvata") } function renderPractices(){ const list=document.getElementById("practiceList");const detail=document.getElementById("practiceDetail");if(!list||!detail)return; const q=(practiceSearch?.value||"").toLowerCase();const f=practiceStatusFilter?.value||""; const rows=state.practices.filter(p=>(!q||[p.code,clientName(p.clientId),p.site,p.status,p.owner].join(" ").toLowerCase().includes(q))&&(!f||p.status===f)); if(!rows.some(p=>p.id==selectedPracticeId))selectedPracticeId=rows[0]?.id||null; list.innerHTML=rows.length?rows.map(p=>``).join(""):'
Nessuna pratica trovata.
'; renderPracticeDetail(selectedPracticeId); } function practiceProgressHTML(status){const n=practiceProgress(status);return `
${[1,2,3,4,5].map(i=>``).join("")}
`} function openPractice(id){selectedPracticeId=Number(id);renderPractices()} function renderPracticeDetail(id){ const el=document.getElementById("practiceDetail");const p=practiceById(id);if(!el)return;if(!p){el.innerHTML='
Seleziona una pratica oppure creane una nuova.
';return} const client=state.clients.find(c=>c.id===p.clientId)||{}; const quoteCount=state.quotes.filter(q=>q.practiceId==p.id).length,measureCount=state.measures.filter(m=>m.practiceId==p.id).length,job=state.jobs.find(j=>j.practiceId==p.id); el.innerHTML=`
${esc(p.status)}● Priorità ${esc(p.priority)}

${p.code} · ${esc(client.name||"Cliente")}

${esc(p.site||client.city||"Cantiere non indicato")} · Responsabile: ${esc(p.owner||"—")}

Valore${money(p.value)}
Origine${esc(p.source||"—")}
Ultimo contatto${p.lastContactAt?fmtDateTime(p.lastContactAt):"Mai"}
Follow-up${p.followUpAt?fmtDateTime(p.followUpAt):"—"}
Preventivi${quoteCount}
Rilievi / Commessa${measureCount} / ${job?job.code:"—"}
Vani / Serramenti${state.openings.filter(x=>x.practiceId===p.id).length} / ${state.serramenti.filter(x=>x.practiceId===p.id).reduce((a,x)=>a+(Number(x.qty)||1),0)}
Giorni nello stato${daysSince(p.stageChangedAt)} gg
Esito${p.status==="Perso"?esc(p.lostReason||"Perso"):(["Confermato","Ordinato","Materiale arrivato","Posa","Collaudo","Chiuso"].includes(p.status)?"Vinto":"Aperto")}
Prossima azione: ${esc(p.nextAction||"Nessuna azione impostata")}
${practiceTabHTML(p,client)}`; } function setPracticeTab(tab){currentPracticeTab=tab;renderPracticeDetail(selectedPracticeId)} function practiceTabHTML(p,c){ if(currentPracticeTab==="overview")return `

Cliente

Telefono${esc(c.phone||"—")}
Email${esc(c.email||"—")}
Città${esc(c.city||"—")}
Creato${fmtDateTime(p.createdAt)}

Note pratica

${esc(p.notes||"Nessuna nota inserita.")}

`; if(currentPracticeTab==="structure")return renderPracticeStructure(p); if(currentPracticeTab==="timeline"){const events=buildTimeline(p);return `
${events.length?events.map(e=>`
${esc(e.type)}

${esc(e.text)}

${fmtDateTime(e.date)}
`).join(""):'
Timeline vuota.
'}
`} if(currentPracticeTab==="tasks")return `
${(p.tasks||[]).length?p.tasks.map(t=>``).join(""):'
Nessuna attività.
'}
`; if(currentPracticeTab==="appointments")return `
${(p.appointments||[]).length?p.appointments.slice().sort((a,b)=>String(a.date).localeCompare(String(b.date))).map(a=>`
${esc(a.type)}
${fmtDateTime(a.date)} · ${esc(a.note||"")}
Agenda
`).join(""):'
Nessun appuntamento.
'}
`; if(currentPracticeTab==="documents")return `
${(p.documents||[]).length?p.documents.map(d=>`
▤
${esc(d.name)}
${esc(d.category)} · ${esc(d.size||"")} · ${fmtDateTime(d.date)}
${d.note?`
${esc(d.note)}
`:""}
`).join(""):'
Nessun documento.
'}
`; return ""; } function propertiesForPractice(practiceId){return state.properties.filter(x=>x.practiceId===Number(practiceId))} function roomsForProperty(propertyId){return state.rooms.filter(x=>x.propertyId===Number(propertyId))} function openingsForRoom(roomId){return state.openings.filter(x=>x.roomId===Number(roomId))} function serramentiForOpening(openingId){return state.serramenti.filter(x=>x.openingId===Number(openingId))} function propertyById(id){return state.properties.find(x=>x.id===Number(id))} function roomById(id){return state.rooms.find(x=>x.id===Number(id))} function openingById(id){return state.openings.find(x=>x.id===Number(id))} function serramentoById(id){return state.serramenti.find(x=>x.id===Number(id))} function renderPracticeStructure(p){ const props=propertiesForPractice(p.id),rooms=state.rooms.filter(x=>x.practiceId===p.id),opens=state.openings.filter(x=>x.practiceId===p.id),sers=state.serramenti.filter(x=>x.practiceId===p.id); return `
Data Model 2.0 attivo. Ogni dato tecnico è ora collegato a un'entità precisa: Cliente → Pratica → Immobile → Ambiente → Vano → Serramento. Misure, configurazioni, preventivi e assistenza potranno riferirsi allo stesso serramento senza duplicazioni.
Immobili${props.length}
Ambienti${rooms.length}
Vani${opens.length}
Serramenti${sers.reduce((a,x)=>a+(Number(x.qty)||1),0)}
Struttura della pratica
${p.code} → immobili → ambienti → vani → serramenti
${props.length?props.map(propertyTreeHTML).join(""):'
Nessun immobile. Crea il primo immobile per iniziare la struttura tecnica.
'}
`; } function propertyTreeHTML(prop){ const rooms=roomsForProperty(prop.id); return `
IMMOBILE

${esc(prop.name)}

${esc(prop.address||"Indirizzo non indicato")} · ${esc(prop.type||"—")}

${rooms.length?rooms.map(roomTreeHTML).join(""):'
Nessun ambiente in questo immobile.
'}
`; } function roomTreeHTML(room){ const opens=openingsForRoom(room.id); return `
${esc(room.name)}
${esc(room.floor||"Piano non indicato")}
${opens.length?opens.map(openingTreeHTML).join(""):'
Nessun vano.
'}
`; } function openingTreeHTML(op){ const sers=serramentiForOpening(op.id),measure=state.measures.find(m=>m.openingId===op.id); return `
${esc(op.code)}
${esc(op.type)}
VANO
${op.w&&op.h?op.w+" × "+op.h+" mm":"Misure da rilevare"}${measure?" · rilievo collegato":""}
${sers.length?sers.map(s=>`
${esc(s.code)}
${esc(s.type)} · ${esc(s.material)} · ${esc(s.status)}
SERRAMENTO
`).join(""):'Nessun serramento associato'}
`; } function nextEntityId(collection,base){return Math.max(base,...collection.map(x=>Number(x.id)||0))+1} function openPropertyModal(practiceId){const p=practiceById(practiceId);if(!p)return;selectedPracticeId=p.id;propertyPracticeLabel.textContent=`${p.code} · ${clientName(p.clientId)}`;propName.value="Immobile principale";propAddress.value=p.site||"";propType.value="Abitazione";propNotes.value="";openModal("propertyModal")} function saveProperty(){const p=practiceById(selectedPracticeId);if(!p||!propName.value.trim()){toast("Inserisci il nome dell'immobile");return}state.properties.push({id:nextEntityId(state.properties,100000),practiceId:p.id,name:propName.value.trim(),address:propAddress.value.trim(),type:propType.value,notes:propNotes.value.trim()});p.activities.unshift({id:Date.now(),type:"Struttura",text:"Aggiunto immobile: "+propName.value.trim(),date:new Date().toISOString()});closeModal("propertyModal");currentPracticeTab="structure";saveState();toast("Immobile creato")} function openRoomModal(propertyId){const prop=propertyById(propertyId);if(!prop)return;selectedPracticeId=prop.practiceId;roomPropertyId.value=prop.id;roomPropertyLabel.textContent=prop.name+" · "+(prop.address||"");roomName.value="";roomFloor.value="";roomNotes.value="";openModal("roomModal")} function saveRoomEntity(){const prop=propertyById(roomPropertyId.value);if(!prop||!roomName.value.trim()){toast("Inserisci il nome dell'ambiente");return}state.rooms.push({id:nextEntityId(state.rooms,2000000),practiceId:prop.practiceId,propertyId:prop.id,name:roomName.value.trim(),floor:roomFloor.value.trim(),notes:roomNotes.value.trim()});const p=practiceById(prop.practiceId);p?.activities.unshift({id:Date.now(),type:"Struttura",text:"Aggiunto ambiente: "+roomName.value.trim(),date:new Date().toISOString()});closeModal("roomModal");currentPracticeTab="structure";saveState();toast("Ambiente creato")} function openOpeningModal(roomId){const room=roomById(roomId);if(!room)return;selectedPracticeId=room.practiceId;openingRoomId.value=room.id;openingRoomLabel.textContent=room.name+" · "+(room.floor||"piano non indicato");const n=openingsForRoom(room.id).length+1;openingCode.value="F"+String(n).padStart(2,"0");openingType.value="Finestra";openingW.value="";openingH.value="";openingNotes.value="";openModal("openingModal")} function saveOpening(){const room=roomById(openingRoomId.value);if(!room||!openingCode.value.trim()){toast("Inserisci il codice del vano");return}const prop=propertyById(room.propertyId);const code=openingCode.value.trim().toUpperCase();if(state.openings.some(x=>x.practiceId===room.practiceId&&x.code.toUpperCase()===code)){toast("Codice vano già presente nella pratica");return}state.openings.push({id:nextEntityId(state.openings,3000000),practiceId:room.practiceId,propertyId:room.propertyId,roomId:room.id,code,type:openingType.value,w:Number(openingW.value)||0,h:Number(openingH.value)||0,notes:openingNotes.value.trim()});const p=practiceById(room.practiceId);p?.activities.unshift({id:Date.now(),type:"Struttura",text:`Creato vano ${code} in ${room.name}`,date:new Date().toISOString()});closeModal("openingModal");currentPracticeTab="structure";saveState();toast("Vano creato")} function openSerramentoModal(openingId){const op=openingById(openingId);if(!op)return;selectedPracticeId=op.practiceId;serOpeningId.value=op.id;serOpeningLabel.textContent=`${op.code} · ${op.type} · ${roomById(op.roomId)?.name||""}`;serCode.value="S-"+op.code+(serramentiForOpening(op.id).length?"-"+(serramentiForOpening(op.id).length+1):"");serType.value=[...serType.options].some(o=>o.value===op.type)?op.type:"Finestra";serMaterial.value="Da definire";serStatus.value="Da rilevare";serOpeningType.value="Da definire";serQty.value=1;serNotes.value="";openModal("serramentoModal")} function saveSerramento(){const op=openingById(serOpeningId.value);if(!op||!serCode.value.trim()){toast("Inserisci il codice serramento");return}const code=serCode.value.trim().toUpperCase();if(state.serramenti.some(x=>x.practiceId===op.practiceId&&x.code.toUpperCase()===code)){toast("Codice serramento già presente");return}state.serramenti.push({id:nextEntityId(state.serramenti,4000000),practiceId:op.practiceId,propertyId:op.propertyId,roomId:op.roomId,openingId:op.id,code,type:serType.value,material:serMaterial.value,opening:serOpeningType.value,status:serStatus.value,qty:Number(serQty.value)||1,notes:serNotes.value.trim()});const p=practiceById(op.practiceId);p?.activities.unshift({id:Date.now(),type:"Struttura",text:`Creato serramento ${code} per vano ${op.code}`,date:new Date().toISOString()});closeModal("serramentoModal");currentPracticeTab="structure";saveState();toast("Serramento creato")} function deleteProperty(id){const prop=propertyById(id);if(!prop)return;const roomIds=roomsForProperty(id).map(x=>x.id),openingIds=state.openings.filter(x=>roomIds.includes(x.roomId)).map(x=>x.id);if((roomIds.length||openingIds.length)&&!confirm("Eliminare immobile, ambienti, vani e serramenti collegati?"))return;state.configurations=state.configurations.filter(x=>!openingIds.includes(x.openingId));state.serramenti=state.serramenti.filter(x=>!openingIds.includes(x.openingId));state.openings=state.openings.filter(x=>!roomIds.includes(x.roomId));state.rooms=state.rooms.filter(x=>x.propertyId!==id);state.properties=state.properties.filter(x=>x.id!==id);state.measures=state.measures.filter(m=>m.propertyId!==id);currentPracticeTab="structure";saveState()} function deleteRoomEntity(id){const room=roomById(id);if(!room)return;const openingIds=openingsForRoom(id).map(x=>x.id);if(openingIds.length&&!confirm("Eliminare ambiente, vani e serramenti collegati?"))return;state.configurations=state.configurations.filter(x=>!openingIds.includes(x.openingId));state.serramenti=state.serramenti.filter(x=>!openingIds.includes(x.openingId));state.openings=state.openings.filter(x=>x.roomId!==id);state.rooms=state.rooms.filter(x=>x.id!==id);state.measures=state.measures.filter(m=>m.roomId!==id);currentPracticeTab="structure";saveState()} function deleteOpening(id){const sers=serramentiForOpening(id);if(sers.length&&!confirm("Eliminare il vano e i serramenti collegati?"))return;state.configurations=state.configurations.filter(x=>x.openingId!==id);state.serramenti=state.serramenti.filter(x=>x.openingId!==id);state.openings=state.openings.filter(x=>x.id!==id);state.measures=state.measures.filter(m=>m.openingId!==id);currentPracticeTab="structure";saveState()} function startMeasureForOpening(id){ const op=openingById(id),room=roomById(op?.roomId),prop=propertyById(op?.propertyId);if(!op||!room||!prop)return; showView("rilievi");setTimeout(()=>{mPractice.value=String(op.practiceId);syncPracticeClient("m");mProperty.value=String(prop.id);renderMeasureStructureSelects("property");activeSurveyOpeningId=op.id;renderSurveyWorkspace(true);loadSurveyOpening(op.id)},0) } function renderMeasureStructureSelects(changed){ const pId=Number(document.getElementById("mPractice")?.value||0),propEl=document.getElementById("mProperty");if(!propEl)return; const oldProp=propEl.value;const props=propertiesForPractice(pId);propEl.innerHTML=''+props.map(x=>``).join(""); if(changed!=="practice"&&props.some(x=>String(x.id)===oldProp))propEl.value=oldProp;else if(props.length===1)propEl.value=String(props[0].id); renderQuickRoomOptions(); } function syncPracticeClient(prefix){const pId=Number(document.getElementById(prefix+"Practice")?.value);const p=practiceById(pId);const clientEl=document.getElementById(prefix+"Client");if(p&&clientEl)clientEl.value=String(p.clientId);if(prefix==="m"){renderMeasureStructureSelects("practice");activeSurveyOpeningId=null;renderSurveyWorkspace(true)}} function buildTimeline(p){const ev=[...(p.activities||[])];(p.appointments||[]).forEach(a=>ev.push({type:"Appuntamento · "+a.type,text:a.note||"Appuntamento pianificato",date:a.date}));(p.documents||[]).forEach(d=>ev.push({type:"Documento · "+d.category,text:d.name,date:d.date}));return ev.sort((a,b)=>String(b.date).localeCompare(String(a.date)))} function addPracticeActivity(){const p=practiceById(selectedPracticeId),el=document.getElementById("activityText");if(!p||!el||!el.value.trim())return;p.activities.unshift({id:Date.now(),type:"Nota",text:el.value.trim(),date:new Date().toISOString()});p.updatedAt=new Date().toISOString();saveState();currentPracticeTab="timeline";toast("Timeline aggiornata")} function addPracticeTask(){const p=practiceById(selectedPracticeId),t=document.getElementById("taskText"),d=document.getElementById("taskDue");if(!p||!t||!t.value.trim())return;p.tasks.unshift({id:Date.now(),text:t.value.trim(),done:false,due:d?.value||""});p.activities.unshift({id:Date.now()+1,type:"Attività",text:"Creata attività: "+t.value.trim(),date:new Date().toISOString()});saveState();currentPracticeTab="tasks";toast("Attività aggiunta")} function togglePracticeTask(id){const p=practiceById(selectedPracticeId),t=p?.tasks.find(x=>x.id==id);if(!t)return;t.done=!t.done;p.activities.unshift({id:Date.now(),type:"Attività",text:`${t.done?"Completata":"Riaperta"}: ${t.text}`,date:new Date().toISOString()});saveState();currentPracticeTab="tasks"} function openPracticeAppointment(){if(!selectedPracticeId)return;paDate.value="";paType.value="Sopralluogo";paNote.value="";openModal("practiceAppointmentModal")} function savePracticeAppointment(){const p=practiceById(selectedPracticeId);if(!p||!paDate.value){toast("Inserisci data e ora");return}p.appointments.push({id:Date.now(),type:paType.value,date:paDate.value,note:paNote.value.trim()});p.activities.unshift({id:Date.now()+1,type:"Agenda",text:`Pianificato ${paType.value.toLowerCase()}`,date:new Date().toISOString()});closeModal("practiceAppointmentModal");saveState();currentPracticeTab="appointments";toast("Appuntamento salvato")} function openPracticeDocument(){if(!selectedPracticeId)return;pdFile.value="";pdCategory.value="Foto";pdNote.value="";openModal("practiceDocumentModal")} function savePracticeDocument(){const p=practiceById(selectedPracticeId),f=pdFile.files?.[0];if(!p||!f){toast("Seleziona un file");return}p.documents.unshift({id:Date.now(),name:f.name,category:pdCategory.value,size:formatBytes(f.size),note:pdNote.value.trim(),date:new Date().toISOString()});p.activities.unshift({id:Date.now()+1,type:"Documento",text:`Aggiunto ${f.name}`,date:new Date().toISOString()});closeModal("practiceDocumentModal");saveState();currentPracticeTab="documents";toast("Documento aggiunto")} function deletePracticeDocument(id){const p=practiceById(selectedPracticeId);if(!p)return;p.documents=p.documents.filter(d=>d.id!=id);saveState();currentPracticeTab="documents"} function quickQuoteFromPractice(id){showView("preventivi");setTimeout(()=>{qPractice.value=String(id);syncPracticeClient("q");calcQuote()},0)} function syncPracticeClient(prefix){const pId=Number(document.getElementById(prefix+"Practice")?.value);const p=practiceById(pId);const clientEl=document.getElementById(prefix+"Client");if(p&&clientEl)clientEl.value=String(p.clientId);if(prefix==="m")renderMeasureStructureSelects("practice")} function newPracticeForClient(id){openNewPractice(id)} function formatBytes(n){if(!n)return"0 B";if(n<1024)return n+" B";if(n<1024*1024)return (n/1024).toFixed(1)+" KB";return (n/1024/1024).toFixed(1)+" MB"} function fmtDateTime(v){if(!v)return"—";try{return new Intl.DateTimeFormat("it-IT",{day:"2-digit",month:"2-digit",year:"numeric",hour:"2-digit",minute:"2-digit"}).format(new Date(v))}catch(e){return v}} const crmStages=["Nuovo","Contattato","Sopralluogo","Preventivo","Trattativa","Confermato"]; const activeCommercialStatuses=["Nuovo","Contattato","Sopralluogo","Preventivo","Trattativa"]; function crmNow(){return new Date()} function daysBetween(a,b){if(!a||!b)return 0;return Math.max(0,Math.floor((new Date(b)-new Date(a))/86400000))} function daysSince(v){if(!v)return 0;return daysBetween(v,new Date().toISOString())} function isWonPractice(p){return ["Confermato","Ordinato","Materiale arrivato","Posa","Collaudo","Chiuso"].includes(p.status)} function isActiveCRM(p){return activeCommercialStatuses.includes(p.status)} function isFollowUpOverdue(p){return isActiveCRM(p)&&p.followUpAt&&new Date(p.followUpAt)=Number(appSettings().commercial.staleDays||4)} function isQuoteStale(p){return ["Preventivo","Trattativa"].includes(p.status)&&daysSince(p.stageChangedAt)>=Number(appSettings().commercial.quoteStaleDays||5)} function defaultNextAction(stage){return {Nuovo:"Contattare il lead",Contattato:"Fissare sopralluogo o prossimo contatto",Sopralluogo:"Completare rilievo e preparare preventivo",Preventivo:"Inviare preventivo e programmare follow-up",Trattativa:"Gestire obiezioni e definire decisione",Confermato:"Avviare ordine e commessa"}[stage]||"Definire prossima azione"} function defaultFollowUpForStage(stage){const days=Number(appSettings().commercial.followUp?.[stage]??0);if(!days)return"";const d=new Date();d.setDate(d.getDate()+days);d.setHours(9,0,0,0);return d.toISOString()} function stageLabel(stage){return stage==="Confermato"?"Vinto":stage} function renderCRM(){ const owner=document.getElementById("crmOwnerFilter"), source=document.getElementById("crmSourceFilter"); if(!owner||!source)return; const ov=owner.value,sv=source.value; const owners=[...new Set(state.practices.map(p=>p.owner).filter(Boolean))].sort(); const sources=[...new Set(state.practices.map(p=>p.source).filter(Boolean))].sort(); owner.innerHTML=''+owners.map(x=>``).join(""); source.innerHTML=''+sources.map(x=>``).join(""); if(owners.includes(ov))owner.value=ov;if(sources.includes(sv))source.value=sv; renderCRMKPIs();renderCRMBoard();renderCRMAttention();renderCRMSourceStats();renderCRMLost(); } function renderCRMKPIs(){ const active=state.practices.filter(isActiveCRM),won=state.practices.filter(isWonPractice),lost=state.practices.filter(p=>p.status==="Perso"); crmOpen.textContent=active.length;crmValue.textContent=money(active.reduce((a,p)=>a+Number(p.value||0),0));crmOverdue.textContent=active.filter(isFollowUpOverdue).length; const closed=won.length+lost.length;crmConversion.textContent=(closed?won.length/closed*100:0).toFixed(1)+"%"; const cycles=won.map(p=>daysBetween(p.createdAt,p.wonAt||p.updatedAt)).filter(n=>Number.isFinite(n));crmCycle.textContent=cycles.length?Math.round(cycles.reduce((a,n)=>a+n,0)/cycles.length)+" gg":"—"; } function filteredCRMPractices(){ const q=(document.getElementById("crmSearch")?.value||"").trim().toLowerCase(),owner=document.getElementById("crmOwnerFilter")?.value||"",source=document.getElementById("crmSourceFilter")?.value||"",health=document.getElementById("crmHealthFilter")?.value||""; return state.practices.filter(p=>{ const c=state.clients.find(x=>x.id==p.clientId)||{}; const hit=!q||[p.code,c.name,c.city,c.phone,p.site,p.nextAction].join(" ").toLowerCase().includes(q); if(!hit||(owner&&p.owner!==owner)||(source&&p.source!==source))return false; if(health==="overdue"&&!isFollowUpOverdue(p))return false;if(health==="stale"&&!isStaleCRM(p))return false;if(health==="noaction"&&p.nextAction)return false;if(health==="quote"&&!isQuoteStale(p))return false; return true; }); } function renderCRMBoard(){ const board=document.getElementById("crmBoard");if(!board)return;const list=filteredCRMPractices(); board.innerHTML=crmStages.map(stage=>{ let ps=list.filter(p=>stage==="Confermato"?isWonPractice(p):p.status===stage);if(stage==="Confermato")ps=ps.filter(p=>p.status!=="Perso"); const total=ps.reduce((a,p)=>a+Number(p.value||0),0); return `
${stageLabel(stage)}
${ps.length} · ${money(total)}
${ps.length}
${ps.length?ps.map(crmCardHTML).join(""):'
Trascina qui una pratica
'}
`; }).join(""); } function crmCardHTML(p){ const c=state.clients.find(x=>x.id==p.clientId)||{},over=isFollowUpOverdue(p),stale=isStaleCRM(p),quoteStale=isQuoteStale(p),days=daysSince(p.stageChangedAt),last=p.lastContactAt?fmtDateTime(p.lastContactAt):"Mai"; const cls=over?"overdue":(stale||quoteStale?"stale":""); return `

${esc(c.name||"Cliente")}

${p.code} · ${esc(p.source||"—")} · ${esc(p.owner||"—")}

${money(p.value)}
${days} gg nello statoUltimo: ${last}
${over?'
● Follow-up scaduto
':quoteStale?'
● Preventivo fermo da '+days+' gg
':stale?'
● Nessun contatto recente
':''}
Prossima: ${esc(p.nextAction||"⚠ Non impostata")}${p.followUpAt?`
${fmtDateTime(p.followUpAt)}`:""}
${!isWonPractice(p)?``:""}
`; } function crmDragStart(ev,id){draggedPracticeId=id;ev.dataTransfer.effectAllowed="move";ev.dataTransfer.setData("text/plain",String(id))} function crmAllowDrop(ev){ev.preventDefault();ev.currentTarget.classList.add("drag-over");ev.dataTransfer.dropEffect="move"} function crmDrop(ev,stage){ev.preventDefault();ev.currentTarget.classList.remove("drag-over");const id=Number(ev.dataTransfer.getData("text/plain")||draggedPracticeId);if(id)changePracticeCommercialStage(id,stage)} function changePracticeCommercialStage(id,stage){ const p=practiceById(id);if(!p||!crmStages.includes(stage))return;const old=p.status;if(old===stage||(stage==="Confermato"&&isWonPractice(p)))return; p.status=stage;p.stageChangedAt=new Date().toISOString();p.updatedAt=p.stageChangedAt;p.nextAction=defaultNextAction(stage);p.followUpAt=defaultFollowUpForStage(stage);if(stage==="Confermato"){p.wonAt=p.wonAt||p.stageChangedAt;p.followUpAt="";p.lostReason=""} p.activities.unshift({id:Date.now(),type:"CRM",text:`Stato commerciale: ${old} → ${stageLabel(stage)}`,date:p.stageChangedAt});syncClientStatusFromPractice(p);saveState();toast(`Spostata in ${stageLabel(stage)}`) } function syncClientStatusFromPractice(p){const c=state.clients.find(x=>x.id==p.clientId);if(!c)return;if(isWonPractice(p))c.status="Cliente";else if(["Preventivo","Trattativa"].includes(p.status))c.status="Preventivo";else if(p.status==="Sopralluogo")c.status="Sopralluogo";else c.status="Nuovo";c.next=p.nextAction||c.next} function openCRMContact(id){const p=practiceById(id);if(!p)return;crmActionPracticeId=id;crmContactPracticeLabel.textContent=`${p.code} · ${clientName(p.clientId)}`;ccChannel.value="Telefono";ccOutcome.value="Risposto";ccNote.value="";ccNext.value=p.nextAction||"";ccFollowUp.value=p.followUpAt?toLocalInput(p.followUpAt):"";openModal("crmContactModal")} function saveCRMContact(){ const p=practiceById(crmActionPracticeId);if(!p)return;const now=new Date().toISOString(),note=ccNote.value.trim();p.crmContacts.unshift({id:Date.now(),channel:ccChannel.value,outcome:ccOutcome.value,note,date:now});p.lastContactAt=now;p.updatedAt=now;p.nextAction=ccNext.value.trim()||defaultNextAction(p.status);p.followUpAt=ccFollowUp.value?new Date(ccFollowUp.value).toISOString():""; if(p.status==="Nuovo") {p.status="Contattato";p.stageChangedAt=now}p.activities.unshift({id:Date.now()+1,type:`Contatto · ${ccChannel.value}`,text:`${ccOutcome.value}${note?": "+note:""}`,date:now});syncClientStatusFromPractice(p);closeModal("crmContactModal");saveState();toast("Contatto registrato") } function openCRMLost(id){const p=practiceById(id);if(!p)return;crmActionPracticeId=id;crmLostPracticeLabel.textContent=`${p.code} · ${clientName(p.clientId)}`;clReason.value=p.lostReason||"Prezzo";clValue.value=Number(p.value||0);clDetail.value=p.lostDetail||"";openModal("crmLostModal")} function saveCRMLost(){const p=practiceById(crmActionPracticeId);if(!p)return;const now=new Date().toISOString();p.status="Perso";p.stageChangedAt=now;p.updatedAt=now;p.lostAt=now;p.lostReason=clReason.value;p.lostDetail=clDetail.value.trim();p.followUpAt="";p.nextAction="";if(clValue.value)p.value=Number(clValue.value);p.activities.unshift({id:Date.now(),type:"CRM",text:`Opportunità persa · ${p.lostReason}${p.lostDetail?": "+p.lostDetail:""}`,date:now});closeModal("crmLostModal");saveState();toast("Motivo perdita registrato")} function reopenLost(id){const p=practiceById(id);if(!p)return;p.status="Contattato";p.stageChangedAt=new Date().toISOString();p.updatedAt=p.stageChangedAt;p.nextAction="Ricontattare il cliente";p.followUpAt="";p.activities.unshift({id:Date.now(),type:"CRM",text:"Opportunità riaperta",date:p.stageChangedAt});syncClientStatusFromPractice(p);saveState();toast("Opportunità riaperta")} function openCRMPractice(id){selectedPracticeId=id;currentPracticeTab="overview";showView("pratiche");renderPractices();renderPracticeDetail(id)} function renderCRMAttention(){ const active=state.practices.filter(isActiveCRM),over=active.filter(isFollowUpOverdue),stale=active.filter(isStaleCRM),quotes=active.filter(isQuoteStale),noaction=active.filter(p=>!p.nextAction); crmAlerts.innerHTML=`
${over.length}Follow-up scaduti
${stale.length}Senza contatti da 4+ giorni
${quotes.length}Preventivi fermi da 5+ giorni
${noaction.length}Senza prossima azione
`; const items=[...over.map(p=>({p,label:"Follow-up scaduto",cls:"danger"})),...quotes.filter(p=>!over.includes(p)).map(p=>({p,label:"Preventivo fermo",cls:"warn"})),...stale.filter(p=>!over.includes(p)&&!quotes.includes(p)).map(p=>({p,label:"Contatto fermo",cls:"warn"})),...noaction.filter(p=>!over.includes(p)&&!quotes.includes(p)&&!stale.includes(p)).map(p=>({p,label:"Azione mancante",cls:"gray"}))].slice(0,8); crmAttentionList.innerHTML=items.length?items.map(x=>`
${x.p.code} · ${esc(clientName(x.p.clientId))}
${esc(x.p.nextAction||"Nessuna prossima azione")} · ${money(x.p.value)}
${x.label}
`).join(""):'
Nessuna criticità commerciale rilevata.
'; } function setCRMHealth(v){crmHealthFilter.value=v;renderCRMBoard();crmBoard.scrollIntoView({behavior:"smooth",block:"start"})} function showCRMQuoteStale(){crmHealthFilter.value="quote";renderCRMBoard();crmBoard.scrollIntoView({behavior:"smooth",block:"start"})} function renderCRMSourceStats(){ const groups={};state.practices.forEach(p=>{const k=p.source||"Non indicata";groups[k]??={all:0,won:0,lost:0,value:0};groups[k].all++;if(isWonPractice(p))groups[k].won++;if(p.status==="Perso")groups[k].lost++;if(isActiveCRM(p))groups[k].value+=Number(p.value||0)}); crmSourceStats.innerHTML='
OriginePraticheVintePersePipeline
'+Object.entries(groups).sort((a,b)=>b[1].all-a[1].all).map(([k,g])=>`
${esc(k)}${g.all}${g.won}${g.lost}${money(g.value)}
`).join(""); } function renderCRMLost(){const lost=state.practices.filter(p=>p.status==="Perso").sort((a,b)=>String(b.lostAt||b.updatedAt).localeCompare(String(a.lostAt||a.updatedAt)));crmLostList.innerHTML=lost.length?lost.map(p=>`
${p.code} · ${esc(clientName(p.clientId))}
${esc(p.lostReason||"Motivo non indicato")} · ${money(p.value)}${p.lostDetail?" · "+esc(p.lostDetail):""}
`).join(""):'
Nessuna opportunità persa registrata.
'} function toLocalInput(v){if(!v)return"";const d=new Date(v),pad=n=>String(n).padStart(2,"0");return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`} function renderClients(){ const q=(document.getElementById("clientSearch")?.value||"").toLowerCase(); const f=document.getElementById("clientStatusFilter")?.value||""; const rows=state.clients.filter(c=>(!q||[c.name,c.phone,c.city,c.email].join(" ").toLowerCase().includes(q))&&(!f||c.status===f)); clientRows.innerHTML=rows.length?rows.map(c=>`${esc(c.name)}
${esc(c.source)}${esc(c.phone)}
${esc(c.email)}${esc(c.city)}${c.status}${esc(c.next||"—")} · `).join(""):'Nessun cliente trovato.'; } function saveClient(){ const name=cName.value.trim(); if(!name){toast("Inserisci il nome");return} state.clients.unshift({id:Date.now(),name,phone:cPhone.value.trim(),email:cEmail.value.trim(),city:cCity.value.trim(),source:cSource.value,status:cStatus.value,next:cNext.value.trim()}); ["cName","cPhone","cEmail","cCity","cNext"].forEach(id=>document.getElementById(id).value=""); closeModal("clientModal");saveState();toast("Cliente salvato"); } function quickQuote(id){showView("preventivi");const p=state.practices.find(x=>x.clientId==id&&!(["Chiuso","Perso"].includes(x.status)));if(p){qPractice.value=String(p.id);syncPracticeClient("q")}else qClient.value=id;calcQuote();window.scrollTo({top:0,behavior:"smooth"})} function badgeClass(s){if(["Cliente","Accettato","Completato"].includes(s))return"ok";if(["Sopralluogo","Inviato","Arrivato","Pianificata"].includes(s))return"warn";if(["Nuovo","Bozza"].includes(s))return"new";if(s==="Perso")return"danger";return"gray"} const calcIds=["qType","qQty","qW","qH","qMaterial","qOpening","qGlass","qColor","qInstall","qMarginTarget","qDiscount","qVat"]; calcIds.forEach(id=>document.getElementById(id).addEventListener("input",calcQuote)); function calcQuote(){ const w=Number(qW.value)||0,h=Number(qH.value)||0,qty=Math.max(1,Number(qQty.value)||1),targetPct=Math.min(80,Math.max(5,Number(qMarginTarget.value)||p7Policy().targetMargin)),discountPct=Math.min(50,Math.max(0,Number(qDiscount.value)||0));const cfg=state.configurations.find(x=>x.id===Number(activeQuoteConfigId));let cost,area=Math.max(.6,(w/1000)*(h/1000))*qty,pricingSnapshot=null,minPrice=0,hardFloorPrice=0; if(cfg){const ser=serramentoById(cfg.serramentoId),e=calculateConfigurationEconomics({...cfg,w,h,qty,glass:qGlass.value,material:qMaterial.value},ser?.id,{targetMargin:targetPct,installMode:qInstall.value});cost=e.cost;minPrice=e.minPrice;hardFloorPrice=e.hardFloorPrice;pricingSnapshot={...e,configurationId:cfg.id,revision:cfg.revision,calculatedAt:new Date().toISOString()};qPricingSource.classList.add("show");qPricingSource.innerHTML=`Distinta P7 attiva · ${esc(ser?.code||"Serramento")} · ${esc(systemById(cfg.systemId)?.code||cfg.system||"")} · costo da distinta tecnica.`}else{const base=Number(state.prices[qMaterial.value]||300),glassAdd={"Doppio vetro":0,"Triplo vetro":52,"Acustico":76,"Antisfondamento":98}[qGlass.value]||0,openAdd={"Battente 2 ante":110,"Battente 1 anta":60,"Anta-ribalta":95,"Scorrevole":210,"Fisso":0}[qOpening.value]||0,colorAdd={"Bianco":0,"Antracite":35,"Effetto legno":58,"Bicolore":72}[qColor.value]||0,installPer={standard:145,certified:210,none:0}[qInstall.value]||0;cost=(area*(base+glassAdd+colorAdd))+((openAdd+installPer)*qty);minPrice=cost/(1-p7Policy().minMargin/100);hardFloorPrice=cost/(1-p7Policy().hardFloorMargin/100);qPricingSource.classList.remove("show");qPricingSource.textContent=""} const target=targetPct/100,grossSale=cost/(1-target),net=grossSale*(1-discountPct/100),realMargin=net?((net-cost)/net)*100:0,vatRate=Number(qVat.value||10)/100,vat=net*vatRate,total=net+vat,health={margin:realMargin,status:realMargin{const e=document.getElementById(id);if(e)e.value=v});if(q.practiceId&&qPractice){qPractice.value=String(q.practiceId);syncPracticeClient("q")}if(q.clientId&&qClient)qClient.value=String(q.clientId)} function syncQuoteHeaderFromForm(){const q=ensureQuoteDraft();q.practiceId=Number(qPractice.value)||null;q.clientId=Number(qClient.value)||null;q.title=qTitle.value.trim()||"Fornitura e posa serramenti";q.globalDiscount=Math.max(0,Math.min(50,Number(qGlobalDiscount.value)||0));q.vatRate=Number(qVat.value)||10;q.validUntil=qValidUntil.value;q.clientNote=qClientNote.value;q.internalNote=qInternalNote.value;q.status=qDocStatus.value;return q} function quoteHeaderChanged(){syncQuoteHeaderFromForm();renderQuoteDraft()} function newQuoteDocument(){editingQuoteId=null;activeQuoteConfigId=null;quoteDraft=emptyQuoteDraft();fillQuoteHeaderForm(quoteDraft);qNewVersionBtn.disabled=true;renderQuoteDraft();renderQuickQuoteUI();toast("Nuovo preventivo pronto")} function nextQuoteNumber(){const s=appSettings().quotes,prefix=s.prefix||"PRE",digits=Number(s.digits)||3;let max=0;state.quotes.forEach(q=>{if(String(q.number||"").startsWith(prefix)){const m=String(q.number||"").match(/(\d+)$/);if(m)max=Math.max(max,Number(m[1]))}});return prefix+"-"+String(max+1).padStart(digits,"0")} function quoteLineDisplay(cfg){const ser=cfg?serramentoById(cfg.serramentoId):null,op=ser?openingById(ser.openingId):null,room=ser?roomById(ser.roomId):null;return{code:ser?.code||op?.code||"",description:[room?.name,op?.code,CONFIG_SCHEMAS[cfg?.schema]?.label,cfg?.material,systemById(cfg?.systemId)?.code].filter(Boolean).join(" · ")}} function addQuoteLine(){const q=syncQuoteHeaderFromForm(),calc=calcQuote();if(calc.priceHealth==="block"){toast("Riga bloccata: margine sotto il floor commerciale");return}const cfg=state.configurations.find(x=>x.id===Number(activeQuoteConfigId)),meta=quoteLineDisplay(cfg),variant=qVariant.value,optional=qOptional.value==="yes"||!!variant,groupIncluded=variant?q.lines.some(l=>l.variantGroup===variant&&l.included):false;const line={id:Date.now()+Math.floor(Math.random()*999),configurationId:cfg?.id||null,code:meta.code||`R${String(q.lines.length+1).padStart(2,"0")}`,description:meta.description||`${qType.value} ${qMaterial.value} · ${qOpening.value} · ${qW.value}×${qH.value} mm`,type:qType.value,qty:Number(qQty.value)||1,w:Number(qW.value)||0,h:Number(qH.value)||0,material:qMaterial.value,opening:qOpening.value,glass:qGlass.value,color:qColor.value,install:qInstall.value,grossNet:round(calc.grossNet),lineDiscount:Number(qDiscount.value)||0,net:round(calc.net),cost:round(calc.cost),margin:Number(calc.margin.toFixed(1)),optional,included:variant?!groupIncluded:!optional,variantGroup:variant,notes:qNotes.value.trim(),priceHealth:calc.priceHealth,pricingSnapshot:calc.pricingSnapshot?clone(calc.pricingSnapshot):null};q.lines.push(line);activeQuoteConfigId=null;qVariant.value="";qOptional.value="no";qDiscount.value=0;qNotes.value="";qPricingSource.classList.remove("show");renderQuoteDraft();toast("Riga aggiunta al preventivo")} function quoteTotals(q){const included=(q.lines||[]).filter(l=>l.included!==false),linesNet=included.reduce((a,l)=>a+Number(l.net||0),0),cost=included.reduce((a,l)=>a+Number(l.cost||0),0),disc=Math.max(0,Math.min(50,Number(q.globalDiscount)||0)),discountValue=linesNet*disc/100,net=linesNet-discountValue,vat=net*(Number(q.vatRate||10)/100),total=net+vat,margin=net?((net-cost)/net)*100:0,minPrice=cost/(1-p7Policy().minMargin/100),hardFloor=cost/(1-p7Policy().hardFloorMargin/100),health=net`

${esc(l.code)} · ${esc(l.description)}${l.optional?'OPTIONAL':""}${l.variantGroup?`VAR. ${esc(l.variantGroup)}`:""}

${l.qty} pz · ${l.w||"—"}×${l.h||"—"} mm${l.notes?" · "+esc(l.notes):""}

Sconto riga%
${money(l.net)}Costo ${money(l.cost)} · M ${Number(l.margin||0).toFixed(1)}%
`).join(""):'
Nessuna riga. Aggiungi una configurazione tecnica o una voce manuale.
';qSumIncluded.textContent=t.included;qSumLines.textContent=money(t.linesNet);qSumDiscount.textContent="- "+money(t.discountValue);qSumNet.textContent=money(t.net);qSumVat.textContent=money(t.vat);qSumTotal.textContent=money(t.total);qSumCost.textContent=money(t.cost);qSumMargin.textContent=t.margin.toFixed(1)+"%";qOverallMinPrice.textContent=money(t.minPrice);if(document.getElementById("qMobileTotal"))qMobileTotal.textContent=money(t.total);if(document.getElementById("q1QuickComposer")&&activeQuoteProfile().id==="q1"){q1QuickCount.textContent=String(q.lines.length);q1QuickTotal.textContent=money(t.total);}qOverallHealth.className=`quote2-health ${t.health}`;qOverallHealth.textContent=t.health==="block"?`BLOCCO: margine ${t.margin.toFixed(1)}% sotto floor ${p7Policy().hardFloorMargin}%`:t.health==="warn"?`ATTENZIONE: margine ${t.margin.toFixed(1)}% sotto minimo ${p7Policy().minMargin}%`:`Margine conforme: ${t.margin.toFixed(1)}%`;const order=["Bozza","Inviato","Visto","Accettato"],pos=order.indexOf(q.status);[...qStatusTrack.children].forEach((s,i)=>s.classList.toggle("on",pos>=i&&pos>=0))} function toggleQuoteLineIncluded(id,checked){const q=ensureQuoteDraft(),l=q.lines.find(x=>x.id===id);if(!l)return;l.included=checked;renderQuoteDraft()} function updateQuoteLineDiscount(id,val){const q=ensureQuoteDraft(),l=q.lines.find(x=>x.id===id);if(!l)return;l.lineDiscount=Math.max(0,Math.min(50,Number(val)||0));l.net=round(l.grossNet*(1-l.lineDiscount/100));l.margin=l.net?Number(((l.net-l.cost)/l.net*100).toFixed(1)):0;l.priceHealth=l.marginx.id!==id);renderQuoteDraft()} function duplicateQuoteLine(id){const q=ensureQuoteDraft(),l=q.lines.find(x=>x.id===id);if(!l)return;const c=clone(l);c.id=Date.now()+Math.floor(Math.random()*999);c.code=l.code+"-COPIA";q.lines.push(c);renderQuoteDraft()} function saveQuoteDocument(){const q=syncQuoteHeaderFromForm();if(!q.clientId||!q.practiceId){toast("Seleziona pratica e cliente");return}if(!q.lines.length){toast("Aggiungi almeno una riga");return}const t=quoteTotals(q);if(!t.included){toast("Includi almeno una riga nel totale");return}if(t.health==="block"){toast("Preventivo bloccato: margine complessivo sotto floor");return}const now=new Date().toISOString();q.number=q.number||nextQuoteNumber();q.date=q.date||now.slice(0,10);q.net=round(t.net);q.vat=round(t.vat);q.total=round(t.total);q.cost=round(t.cost);q.margin=Number(t.margin.toFixed(1));q.minPrice=round(t.minPrice);q.priceHealth=t.health;q.desc=`${q.lines.length} righe · ${q.title}`;q.updatedAt=now;if(editingQuoteId){const idx=state.quotes.findIndex(x=>x.id===editingQuoteId);q.id=editingQuoteId;if(idx>=0)state.quotes[idx]=clone(q)}else{q.id=Date.now();editingQuoteId=q.id;state.quotes.unshift(clone(q))}const p=practiceById(q.practiceId);if(q.status==="Inviato"&&!q.sentAt)q.sentAt=now;if(q.status==="Visto"&&!q.viewedAt)q.viewedAt=now;if(q.status==="Accettato"&&!q.acceptedAt)q.acceptedAt=now;if(q.status==="Rifiutato"&&!q.rejectedAt)q.rejectedAt=now;if(p){if(q.status==="Accettato"){p.status="Confermato";p.stageChangedAt=now;p.wonAt=p.wonAt||now;p.followUpAt="";p.nextAction="Trasformare preventivo in commessa"}else if(!["Confermato","Ordinato","Materiale arrivato","Posa","Chiuso"].includes(p.status)){p.status="Preventivo";p.stageChangedAt=now;p.nextAction=q.status==="Inviato"?"Follow-up preventivo":"Inviare preventivo";p.followUpAt=defaultFollowUpForStage("Preventivo")}p.value=q.net;p.updatedAt=now;p.activities.unshift({id:Date.now()+5,type:"Preventivo",text:`Salvato ${q.number} v${q.version} · ${money(q.total)} · ${q.lines.length} righe`,date:now})}platformAudit("Salvataggio preventivo","Preventivi",`${q.number} · ${money(q.total)}`);persistActiveWorkspaceSnapshot();rawPersist();qNewVersionBtn.disabled=false;renderAfterSave("Preventivi");renderQuoteDraft();renderQuickQuoteUI();toast(t.health==="warn"?"Preventivo salvato con warning margine":"Preventivo salvato")} function loadQuoteForEdit(id){const q=state.quotes.find(x=>x.id===Number(id));if(!q)return;editingQuoteId=q.id;quoteDraft=clone(q);fillQuoteHeaderForm(quoteDraft);qNewVersionBtn.disabled=false;renderQuoteDraft();showView("preventivi");window.scrollTo({top:0,behavior:"smooth"})} function createVersionFromDraft(){if(!editingQuoteId){toast("Salva prima il preventivo");return}createNewQuoteVersion(editingQuoteId)} function createNewQuoteVersion(id){const src=state.quotes.find(x=>x.id===Number(id));if(!src)return;const versions=state.quotes.filter(x=>x.number===src.number).map(x=>Number(x.version)||1),q=clone(src);q.id=Date.now();q.version=Math.max(...versions,1)+1;q.status="Bozza";q.sentAt=q.viewedAt=q.acceptedAt=q.rejectedAt="";q.date=new Date().toISOString().slice(0,10);q.updatedAt=new Date().toISOString();q.parentQuoteId=src.parentQuoteId||src.id;state.quotes.unshift(q);if(src.status!=="Accettato")src.status="Superato";persistActiveWorkspaceSnapshot();rawPersist();editingQuoteId=q.id;quoteDraft=clone(q);fillQuoteHeaderForm(q);renderQuotes();renderQuoteDraft();renderQuickQuoteUI();toast(`${q.number} v${q.version} creata`)} function setQuoteStatus(id,status){const q=state.quotes.find(x=>x.id===Number(id));if(!q)return;const now=new Date().toISOString();q.status=status;if(status==="Inviato")q.sentAt=now;if(status==="Visto")q.viewedAt=now;if(status==="Accettato"){q.acceptedAt=now;state.quotes.filter(x=>x.number===q.number&&x.id!==q.id&&x.status!=="Accettato").forEach(x=>x.status="Superato")}if(status==="Rifiutato")q.rejectedAt=now;const p=practiceById(q.practiceId);if(p){if(status==="Accettato"){p.status="Confermato";p.stageChangedAt=now;p.wonAt=now;p.followUpAt="";p.nextAction="Trasformare preventivo in commessa"}else if(status==="Rifiutato")p.nextAction="Analizzare motivo rifiuto / recupero";else if(status==="Inviato"){p.nextAction="Follow-up preventivo";p.followUpAt=defaultFollowUpForStage("Preventivo")}p.activities.unshift({id:Date.now(),type:"Preventivo",text:`${q.number} v${q.version}: stato ${status}`,date:now})}saveState();toast("Stato preventivo aggiornato")} function renderQuotes(){if(!quoteDraft){quoteDraft=emptyQuoteDraft();setTimeout(()=>{fillQuoteHeaderForm(quoteDraft);renderQuoteDraft()},0)}const sorted=[...state.quotes].sort((a,b)=>String(b.date||"").localeCompare(String(a.date||""))||Number(b.version)-Number(a.version));quoteRows.innerHTML=sorted.length?sorted.map(q=>`
${esc(q.number)}v${q.version||1}
${fmtDate(q.date)} · ${esc(profileForQuote(q).name)} · validità ${fmtDate(q.validUntil)}${esc(clientName(q.clientId))}${esc(q.title||"")}${q.lines?.length||1}${(q.lines||[]).filter(l=>l.optional).length} optional${money(q.total)}Netto ${money(q.net)}${Number(q.margin||0).toFixed(1)}%${q.priceHealth==="warn"?"Sotto minimo":q.priceHealth==="block"?"Sotto floor":"OK"} · · ${q.status==="Accettato"&&!state.jobs.some(j=>j.quoteId===q.id)?` · `:""}`).join(""):'Nessun preventivo salvato.';setTimeout(()=>renderQuoteDraft(),0)} function quoteToJob(id){const q=state.quotes.find(x=>x.id===Number(id));if(!q)return;if(q.status!=="Accettato"){toast("Il preventivo deve essere Accettato");return}const n=String(19+state.jobs.length).padStart(3,"0"),now=new Date().toISOString();state.jobs.unshift({id:Date.now(),quoteId:q.id,practiceId:q.practiceId||null,code:"COM-"+n,clientId:q.clientId,value:q.net,status:"Ordinato",progress:25,next:"Inviare ordine al fornitore",due:addDays(21),quoteVersion:q.version});const c=state.clients.find(x=>x.id===q.clientId);if(c)c.status="Cliente";const p=practiceById(q.practiceId);if(p){p.status="Ordinato";p.stageChangedAt=now;p.wonAt=p.wonAt||now;p.followUpAt="";p.value=q.net;p.nextAction="Attendere conferma consegna fornitore";p.updatedAt=now;p.activities.unshift({id:Date.now()+2,type:"Commessa",text:`Creata commessa COM-${n} da ${q.number} v${q.version}`,date:now})}saveState();showView("commesse");toast("Commessa creata")} function quotePrintHtml(q){ const c=state.clients.find(x=>x.id===q.clientId)||{},p=practiceById(q.practiceId),t=quoteTotals(q),profile=profileForQuote(q),co=appSettings().company,qs=appSettings().quotes; const lines=(q.lines||[]).filter(l=>profile.printOptional||!l.optional).map(l=>{ const cfg=state.configurations.find(x=>x.id===Number(l.configurationId));let drawing=""; if(cfg&&profile.printDrawing)drawing=`
${renderWindowSVG(cfg)}
`; const technical=cfg&&profile.printTechnical?`

${esc([cfg.material,cfg.system,cfg.glass,cfg.finish].filter(Boolean).join(" · "))}

`:""; return`
${drawing}

${esc(l.code)} · ${esc(l.description)} ${l.optional?"OPTIONAL":""}

${l.qty} pz${profile.printNotes&&l.notes?" · "+esc(l.notes):""}

${technical}${profile.printOptional&&l.variantGroup?`Variante ${esc(l.variantGroup)}`:""}
${profile.printLinePrice?`
${money(l.net)}${l.included===false?"non incluso":""}
`:""}
` }).join(""); const terms=profile.printTerms?`
Imponibile${money(t.net)}
IVA ${q.vatRate}%${money(t.vat)}
`:""; const cols=`${profile.printDrawing?"150px ":""}1fr ${profile.printLinePrice?"120px":""}`; return `${esc(q.number)} v${q.version}
${co.logoData?``:""}
${esc(co.name||"SerramentiOS")}
${esc([co.address,co.zip,co.city,co.province].filter(Boolean).join(" · "))}
${esc([co.phone,co.email,co.vatNumber?"P.IVA "+co.vatNumber:""].filter(Boolean).join(" · "))}

${esc(q.number)} v${q.version}

${esc(q.title||"Preventivo")}
${esc(profile.name)}
${esc(c.name||"")}
${esc(p?.site||c.city||"")}${profile.printTerms?`
Valido fino al ${fmtDate(q.validUntil)}`:""}
${lines}
${terms}
Totale${money(t.total)}
${profile.printNotes&&(q.clientNote||qs.paymentTerms||qs.deliveryText)?`
Note e condizioni
${q.clientNote?esc(q.clientNote).replace(/\n/g,"
")+"

":""}${qs.paymentTerms?`Pagamento: ${esc(qs.paymentTerms)}
`:""}${qs.deliveryText?`Consegna: ${esc(qs.deliveryText)}
`:""}${qs.warrantyMonths?`Garanzia: ${Number(qs.warrantyMonths)} mesi
`:""}${qs.depositPct?`Acconto suggerito: ${Number(qs.depositPct)}%
`:""}${co.footer?`
${esc(co.footer)}`:""}
`:""}