`; fetch(SUPA_URL+'/functions/v1/send-email',{ method:'POST', headers:{'Content-Type':'application/json','Authorization':'Bearer '+SUPA_KEY}, body:JSON.stringify({ to:'info@becomingdesign.it', subject:'[Becoming] '+nomeCliente+' ha aperto l\u2019offerta '+tipoLabel+' '+( prev.numero||'')+' '+(prev.revisione||''), html }) }).catch(()=>{}); } async function _notificaRegistrazione(){ const user = (await supa.auth.getUser())?.data?.user; const email = user?.email || '—'; const ora = new Date().toLocaleString('it-IT'); const html=`
BECOMING — Portale Clienti

Nuovo cliente registrato

Un cliente ha completato la registrazione al portale impostando la propria password.

Email cliente
${email}
Registrato il ${ora}

Accedi al gestionale per visualizzare il profilo del cliente.

Notifica automatica dal portale clienti Becoming
`; await fetch(SUPA_URL+'/functions/v1/send-email',{ method:'POST', headers:{'Content-Type':'application/json','Authorization':'Bearer '+SUPA_KEY}, body:JSON.stringify({ to:'info@becomingdesign.it', subject:`[Becoming] Nuovo cliente registrato: ${email}`, html }) }); } // ---- DRAWER SWIPE TO CLOSE ---- (function(){ const d=document.getElementById('drawer'); let startY=0,isDragging=false; d.addEventListener('touchstart',e=>{startY=e.touches[0].clientY;isDragging=true;},{passive:true}); d.addEventListener('touchmove',e=>{ if(!isDragging)return; const dy=e.touches[0].clientY-startY; if(dy>0)d.style.transform=`translateY(${dy}px)`; },{passive:true}); d.addEventListener('touchend',e=>{ const dy=e.changedTouches[0].clientY-startY; d.style.transform=''; if(dy>80)closeDrawer(); isDragging=false; }); document.addEventListener('keydown',e=>{if(e.key==='Escape')closeDrawer();}); })(); // ---- CARICA DATI ---- async function caricaDati(){ if(!currentUser)return; const email=currentUser.email; document.getElementById('user-name').textContent=email; document.getElementById('drawer-user-name').textContent=email; document.getElementById('drawer-user-email').textContent=''; try{ // Carica cliente per email const{data:cliArr,error:cliErr}=await supa.from('clienti').select('*').ilike('email',email).limit(1); const cli=(cliArr&&cliArr.length)?cliArr[0]:null; if(cliErr)console.warn('clienti error:',cliErr); if(cli){ clienteData=cli; document.getElementById('user-name').textContent=cli.nome||email; document.getElementById('drawer-user-name').textContent=cli.nome||email; document.getElementById('drawer-user-email').textContent=email; // Carica preventivi visibili nel portale (visibile_portale=true, con fallback se colonna assente) try{ const{data:prev, error:prevErr}=await supa.from('preventivi') .select('*') .eq('cliente_id',cli.id) .eq('visibile_portale',true) .order('created_at',{ascending:false}); if(prevErr){ // Colonna non ancora migrata → fallback a stato inviato/approvato console.warn('[portale] visibile_portale mancante, fallback:', prevErr.message); const{data:prev2}=await supa.from('preventivi') .select('*') .eq('cliente_id',cli.id) .in('stato',['inviato','approvato']) .order('created_at',{ascending:false}); preventiviData=prev2||[]; } else { preventiviData=prev||[]; } // Normalizza il campo tipo a valori canonici preventiviData = preventiviData.map(p=>{ const t=(p.tipo||'').toLowerCase().trim(); p.tipo = t==='clima'||t==='condizionamento'||t==='termico' ? 'condizionamento' : t==='fv'||t==='fotovoltaico' ? 'fotovoltaico' : t==='misto'||t==='fv+clima' ? 'misto' : t||'fotovoltaico'; return p; }); }catch(e){console.warn('preventivi error:',e);preventiviData=[];} // Carica documenti (tabella potrebbe non esistere ancora) try{ const{data:docs}=await supa.from('documenti').select('*').eq('cliente_id',cli.id).order('created_at',{ascending:false}); documentiData=docs||[]; }catch(e){console.warn('documenti error:',e);documentiData=[];} // Carica richieste assistenza try{ const{data:rich}=await supa.from('richieste_assistenza').select('*').eq('cliente_id',cli.id).order('created_at',{ascending:false}); richiesteData=rich||[]; }catch(e){console.warn('richieste error:',e);richiesteData=[];} // Aggiorna badge assistenza senza aprire la pagina _aggiornaBadgeAssistenza(); // Carica appuntamenti try{ const prevIds=preventiviData.map(p=>p.id).filter(Boolean); if(prevIds.length){ const{data:appts}=await supa.from('appuntamenti').select('*').in('preventivo_id',prevIds).order('data_proposta',{ascending:true}); appuntamentiData=appts||[]; } }catch(e){console.warn('appuntamenti error:',e);appuntamentiData=[];} } else { console.warn('Nessun cliente trovato per email:',email); } _applicaModalita(); renderHome();renderDocumenti(); }catch(e){ console.error('caricaDati error:',e); _applicaModalita(); renderHome();renderDocumenti(); } } // ---- NAVIGATION ---- const MAIN_PAGES=['home','fv','clima','monitoraggio','documenti','assistenza']; function showPage(id,btn){ document.querySelectorAll('.page').forEach(p=>p.classList.remove('active')); document.querySelectorAll('.nav-item').forEach(b=>b.classList.remove('active')); const page=document.getElementById('page-'+id); if(page)page.classList.add('active'); // Attiva il tab corretto nella navbar const navMap={'home':'nav-home','monitoraggio':'nav-monitoraggio','bollette':'nav-monitoraggio','fv':'nav-home','clima':'nav-home'}; const navId=navMap[id]||'nav-'+id; const navBtn=document.getElementById(navId); if(navBtn)navBtn.classList.add('active'); else if(btn)btn.classList.add('active'); if(id==='monitoraggio') renderMonitoraggio(); if(id==='bollette') renderBollette(); if(id==='fv') { ricaricaPreventivi().then(()=>renderFV()); } if(id==='clima') { ricaricaPreventivi().then(()=>renderClima()); } // Stoppa polling grafico se si esce dal monitor if(id!=='monitoraggio' && _chartGiornalieroTimer){ clearInterval(_chartGiornalieroTimer); _chartGiornalieroTimer=null; } } // ---- MODALITA PORTALE ---- function _applicaModalita(){ const soloMonitor = !!clienteData?.solo_monitoraggio; if(soloMonitor){ showPage('monitoraggio', document.getElementById('nav-monitoraggio')); } } function openDrawer(){ document.getElementById('drawer-overlay').classList.add('open'); document.getElementById('drawer').classList.add('open'); } function closeDrawer(){ document.getElementById('drawer-overlay').classList.remove('open'); document.getElementById('drawer').classList.remove('open'); } // ---- REFERRAL ---- function renderReferral(){ const el=document.getElementById('referral-content'); if(!el)return; el.innerHTML=`
Conosci qualcuno interessato?
Se ha un amico, un familiare o un collega che potrebbe essere interessato a un impianto fotovoltaico o a un sistema di condizionamento, ce lo segnali. Lo contatteremo con cura, senza pressioni.
Dati del contatto
I dati del contatto saranno utilizzati esclusivamente per un primo contatto da parte di Becoming S.r.l. e non saranno ceduti a terzi.
`; } async function inviaReferral(){ const nome=document.getElementById('ref-nome')?.value.trim(); const email=document.getElementById('ref-email')?.value.trim(); const tel=document.getElementById('ref-tel')?.value.trim(); const interesse=document.getElementById('ref-interesse')?.value; const msg=document.getElementById('ref-msg')?.value.trim(); const errEl=document.getElementById('ref-error'); const okEl=document.getElementById('ref-success'); const btn=document.getElementById('ref-btn'); if(errEl)errEl.style.display='none'; if(okEl)okEl.style.display='none'; if(!nome){if(errEl){errEl.textContent='Inserisci il nome del contatto';errEl.style.display='block';}return;} if(!email&&!tel){if(errEl){errEl.textContent='Inserisci almeno un recapito (email o telefono)';errEl.style.display='block';}return;} if(btn){btn.textContent='Invio in corso…';btn.disabled=true;} try{ const nomeRef=clienteData?.nome||currentUser?.email||''; const emailRef=clienteData?.email||currentUser?.email||''; const res=await fetch(SUPA_URL+'/functions/v1/invia-referral',{ method:'POST', headers:{'Content-Type':'application/json','Authorization':'Bearer '+SUPA_KEY}, body:JSON.stringify({ nomeReferente:nomeRef, emailReferente:emailRef, nomeContatto:nome, contattoEmail:email||'', contattoTel:tel||'', interesse:interesse||'', messaggio:msg||'' }) }); const data=await res.json(); if(!res.ok)throw new Error(data.error||'Errore'); if(okEl)okEl.style.display='block'; // Reset form ['ref-nome','ref-email','ref-tel','ref-msg'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';}); const sel=document.getElementById('ref-interesse');if(sel)sel.value=''; } catch (e) { if (errEl) { // Usiamo le virgolette doppie "" all'esterno così l'apostrofo interno non rompe il codice errEl.textContent = "Errore durante l'invio: " + e.message; errEl.style.display = 'block'; } } finally { if(btn){btn.textContent='Invia segnalazione';btn.disabled=false;} } } function openDrawerPage(id){ closeDrawer(); document.querySelectorAll('.nav-item').forEach(b=>b.classList.remove('active')); document.getElementById('nav-menu').classList.add('active'); document.querySelectorAll('.page').forEach(p=>p.classList.remove('active')); const page=document.getElementById('page-'+id); if(page)page.classList.add('active'); if(id==='assistenza')renderAssistenza(); if(id==='documenti')renderDocumenti(); if(id==='dati-personali')renderDatiPersonali(); if(id==='referral')renderReferral(); } // ---- TOAST ---- function toast(msg,type='ok'){ const t=document.getElementById('toast'); t.textContent=msg;t.className='toast'+(type==='error'?' error':''); setTimeout(()=>t.classList.add('show'),10); setTimeout(()=>t.classList.remove('show'),3000); } // ---- RENDER HOME ---- // ---- APPUNTAMENTI ---- function rispondiAppuntamento(apptId){ const appt=appuntamentiData.find(a=>a.id===apptId); if(!appt)return; const tipoLabel={sopralluogo:'Sopralluogo',installazione:'Installazione'}; const data=new Date(appt.data_proposta).toLocaleDateString('it-IT',{weekday:'long',year:'numeric',month:'long',day:'numeric'}); const overlay=document.createElement('div'); overlay.id='appt-modal'; overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;display:flex;align-items:center;justify-content:center'; overlay.innerHTML='
' +'

'+tipoLabel[appt.tipo]+'

' +'
' +'
Data proposta
' +'
'+data+'
' +(appt.ora_proposta?'
ore '+appt.ora_proposta+'
':'') +'
' +'
' +'' +'' +'
' +'' +'' +'
'; overlay.style.position='fixed'; document.body.appendChild(overlay); overlay.querySelector('#appt-close').onclick=()=>overlay.remove(); overlay.onclick=e=>{if(e.target===overlay)overlay.remove();}; } function showAltData(apptId){ const sec=document.getElementById('alt-data-section'); if(sec)sec.style.display='block'; } async function confermaAppuntamento(apptId){ try{ // Leggi dati appuntamento prima di aggiornare const {data:apptRow}=await supa.from('appuntamenti').select('*').eq('id',apptId).single(); await supa.from('appuntamenti').update({stato:'confermata'}).eq('id',apptId); const a=appuntamentiData.find(x=>x.id===apptId); if(a)a.stato='confermata'; document.getElementById('appt-modal')?.remove(); toast('Appuntamento confermato!'); renderHome(); // Notifica email al team Becoming (non bloccante) notificaRispostaAppuntamento(apptRow,'confermata',null).catch(()=>{}); }catch(e){toast('Errore: '+e.message,'error');} } async function inviaAlternativa(apptId){ const altData=document.getElementById('alt-data-input')?.value; if(!altData){toast('Inserisci una data','error');return;} try{ const {data:apptRow}=await supa.from('appuntamenti').select('*').eq('id',apptId).single(); await supa.from('appuntamenti').update({stato:'alternativa',data_alternativa:altData}).eq('id',apptId); const a=appuntamentiData.find(x=>x.id===apptId); if(a){a.stato='alternativa';a.data_alternativa=altData;} document.getElementById('appt-modal')?.remove(); toast('Proposta inviata a Becoming!'); renderHome(); // Notifica email al team Becoming (non bloccante) notificaRispostaAppuntamento(apptRow,'alternativa',altData).catch(()=>{}); }catch(e){toast('Errore: '+e.message,'error');} } async function notificaRispostaAppuntamento(appt, tipoRisposta, dataAlternativa){ if(!appt)return; const cli=clienteData; const nomeCliente=cli?.nome||'Cliente'; const tipoAppt={sopralluogo:'Sopralluogo',installazione:'Installazione'}[appt.tipo]||appt.tipo||'Appuntamento'; const dataProposta=appt.data_proposta ?new Date(appt.data_proposta).toLocaleDateString('it-IT',{weekday:'long',day:'numeric',month:'long',year:'numeric'}) :'—'; const dataAlt=dataAlternativa ?new Date(dataAlternativa).toLocaleDateString('it-IT',{weekday:'long',day:'numeric',month:'long',year:'numeric'}) :null; const isConferma=tipoRisposta==='confermata'; const titolo=isConferma ?`${nomeCliente} ha confermato il ${tipoAppt.toLowerCase()}` :`${nomeCliente} propone una data alternativa per il ${tipoAppt.toLowerCase()}`; const corpo=isConferma ?`

${nomeCliente} ha confermato la data proposta per il ${tipoAppt.toLowerCase()}:

${dataProposta}

Accedi al gestionale per visualizzare i dettagli.

` :`

${nomeCliente} non può nella data proposta (${dataProposta}) e suggerisce:

${dataAlt||'—'}

Accedi al gestionale per confermare o proporre una nuova data.

`; const html=`
BECOMING — Gestionale

${titolo}

${corpo}
Notifica automatica dal portale clienti · ${new Date().toLocaleString('it-IT')}
`; await fetch(SUPA_URL+'/functions/v1/send-email',{ method:'POST', headers:{'Content-Type':'application/json','Authorization':'Bearer '+SUPA_KEY}, body:JSON.stringify({ to:'info@becomingdesign.it', subject:`[Becoming] ${titolo}`, html }) }); } // ---- RICARICA PREVENTIVI (usata all'apertura pagina FV/Clima) ---- async function ricaricaPreventivi(){ if(!clienteData) return; try{ const{data:prev,error:prevErr}=await supa.from('preventivi') .select('*') .eq('cliente_id',clienteData.id) .eq('visibile_portale',true) .order('created_at',{ascending:false}); const raw = prevErr ? [] : (prev||[]); preventiviData = raw.map(p=>{ const t=(p.tipo||'').toLowerCase().trim(); p.tipo = t==='clima'||t==='condizionamento'||t==='termico' ? 'condizionamento' : t==='fv'||t==='fotovoltaico' ? 'fotovoltaico' : t==='misto'||t==='fv+clima' ? 'misto' : t||'fotovoltaico'; return p; }); }catch(e){console.warn('[portale] ricaricaPreventivi error:',e);} } // ---- RENDER HOME ---- function renderHome(){ const el=document.getElementById('home-content'); if(!clienteData){ el.innerHTML='
Account non collegato

Nessun cliente trovato. Contatta Becoming.

'; return; } if(clienteData.solo_monitoraggio){ el.innerHTML=`
Benvenuto

Usa la sezione Monitor per seguire il tuo impianto, o Assistenza dal menu per contattarci.

`; return; } // Separa preventivi FV e Clima const prevFV = preventiviData.filter(p=>!p.tipo||p.tipo==='fotovoltaico'||p.tipo==='fv'); const prevClima = preventiviData.filter(p=>p.tipo==='clima'||p.tipo==='condizionamento'); const nomeCliente = clienteData.nome ? clienteData.nome.split(' ')[0] : ''; const saluto = nomeCliente ? `Benvenuto, ${esc(nomeCliente)}` : 'Benvenuto'; // ── Helper: card impianto ──────────────────────────────────────────────── function cardImpianto(tipo, icon, titolo, prevList, pageId){ const prev = prevList.find(p=>p.approvato) || prevList[0]; const hasOfferta = !!prev; const approvato = prev?.approvato; // Stato avanzamento const passiKeys=['offerta','accettata','sopralluogo','installazione','collaudo','gse']; const passiLabels=['Offerta','Accettata','Sopralluogo','Installazione','Collaudo','GSE']; let statoIdx=0, statoLabel='Offerta inviata'; if(approvato){ const avanz=prev.stato_avanzamento||'accettata'; statoIdx=Math.max(1,passiKeys.indexOf(avanz)); if(statoIdx<0)statoIdx=1; statoLabel=passiLabels[statoIdx]||'In corso'; } // Colori per tipo const colori = tipo==='fv' ? {bg:'#f0f9e8',iconBg:'#f0f9e8',accent:'#7ab800',accentLight:'#9acc33',badge:'#eaf3de',badgeTxt:'#3B6D11'} : {bg:'#f0f7ff',iconBg:'#e8f4ff',accent:'#378ADD',accentLight:'#85B7EB',badge:'#E6F1FB',badgeTxt:'#185FA5'}; if(!hasOfferta){ // Card invito commerciale return `
${icon}
${titolo}
Non hai ancora un'offerta per questo tipo di impianto.
Vuoi saperne di più?
`; } // Badge stato const badgeLabel = approvato ? statoLabel : 'In attesa'; const badgeBg = approvato ? colori.badge : '#FAEEDA'; const badgeTxt = approvato ? colori.badgeTxt : '#854F0B'; // Stat cards per FV (solo se ha monitoraggio attivo) let statsHtml = ''; if(tipo==='fv' && clienteData.fusionsolar_plant_id){ statsHtml = `
kWh oggi
kWh mese
€ risparmio
`; } // Barra avanzamento const steps = passiKeys.map((_,i)=>{ const cls = i`; }).join(''); return `
${icon}
${esc(prev.titolo||titolo)}
${prev.numero?'Offerta n° '+esc(prev.numero):''}
${badgeLabel}
${statsHtml}
Stato progetto ${passiLabels[statoIdx]}
${steps}
Offerta · Componenti · Documenti
`; } const cardFV = cardImpianto('fv', '☀️', 'Fotovoltaico', prevFV, 'fv'); const cardClima = cardImpianto('clima', '❄️', 'Condizionamento', prevClima, 'clima'); el.innerHTML = `
${saluto}
Riepilogo dei tuoi impianti
${cardFV} ${cardClima} ${_renderAppuntamentiHome()}
`; // Popola stat FV se disponibili _populateHomeStatsFV(); } function _renderAppuntamentiHome(){ const prossimi = appuntamentiData .filter(a=>a.stato==='confermata'&&new Date(a.data_proposta)>=new Date()) .sort((a,b)=>new Date(a.data_proposta)-new Date(b.data_proposta)) .slice(0,2); if(!prossimi.length) return ''; const tipoLabel={sopralluogo:'Sopralluogo',installazione:'Installazione'}; const rows = prossimi.map(a=>{ const d=new Date(a.data_proposta).toLocaleDateString('it-IT',{weekday:'short',day:'numeric',month:'short'}); return `
📅
${tipoLabel[a.tipo]||a.tipo}
${d}${a.ora_proposta?' · ore '+a.ora_proposta:''}
`; }).join(''); return `
📅 Prossimi appuntamenti
${rows}
`; } function _populateHomeStatsFV(){ // Legge i dati dal monitor se già caricato const snap = _lastSnapshot; if(!snap) return; const oggi = snap.day_power_kwh!=null ? Number(snap.day_power_kwh).toFixed(1) : '—'; const mese = snap.month_power_kwh!=null ? Number(snap.month_power_kwh).toFixed(0) : '—'; const risp = snap.day_power_kwh!=null ? '€ '+Math.round(Number(snap.day_power_kwh)*(clienteData?.tariffa_acquisto||0.25)).toString() : '—'; const eOggi = document.getElementById('home-fv-oggi'); const eMese = document.getElementById('home-fv-mese'); const eRisp = document.getElementById('home-fv-risp'); if(eOggi) eOggi.textContent = oggi; if(eMese) eMese.textContent = mese; if(eRisp) eRisp.textContent = risp; } // Snapshot più recente tenuto in memoria per la home let _lastSnapshot = null; // ---- RENDER FV (pagina dettaglio fotovoltaico) ---- function renderFV(){ const el = document.getElementById('fv-content'); if(!el) return; // Ordina per revisione decrescente (V3 > V2 > V1) così il più recente è sempre [0] const prevList = preventiviData .filter(p=>!p.tipo||p.tipo==='fotovoltaico'||p.tipo==='fv') .sort((a,b)=>{ const ra=parseInt((a.revisione||'V1').replace(/\D/g,''))||1; const rb=parseInt((b.revisione||'V1').replace(/\D/g,''))||1; return rb-ra; }); if(!prevList.length){ el.innerHTML = `
☀️
Nessuna offerta fotovoltaico
Non hai ancora un'offerta per l'impianto fotovoltaico.
`; return; } // Header con back button const backBtn = ``; // Default: mostra il più recente (indice 0 dopo ordinamento desc) const prevDefault = prevList[0]; const avanzHtml = _renderAvanzamentoProgetto(prevDefault); // Selettore revisioni se più versioni let selHtml = ''; if(prevList.length>1){ selHtml = '
' +'
Versioni disponibili
' +'
' +prevList.map((p,i)=>{ const rev = p.revisione||'V1'; const isUltima = i===0; const isAccettata = !!p.approvato; const isDefault = p.id===prevDefault.id; let badge = ''; if(isUltima && isAccettata) badge = ' Ultima · Accettata'; else if(isUltima) badge = ' Ultima'; else if(isAccettata) badge = ' Accettata ✓'; const label = 'N° '+(p.numero||'—')+' '+rev; return ``; }).join('') +'
'; } el.innerHTML = backBtn + avanzHtml + selHtml + '
'; _renderFVCard(prevDefault); } function renderFVById(id){ const prev = preventiviData.find(p=>p.id===id); if(!prev) return; // Aggiorna evidenziazione pulsanti document.querySelectorAll('[id^="fv-rev-btn-"]').forEach(b=>{ const bid = parseInt(b.id.replace('fv-rev-btn-','')); b.className = 'btn btn-sm '+(bid===id?'btn-primary':''); }); _renderFVCard(prev); } function _renderFVCard(prev){ const el = document.getElementById('fv-card-content'); if(!el) return; // Resetta la selezione varianti se si cambia preventivo if(_prevAttivoId !== prev.id){ _prevAttivoId = prev.id; _varianteSelezionata = {base: null, opzioni: []}; } el.innerHTML = renderOffertaCard(prev); const btn = document.getElementById('btn-approva'); if(btn) btn.addEventListener('click',()=>approvaOfferta(Number(btn.dataset.previd))); _notificaAperturaOfferta(prev).catch(()=>{}); } // ---- RENDER CLIMA (pagina dettaglio condizionamento) ---- function renderClima(){ const el = document.getElementById('clima-content'); if(!el) return; // Ordina per revisione decrescente const prevList = preventiviData .filter(p=>p.tipo==='clima'||p.tipo==='condizionamento') .sort((a,b)=>{ const ra=parseInt((a.revisione||'V1').replace(/\D/g,''))||1; const rb=parseInt((b.revisione||'V1').replace(/\D/g,''))||1; return rb-ra; }); if(!prevList.length){ el.innerHTML = `
❄️
Nessuna offerta condizionamento
Scopri le nostre soluzioni per il riscaldamento e raffrescamento della tua abitazione.
`; return; } const backBtn = ``; // Default: mostra il più recente const prevDefault = prevList[0]; const avanzHtml = _renderAvanzamentoProgetto(prevDefault); let selHtml = ''; if(prevList.length>1){ selHtml = '
' +'
Versioni disponibili
' +'
' +prevList.map((p,i)=>{ const rev = p.revisione||'V1'; const isUltima = i===0; const isAccettata = !!p.approvato; const isDefault = p.id===prevDefault.id; let badge = ''; if(isUltima && isAccettata) badge = ' Ultima · Accettata'; else if(isUltima) badge = ' Ultima'; else if(isAccettata) badge = ' Accettata ✓'; const label = 'N° '+(p.numero||'—')+' '+rev; return ``; }).join('') +'
'; } el.innerHTML = backBtn + avanzHtml + selHtml + '
'; _renderClimaCard(prevDefault); } function renderClimaById(id){ const prev = preventiviData.find(p=>p.id===id); if(!prev) return; document.querySelectorAll('[id^="clima-rev-btn-"]').forEach(b=>{ const bid = parseInt(b.id.replace('clima-rev-btn-','')); b.className = 'btn btn-sm '+(bid===id?'btn-primary':''); }); _renderClimaCard(prev); } function _renderClimaCard(prev){ const el = document.getElementById('clima-card-content'); if(!el) return; if(_prevAttivoId !== prev.id){ _prevAttivoId = prev.id; _varianteSelezionata = {base: null, opzioni: []}; } el.innerHTML = renderOffertaCard(prev); const btn = document.getElementById('btn-approva'); if(btn) btn.addEventListener('click',()=>approvaOfferta(Number(btn.dataset.previd))); _notificaAperturaOfferta(prev).catch(()=>{}); } // ---- Helper: avanzamento progetto (usato da FV e Clima) ---- function _renderAvanzamentoProgetto(prev){ if(!prev) return ''; const passiKeys=['offerta','accettata','sopralluogo','installazione','collaudo','gse']; const passiLabels=['Offerta','Accettata','Sopralluogo','Installazione','Collaudo','GSE']; const approvato = prev.approvato; let statoIdx=0; if(approvato){ const avanz=prev.stato_avanzamento||'accettata'; statoIdx=Math.max(1,passiKeys.indexOf(avanz)); if(statoIdx<0)statoIdx=1; } const apptSopr=appuntamentiData.find(a=>a.tipo==='sopralluogo'&&a.stato==='confermata')||appuntamentiData.find(a=>a.tipo==='sopralluogo'); const apptInst=appuntamentiData.find(a=>a.tipo==='installazione'&&a.stato==='confermata')||appuntamentiData.find(a=>a.tipo==='installazione'); const stepDates={ accettata:prev.data_approvazione?new Date(prev.data_approvazione).toLocaleDateString('it-IT'):'', sopralluogo:apptSopr?new Date(apptSopr.data_proposta).toLocaleDateString('it-IT'):'', installazione:apptInst?new Date(apptInst.data_proposta).toLocaleDateString('it-IT'):'', collaudo:prev.collaudo_data?new Date(prev.collaudo_data).toLocaleDateString('it-IT'):'', gse:prev.gse_data?new Date(prev.gse_data).toLocaleDateString('it-IT'):'' }; const passiHtml=passiLabels.map((p,i)=>{ const cls=i'+d+'':''; return '
'+pre+p+dateHtml+'
'; }).join(''); return `
Stato progetto
${passiHtml}
`; } // ---- RENDER DATI PERSONALI ---- function renderDatiPersonali(){ const page = document.getElementById('page-dati-personali'); if(!page){ // Crea la pagina se non esiste const div = document.createElement('div'); div.className = 'page'; div.id = 'page-dati-personali'; div.innerHTML = '
'; document.querySelector('.content').appendChild(div); } const el = document.getElementById('dati-personali-content'); if(!el) return; const cli = clienteData; if(!cli){ el.innerHTML='

Dati non disponibili.

'; return; } el.innerHTML = `
Dati personali
Nome
${esc(cli.nome||'—')}
Email
${esc(cli.email||'—')}
${cli.tel?`
Telefono
${esc(cli.tel)}
`:''} ${cli.indirizzo?`
Indirizzo
${esc(cli.indirizzo+(cli.citta?', '+cli.citta:''))}
`:''} ${cli.cf?`
Codice fiscale
${esc(cli.cf)}
`:''}
Account
Email di accesso
${esc(currentUser?.email||'—')}
`; } // ---- RENDER OFFERTA (mantenuta per compatibilità) ---- function renderOfferta(){ // Ora il rendering delle offerte avviene in renderFV e renderClima // Questa funzione è mantenuta per non rompere chiamate esistenti } function renderOffertaCard(prev){ const approvato=prev.approvato; const dataAppr=prev.data_approvazione?new Date(prev.data_approvazione).toLocaleDateString('it-IT'):''; const iva=(prev.iva||10)/100; const fmtP = n => Number(n).toLocaleString('it-IT',{minimumFractionDigits:0,maximumFractionDigits:0}); // Note portale const noteHtml=prev.note_portale ?('
'+esc(prev.note_portale)+'
'):''; // PDF allegato const pdfHtml=prev.allegato_url ?('
' +'
' +'
'+esc(prev.allegato_nome||'Offerta.pdf')+'
' +'
Offerta commerciale PDF
' +'Scarica' +'
'):''; // ── VARIANTI ──────────────────────────────────────────────────────────── const v = prev.varianti; // Considera "senza varianti" anche se l'oggetto esiste ma ha tutti i prezzi a 0 o le chiavi base mancano const hasVarianti = v && typeof v === 'object' && (v.easy?.prezzo > 0 || v.pro?.prezzo > 0); let variantiHtml = ''; if(hasVarianti && !approvato){ // Mostra le card varianti selezionabili const varScelta = prev.variante_scelta ? JSON.parse(typeof prev.variante_scelta==='string' ? prev.variante_scelta : JSON.stringify(prev.variante_scelta)) : null; const baseScelta = varScelta?.base || null; // 'easy' o 'pro' const opzioniScelte = varScelta?.opzioni || []; const cardBase = (key, titolo, sottotitolo, icon) => { const p = v[key]?.prezzo||0; if(!p) return ''; const sel = baseScelta===key; return `
${icon}
${titolo}
${sottotitolo}
€ ${fmtP(p)}
+ IVA ${prev.iva||10}% = € ${fmtP(p*(1+iva))}
`; }; const cardOpzione = (key) => { const op = v[key]; if(!op?.attiva||!op.prezzo) return ''; const sel = opzioniScelte.includes(key); const label = op.label || key; const icons = {acc1:'🔋',acc2:'🔋',wallbox:'🔌',backup:'🛡️'}; return `
${icons[key]||'➕'}
${esc(label)}
+€ ${fmtP(op.prezzo)}
+ IVA
`; }; const easyCard = cardBase('easy','Sun Easy','Impianto base — efficiente e semplice da usare','☀️'); const proCard = cardBase('pro', 'Sun Pro', 'Con ottimizzatori e gestione avanzata dei consumi','⚡'); const accOpts = [cardOpzione('acc1'), cardOpzione('acc2'), cardOpzione('wallbox'), cardOpzione('backup')].filter(Boolean).join(''); variantiHtml = `
1 — Scegli la tua soluzione
${easyCard}${proCard}
${accOpts?`
2 — Opzioni aggiuntive
${accOpts}
`:''}
`; } else if(hasVarianti && approvato && prev.variante_scelta){ // Mostra la scelta fatta const vs = typeof prev.variante_scelta==='string' ? JSON.parse(prev.variante_scelta) : prev.variante_scelta; const baseLabel = vs.base==='easy'?'☀️ Sun Easy':'⚡ Sun Pro'; const opLabels = (vs.opzioni||[]).map(k=>v[k]?.label||k).join(', '); variantiHtml = `
Configurazione scelta
${baseLabel}${opLabels?' + '+opLabels:''}
`; } // ── COMPONENTI (per preventivi senza varianti — es. Clima) ────────────── let componentiHtml = ''; if(!hasVarianti){ const voci = prev.voci||[]; const sezioni = {}; voci.forEach(voce=>{ if(!voce.desc) return; const sez = voce.sezione||'Componenti'; if(!sezioni[sez]) sezioni[sez]=[]; sezioni[sez].push(voce); }); if(Object.keys(sezioni).length){ const righe = Object.entries(sezioni).map(([sez, vv])=>{ const items = vv.map(v=>`
${esc(v.desc)}
${v.qty&&v.qty>1?`
Quantità: ${v.qty} ${esc(v.udm||'cad')}
`:''}
`).join(''); return `
${esc(sez)}
${items}
`; }).join(''); const tot = Number(prev.totale||prev._totale||0); const totHtml = tot>0 ? `
Totale IVA ${prev.iva||10}% incl. € ${tot.toLocaleString('it-IT',{minimumFractionDigits:2,maximumFractionDigits:2})}
` : ''; componentiHtml = `
Componenti principali
${righe}${totHtml}
`; } } // ── RISPARMIO STIMATO (solo clima, da voci PDC) ────────────────────── let risparmioCTHtml = ''; const isClimaCard = prev.tipo==='condizionamento'||prev.tipo==='clima'; if(isClimaCard && !approvato){ // Cerca SCOP nelle voci (campo scop_w35 salvato nel desc o recupera default) // Usa valore di riferimento SCOP 4.5 (medio gamma Panasonic zona D) const scop = 4.5; const risparmioGas = Math.round((1 - 1/scop * (1/0.9) * (1.1/0.115)) * 100); // Incentivo CT stimato (da gestionale) const ct = prev.incentivo_conto_termico ? Number(prev.incentivo_conto_termico) : null; const rispPct = Math.max(0, Math.min(70, Math.round((1 - 0.115/(scop*0.25)) * 100))); risparmioCTHtml = `
`; } // ── INCENTIVI ──────────────────────────────────────────────────────── let incentiviHtml = ''; { const ct = prev.incentivo_conto_termico ? Number(prev.incentivo_conto_termico) : null; const tot = Number(prev.totale||prev._totale||0); const iva = (prev.iva||10)/100; const totIva = tot > 0 ? tot : 0; const isFV2 = !prev.tipo||prev.tipo==='fotovoltaico'||prev.tipo==='fv'; const isClima2 = prev.tipo==='condizionamento'||prev.tipo==='clima'; let rows = ''; if(isFV2){ rows += `
Detrazione IRPEF 50% calcolata automaticamente
Detraibile in 10 anni dalla dichiarazione dei redditi, in base alla configurazione scelta.
`; } if(isClima2){ if(ct){ rows += `
Conto Termico 3.0 € ${ct.toLocaleString('it-IT')}
Contributo stimato erogato dal GSE entro 60 giorni dalla domanda (per importi <€15.000). D.M. 07/08/2025, in vigore dal 25/12/2025.
`; } else { rows += `
Conto Termico 3.0 fino a €5.000
Contributo erogato dal GSE entro 60 giorni dalla domanda (per importi <€15.000). D.M. 07/08/2025, in vigore dal 25/12/2025.
`; } rows += `
Ecobonus 50% detrazione IRPEF
Detrazione in 10 anni. Scenderà al 36% dal 2027 sulle prime case. Le consigliamo di valutare con noi quale incentivo è più conveniente per la Sua situazione.
`; } if(rows){ const disclaimer = `
L'erogazione degli incentivi è soggetta a requisiti normativi, conformità e disponibilità dei fondi stanziati dagli Enti (GSE/Stato). La scrivente garantisce la correttezza delle pratiche inviate, ma non risponde del mancato riconoscimento del beneficio per esaurimento budget, modifiche legislative o cause non imputabili alla propria diligenza professionale.
`; incentiviHtml = `
`; } } // ── FAQ ─────────────────────────────────────────────────────────────── let faqHtml = ''; { const isFV3 = !prev.tipo||prev.tipo==='fotovoltaico'||prev.tipo==='fv'; const isClima3 = prev.tipo==='condizionamento'||prev.tipo==='clima'; let faqItems = ''; if(isClima3){ const faqC = [ ['La pompa di calore è rumorosa?','No. Le pompe di calore moderne sono generalmente molto silenziose. Il livello di rumore dipende soprattutto dalla qualità della macchina e da una corretta installazione. Se ben progettato, l\'impianto risulta normalmente compatibile con un contesto residenziale.'], ['Funziona bene anche quando fa molto freddo?','Sì, una pompa di calore di qualità continua a funzionare anche con temperature esterne basse. Le migliori possono arrivare a lavorare con temperature esterne di −25°C. È vero che, con il freddo intenso, l\'efficienza tende a ridursi rispetto alle mezze stagioni, ma con un corretto dimensionamento la macchina resta comunque efficace e affidabile.'], ['Richiede molta manutenzione?','No, la manutenzione ordinaria è generalmente contenuta. Rispetto a un sistema a combustione non ci sono fumi, bruciatori o canne fumarie da gestire. Restano comunque consigliati controlli periodici per mantenere l\'impianto efficiente e affidabile nel tempo.'], ['Può funzionare bene anche con i radiatori?','Sì, ma dipende dalle caratteristiche dell\'abitazione e dell\'impianto esistente. In case ben isolate o con radiatori adeguati, la pompa di calore può offrire ottimi risultati. Per questo è sempre importante la nostra valutazione tecnica preliminare.'], ['Qual è il modo migliore per utilizzarla?','La pompa di calore lavora al meglio in modo continuo e regolare, evitando accensioni e spegnimenti frequenti. Questa modalità consente di migliorare comfort, stabilità della temperatura ed efficienza complessiva del sistema.'], ]; faqItems = faqC.map(([q,a])=>`
${esc(q)}
${esc(a)}
`).join(''); } else if(isFV3){ const faqFV = [ ['Con il fotovoltaico smetto di pagare la bolletta?','No, nella maggior parte dei casi la bolletta non si azzera, ma può ridursi in modo molto importante. Il risparmio dipende soprattutto da quanta energia riesci ad autoconsumare. Numeri tipici: 30% di autoconsumo senza accumulo, 80% con l\'accumulo. L\'energia non utilizzata viene immessa in rete e pagata dal GSE tramite bonifico.'], ['Funziona anche quando è nuvoloso o in inverno?','Sì, l\'impianto continua a produrre anche con cielo coperto, semplicemente produce meno rispetto a una giornata soleggiata. Di notte non produce — l\'energia viene prelevata dalla rete oppure da un\'eventuale batteria.'], ['Conviene aggiungere una batteria di accumulo?','Dipende dal profilo di consumo. L\'accumulo è particolarmente utile quando gran parte dei consumi avviene la sera o al mattino presto, fuori dalle ore di produzione solare — tipica situazione residenziale. Per le applicazioni commerciali, bisogna valutare i consumi specifici.'], ['Un impianto fotovoltaico richiede molta manutenzione?','No, la manutenzione ordinaria è generalmente contenuta. Un impianto ben realizzato richiede soprattutto controlli periodici e, quando serve, la pulizia dei moduli per evitare perdite di produzione.'], ['Il fotovoltaico oggi conviene davvero?','Con i costi raggiunti oggi sì, soprattutto quando dimensionato bene sui consumi reali. Inoltre consente di aumentare i propri consumi elettrici, ad esempio eliminando il gas con l\'installazione di pompe di calore, e di rendersi indipendenti anche in caso di blackout della rete elettrica.'], ]; faqItems = faqFV.map(([q,a])=>`
${esc(q)}
${esc(a)}
`).join(''); } if(faqItems){ faqHtml = `
`; } } // ── ACCETTAZIONE ────────────────────────────────────────────────────── let approveHtml=''; if(approvato){ approveHtml='
' +'' +'
Offerta accettata
' +'
Accettata il '+dataAppr+' — grazie per la fiducia!
' +'
'; } else { const totaleAttualeStr = Number(prev.totale||0).toLocaleString('it-IT',{minimumFractionDigits:2,maximumFractionDigits:2}); approveHtml='
' +'
Accetta questa offerta
' +(hasVarianti ?'

Seleziona la configurazione desiderata sopra, poi inserisci il tuo nome per confermare.

' :'') +'
' +'' +'
' +'🔒 Cliccando Accetto l’offerta dichiari di aver letto e accettato integralmente la presente proposta commerciale. ' +'Verranno registrati: nome, data, ora e indirizzo IP per validità legale.' +'
' +'' +'
'; } // Rate — visibili solo se presenti e offerta approvata const rate = prev.rate||[]; let rateHtml = ''; if(rate.length){ const fmtE = n => '€ '+Number(n||0).toLocaleString('it-IT',{minimumFractionDigits:2,maximumFractionDigits:2}); const pagate = rate.filter(r=>r.pagato).length; const rateRows = rate.map((r,i)=>`
${r.pagato ?'' :''}
${esc(r.descrizione||'Rata '+(i+1))}
${r.scadenza?'
Scadenza: '+new Date(r.scadenza).toLocaleDateString('it-IT')+'
':''}
${fmtE(r.importo)}
`).join(''); rateHtml = `
💳 Piano pagamenti
${pagate} di ${rate.length} rata${rate.length>1?'e':''} pagata${pagate!==1?'':''}
${rateRows}
`; } // ── PAGAMENTO (FV e Clima) ─────────────────────────────────────────── const isFV = !prev.tipo || prev.tipo === 'fotovoltaico' || prev.tipo === 'fv'; const isClima = prev.tipo === 'condizionamento' || prev.tipo === 'clima'; let pagamentoHtml = ''; if(!approvato && (isFV || isClima)){ const pid = 'fin-'+prev.id; // Importo da finanziare = totale IVA inclusa (o totale variante se scelta) const importoFin = prev.totale || prev._totale || 0; const importoFmt = importoFin>0 ? importoFin.toLocaleString('it-IT',{minimumFractionDigits:0,maximumFractionDigits:0}) : '—'; pagamentoHtml = `
`; } return '
' +'
Offerta n° '+esc(prev.numero||'—')+' '+esc(prev.revisione||'V1')+'
' +'
'+esc(prev.titolo||'Impianto fotovoltaico')+'
' +'
'+(prev.data||'')+'
' +noteHtml +pdfHtml +componentiHtml +variantiHtml +risparmioCTHtml +incentiviHtml +faqHtml +pagamentoHtml +approveHtml +rateHtml +'
'; } function toggleSez(id){ const el=document.getElementById(id); if(!el)return; const btn=el.querySelector('.info-toggle'); const body=el.querySelector('.info-body'); const open=body.style.display==='none'||body.style.display===''; body.style.display=open?'block':'none'; if(btn)btn.classList.toggle('toggle-open',open); } function selFin(pid,mode){ const cBtn=document.getElementById(pid+'-c'); const fBtn=document.getElementById(pid+'-f'); const det=document.getElementById(pid+'-detail'); if(cBtn){cBtn.className='fin-btn'+(mode==='contanti'?' sel':'');} if(fBtn){fBtn.className='fin-btn'+(mode==='finanziamento'?' sel':'');} if(det) det.style.display=mode==='finanziamento'?'block':'none'; // Estrai prevId dal pid (formato: 'fin-') const prevId = parseInt(pid.replace('fin-','')); const prev = preventiviData?.find(p=>p.id===prevId); const importo = prev?.totale || prev?._totale || 0; if(mode==='finanziamento') aggiornaSimulatore(pid, importo); // Salva selezione in memoria per includerla nell'approvazione if(!window._finScelta) window._finScelta={}; window._finScelta[prevId]=mode; } // ── Calcola e aggiorna la rata mensile stimata ────────────────────────────── function aggiornaSimulatore(pid, importoTotale){ const slider = document.getElementById(pid+'-slider'); const nMesiEl = document.getElementById(pid+'-nmesi'); const rataEl = document.getElementById(pid+'-rata'); const totFinEl = document.getElementById(pid+'-totfin'); const accontoEl = document.getElementById(pid+'-acconto'); const impFinLbl = document.getElementById(pid+'-imp-fin-label'); const impOffertaLbl = document.getElementById(pid+'-imp-offerta'); if(!slider||!rataEl) return; // Se il preventivo ha varianti, usa sempre il totale aggiornato dalla selezione // (può essere diverso da importoTotale passato nel template al momento del render) const prevId = parseInt(pid.replace('fin-','')); const prevFin = preventiviData?.find(p=>p.id===prevId); if(prevFin?.varianti && _varianteSelezionata.base && _prevAttivoId===prevId){ const v = prevFin.varianti; const iva = (prevFin.iva||10)/100; let tot = (v[_varianteSelezionata.base]?.prezzo||0); _varianteSelezionata.opzioni.forEach(k=>{ tot+=(v[k]?.prezzo||0); }); importoTotale = Math.round(tot*(1+iva)); if(accontoEl) accontoEl.max = importoTotale; } // Aggiorna label importo offerta if(impOffertaLbl) impOffertaLbl.textContent='€ '+importoTotale.toLocaleString('it-IT'); const n = parseInt(slider.value)||60; if(nMesiEl) nMesiEl.textContent=n; const acconto = Math.min(parseFloat(accontoEl?.value)||0, importoTotale); const importo = importoTotale - acconto; if(impFinLbl){ if(acconto>0){ impFinLbl.textContent='Importo finanziato: €'+Math.round(importo).toLocaleString('it-IT') +' (dopo acconto €'+Math.round(acconto).toLocaleString('it-IT')+')'; } else { impFinLbl.textContent='Importo finanziato: €'+importoTotale.toLocaleString('it-IT'); } } if(!importo||importo<=0){rataEl.textContent='—';if(totFinEl)totFinEl.textContent='';return;} const i = 8.75/100/12; const rata = importo * (i*Math.pow(1+i,n)) / (Math.pow(1+i,n)-1); const totale = rata*n; rataEl.textContent = '€ '+rata.toFixed(2).replace('.',','); if(totFinEl) totFinEl.textContent = 'Totale restituito: €'+Math.round(totale).toLocaleString('it-IT') +' (interessi: €'+Math.round(totale-importo).toLocaleString('it-IT')+')'; if(!window._finScelta) window._finScelta={}; if(!window._finDettagli) window._finDettagli={}; window._finDettagli[prevId]={ rate_mesi: n, rata_mensile_stimata: Math.round(rata*100)/100, importo_finanziato: Math.round(importo*100)/100, acconto: acconto>0 ? Math.round(acconto*100)/100 : null, }; } // ---- SELEZIONE VARIANTI ---- let _varianteSelezionata = {base: null, opzioni: []}; let _prevAttivoId = null; // id del preventivo attualmente visualizzato function selezionaBase(key){ _varianteSelezionata.base = key; ['easy','pro'].forEach(k=>{ const card=document.getElementById('var-card-'+k); if(!card)return; card.classList.toggle('selected', k===key); }); aggiornaTotaleVarianti(); } function toggleOpzione(key){ const idx=_varianteSelezionata.opzioni.indexOf(key); if(idx>=0) _varianteSelezionata.opzioni.splice(idx,1); else _varianteSelezionata.opzioni.push(key); const card=document.getElementById('var-opt-'+key); if(card){ const sel=_varianteSelezionata.opzioni.includes(key); card.classList.toggle('selected', sel); } aggiornaTotaleVarianti(); } function aggiornaTotaleVarianti(){ // Usa il preventivo attualmente visualizzato const prev = _prevAttivoId ? preventiviData.find(p=>p.id===_prevAttivoId) : preventiviData.find(p=>p.varianti); if(!prev) return; const v=prev.varianti; if(!v) return; const iva=(prev.iva||10)/100; const box=document.getElementById('totale-varianti-box'); const valEl=document.getElementById('totale-varianti-val'); const ivaEl=document.getElementById('totale-varianti-iva'); if(!box||!valEl) return; if(!_varianteSelezionata.base){ box.style.display='none'; return; } let tot=(v[_varianteSelezionata.base]?.prezzo||0); _varianteSelezionata.opzioni.forEach(k=>{ tot+=(v[k]?.prezzo||0); }); const totIva=Math.round(tot*(1+iva)); box.style.display=''; valEl.textContent='€ '+totIva.toLocaleString('it-IT'); ivaEl.textContent='€ '+tot.toLocaleString('it-IT')+' + IVA '+Math.round(iva*100)+'% = € '+totIva.toLocaleString('it-IT'); // Aggiorna anche il box detrazione 50% se presente const detBox=document.getElementById('inc-det-box-'+_prevAttivoId); const detVal=document.getElementById('inc-det-val-'+_prevAttivoId); const detNote=document.getElementById('inc-det-note-'+_prevAttivoId); if(detBox&&detVal&&detNote){ const det=Math.round(totIva*0.5); const ann=Math.round(det/10); const baseLabel=_varianteSelezionata.base==='easy'?'Sun Easy':'Sun Pro'; const optsLabel=_varianteSelezionata.opzioni.length?' + opzioni':''; detBox.style.display='block'; detVal.textContent='€ '+det.toLocaleString('it-IT'); detNote.textContent='su € '+totIva.toLocaleString('it-IT')+' IVA incl. — '+baseLabel+optsLabel+' · €\u00a0'+ann.toLocaleString('it-IT')+'/anno per 10 anni'; } // Aggiorna il simulatore finanziamento se visibile (importo cambia con le scelte) if(_prevAttivoId){ const pid='fin-'+_prevAttivoId; const simDetail=document.getElementById(pid+'-detail'); if(simDetail&&simDetail.style.display!=='none'){ // Aggiorna label importo offerta const impLabel=simDetail.querySelector('[data-imp-offerta]'); if(impLabel) impLabel.textContent='€ '+totIva.toLocaleString('it-IT'); aggiornaSimulatore(pid, totIva); } // Aggiorna anche max acconto e oninput con nuovo importo const accontoEl=document.getElementById(pid+'-acconto'); if(accontoEl) accontoEl.max=totIva; // Aggiorna il valore fisso nell'oninput dello slider (non possiamo cambiare l'attributo // del template, quindi aggiornaSimulatore legge il totale da _varianteSelezionata) const sliderEl=document.getElementById(pid+'-slider'); if(sliderEl) sliderEl.setAttribute('data-importo-totale', totIva); } } // ---- APPROVA OFFERTA ---- async function approvaOfferta(prevId){ const nome=document.getElementById('sign-nome')?.value.trim(); if(!nome){toast('Inserisci il tuo nome e cognome','error');return;} const prev=preventiviData.find(p=>p.id===prevId); const hasVarianti = prev?.varianti; // Verifica selezione variante se presenti if(hasVarianti && !_varianteSelezionata.base){ toast('Seleziona prima la soluzione desiderata (Easy o Pro)','error'); return; } // Calcola totale con IVA dalla variante scelta let nuovoTotale = null; if(hasVarianti){ const v=prev.varianti; const iva=(prev.iva||10)/100; let tot=(v[_varianteSelezionata.base]?.prezzo||0); _varianteSelezionata.opzioni.forEach(k=>{ tot+=(v[k]?.prezzo||0); }); nuovoTotale = Math.round(tot*(1+iva)*100)/100; } const btn=document.getElementById('btn-approva'); if(btn){btn.textContent='Registrazione...';btn.disabled=true;} try{ const ts=new Date().toISOString(); const email=clienteData?.email||currentUser?.email||''; // 1. Salva approvazione — upsert per gestire ri-accettazioni // NOTA: richiede colonna cliente_id su approvazioni + policy RLS (vedi SQL migration) // Legge la scelta finanziamento se presente const modalitaPag = window._finScelta?.[prevId] || 'contanti'; const finDet = window._finDettagli?.[prevId] || {}; const approvazionePayload = { preventivo_id:prevId, nome_firmatario:nome, email_firmatario:email, user_agent:navigator.userAgent.slice(0,200), timestamp_firma:ts, modalita_pagamento: modalitaPag, rate_mesi: modalitaPag==='finanziamento' ? (finDet.rate_mesi||null) : null, rata_mensile_stimata: modalitaPag==='finanziamento' ? (finDet.rata_mensile_stimata||null) : null, importo_finanziato: modalitaPag==='finanziamento' ? (finDet.importo_finanziato||null) : null, acconto: modalitaPag==='finanziamento' ? (finDet.acconto||null) : null, }; if(clienteData?.id) approvazionePayload.cliente_id = clienteData.id; const{error:e1}=await supa.from('approvazioni').upsert( approvazionePayload, {onConflict:'preventivo_id'} ); if(e1)throw e1; // 2. Aggiorna preventivo — stato, totale e variante_scelta const updatePayload={approvato:true,stato:'approvato',stato_avanzamento:'accettata',data_approvazione:ts}; if(hasVarianti){ updatePayload.variante_scelta = JSON.stringify(_varianteSelezionata); if(nuovoTotale) updatePayload.totale = nuovoTotale; } const{error:e2}=await supa.from('preventivi').update(updatePayload).eq('id',prevId); if(e2)throw e2; // Aggiorna locale if(prev){ prev.approvato=true;prev.stato='approvato';prev.stato_avanzamento='accettata';prev.data_approvazione=ts; if(hasVarianti){ prev.variante_scelta=_varianteSelezionata; if(nuovoTotale)prev.totale=nuovoTotale; } } // 3. Genera documento firma e carica if(btn)btn.textContent='Generazione documento...'; try{ const prevPerPdf = preventiviData.find(p=>String(p.id)===String(prevId)) || prev || {numero:prevId,revisione:'V1',data:ts,totale:nuovoTotale||0,voci:[]}; const docBlob = await generaFirmaPDF(prevPerPdf,nome,email,ts); if(!docBlob) throw new Error('generaFirmaPDF ha restituito null'); // Sempre HTML con octet-stream: Supabase preserva il nome file const ext = 'html'; const contentType = 'application/octet-stream'; const filePath = 'firme/'+prevId+'/conferma_firma_'+Date.now()+'.'+ext; const nomeFile = 'Conferma_accettazione_'+((prevPerPdf?.numero||'offerta').split('/').join('-'))+'.'+ext; const{error:upErr}=await supa.storage.from('documenti').upload(filePath,docBlob,{contentType,upsert:true}); if(upErr) throw new Error('Upload: '+upErr.message); const{data:signed,error:signErr}=await supa.storage.from('documenti').createSignedUrl(filePath,60*60*24*365*10); if(signErr) throw new Error('SignedURL: '+signErr.message); const url=signed?.signedUrl||''; if(url){ // Insert diretto — se già esiste un record per questo preventivo lo sovrascriviamo await supa.from('documenti') .delete() .eq('preventivo_id', prevId) .eq('tipo', 'contratto'); await supa.from('documenti').insert({ cliente_id:clienteData?.id, preventivo_id:prevId, nome:nomeFile, url, tipo:'contratto', dimensione:docBlob.size, caricato_da:currentUser?.id }); const{data:docs}=await supa.from('documenti').select('*').eq('cliente_id',clienteData.id).order('created_at',{ascending:false}); documentiData=docs||[]; } }catch(pdfErr){ console.error('[Documento firma]', pdfErr?.message||pdfErr); // Non blocca il flusso principale — l'accettazione è già salvata } // Costruisci label variante scelta const v = prev?.varianti; const confLabel = hasVarianti ? (_varianteSelezionata.base==='easy'?'☀️ Sun Easy':'⚡ Sun Pro') +(_varianteSelezionata.opzioni.length ? ' + '+_varianteSelezionata.opzioni.map(k=>v?.[k]?.label||k).join(', ') : '') +(nuovoTotale ? ' — € '+nuovoTotale.toLocaleString('it-IT',{minimumFractionDigits:2}) : '') : ''; // Email notifica a Becoming (non bloccante) notificaAccettazione(prev, nome, email, ts, confLabel, nuovoTotale).catch(e=>console.warn('Email Becoming:',e.message)); toast('Offerta accettata! '+(confLabel||'PDF di conferma salvato.')); renderOfferta();renderHome();renderDocumenti(); }catch(e){ toast('Errore: '+e.message,'error'); if(btn){btn.textContent="Accetto l\u2019offerta";btn.disabled=false;} } } async function notificaAccettazione(prev, nomeFirmatario, emailCliente, ts, confLabel, totale){ const nomeCliente = clienteData?.nome || nomeFirmatario || 'Cliente'; const numOfferta = prev?.numero || '—'; const dataFirma = new Date(ts).toLocaleString('it-IT'); const totStr = totale ? '€ '+totale.toLocaleString('it-IT',{minimumFractionDigits:2}) : (prev?.totale ? '€ '+Number(prev.totale).toLocaleString('it-IT',{minimumFractionDigits:2}) : '—'); // Recupera PDF firmato dai documenti (appena salvato) let pdfLink = ''; try{ const{data:docs}=await supa.from('documenti') .select('url,nome').eq('preventivo_id',prev.id).eq('tipo','contratto') .order('created_at',{ascending:false}).limit(1); if(docs?.[0]?.url) pdfLink = `

📄 Scarica conferma offerta

`; }catch(e){} const varianteHtml = confLabel ? `
CONFIGURAZIONE SCELTA
${confLabel}
${totStr} IVA incl.
` : `
Totale: ${totStr}
`; const html = `
BECOMING — Gestionale

✅ Offerta accettata

Il cliente ha accettato l'offerta dal portale.

Cliente${nomeCliente}
Email${emailCliente}
N° offerta${numOfferta}
Titolo${prev?.titolo||'—'}
Data firma${dataFirma}
${varianteHtml} ${pdfLink}

Accedi al gestionale per aggiornare lo stato del progetto.

Notifica automatica dal portale clienti · ${dataFirma}
`; await fetch(SUPA_URL+'/functions/v1/send-email',{ method:'POST', headers:{'Content-Type':'application/json','Authorization':'Bearer '+SUPA_KEY}, body:JSON.stringify({ to:'info@becomingdesign.it', subject:`[Becoming] ${nomeCliente} ha accettato l'offerta ${numOfferta}`, html }) }); // Email di conferma anche al cliente (se ha un'email) if(emailCliente){ const htmlCliente = `
BECOMING

La sua offerta è stata confermata ✅

Gentile ${nomeCliente},

Confermiamo la ricezione della sua accettazione dell'offerta n. ${numOfferta}.

${varianteHtml}

Il documento di conferma sarà disponibile a breve nella sua area personale su clienti.becomingdesign.it, nella sezione Documenti.

Il team Becoming la contatterà a breve per concordare i prossimi passi.
Per qualsiasi necessità: info@becomingdesign.it · 06.61521259

Becoming S.r.l. · Via Marentino 67, 00166 Roma · ${dataFirma}
`; await fetch(SUPA_URL+'/functions/v1/send-email',{ method:'POST', headers:{'Content-Type':'application/json','Authorization':'Bearer '+SUPA_KEY}, body:JSON.stringify({ to:emailCliente, subject:`Becoming — Conferma accettazione offerta n. ${numOfferta}`, html:htmlCliente }) }).catch(e=>console.warn('Email cliente:',e.message)); } } async function generaFirmaPDF(prev,nome,email,ts){ // Prova a usare jsPDF se già caricato, altrimenti genera documento HTML→Blob const hasjsPDF = !!(window.jspdf?.jsPDF); if(!hasjsPDF){ // Fallback: genera un documento HTML ben formattato come blob PDF-like return generaFirmaHTML(prev,nome,email,ts); } const {jsPDF}=window.jspdf; const doc=new jsPDF({unit:'mm',format:'a4'}); const W=210,M=20; // page width, margin const CW=W-M*2; // content width let y=M; // cursor Y // Colori Becoming const GREEN=[138,184,0]; const GREEN_DARK=[39,80,10]; const GRAY=[136,136,136]; const TEXT=[51,51,51]; const BG_LIGHT=[248,248,246]; const BG_GREEN=[234,243,222]; // ---- HEADER ---- doc.setFontSize(16);doc.setFont('helvetica','bold');doc.setTextColor(...GREEN); doc.text('Becoming',M,y); doc.setFontSize(9);doc.setFont('helvetica','normal');doc.setTextColor(...GRAY); doc.text('Becoming S.r.l.',W-M,y-4,{align:'right'}); doc.text('info@becomingdesign.it',W-M,y+1,{align:'right'}); y+=4; doc.setDrawColor(...GREEN);doc.setLineWidth(0.8); doc.line(M,y,W-M,y); y+=10; // ---- TITOLO ---- doc.setFontSize(16);doc.setFont('helvetica','bold');doc.setTextColor(...GREEN); doc.text('Conferma di accettazione offerta',M,y); y+=6; const dataFirma=new Date(ts).toLocaleString('it-IT'); doc.setFontSize(9);doc.setFont('helvetica','normal');doc.setTextColor(...GRAY); doc.text('Documento generato il '+dataFirma,M,y); y+=10; // ---- BOX DATI OFFERTA ---- const boxH=28; doc.setFillColor(...BG_LIGHT);doc.roundedRect(M,y,CW,boxH,2,2,'F'); y+=6; doc.setFontSize(8);doc.setTextColor(...GRAY); doc.text('DATI OFFERTA',M+6,y); y+=6; doc.setFontSize(10);doc.setTextColor(...TEXT); const numOff=(prev?.numero||'\u2014')+' '+(prev?.revisione||'V1'); doc.setFont('helvetica','normal'); doc.text('N\u00b0 Offerta:',M+6,y);doc.setFont('helvetica','bold');doc.text(numOff,M+40,y); y+=5; doc.setFont('helvetica','normal'); doc.text('Data offerta:',M+6,y);doc.text(prev?.data||'\u2014',M+40,y); y+=5; const totale='\u20ac '+Number(prev?.totale||0).toLocaleString('it-IT',{minimumFractionDigits:2}); doc.text('Totale IVA incl.:',M+6,y);doc.setFont('helvetica','bold');doc.setTextColor(...GREEN); doc.text(totale,M+40,y); y+=10; // ---- TABELLA VOCI ---- const voci=(prev?.voci||[]).slice(0,10); if(voci.length){ y+=2; // Intestazione tabella doc.setFillColor(...GREEN);doc.rect(M,y,CW,6,'F'); doc.setFontSize(8);doc.setFont('helvetica','bold');doc.setTextColor(255,255,255); doc.text('Descrizione',M+3,y+4); doc.text('Qt.',M+CW-40,y+4,{align:'right'}); doc.text('Totale',M+CW-3,y+4,{align:'right'}); y+=6; // Righe doc.setFont('helvetica','normal');doc.setTextColor(...TEXT);doc.setFontSize(8); voci.forEach(function(v){ if(y>265){doc.addPage();y=M;} doc.setDrawColor(240,240,240);doc.line(M,y,M+CW,y); var desc=doc.splitTextToSize(String(v.desc||''),CW-55); doc.text(desc,M+3,y+4); doc.text(v.qty+' '+(v.udm||'cad'),M+CW-40,y+4,{align:'right'}); doc.text('\u20ac'+Number(v.prezzoTot||0).toLocaleString('it-IT',{minimumFractionDigits:2}),M+CW-3,y+4,{align:'right'}); y+=Math.max(6,desc.length*4+2); }); y+=6; } // ---- BOX FIRMA ---- if(y>220){doc.addPage();y=M;} var firmaY=y; var firmaH=72; doc.setFillColor(...BG_GREEN);doc.setDrawColor(151,196,89);doc.setLineWidth(0.3); doc.roundedRect(M,firmaY,CW,firmaH,2,2,'FD'); y=firmaY+7; doc.setFontSize(12);doc.setFont('helvetica','bold');doc.setTextColor(...GREEN_DARK); doc.text('\u2714 Dichiarazione di accettazione',M+6,y); y+=8; doc.setFontSize(9);doc.setFont('helvetica','normal');doc.setTextColor(85,85,85); doc.text('Il/La Sottoscritto/a:',M+6,y); doc.setFont('helvetica','bold');doc.setTextColor(...TEXT); doc.text(String(nome),M+48,y); y+=5; doc.setFont('helvetica','normal');doc.setTextColor(85,85,85); doc.text('Email verificata:',M+6,y);doc.setTextColor(...TEXT);doc.text(String(email),M+48,y); y+=5; doc.setTextColor(85,85,85); doc.text('Data e ora accettazione:',M+6,y);doc.setFont('helvetica','bold');doc.setTextColor(...TEXT); doc.text(dataFirma,M+48,y); y+=5; // Modalità pagamento (se salvata) const _modPag = window._finScelta?.[prev?.id]; if(_modPag){ doc.setFont('helvetica','normal');doc.setTextColor(85,85,85); const _finDet = window._finDettagli?.[prev?.id]||{}; let _modLabel = ''; if(_modPag==='finanziamento'){ _modLabel = 'Finanziamento'; if(_finDet.acconto) _modLabel += ' · acconto €'+Number(_finDet.acconto).toLocaleString('it-IT'); if(_finDet.importo_finanziato) _modLabel += ' · importo finanziato €'+Number(_finDet.importo_finanziato).toLocaleString('it-IT'); if(_finDet.rate_mesi) _modLabel += ' · '+_finDet.rate_mesi+' rate da €'+Number(_finDet.rata_mensile_stimata||0).toFixed(2)+' (stimata)'; } else { _modLabel = 'Pagamento in contanti'; } doc.text('Modalit\u00e0 pagamento:',M+6,y);doc.setFont('helvetica','bold');doc.setTextColor(...TEXT); doc.text(_modLabel,M+48,y); y+=5; } y+=3; doc.setFont('helvetica','normal');doc.setTextColor(85,85,85);doc.setFontSize(9); var dichiarazione='dichiara di aver letto e accettato integralmente la presente offerta commerciale, nelle modalit\u00e0 e ai prezzi in essa contenuti. L\u2019accettazione \u00e8 avvenuta tramite portale web autenticato con credenziali personali.'; var dichLines=doc.splitTextToSize(dichiarazione,CW-12); doc.text(dichLines,M+6,y); y+=dichLines.length*4+4; // Codice riferimento var codice='BEC-'+Date.now().toString(36).toUpperCase(); var codeBoxW=80,codeBoxH=12; var codeX=M+(CW-codeBoxW)/2; doc.setFillColor(...GREEN);doc.roundedRect(codeX,y,codeBoxW,codeBoxH,2,2,'F'); doc.setFontSize(7);doc.setTextColor(255,255,255);doc.setFont('helvetica','normal'); doc.text('Codice riferimento elettronico',codeX+codeBoxW/2,y+4,{align:'center'}); doc.setFontSize(11);doc.setFont('courier','bold'); doc.text(codice,codeX+codeBoxW/2,y+10,{align:'center'}); // ---- FOOTER ---- y=firmaY+firmaH+8; if(y>275){doc.addPage();y=M;} doc.setDrawColor(224,224,224);doc.line(M,y,W-M,y); y+=4; doc.setFontSize(7);doc.setFont('helvetica','normal');doc.setTextColor(153,153,153); var footerText='Firma elettronica semplice ai sensi del Regolamento UE n. 910/2014 (eIDAS). Il presente documento attesta l\u2019accettazione dell\u2019offerta da parte del committente. Conservare copia del presente documento.'; var footLines=doc.splitTextToSize(footerText,CW); doc.text(footLines,M,y); return doc.output('blob'); } // escH definita sotto con escape completo // ---- RENDER DOCUMENTI ---- function renderDocumenti(){ const el=document.getElementById('documenti-content'); const tipoBadge={scheda:'badge-blue','pratica-gse':'badge-amber','pratica-enea':'badge-amber',contratto:'badge-green',conformita:'badge-green',foto:'badge-gray',altro:'badge-gray'}; const tipoLabel={scheda:'Scheda tecnica','pratica-gse':'Pratica GSE','pratica-enea':'Pratica Enea',contratto:'Contratto',conformita:'Dichiarazione conformità',foto:'Foto/planimetria',altro:'Documento'}; // Also include PDF offerte from preventivi const allDocs=[...documentiData]; preventiviData.forEach(p=>{ if(p.allegato_url)allDocs.push({id:'prev-'+p.id,nome:p.allegato_nome||'Offerta.pdf',url:p.allegato_url,tipo:'offerta',created_at:p.created_at,_label:'Offerta n°'+p.numero}); }); if(!allDocs.length){ el.innerHTML='
Documenti

Nessun documento disponibile.

'; return; } const rows=allDocs.map(d=>{ const badge=d.tipo==='offerta'?'badge-red':(tipoBadge[d.tipo]||'badge-gray'); const label=d._label||(tipoLabel[d.tipo]||'Documento'); const data=d.created_at?new Date(d.created_at).toLocaleDateString('it-IT'):''; return `
${esc(d.nome)}
${label}${data?' · '+data:''}
Apri
`; }).join(''); el.innerHTML=`
Documenti
${allDocs.length} documenti disponibili
${rows}
`; } // ---- RENDER ASSISTENZA ---- function renderAssistenza(){ const el=document.getElementById('assistenza-content'); const prevOpts=preventiviData.map(p=>``).join(''); // Segna tutte le risposte presenti come lette (timestamp in memoria) const risposteIds=richiesteData.filter(r=>r.risposta&&r.risposta.trim()!=='').map(r=>r.id); try{ const viste=JSON.parse(sessionStorage.getItem('ass_risposte_viste')||'[]'); const nuove=risposteIds.filter(id=>!viste.includes(id)); // Unione e salvataggio sessionStorage.setItem('ass_risposte_viste',JSON.stringify([...new Set([...viste,...risposteIds])])); }catch(_){} // Dopo l'apertura non ci sono più risposte non lette const badge=document.getElementById('badge-ass-cliente'); if(badge)badge.style.display='none'; const dot=document.getElementById('menu-dot'); if(dot)dot.style.display='none'; const storicoRows=richiesteData.map(r=>{ const badge2=r.stato==='chiusa'?'badge-green':r.stato==='in-lavorazione'?'badge-amber':'badge-blue'; const label=r.stato==='chiusa'?'Chiusa':r.stato==='in-lavorazione'?'In lavorazione':'Aperta'; const data=new Date(r.created_at).toLocaleDateString('it-IT'); const rispostaHtml=r.risposta ? '
' +'
Risposta Becoming
' +esc(r.risposta)+'
' : ''; return '
' +'
' +'
'+esc(r.oggetto)+'
' +'
'+data+(r.risposta?' · Risposta ricevuta':'')+'
' +'
' +''+label+'
' +rispostaHtml+'
'; }).join(''); el.innerHTML=`
Assistenza
Nuova richiesta
${prevOpts?`
`:''}
${richiesteData.length?`
Storico richieste
${storicoRows}
`:''}`; } // Calcola badge assistenza (chiamata al caricamento dati e dal polling) function _aggiornaBadgeAssistenza(){ try{ const viste=JSON.parse(sessionStorage.getItem('ass_risposte_viste')||'[]'); const nonLette=richiesteData.filter(r=>r.risposta&&r.risposta.trim()!==''&&!viste.includes(r.id)); const badge=document.getElementById('badge-ass-cliente'); if(badge)badge.style.display=nonLette.length>0?'inline':'none'; const dot=document.getElementById('menu-dot'); if(dot)dot.style.display=nonLette.length>0?'block':'none'; }catch(_){} } // ---- INVIA RICHIESTA ---- async function inviaRichiesta(){ const oggetto=document.getElementById('ass-oggetto')?.value.trim(); const msg=document.getElementById('ass-msg')?.value.trim(); const prevId=document.getElementById('ass-prev')?.value||null; if(!oggetto||!msg){toast('Compila oggetto e messaggio','error');return;} const btn=document.getElementById('btn-invia-richiesta'); if(btn){btn.textContent='Invio...';btn.disabled=true;} try{ const{error}=await supa.from('richieste_assistenza').insert({ cliente_id:clienteData?.id, preventivo_id:prevId?parseInt(prevId):null, oggetto,messaggio:msg, email_cliente:clienteData?.email||currentUser?.email||'' }); if(error)throw error; toast('Richiesta inviata! Ti risponderemo presto.'); document.getElementById('ass-oggetto').value=''; document.getElementById('ass-msg').value=''; // Reload const{data}=await supa.from('richieste_assistenza').select('*').eq('cliente_id',clienteData.id).order('created_at',{ascending:false}); richiesteData=data||[]; renderAssistenza(); }catch(e){ toast('Errore: '+e.message,'error'); if(btn){btn.textContent='Invia richiesta';btn.disabled=false;btn.innerHTML=' Invia richiesta';} } } // ---- MONITORAGGIO FUSIONSOLAR (legge da DB - polling ogni 5 min) ---- async function renderMonitoraggio(){ const el = document.getElementById('monitor-content'); if(!el) return; const cli = clienteData; const hasFV = !!(cli?.fusionsolar_plant_id && cli?.fusionsolar_username); const hasPDC = !!(cli?.aquarea_device_id); if(!hasFV && !hasPDC){ el.innerHTML = '
' +'' +'
Monitoraggio non ancora attivo
' +'
Disponibile dopo l\'installazione.
Sarà attivato dal team Becoming.
' +'
'; return; } el.innerHTML = '
Caricamento dati impianto...
'; try{ let snap = null, alarms = [], aquareaData = null; if(hasFV){ const {data:snaps, error:snapErr} = await supa .from('monitoring_snapshots').select('*').eq('cliente_id', cli.id) .order('captured_at', {ascending: false}).limit(1); if(snapErr) console.warn('[monitor] snapErr=', snapErr); snap = snaps?.[0] || null; const {data:al} = await supa.from('monitoring_alarms').select('*') .eq('cliente_id', cli.id).is('resolved_at', null).order('severity', {ascending: true}); alarms = al || []; } if(hasPDC){ const {data:aqRows} = await supa.from('aquarea_readings').select('*') .eq('device_id', cli.aquarea_device_id).limit(1); aquareaData = aqRows?.[0] || null; } if(hasFV && !snap){ el.innerHTML = '
' +'Nessun dato disponibile.
Il monitoraggio si aggiornerà entro 10 minuti.' +'

cliente_id: '+cli.id+'
'; return; } if(!snap && !aquareaData){ el.innerHTML = '
' +'Nessun dato disponibile.
Il monitoraggio si aggiornerà entro 15 minuti.' +'

cliente_id: '+cli.id+'
'; return; } renderMonitoraggioUI(el, snap, alarms, cli, aquareaData); if(snap){ _lastSnapshot = snap; _populateHomeStatsFV(); } }catch(e){ el.innerHTML = '
' +'
' +'Errore caricamento dati
'+esc(e.message)+'

' +'' +'
'; } } function renderMonitoraggioUI(el, d, alarms, cli, aq){ const fmt = n => (typeof n==='number' && !isNaN(n)) ? n.toLocaleString('it-IT',{maximumFractionDigits:2}) : '—'; const fmtK = n => (typeof n==='number' && !isNaN(n)) ? n.toLocaleString('it-IT',{maximumFractionDigits:1}) : '—'; const lastUpdate = d.captured_at ? new Date(d.captured_at).toLocaleString('it-IT') : ''; // ── Stato impianto ──────────────────────────────────────────────────────── const hasCritical = alarms.some(a=>a.severity<=2); const hasWarning = alarms.some(a=>a.severity>=3); const isProducing = (d.pv_power_kw > 0) || (d.day_power_kwh > 0); let statusLabel, statusMsg, statusIcon; if(hasCritical){ statusLabel='Anomalia rilevata'; statusIcon='⚠️'; statusMsg='Contatta il team Becoming per assistenza.'; } else if(hasWarning){ statusLabel='Attenzione'; statusIcon='🔶'; statusMsg='Presenza di avvisi.'; } else if(isProducing || d.health_state===3){ statusLabel='Operativo'; statusIcon='✅'; statusMsg=''; } else { statusLabel='Offline'; statusIcon='💤'; statusMsg=''; } // ── Valori ──────────────────────────────────────────────────────────────── const hasBatt = d.battery_soc != null; const battSoc = hasBatt ? Number(d.battery_soc) : null; let battPowerKw = d.battery_power_kw != null ? Number(d.battery_power_kw) : null; if(battPowerKw != null && Math.abs(battPowerKw) > 100) battPowerKw = battPowerKw / 1000; const hasGrid = d.grid_power_kw != null; const gridKw = hasGrid ? Number(d.grid_power_kw) : null; const loadKw = d.load_power_kw != null ? Number(d.load_power_kw) : null; const dayUseEnergy = d.raw_kpi?.day_use_energy != null ? Number(d.raw_kpi.day_use_energy) : null; const dayOnGrid = d.raw_kpi?.day_on_grid_energy != null ? Number(d.raw_kpi.day_on_grid_energy) : null; const dayIncome = d.raw_kpi?.day_income != null ? Number(d.raw_kpi.day_income) : null; const totalIncome = d.raw_kpi?.total_income != null ? Number(d.raw_kpi.total_income) : null; // Tariffa: usa costo_kwh dall'ultima bolletta inserita, fallback su DB, poi default const tariffaAcquisto = Number(clienteData?._ultimaBollettaKwh || cli.tariffa_acquisto) || 0.25; const tariffaIncentivo = Number(cli.tariffa_incentivo) || 0.10; const dayProd = d.day_power_kwh != null ? Number(d.day_power_kwh) : null; const pvKw = d.pv_power_kw != null ? Number(d.pv_power_kw) : null; const dayAutoconsumo = (dayProd != null && dayOnGrid != null) ? Math.max(0, dayProd - dayOnGrid) : null; const risparmioOggi = dayAutoconsumo != null ? dayAutoconsumo * tariffaAcquisto + (dayOnGrid||0) * tariffaIncentivo : null; const autoconsumoPct = (dayAutoconsumo != null && dayProd > 0) ? Math.round(dayAutoconsumo / dayProd * 100) : null; // ── HTML ────────────────────────────────────────────────────────────────── let html = ''; const isPremium = clienteData?.piano_monitoraggio === 'premium'; // ── Sezione Fotovoltaico ───────────────────────────────────────────────────── html += `
☀️ Fotovoltaico
`; // ── Topbar ────────────────────────────────────────────────────────────────── html += `
${lastUpdate ? 'Aggiornato: '+lastUpdate : ''}
`; // ── Logo Becoming — copertura consumo istantaneo ──────────────────────────── (function(){ const _imm = Math.max(0, gridKw || 0); const _pvCon = loadKw > 0 ? Math.min(Math.max(0, (pvKw||0) - _imm), loadKw) : 0; const _batKw = (battPowerKw != null && battPowerKw < 0) ? Math.min(Math.abs(battPowerKw), Math.max(0, (loadKw||0) - _pvCon)) : 0; const _load = loadKw || 0; const _pvP = _load > 0 ? Math.round(_pvCon / _load * 100) : 0; const _batP = _load > 0 ? Math.round(_batKw / _load * 100) : 0; const _gridP = 100 - _pvP - _batP; const _glow = _pvP >= _batP && _pvP >= _gridP ? 'rgba(150,189,13,0.18)' : _batP >= _gridP ? 'rgba(110,135,151,0.16)' : 'rgba(182,109,75,0.16)'; window._becLogoData = {pvP:_pvP, batP:_batP, gridP:_gridP, glow:_glow}; })(); html += '
' + '
Copertura consumo istantaneo
' + '
'+ '
'+ ''+ 'Fotovoltaico'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
' + '
' + '
'+(loadKw!=null?fmt(loadKw)+' kW':'—')+'
' + '
Consumo casa
' + '
' + '
Fotovoltaico
0%
' + '
Batteria
0%
' + '
Rete
0%
' + '
' + '
' + '
'+(pvKw!=null?fmt(pvKw)+' kW':'—')+'
FV
' + (hasBatt?'
'+battSoc+'%
Batteria
':'') + '
'+(gridKw!=null?fmt(Math.abs(gridKw))+' kW':'—')+'
' + '
'+(gridKw!=null&&gridKw>0?'Immissione':gridKw!=null&&gridKw<0?'Prelievo':'Rete')+'
' + '
'; // ── Tab periodo ────────────────────────────────────────────────────────────── html += `
`; // ── Card risparmio ─────────────────────────────────────────────────────────── const tariffaLabel = clienteData?._ultimaBollettaKwh ? '\u20ac '+Number(clienteData._ultimaBollettaKwh).toFixed(4)+'/kWh (da bolletta)' : '\u20ac '+Number(tariffaAcquisto).toFixed(2)+'/kWh (predefinita)'; html += `
Risparmio stimato
${risparmioOggi!=null ? '\u20ac '+fmt(risparmioOggi) : '\u2014'}
risparmiati oggi
${dayProd!=null ? fmt(dayProd)+' kWh' : '\u2014'}
Produzione
${autoconsumoPct!=null ? autoconsumoPct+'%' : '\u2014'}
Autoconsumo
${dayOnGrid!=null ? fmt(dayOnGrid)+' kWh' : '\u2014'}
Immesso in rete
\u2014
Prelevato dalla rete
Tariffa: ${tariffaLabel}
`; // ── Bottone bollette (card visibile) ───────────────────────────────────────── html += `
`; // ── Grafico produzione vs consumo (storico mensile) ────────────────────────── html += `
Produzione vs consumo
Produzione
Consumo
`; // ── Grafico 24h (produzione + consumo) ─────────────────────────────────────── html += '
' + '
Produzione e consumo — ultime 24h
' + '
' + '
' + '
'; // ── Autoconsumo ────────────────────────────────────────────────────────────── html += '
'; // ── Analisi AI ─────────────────────────────────────────────────────────────── // Premium: placeholder popolato da _renderAnomalieAI — Base: banner upsell if(isPremium){ html += '
'; } else { html += `
Analisi AI anomalie
Rilevamento automatico di cali di produzione e guasti — disponibile con il piano Premium
`; } // ── Allarmi FV ─────────────────────────────────────────────────────────────── const sevColor = {1:'#A32D2D', 2:'#854F0B', 3:'#BA7517', 4:'#5F5E5A'}; const sevBg = {1:'#FCEBEB', 2:'#FAEEDA', 3:'#FAEEDA', 4:'#F1EFE8'}; const sevLabel = {1:'Critico', 2:'Grave', 3:'Avviso', 4:'Info'}; const isPremiumAlarms = isPremium; if(alarms.length === 0){ html += `
Nessun errore rilevato
L\'impianto FV non presenta anomalie segnalate
`; } else { html += `
Avvisi impianto (${alarms.length})
`; const alarmsMostrati = isPremiumAlarms ? alarms : alarms.slice(0,3); alarmsMostrati.forEach(a => { const col = sevColor[a.severity] || '#5F5E5A'; const bg = sevBg[a.severity] || '#F1EFE8'; const lbl = sevLabel[a.severity]|| 'Avviso'; html += `
${esc(a.alarm_name||a.name||'Anomalia rilevata')}
${isPremiumAlarms ? `
${a.device_name||''} ${a.started_at?'· '+new Date(a.started_at).toLocaleDateString('it-IT'):''}
` : ''}
${lbl}
`; }); if(!isPremiumAlarms && alarms.length > 3){ html += `
+${alarms.length-3} altri avvisi — disponibili con Premium
`; } html += `
`; } // ── Sezione Pompa di calore ────────────────────────────────────────────────── if(aq){ html += `
❄ Pompa di calore
`; } if(aq){ const fmtAq = n => (n!=null&&!isNaN(Number(n))) ? Number(n).toLocaleString('it-IT',{maximumFractionDigits:1}) : '—'; const fmtAq2 = n => (n!=null&&!isNaN(Number(n))) ? Number(n).toLocaleString('it-IT',{maximumFractionDigits:2}) : '—'; const pvKw = d ? Number(d.pv_power_kw||0) : 0; const battDisKw = d ? Math.max(0,-(Number(d.battery_power_kw||0))) : 0; const pdcKw = Number(aq.power_consumption||0); const fvCopre = pdcKw>0 && (pvKw+battDisKw)>=pdcKw; const fvCopreparz = pdcKw>0 && !fvCopre && (pvKw+battDisKw)>0; const modePDC = aq.working_mode || null; const isOn = aq.running_status==='Acceso' || aq.running_status==='2006-0310'; const dotColor = isOn ? 'var(--accent)' : 'var(--text3)'; const pcHeat = aq.power_consumption_heating!=null ? Number(aq.power_consumption_heating) : null; const pcDhw = aq.power_consumption_dhw!=null ? Number(aq.power_consumption_dhw) : null; const hgHeat = aq.heat_generated_heating!=null ? Number(aq.heat_generated_heating) : null; const hgDhw = aq.heat_generated_dhw!=null ? Number(aq.heat_generated_dhw) : null; const copDhw = (pcDhw&&pcDhw>0&&hgDhw&&hgDhw>0) ? Math.round(hgDhw/pcDhw*10)/10 : null; const copHeat = (pcHeat&&pcHeat>0&&hgHeat&&hgHeat>0) ? Math.round(hgHeat/pcHeat*10)/10 : null; const pcTotLog = (pcHeat||0)+(pcDhw||0); const hgTotLog = (hgHeat||0)+(hgDhw||0); const cop = aq.cop_realtime!=null ? Number(aq.cop_realtime) : (pcTotLog>0&&hgTotLog>0) ? Math.round(hgTotLog/pcTotLog*10)/10 : null; html += '
'; html += '
'; html += '
Pompa di calore
'; html += isOn ? '
In funzione
' : '
Standby
'; html += '
'; if(d && fvCopre){ const bannerTxt = battDisKw>0 ? 'Alimentata da sole e batteria' : 'Alimentata dal sole'; html += '
'; html += ''; html += '
'+bannerTxt+'
'; html += '
Il fotovoltaico copre i consumi della pompa di calore
'; } else if(d && fvCopreparz){ html += '
⚡ Copertura parziale dal fotovoltaico
'; } if(modePDC){ html += '
'; html += '
'; html += '
'+esc(modePDC)+'
'; } html += '
'; html += '
Temp. esterna
'+fmtAq(aq.outdoor_temp)+' °C
'; html += '
Mandata
'+fmtAq(aq.water_outlet_temp)+' °C
'; html += '
Ritorno
'+fmtAq(aq.water_inlet_temp)+' °C
'; html += '
Acqua calda sanitaria
'+fmtAq(aq.dhw_tank_actual_temp)+' °C
'; html += '
'; const hasConsumo = (pcHeat!=null&&pcHeat>0) || (pcDhw!=null&&pcDhw>0); if(hasConsumo || (pcHeat===0&&pcDhw===0)){ html += '
'; html += '
Consumo elettrico istantaneo
'; if(pcHeat!=null&&pcHeat>0){ html += '
'; html += 'Riscaldamento / Raffrescamento'; html += ''+fmtAq2(pcHeat)+' kW'+(copHeat?' — COP '+copHeat.toLocaleString('it-IT',{maximumFractionDigits:1}):'')+'
'; } if(pcDhw!=null&&pcDhw>0){ html += '
'; html += 'Acqua calda sanitaria'; html += ''+fmtAq2(pcDhw)+' kW'+(copDhw?' — COP '+copDhw.toLocaleString('it-IT',{maximumFractionDigits:1}):'')+'
'; } if(pcHeat===0&&pcDhw===0) html += '
Compressore fermo
'; html += '
'; } html += '
'; html += '
Storico impianto
'; html += '
Ore di funzionamento'+fmtAq(aq.runtime_hours)+' h
'; html += '
Cicli ON/OFF'+fmtAq(aq.run_count)+'
'; const errHistory = Array.isArray(aq.error_history) ? aq.error_history : []; html += '
Errori PDC
'; if(errHistory.length > 0){ errHistory.slice(0,3).forEach(function(er){ const dt = er.errorDate ? new Date(er.errorDate).toLocaleDateString('it-IT') : '—'; html += '
'; html += ''+esc(er.errorCode||'—')+''; html += ''+dt+'
'; }); } else { html += '
'; html += ''; html += 'Nessun errore registrato
'; } if(aq.fetched_at) html += '
Aggiornato: '+new Date(aq.fetched_at).toLocaleString('it-IT')+'
'; html += '
'; // Placeholder grafico storico PDC (caricato async) html += '
'; } html += '
'; el.innerHTML = html; // Grafico a linee — aspetta il render del DOM prima di inizializzare Chart.js requestAnimationFrame(() => { _renderGraficoGiornaliero(cli.id); _aggiornaLogo(); }); // Aggiorna KPI periodo (default: mese) e grafico barre mensile _aggiornaKpiPeriodo('mese').catch(()=>{}); // Suggerimenti (Base + Premium) // Carica storico PDC in background if(aq && clienteData.aquarea_device_id){ caricaStoricoPDC(clienteData.aquarea_device_id); } calcolaUpsellPortale(clienteData.id, tariffaAcquisto, hasBatt).then(sugg=>{ const box = document.getElementById('mon-suggerimenti-box'); if(!box || !sugg.length) return; const titolo = isPremium ? '⭐ Suggerimenti personalizzati' : '💡 Suggerimenti'; let s = `
${titolo}
`; sugg.forEach(sg=>{ s += `
${sg.icona} ${sg.titolo}
${sg.testo}
`; }); s += '
'; box.innerHTML = s; }).catch(()=>{}); // Analisi autoconsumo — visibile a tutti (Base + Premium) _renderAnalisiAutoconsumo(cli.id, d).catch(()=>{}); // Anomalie AI — solo Premium if(isPremium){ _renderAnomalieAI(cli.id).catch(e => console.error('[anomalie AI]', e)); } } // ============================================================ // FUNZIONI PREMIUM // ============================================================ async function _renderAnalisiAutoconsumo(clienteId, d){ const box = document.getElementById('mon-autoconsumo-box'); if(!box) return; // Mostra subito uno stato di caricamento box.innerHTML = `
⚡ Analisi autoconsumo
Calcolo in corso…
`; const kwhAccumulo = clienteData?.kwh_accumulo; const tariff = clienteData?.tariffa_acquisto || 0.25; const fmtE = n => '€ '+Number(n).toLocaleString('it-IT',{minimumFractionDigits:0,maximumFractionDigits:0}); const fmtK = n => Number(n).toLocaleString('it-IT',{minimumFractionDigits:1,maximumFractionDigits:1}); try{ // Usa RPC che aggrega per giorno lato DB — evita il limite 1000 righe di Supabase const {data:giorni, error:rpcErr} = await supa .rpc('get_autoconsumo_annuale', {p_cliente_id: clienteId}); if(rpcErr) throw new Error('RPC autoconsumo: '+rpcErr.message); // Somma produzione, consumo e export annuali dai valori aggregati per giorno let prodAnno = 0, useAnno = 0, exportAnno = 0, giorniValidi = 0; (giorni||[]).forEach(g=>{ const prod = g.day_power || 0; const exp = g.day_export || 0; const use = g.day_use || 0; if(prod > 0){ prodAnno += prod; useAnno += use; exportAnno += exp; giorniValidi++; } }); // % autoconsumo annuale = (produzione - export) / produzione let autoconsumoPct = null; let periodoLabel = ''; let periodoSub = ''; if(giorniValidi >= 30 && prodAnno > 0){ const autoconsAnno = Math.max(0, prodAnno - exportAnno); autoconsumoPct = Math.min(100, Math.round(autoconsAnno / prodAnno * 100)); const mesi = Math.round(giorniValidi / 30); periodoLabel = mesi >= 11 ? 'Ultimi 12 mesi' : `Ultimi ${mesi} mesi`; periodoSub = `Su ${giorniValidi} giorni di dati · produzione ${fmtK(prodAnno)} kWh`; } else if(giorniValidi >= 7 && prodAnno > 0){ // Fallback: usa i dati disponibili anche se pochi const autoconsAnno = Math.max(0, prodAnno - exportAnno); autoconsumoPct = Math.min(100, Math.round(autoconsAnno / prodAnno * 100)); periodoLabel = `Ultimi ${giorniValidi} giorni`; periodoSub = `Dati insufficienti per stima annuale`; } else { // Fallback finale: usa i dati dell'ultimo snapshot const prodOggi = d.day_power_kwh || 0; const exportOggi = d.raw_kpi?.day_on_grid_energy ?? null; const useOggi = d.raw_kpi?.day_use_energy ?? null; if(prodOggi > 0.1 && exportOggi != null){ autoconsumoPct = Math.min(100, Math.round(Math.max(0, prodOggi - exportOggi) / prodOggi * 100)); periodoLabel = 'Solo oggi'; periodoSub = 'Non ci sono ancora abbastanza dati storici'; } } if(autoconsumoPct === null){ box.innerHTML=''; return; } const color = autoconsumoPct >= 60 ? '#22c55e' : autoconsumoPct >= 35 ? '#f59e0b' : '#ef4444'; const label = autoconsumoPct >= 60 ? 'Ottimo' : autoconsumoPct >= 35 ? 'Migliorabile' : 'Basso'; // Suggerimento batteria se autoconsumo basso e senza accumulo let suggerimento = ''; if(autoconsumoPct < 45 && !kwhAccumulo && giorniValidi >= 7){ const exportMedioGiorno = exportAnno / giorniValidi; // Con batteria si recupera circa il 50% dell'export (stima conservativa) const risparmioAnnuo = Math.round(exportMedioGiorno * 365 * tariff * 0.5); suggerimento = `
🔋 Opportunità batteria — Con un sistema di accumulo potresti portare l'autoconsumo all'80%+, risparmiando fino a ${fmtE(risparmioAnnuo)}/anno aggiuntivi in bolletta.
Richiedi un preventivo →
`; } box.innerHTML = `
⚡ Analisi autoconsumo
${autoconsumoPct}%
${label}
dell'energia prodotta
viene autoconsumata
0%100%
${periodoLabel}${periodoSub ? ' · '+periodoSub : ''}
${suggerimento}
`; }catch(e){ console.warn('[autoconsumo]', e.message); box.innerHTML=''; } } // ── Monitor: cambio periodo (oggi / mese / anno) ──────────────────────────── let _monPeriod = 'mese'; function monSetPeriod(period, btn){ _monPeriod = period; document.querySelectorAll('.mon-ptab').forEach(t => { const isActive = t === btn; t.style.background = isActive ? 'var(--green-bg,#f0f9e8)' : 'var(--surface)'; t.style.color = isActive ? 'var(--accent)' : 'var(--text2)'; t.style.fontWeight = isActive ? '600' : '400'; t.style.border = isActive ? '0.5px solid var(--accent)' : '0.5px solid var(--border2)'; }); _aggiornaKpiPeriodo(period); } async function _aggiornaKpiPeriodo(period){ if(!clienteData?.id) return; const numEl = document.getElementById('mon-risp-num'); const lblEl = document.getElementById('mon-risp-lbl'); const rowsEl = document.getElementById('mon-risp-rows'); if(!numEl) return; const tariffaAcq = Number(clienteData?._ultimaBollettaKwh || clienteData?.tariffa_acquisto) || 0.25; const tariffaInc = Number(clienteData?.tariffa_incentivo) || 0.10; const fmt = n => Number(n).toLocaleString('it-IT',{maximumFractionDigits:1}); const fmtE = n => '€ '+Number(n).toLocaleString('it-IT',{minimumFractionDigits:0,maximumFractionDigits:0}); // Calcola range date const now = new Date(); let dateFrom, labelPeriodo; if(period === 'oggi'){ dateFrom = new Date(now); dateFrom.setHours(0,0,0,0); labelPeriodo = 'risparmiati oggi'; } else if(period === 'mese'){ dateFrom = new Date(now.getFullYear(), now.getMonth(), 1); labelPeriodo = 'risparmiati a ' + now.toLocaleDateString('it-IT',{month:'long'}); } else { dateFrom = new Date(now.getFullYear(), 0, 1); labelPeriodo = 'risparmiati nel ' + now.getFullYear(); } if(lblEl) lblEl.textContent = labelPeriodo; if(numEl) numEl.innerHTML = '…'; try{ const {data} = await supa.rpc('get_autoconsumo_annuale', {p_cliente_id: clienteData.id}); if(!data?.length){ if(numEl) numEl.innerHTML = '—'; return; } const filtered = data.filter(g => new Date(g.giorno) >= dateFrom); const prodTot = filtered.reduce((s,g) => s+(Number(g.day_power)||0), 0); const expTot = filtered.reduce((s,g) => s+(Number(g.day_export)||0), 0); const useTot = filtered.reduce((s,g) => s+(Number(g.day_use)||0), 0); const autocons = Math.max(0, prodTot - expTot); // Prelievo dalla rete = consumo totale - autoconsumo // Se day_use disponibile (misurato), usiamo quello; altrimenti stima da bollette const importCalc = useTot > 0 ? Math.max(0, useTot - autocons) : null; const risparmio = autocons * tariffaAcq + expTot * tariffaInc; const autoPct = prodTot > 0 ? Math.round(autocons/prodTot*100) : null; // Prova a raffinare il prelievo con le bollette se disponibili let importTot = importCalc; try{ const {data:boll} = await supa.from('bollette') .select('kwh_consumati,periodo_da,periodo_a') .gte('periodo_da', dateFrom.toISOString().slice(0,10)); if(boll?.length){ const importBoll = boll.reduce((s,b) => s+Number(b.kwh_consumati||0), 0); if(importBoll > 0) importTot = importBoll; } }catch(_){} if(numEl) numEl.innerHTML = risparmio > 0 ? fmtE(risparmio) : '—'; if(rowsEl){ rowsEl.innerHTML = `
${prodTot>0 ? fmt(prodTot)+' kWh' : '—'}
Produzione
${autoPct!=null ? autoPct+'%' : '—'}
Autoconsumo
${expTot>0 ? fmt(expTot)+' kWh' : '—'}
Immesso in rete
${importTot!=null ? fmt(importTot)+' kWh' : '—'}
Prelevato dalla rete
`; } // Aggiorna grafico a barre mensile _renderBarChartMensile(data); }catch(e){ console.warn('[monPeriod]', e.message); if(numEl) numEl.innerHTML = '—'; } } function _renderBarChartMensile(data){ const wrap = document.getElementById('mon-barchart-wrap'); const labels = document.getElementById('mon-barchart-labels'); if(!wrap || !data?.length) return; // Aggrega per mese (ultimi 7 mesi) const meseMap = {}; data.forEach(g => { const d = new Date(g.giorno); const key = d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'); if(!meseMap[key]) meseMap[key] = {prod:0, cons:0, label:d.toLocaleDateString('it-IT',{month:'short'})}; meseMap[key].prod += Number(g.day_power)||0; // consumo casa = autoconsumo + prelievo dalla rete // Usiamo day_use (consumo totale misurato) se disponibile, altrimenti stimiamo const dayUse = Number(g.day_use)||0; const autocons = Math.max(0, (Number(g.day_power)||0) - (Number(g.day_export)||0)); meseMap[key].cons += dayUse > 0 ? dayUse : autocons; }); const mesi = Object.entries(meseMap).sort((a,b)=>a[0][v.prod, Math.max(0,v.cons)]), 1); const now = new Date(); const curKey = now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0'); wrap.innerHTML = ''; labels.innerHTML = ''; mesi.forEach(([key, v]) => { const isCur = key === curKey; const hProd = Math.max(2, Math.round(v.prod/maxVal*90)); const hCons = Math.max(2, Math.round(Math.max(0,v.cons)/maxVal*90)); const g = document.createElement('div'); g.style.cssText = 'flex:1;display:flex;gap:2px;align-items:flex-end'; g.innerHTML = `
`; wrap.appendChild(g); const lbl = document.createElement('div'); lbl.style.cssText = `flex:1;text-align:center;font-size:10px;color:${isCur?'var(--text)':'var(--text3)'};font-weight:${isCur?'600':'400'}`; lbl.textContent = v.label.replace('.',''); labels.appendChild(lbl); }); } // ── Helper: geocodifica città → {lat, lon} via Open-Meteo ────────────────── let _coordsCache = {}; async function _getCoords(citta){ const key = (citta||'Roma').toLowerCase().trim(); if(_coordsCache[key]) return _coordsCache[key]; try{ const r = await fetch( 'https://geocoding-api.open-meteo.com/v1/search?name='+encodeURIComponent(key)+'&count=1&language=it&format=json' ); const j = await r.json(); if(j.results?.length){ const {latitude:lat, longitude:lon} = j.results[0]; _coordsCache[key] = {lat, lon}; return {lat, lon}; } }catch(_){} // Fallback Roma return {lat:41.9028, lon:12.4964}; } // ── Helper: irraggiamento orario (W/m²) per un range di date ─────────────── async function _getIrraggiamento(lat, lon, dateFrom, dateTo){ // dateFrom/dateTo: 'YYYY-MM-DD' const url = 'https://archive-api.open-meteo.com/v1/archive' +'?latitude='+lat+'&longitude='+lon +'&start_date='+dateFrom+'&end_date='+dateTo +'&hourly=shortwave_radiation&timezone=Europe%2FRome'; const r = await fetch(url); const j = await r.json(); // Restituisce mappa { 'YYYY-MM-DDTHH:00' : W/m² } const map = {}; const times = j.hourly?.time || []; const vals = j.hourly?.shortwave_radiation || []; times.forEach((t,i) => { map[t] = vals[i] ?? 0; }); return map; } // ── Helper: irraggiamento odierno (forecast) ──────────────────────────────── async function _getIrraggiamentoOggi(lat, lon){ const oggi = new Date().toISOString().slice(0,10); const url = 'https://api.open-meteo.com/v1/forecast' +'?latitude='+lat+'&longitude='+lon +'&hourly=shortwave_radiation&timezone=Europe%2FRome' +'&start_date='+oggi+'&end_date='+oggi; const r = await fetch(url); const j = await r.json(); const map = {}; const times = j.hourly?.time || []; const vals = j.hourly?.shortwave_radiation || []; times.forEach((t,i) => { map[t] = vals[i] ?? 0; }); return map; } async function _renderAnomalieAI(clienteId){ const box = document.getElementById('mon-anomalie-box'); if(!box) return; box.innerHTML = '
Analisi AI in corso…
'; try{ // ── 1. Coordinating meteo dalla città del cliente ───────────────────── const citta = clienteData?.citta || 'Roma'; const {lat, lon} = await _getCoords(citta); // ── 2. Snapshot FV ultimi 30 giorni ────────────────────────────────── const da30 = new Date(Date.now() - 30*24*60*60*1000).toISOString(); const da30date = da30.slice(0,10); const {data:snaps} = await supa .from('monitoring_snapshots') .select('captured_at, pv_power_kw') .eq('cliente_id', clienteId) .gte('captured_at', da30) .order('captured_at', {ascending:true}) .limit(5000); if(!snaps?.length || snaps.length < 10){ box.innerHTML=''; return; } const oggi = new Date().toISOString().slice(0,10); const ieri = new Date(Date.now()-24*60*60*1000).toISOString().slice(0,10); const annoPrecedente = (parseInt(oggi.slice(0,4))-1)+oggi.slice(4); const mesePrecFrom = (parseInt(oggi.slice(0,4))-1)+'-'+oggi.slice(5,7)+'-01'; const mesePrecTo = (parseInt(oggi.slice(0,4))-1)+'-'+oggi.slice(5,7)+'-28'; // fine mese prec. approssimato // ── 3. Dati meteo: storico 30gg + oggi ─────────────────────────────── const [meteoStorico, meteoOggi] = await Promise.all([ _getIrraggiamento(lat, lon, da30date, ieri), _getIrraggiamentoOggi(lat, lon), ]); const meteoTutto = {...meteoStorico, ...meteoOggi}; // Helper: chiave meteo per uno snapshot → 'YYYY-MM-DDTHH:00' const meteoKey = ts => { const d = new Date(ts); const pad = n => String(n).padStart(2,'0'); return d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate()) +'T'+pad(d.getHours())+':00'; }; // ── 4. Efficienza normalizzata = pv_kw / irraggiamento (W/m²) ──────── // Solo ore solari con irraggiamento > 50 W/m² (elimina alba/tramonto) const snapConMeteo = snaps.map(s => { const h = new Date(s.captured_at).getHours(); if(h < 8 || h > 19) return null; const rad = meteoTutto[meteoKey(s.captured_at)] ?? 0; if(rad < 50) return null; const pv = Number(s.pv_power_kw) || 0; return { ts: s.captured_at, h, pv, rad, eff: pv / rad }; }).filter(Boolean); if(snapConMeteo.length < 10){ box.innerHTML=''; return; } // ── 5. Baseline: efficienza media per ora (escludo ultime 24h) ──────── const cutoff24h = new Date(Date.now()-24*60*60*1000).toISOString(); const storici = snapConMeteo.filter(s => s.ts < cutoff24h); const effPerOra = {}; storici.forEach(s => { if(!effPerOra[s.h]) effPerOra[s.h] = []; effPerOra[s.h].push(s.eff); }); const mediaEffOra = {}; Object.entries(effPerOra).forEach(([h,vals]) => { if(vals.length >= 3) mediaEffOra[h] = vals.reduce((a,b)=>a+b,0) / vals.length; }); // ── 6. Analisi ultime 24h — anomalie per efficienza normalizzata ────── const ultimiSnap = snapConMeteo.filter(s => s.ts >= cutoff24h); const anomalie = []; ultimiSnap.forEach(s => { const mediaEff = mediaEffOra[s.h]; if(!mediaEff || mediaEff < 0.001) return; if(s.eff < mediaEff * 0.6){ anomalie.push({ ora: s.h, pv: s.pv, rad: s.rad, calo: Math.round((1 - s.eff/mediaEff)*100), }); } }); // ── 7. Confronto con stesso mese anno precedente ────────────────────── let confrontoAnnoHtml = ''; try{ const {data:snapsAnnoPrec} = await supa .from('monitoring_snapshots') .select('captured_at, pv_power_kw') .eq('cliente_id', clienteId) .gte('captured_at', mesePrecFrom+'T00:00:00Z') .lte('captured_at', mesePrecTo+'T23:59:59Z') .limit(5000); if(snapsAnnoPrec?.length >= 10){ const meteoAnnoPrec = await _getIrraggiamento(lat, lon, mesePrecFrom, mesePrecTo); const effPrecAnno = snapsAnnoPrec.map(s=>{ const h = new Date(s.captured_at).getHours(); if(h<8||h>19) return null; const rad = meteoAnnoPrec[meteoKey(s.captured_at)]??0; if(rad<50) return null; return (Number(s.pv_power_kw)||0)/rad; }).filter(v=>v!=null&&v>0); const effMeseAttuale = snapConMeteo.map(s=>s.eff); const mediaPrec = effPrecAnno.reduce((a,b)=>a+b,0)/effPrecAnno.length; const mediaAtt = effMeseAttuale.reduce((a,b)=>a+b,0)/effMeseAttuale.length; if(mediaPrec > 0.001){ const deltaPct = Math.round((mediaAtt/mediaPrec-1)*100); const segno = deltaPct >= 0 ? '+' : ''; const col = deltaPct >= -5 ? '#3B6D11' : deltaPct >= -15 ? '#BA7517' : '#A32D2D'; const lbl = deltaPct >= -5 ? 'In linea con l\'anno scorso' : deltaPct >= -15 ? 'Lieve calo rispetto all\'anno scorso' : 'Calo significativo rispetto all\'anno scorso'; confrontoAnnoHtml = `
Confronto stesso mese anno precedente
Normalizzato per irraggiamento solare
${segno}${deltaPct}%
${lbl}
`; } } }catch(_){} // ── 8. Confronto con ieri (corretto per meteo) ──────────────────────── let confrontoIeriHtml = ''; try{ const snapsIeri = snaps.filter(s=>s.captured_at.slice(0,10)===ieri); const snapsOggi = snaps.filter(s=>s.captured_at.slice(0,10)===oggi); if(snapsIeri.length >= 3 && snapsOggi.length >= 3){ const effMedia = arr => { const vals = arr.map(s=>{ const h = new Date(s.captured_at).getHours(); if(h<8||h>19) return null; const rad = meteoTutto[meteoKey(s.captured_at)]??0; if(rad<50) return null; return (Number(s.pv_power_kw)||0)/rad; }).filter(v=>v!=null&&v>0); return vals.length ? vals.reduce((a,b)=>a+b,0)/vals.length : null; }; const eIeri = effMedia(snapsIeri); const eOggi = effMedia(snapsOggi); if(eIeri && eOggi && eIeri > 0.001){ const deltaPct = Math.round((eOggi/eIeri-1)*100); const segno = deltaPct >= 0 ? '+' : ''; const col = deltaPct >= -10 ? '#3B6D11' : deltaPct >= -25 ? '#BA7517' : '#A32D2D'; const lbl = deltaPct >= -10 ? 'In linea con ieri' : deltaPct >= -25 ? 'Produzione leggermente inferiore a ieri' : 'Produzione significativamente inferiore a ieri'; confrontoIeriHtml = `
Confronto con ieri
A parità di condizioni meteo
${segno}${deltaPct}%
${lbl}
`; } } }catch(_){} // ── 9. Render risultato ─────────────────────────────────────────────── const hasAnomalies = anomalie.length > 0; const maxCalo = hasAnomalies ? Math.max(...anomalie.map(a=>a.calo)) : 0; const oreAnomale = hasAnomalies ? [...new Set(anomalie.map(a=>a.ora))].sort((a,b)=>a-b).map(h=>h+':00').join(', ') : ''; const borderCol = hasAnomalies ? '#f59e0b' : '#22c55e'; const titolo = hasAnomalies ? '⚠️ Anomalia rilevata' : 'Impianto nella norma'; const titoloCol = hasAnomalies ? '#b45309' : '#22c55e'; const sottotit = hasAnomalies ? 'La produzione nelle ore '+oreAnomale+' è risultata inferiore del '+maxCalo+'% rispetto all\'efficienza media storica, corretta per le condizioni meteo.' : 'Nessuna anomalia nelle ultime 24 ore — efficienza in linea con la media storica.'; let html = `
${titolo}
${sottotit}
`; if(hasAnomalies){ html += `
Cause più comuni: pannelli sporchi, ombreggiamento, guasto all\'inverter o a un ottimizzatore.
Segnala a Becoming → `; } html += confrontoAnnoHtml + confrontoIeriHtml; html += `
☀️ Analisi basata sull\'efficienza normalizzata per irraggiamento solare (${citta}). La precisione dipende dalla qualità dei dati meteo della zona — piccole differenze locali (ombreggiamento, microclima) possono influenzare i valori.
`; html += '
'; box.innerHTML = html; }catch(e){ console.warn('[anomalie AI]', e.message); box.innerHTML = ''; } } // ============================================================ // FINE FUNZIONI PREMIUM // ============================================================ // ── Salute impianto ────────────────────────────────────────────────────── async function generaFirmaHTML(prev,nome,email,ts){ const dataFirma = new Date(ts).toLocaleString('it-IT'); const numOff = (prev?.numero||'—')+' '+(prev?.revisione||'V1'); const totale = prev?.totale ? '€ '+Number(prev.totale).toLocaleString('it-IT',{minimumFractionDigits:2}) : '—'; const codice = 'BEC-'+Date.now().toString(36).toUpperCase(); const escH = s => String(s||'').replace(/&/g,'&').replace(//g,'>'); const html = ` Conferma offerta ${escH(numOff)} - Becoming

BECOMING — Conferma di accettazione offerta

Documento generato il ${escH(dataFirma)}
N° Offerta${escH(numOff)}
Titolo${escH(prev?.titolo||'Impianto fotovoltaico')}
Data offerta${escH(prev?.data||'—')}
Totale IVA incl.${totale}

✔ Dichiarazione di accettazione

Nome firmatario${escH(nome)}
Email verificata${escH(email)}
Data e ora firma${escH(dataFirma)}

Il/La sottoscritto/a dichiara di aver letto e accettato integralmente la presente proposta commerciale e autorizza il trattamento dei dati personali ai sensi del Regolamento UE 2016/679 (GDPR).

Codice riferimento: ${escH(codice)}
`; // Carica come octet-stream: Supabase preserva il nome file con estensione .html return new Blob([html], {type:'application/octet-stream'}); } // ---- GRAFICO GIORNALIERO PRODUZIONE/CONSUMO ---- let _chartGiornaliero = null; let _chartGiornalieroTimer = null; // ── Logo Becoming — colorazione dinamica ───────────────────────────────────── function _aggiornaLogo(){ const data = window._becLogoData; if(!data) return; const segmentOrder = [1, 0, 2, 3, 4, 5, 6, 7, 8, 11, 10, 9]; const segmentWeights = [5, 5, 5, 5, 10, 10, 10, 10, 10, 10, 10, 10]; const round10 = v => Math.round(v / 10) * 10; let rGrid = round10(data.gridP), rBat = round10(data.batP), rPv = round10(data.pvP); const sum = rGrid + rBat + rPv; if(sum !== 100){ const entries = [ {key:'grid', orig:data.gridP, r:rGrid}, {key:'bat', orig:data.batP, r:rBat}, {key:'pv', orig:data.pvP, r:rPv} ].sort((a,b) => b.orig - a.orig); entries[0].r += (100 - sum); rGrid = entries.find(e=>e.key==='grid').r; rBat = entries.find(e=>e.key==='bat').r; rPv = entries.find(e=>e.key==='pv').r; } const segs = Array.from({length:12}, (_,i) => document.getElementById('bseg-'+i)); let gRem = rGrid, bRem = rBat, pRem = rPv; segs.forEach(s => s && s.classList.remove('pv','battery','grid')); segmentOrder.forEach((segIdx, ordIdx) => { const seg = segs[segIdx]; const w = segmentWeights[ordIdx]; if(!seg) return; if(gRem > 0) { seg.classList.add('grid'); gRem -= w; } else if(bRem > 0) { seg.classList.add('battery'); bRem -= w; } else { seg.classList.add('pv'); pRem -= w; } }); // Glow const glow = document.getElementById('becoming-logo-glow'); if(glow) glow.style.background = data.glow; // Anelli — colore in base alla fonte dominante const ringColor = rPv >= rBat && rPv >= rGrid ? 'rgba(150,189,13,0.28)' : rBat >= rGrid ? 'rgba(110,135,151,0.25)' : 'rgba(182,109,75,0.25)'; ['becoming-ring-1','becoming-ring-2','becoming-ring-3'].forEach(id => { const el = document.getElementById(id); if(el) el.style.borderColor = ringColor; }); // Badge stato const badge = document.getElementById('becoming-stato-badge'); const dot = document.getElementById('becoming-stato-dot'); const txt = document.getElementById('becoming-stato-txt'); if(badge && dot && txt){ if(rPv >= rBat && rPv >= rGrid){ badge.style.background='#EAF3DE'; badge.style.color='#27500A'; dot.style.background='#639922'; txt.textContent='Fotovoltaico'; } else if(rBat >= rGrid){ badge.style.background='#E6F1FB'; badge.style.color='#0C447C'; dot.style.background='#6E8797'; txt.textContent='Batteria'; } else { badge.style.background='#FAEEDA'; badge.style.color='#633806'; dot.style.background='#B66D4B'; txt.textContent='Rete'; } } // Barre const setBar = (id, pct, pctId) => { const b = document.getElementById(id); const p = document.getElementById(pctId); if(b) b.style.width = pct+'%'; if(p) p.textContent = pct+'%'; }; setBar('bbar-pv', rPv, 'bpct-pv'); setBar('bbar-bat', rBat, 'bpct-bat'); setBar('bbar-grid', rGrid, 'bpct-grid'); } async function _renderGraficoGiornaliero(clienteId){ const chartDiv = document.getElementById('chart-giornaliero'); const statusEl = document.getElementById('chart-giornaliero-status'); if(!chartDiv || typeof Chart === 'undefined') return; // Distrugge istanza precedente if(_chartGiornaliero){ _chartGiornaliero.destroy(); _chartGiornaliero=null; } // Crea (o riusa) il canvas dentro il div let chartEl = chartDiv.querySelector('canvas'); if(!chartEl){ chartEl = document.createElement('canvas'); chartEl.style.cssText = 'width:100%;height:100%;display:block'; chartDiv.appendChild(chartEl); } // Ultime 24 ore const ora24fa = new Date(Date.now() - 24*60*60*1000).toISOString(); try{ const{data:snaps} = await supa .from('monitoring_snapshots') .select('captured_at, pv_power_kw, load_power_kw, grid_power_kw, battery_power_kw') .eq('cliente_id', clienteId) .gte('captured_at', ora24fa) .order('captured_at', {ascending:true}); if(!snaps?.length){ if(statusEl) statusEl.textContent = 'Nessun dato disponibile nelle ultime 24 ore.'; return; } // Prepara i dati — se load_power_kw è null ma abbiamo grid e pv, calcoliamo lato client // formula: load = pv + grid - battery (grid>0=prelievo, battery>0=carica) const labels = snaps.map(s => { const d = new Date(s.captured_at); return d.toLocaleTimeString('it-IT',{hour:'2-digit',minute:'2-digit'}); }); const produzione = snaps.map(s => s.pv_power_kw != null ? +Number(s.pv_power_kw).toFixed(3) : null); const consumo = snaps.map(s => { if(s.load_power_kw != null) return +Number(s.load_power_kw).toFixed(3); // Fallback: calcola da pv - grid - battery se disponibili // Convenzione meter Huawei: grid > 0 = immissione, grid < 0 = prelievo const pv = s.pv_power_kw != null ? Number(s.pv_power_kw) : null; const gr = s.grid_power_kw != null ? Number(s.grid_power_kw) : null; const bat = s.battery_power_kw != null ? Number(s.battery_power_kw) : null; if(pv != null && gr != null){ const load = pv - gr - (bat ?? 0); return +Math.max(0, load).toFixed(3); } return null; }); const hasConsumo = consumo.some(v => v != null && v > 0); if(statusEl){ const ultimo = new Date(snaps[snaps.length-1].captured_at); statusEl.textContent = `Aggiornato: ${ultimo.toLocaleTimeString('it-IT',{hour:'2-digit',minute:'2-digit'})} · ${snaps.length} letture`; } const datasets = [{ label: 'Produzione FV', data: produzione, borderColor: '#8ab800', backgroundColor: 'rgba(138,184,0,.08)', borderWidth: 2, pointRadius: snaps.length > 50 ? 0 : 2, pointHoverRadius: 4, tension: 0.3, fill: true, spanGaps: true, }]; if(hasConsumo){ datasets.push({ label: 'Consumo casa', data: consumo, borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,.06)', borderWidth: 2, pointRadius: snaps.length > 50 ? 0 : 2, pointHoverRadius: 4, tension: 0.3, fill: true, spanGaps: true, }); } _chartGiornaliero = new Chart(chartEl, { type: 'line', data: { labels, datasets }, options:{ responsive: true, maintainAspectRatio: false, animation: false, interaction: { mode:'index', intersect:false }, plugins:{ legend:{ display:false }, tooltip:{ callbacks:{ label: ctx => `${ctx.dataset.label}: ${ctx.parsed.y != null ? ctx.parsed.y.toLocaleString('it-IT',{maximumFractionDigits:2})+' kW' : '—'}` } } }, scales:{ x:{ grid:{ display:false }, ticks:{ font:{size:10}, color:'#999', maxTicksLimit: 8, maxRotation: 0, } }, y:{ beginAtZero: true, grid:{ color:'rgba(0,0,0,.05)' }, ticks:{ font:{size:10}, color:'#999', callback: v => v.toLocaleString('it-IT',{maximumFractionDigits:1})+' kW' } } } } }); // Avvia polling automatico ogni 5 min (aggiorna solo il grafico, non tutta la pagina) if(!_chartGiornalieroTimer){ _chartGiornalieroTimer = setInterval(()=>{ const el = document.getElementById('chart-giornaliero'); if(!el){ clearInterval(_chartGiornalieroTimer); _chartGiornalieroTimer=null; return; } _renderGraficoGiornaliero(clienteId); }, 5*60*1000); } }catch(e){ console.warn('[grafico giornaliero]', e.message); if(statusEl) statusEl.textContent = 'Errore caricamento dati grafico.'; } } async function caricaStoricoPDC(deviceId){ const box = document.getElementById('mon-pdc-storico-box'); if(!box) return; try{ const since = new Date(Date.now()-30*24*60*60*1000).toISOString(); const {data:rows, error} = await supa .from('aquarea_history') .select('fetched_at,power_consumption,power_consumption_heating,power_consumption_dhw,heat_generated,heat_generated_heating,heat_generated_dhw') .eq('device_id', deviceId) .gte('fetched_at', since) .order('fetched_at', {ascending: true}); if(error || !rows || rows.length < 4) return; // Aggrega per giorno: kW × 0.25h = kWh (ogni record = 15 minuti) const dayMap = {}; rows.forEach(r => { const day = r.fetched_at.slice(0,10); if(!dayMap[day]) dayMap[day] = {elec:0, heat:0}; const pcH = Number(r.power_consumption_heating||0); const pcD = Number(r.power_consumption_dhw||0); const pc = Number(r.power_consumption||0); const elec = (pcH+pcD)>0 ? pcH+pcD : pc; const hgH = Number(r.heat_generated_heating||0); const hgD = Number(r.heat_generated_dhw||0); const hg = Number(r.heat_generated||0); const heat = (hgH+hgD)>0 ? hgH+hgD : hg; dayMap[day].elec += elec * 0.25; dayMap[day].heat += heat * 0.25; }); const days = Object.keys(dayMap).sort(); if(days.length < 2) return; const totElec = days.reduce((s,d)=>s+dayMap[d].elec, 0); const totHeat = days.reduce((s,d)=>s+dayMap[d].heat, 0); const copPeriodo = (totElec>0.1&&totHeat>0.1) ? Math.round(totHeat/totElec*10)/10 : null; const fmt1 = n => Number(n).toLocaleString('it-IT',{maximumFractionDigits:1}); // ── Sinergia FV/PDC precisa (solo Premium) — join temporale slot per slot ── // Per ogni slot PDC (15 min) trova lo snapshot FV più vicino (entro 10 min) // e calcola: sinergia = min(autoconsumo_FV_istantaneo, consumo_PDC_istantaneo) let sinergiaKwh = 0; let totElecPreciso = 0; const isPremiumSin = clienteData?.piano_monitoraggio === 'premium'; if(isPremiumSin && clienteData?.id){ try{ // Query parallele: snapshot FV e history PDC ultimi 30 giorni // Entrambi i sistemi ora pollati ogni 10 min — join temporale preciso const [fvRes] = await Promise.all([ supa.from('monitoring_snapshots') .select('captured_at,pv_power_kw,grid_power_kw,load_power_kw') .eq('cliente_id', clienteData.id) .gte('captured_at', since) .order('captured_at', {ascending: true}) .limit(5000), ]); const fvSnaps = fvRes.data || []; if(fvSnaps.length > 0){ // Finestra di match: 7 minuti (metà del polling interval da 10 min) const MAX_DIFF_MS = 7 * 60 * 1000; let fvIdx = 0; rows.forEach(r => { const tPDC = new Date(r.fetched_at).getTime(); const pcH = Number(r.power_consumption_heating||0); const pcD = Number(r.power_consumption_dhw||0); const pc = Number(r.power_consumption||0); const elecPDC = (pcH+pcD) > 0 ? pcH+pcD : pc; // kW istantanei if(elecPDC <= 0) return; // Avanza l'indice FV verso il timestamp PDC while(fvIdx < fvSnaps.length - 1 && Math.abs(new Date(fvSnaps[fvIdx+1].captured_at).getTime() - tPDC) < Math.abs(new Date(fvSnaps[fvIdx].captured_at).getTime() - tPDC)){ fvIdx++; } const fv = fvSnaps[fvIdx]; const tFV = new Date(fv.captured_at).getTime(); if(Math.abs(tFV - tPDC) > MAX_DIFF_MS) return; // nessun FV vicino // Autoconsumo FV istantaneo: pv - immissione_rete // grid > 0 = immissione, grid < 0 = prelievo (conv. Huawei) const pvKwSlot = Number(fv.pv_power_kw||0); const gridKwSlot = Number(fv.grid_power_kw||0); const autoconsFV = Math.max(0, pvKwSlot - Math.max(0, gridKwSlot)); // Sinergia slot: min tra autoconsumo FV e consumo PDC (kW × 15min = kWh) const sinSlot = Math.min(autoconsFV, elecPDC); sinergiaKwh += sinSlot * 0.25; // kW × 0.25h = kWh totElecPreciso += elecPDC * 0.25; }); } }catch(e){ console.warn('[sinergia]', e.message); } } // Usa totElecPreciso se disponibile (join slot-per-slot), altrimenti totElec aggregato const totElecPerPct = totElecPreciso > 0.1 ? totElecPreciso : totElec; const sinergiaPct = (isPremiumSin && sinergiaKwh > 0 && totElecPerPct > 0.1) ? Math.min(100, Math.round(sinergiaKwh / totElecPerPct * 100)) : null; // Grafico SVG a barre const W=280, H=80, padL=8, padR=8, padT=6, padB=18; const chartW=W-padL-padR, chartH=H-padT-padB; const maxVal = Math.max(...days.map(d=>Math.max(dayMap[d].elec, dayMap[d].heat)), 0.1); const gap = Math.floor(chartW/days.length); const barW = Math.max(1, gap-1); let bars = ''; days.forEach((day,i) => { const x = padL + i*gap; const hHeat = Math.round(dayMap[day].heat/maxVal*chartH); const hElec = Math.round(dayMap[day].elec/maxVal*chartH); bars += ''; bars += ''; }); const lbl1 = days[0].slice(5).replace('-','/'); const lbl2 = days[days.length-1].slice(5).replace('-','/'); const svg = '' +bars +''+lbl1+'' +''+lbl2+'' +''; let s = '
'; s += '
Energia pompa di calore — ultimi 30 giorni
'; // Griglia KPI 4 colonne: elec, heat, COP, sinergia FV (🔒 base, valore premium) const sinCella = isPremiumSin && sinergiaPct!=null ? '
'+sinergiaPct+'%
Alimentata da FV
' : isPremiumSin ? '
Alimentata da FV
' : '
🔒
Sinergia FV/PDC
Piano Premium
'; s += '
'; s += '
'+fmt1(totElec)+'
kWh consumati
'; s += '
'+fmt1(totHeat)+'
kWh termici
'; s += '
'+(copPeriodo!=null?copPeriodo.toLocaleString('it-IT',{maximumFractionDigits:1}):'—')+'
COP medio
'; s += '
'+sinCella+'
'; s += '
'; if(isPremiumSin && sinergiaPct!=null){ const sinColor = sinergiaPct>=70?'#3B6D11':sinergiaPct>=40?'#639922':'var(--text2)'; const sinLabel = sinergiaPct>=70?'Ottima sinergia':sinergiaPct>=40?'Buona sinergia':'Sinergia parziale'; s += '
'; s += '
Energia FV che alimenta la PDC'+sinLabel+'
'; s += '
'; s += '
'; } s += '
'; s += 'Energia termica'; s += 'Consumo elettrico'; s += '
'; s += svg; s += '
Precisione ±10 min — dati aggiornati ogni 10 minuti
'; s += '
'; box.innerHTML = s; }catch(e){ console.warn('caricaStoricoPDC:', e); } } async function calcolaUpsellPortale(clienteId, tariffaAcquisto, hasBatt){ // Attiva solo dopo 6 mesi dall'installazione (usa total_power come proxy) // e solo se ci sono abbastanza dati storici try{ const thirtyDaysAgo = new Date(Date.now()-30*24*60*60*1000).toISOString(); const{data:snaps} = await supa .from('monitoring_snapshots') .select('raw_kpi') .eq('cliente_id', clienteId) .gte('captured_at', thirtyDaysAgo) .not('raw_kpi', 'is', null); if(!snaps || snaps.length < 20) return []; // troppo pochi dati // Controlla se impianto ha almeno 6 mesi di produzione const totPowerAll = snaps[0]?.raw_kpi?.total_power; if(!totPowerAll || totPowerAll < 500) return []; // meno di 500 kWh totali → troppo nuovo // Aggrega dati giornalieri const dayMap = {}; snaps.forEach(s=>{ const kpi = s.raw_kpi || {}; const prod = Number(kpi.day_power) || 0; const onGrid = Number(kpi.day_on_grid_energy) || 0; const uso = Number(kpi.day_use_energy) || 0; const key = String(prod.toFixed(1)); // raggruppa per produzione simile come proxy giorno if(!dayMap[key]) dayMap[key] = {onGrid:0, uso:0, prod:0, n:0}; dayMap[key].onGrid = Math.max(dayMap[key].onGrid, onGrid); dayMap[key].uso = Math.max(dayMap[key].uso, uso); dayMap[key].prod = Math.max(dayMap[key].prod, prod); dayMap[key].n++; }); const giorni = Object.values(dayMap).filter(g=>g.prod>0); if(!giorni.length) return []; const totImmissione = giorni.reduce((s,g)=>s+g.onGrid, 0); const totUso = giorni.reduce((s,g)=>s+g.uso, 0); const totProd = giorni.reduce((s,g)=>s+g.prod, 0); // Stima prelievo = consumo - autoconsumo = uso - (prod - onGrid) const totPrelievo = giorni.reduce((s,g)=>s+Math.max(0,g.uso-(g.prod-g.onGrid)),0); const sugg = []; // Suggerisci batteria/potenziamento: immissione > 50% del prelievo if(totPrelievo > 0 && totImmissione / Math.max(totPrelievo,1) > 0.5){ const kwh = Math.round(totImmissione / giorni.length * 30); const risparmio = Math.round(kwh * 12 * Number(tariffaAcquisto)); if(!hasBatt){ sugg.push({ icona:'🔋', titolo:'Aggiungi un sistema di accumulo', testo:'L\'impianto esporta molta energia in rete. Con una batteria potresti usare questa energia la sera, risparmiando fino a €'+risparmio+'/anno in bolletta.' }); } else { sugg.push({ icona:'⬆️', titolo:'Aumenta la capacità della batteria', testo:'La batteria non riesce a immagazzinare tutta l\'energia prodotta. Aumentando la capacità potresti risparmiare fino a €'+risparmio+'/anno.' }); } } // Suggerisci più pannelli: prelievo rete > 30% del consumo if(totUso > 0 && totPrelievo/totUso > 0.3 && totPrelievo > 30){ const kwh = Math.round(totPrelievo / giorni.length * 30); const risparmio = Math.round(kwh * 12 * Number(tariffaAcquisto)); sugg.push({ icona:'☀️', titolo:'Aumenta la potenza del tuo impianto', testo:'Parte dei tuoi consumi è ancora coperta dalla rete. Con più pannelli potresti produrre altri ~'+kwh+' kWh/mese, risparmiando fino a €'+risparmio+'/anno.' }); } return sugg; }catch(e){ console.warn('calcolaUpsellPortale:', e); return []; } } // ---- UTILS ---- function esc(s){return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');} function escH(s){return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');} function safeUrl(url){try{const u=new URL(url);return['http:','https:'].includes(u.protocol)?url:'#';}catch{return '#';}}