[FEAT] Fase 3+4 frontend: supplier-portal.html (OTP/magic-link + compilazione)
Portale fornitore pubblico self-contained (CSS+JS inline, noindex): - Auth: richiesta OTP (risposta opaca), verifica codice con errori specifici + countdown reinvio 60s, magic-link da ?magic= (consumo su click), sessione JWT supplier in sessionStorage (4h). - Dashboard: 1 questionario aperto -> diretto, multi -> lista con badge scadenza. - Compilazione (Fase 4): tutti i tipi domanda (yes_no_partial, single/multi_choice, scale_1_5 con etichette estremi, number, text, file) + "Non applicabile"; progress bar, badge scadenza sticky, help_text + nis2_ref visibili. - Autosave PATCH debounce 800ms + "bozza salvata"; salva bozza vs invia definitivo con conferma + validazione obbligatorie client. - Ricevuta: conferma + score solo se show_score. Footer GDPR Art.28. - a11y: input nativi, OTP autocomplete=one-time-code inputmode=numeric, target >=44px. Inline JS validato (node --check). File statico -> live via nginx. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
de09af6d7e
commit
78dcb412ad
441
public/supplier-portal.html
Normal file
441
public/supplier-portal.html
Normal file
@ -0,0 +1,441 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex,nofollow">
|
||||||
|
<title>Portale Fornitori - NIS2 Agile</title>
|
||||||
|
<style>
|
||||||
|
:root{--primary:#06B6D4;--primary-dark:#0891b2;--gray-50:#f8fafc;--gray-100:#f1f5f9;--gray-200:#e2e8f0;--gray-400:#94a3b8;--gray-500:#64748b;--gray-700:#334155;--gray-900:#0f172a;--danger:#dc2626;--warning:#f59e0b;--success:#16a34a;--radius:10px}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--gray-100);color:var(--gray-900);line-height:1.6;min-height:100vh}
|
||||||
|
.wrap{max-width:560px;margin:0 auto;padding:24px 16px}
|
||||||
|
.card{background:#fff;border-radius:var(--radius);box-shadow:0 1px 3px rgba(0,0,0,.08);padding:24px;margin-bottom:16px}
|
||||||
|
.brand{text-align:center;margin-bottom:20px}
|
||||||
|
.brand h1{font-size:1.15rem;color:var(--primary-dark)}
|
||||||
|
.brand p{font-size:.85rem;color:var(--gray-500)}
|
||||||
|
h2{font-size:1.1rem;margin-bottom:12px}
|
||||||
|
p.sub{color:var(--gray-500);font-size:.9rem;margin-bottom:16px}
|
||||||
|
label{display:block;font-size:.85rem;font-weight:600;margin-bottom:6px;color:var(--gray-700)}
|
||||||
|
input[type=email],input[type=text],input[type=number],textarea,select{width:100%;padding:12px;border:1px solid var(--gray-200);border-radius:8px;font-size:1rem;font-family:inherit}
|
||||||
|
textarea{min-height:88px;resize:vertical}
|
||||||
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;width:100%;min-height:48px;padding:12px 20px;border:none;border-radius:8px;font-size:1rem;font-weight:600;cursor:pointer;background:var(--primary);color:#fff;transition:background .15s}
|
||||||
|
.btn:hover{background:var(--primary-dark)}
|
||||||
|
.btn:disabled{opacity:.6;cursor:not-allowed}
|
||||||
|
.btn-ghost{background:transparent;color:var(--primary-dark);min-height:auto;width:auto;padding:6px 0;font-size:.85rem;text-decoration:underline}
|
||||||
|
.btn-secondary{background:var(--gray-100);color:var(--gray-700)}
|
||||||
|
.btn-secondary:hover{background:var(--gray-200)}
|
||||||
|
.msg{padding:12px 14px;border-radius:8px;font-size:.88rem;margin:12px 0}
|
||||||
|
.msg.err{background:#fef2f2;color:var(--danger);border-left:3px solid var(--danger)}
|
||||||
|
.msg.ok{background:#f0fdf4;color:var(--success);border-left:3px solid var(--success)}
|
||||||
|
.msg.info{background:#eff6ff;color:#1e40af;border-left:3px solid #1e40af}
|
||||||
|
.otp-input{letter-spacing:.4em;text-align:center;font-size:1.4rem;font-family:monospace}
|
||||||
|
.muted{color:var(--gray-400);font-size:.78rem;margin-top:8px}
|
||||||
|
.hidden{display:none!important}
|
||||||
|
.spinner{width:32px;height:32px;border:3px solid var(--gray-200);border-top-color:var(--primary);border-radius:50%;animation:spin .8s linear infinite;margin:30px auto}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
.q{padding:16px 0;border-bottom:1px solid var(--gray-100)}
|
||||||
|
.q:last-child{border-bottom:none}
|
||||||
|
.q-text{font-size:.95rem;font-weight:600;margin-bottom:4px}
|
||||||
|
.q-help{font-size:.8rem;color:var(--gray-500);margin-bottom:10px}
|
||||||
|
.q-ref{display:inline-block;background:#eff6ff;color:var(--primary-dark);font-size:.7rem;font-weight:700;padding:2px 7px;border-radius:20px;margin-left:6px}
|
||||||
|
.opts{display:flex;flex-wrap:wrap;gap:8px}
|
||||||
|
.opt{flex:0 0 auto}
|
||||||
|
.opt input{position:absolute;opacity:0}
|
||||||
|
.opt label{display:inline-flex;align-items:center;min-height:44px;padding:8px 16px;border:1.5px solid var(--gray-200);border-radius:24px;cursor:pointer;font-weight:600;margin:0;font-size:.9rem}
|
||||||
|
.opt input:checked+label{background:var(--primary);color:#fff;border-color:var(--primary)}
|
||||||
|
.scale-ends{display:flex;justify-content:space-between;font-size:.72rem;color:var(--gray-400);margin-top:4px}
|
||||||
|
.badge{display:inline-block;padding:3px 10px;border-radius:20px;font-size:.72rem;font-weight:700}
|
||||||
|
.badge-warn{background:#fffbeb;color:#a16207}.badge-danger{background:#fef2f2;color:var(--danger)}.badge-neutral{background:var(--gray-100);color:var(--gray-500)}.badge-ok{background:#f0fdf4;color:var(--success)}
|
||||||
|
.due-bar{position:sticky;top:0;background:#fff;padding:10px 0;z-index:5;border-bottom:1px solid var(--gray-100);margin-bottom:8px}
|
||||||
|
.progress{height:6px;background:var(--gray-100);border-radius:3px;overflow:hidden;margin:8px 0}
|
||||||
|
.progress-fill{height:100%;background:var(--primary);transition:width .3s}
|
||||||
|
.camp{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:14px 0;border-bottom:1px solid var(--gray-100);flex-wrap:wrap}
|
||||||
|
.camp:last-child{border-bottom:none}
|
||||||
|
.save-state{font-size:.78rem;color:var(--gray-400);text-align:right;min-height:18px}
|
||||||
|
.foot{text-align:center;font-size:.72rem;color:var(--gray-400);margin-top:20px;line-height:1.5}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="brand">
|
||||||
|
<h1 id="brand-title">Portale Fornitori</h1>
|
||||||
|
<p id="brand-sub">Compilazione questionari di sicurezza NIS2</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LOADING -->
|
||||||
|
<div id="view-loading" class="card"><div class="spinner"></div></div>
|
||||||
|
|
||||||
|
<!-- RICHIESTA ACCESSO -->
|
||||||
|
<div id="view-request" class="card hidden">
|
||||||
|
<h2>Accedi al portale</h2>
|
||||||
|
<p class="sub">Inserisci l'email a cui hai ricevuto l'invito. Ti invieremo un link di accesso e un codice.</p>
|
||||||
|
<div id="req-msg"></div>
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" inputmode="email" autocomplete="email" placeholder="nome@azienda.it" autofocus>
|
||||||
|
<div style="height:12px"></div>
|
||||||
|
<button class="btn" id="btn-request">Invia link di accesso</button>
|
||||||
|
<p class="muted">Trattiamo la tua email solo per gestire il questionario. <a href="#" id="privacy-link">Informativa privacy</a>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EMAIL INVIATA / VERIFICA OTP -->
|
||||||
|
<div id="view-verify" class="card hidden">
|
||||||
|
<h2>Controlla la tua email</h2>
|
||||||
|
<p class="sub">Abbiamo inviato un link di accesso e un codice a <strong id="verify-email"></strong>. Apri l'email e tocca il pulsante, oppure inserisci qui sotto il codice.</p>
|
||||||
|
<div class="msg info">Non la trovi? Controlla la cartella <strong>spam/posta indesiderata</strong>.</div>
|
||||||
|
<div id="verify-msg"></div>
|
||||||
|
<label for="otp">Codice di accesso</label>
|
||||||
|
<input type="text" id="otp" class="otp-input" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9 ]*" maxlength="11" placeholder="········">
|
||||||
|
<div style="height:12px"></div>
|
||||||
|
<button class="btn" id="btn-verify">Entra nel portale</button>
|
||||||
|
<div style="height:10px"></div>
|
||||||
|
<button class="btn-ghost" id="btn-resend" disabled>Invia di nuovo l'email</button>
|
||||||
|
<button class="btn-ghost" id="btn-other-email" style="float:right">Usa un'altra email</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DASHBOARD -->
|
||||||
|
<div id="view-dash" class="card hidden">
|
||||||
|
<h2 id="dash-supplier"></h2>
|
||||||
|
<p class="sub" id="dash-committente"></p>
|
||||||
|
<div id="dash-campaigns"></div>
|
||||||
|
<div style="height:14px"></div>
|
||||||
|
<button class="btn-ghost" id="btn-logout">Esci</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- COMPILAZIONE -->
|
||||||
|
<div id="view-fill" class="card hidden">
|
||||||
|
<div class="due-bar">
|
||||||
|
<button class="btn-ghost" id="btn-back-dash">← Torna ai questionari</button>
|
||||||
|
<div id="fill-due"></div>
|
||||||
|
<div class="progress"><div class="progress-fill" id="fill-progress" style="width:0"></div></div>
|
||||||
|
<div style="font-size:.78rem;color:var(--gray-500)" id="fill-count"></div>
|
||||||
|
<div class="save-state" id="fill-savestate"></div>
|
||||||
|
</div>
|
||||||
|
<h2 id="fill-title">Questionario di sicurezza</h2>
|
||||||
|
<div id="fill-questions"></div>
|
||||||
|
<div id="fill-msg"></div>
|
||||||
|
<div style="height:8px"></div>
|
||||||
|
<button class="btn" id="btn-submit">Invia definitivo</button>
|
||||||
|
<div style="height:8px"></div>
|
||||||
|
<button class="btn btn-secondary" id="btn-draft">Salva bozza</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- COMPLETATO -->
|
||||||
|
<div id="view-done" class="card hidden">
|
||||||
|
<div style="text-align:center;font-size:3rem">✓</div>
|
||||||
|
<h2 style="text-align:center">Questionario inviato. Grazie!</h2>
|
||||||
|
<p class="sub" style="text-align:center" id="done-msg"></p>
|
||||||
|
<div id="done-score"></div>
|
||||||
|
<button class="btn-ghost" id="btn-done-back" style="display:block;margin:0 auto">Torna ai questionari</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="foot">
|
||||||
|
Servizio fornito da <strong>NIS2 Agile</strong> per conto del committente.<br>
|
||||||
|
Il committente è titolare del trattamento; NIS2 Agile agisce come responsabile (Art. 28 GDPR).<br>
|
||||||
|
Se non eri tu a richiedere questo accesso, ignora l'email ricevuta.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
const API = '/api/supplier-portal';
|
||||||
|
let TOKEN = sessionStorage.getItem('sp_token') || '';
|
||||||
|
let RESEND_TIMER = null;
|
||||||
|
let CURRENT = null; // {campaignId, questions, answers, dueAt}
|
||||||
|
let SAVE_DEBOUNCE = null;
|
||||||
|
|
||||||
|
function $(id){return document.getElementById(id);}
|
||||||
|
function show(view){['loading','request','verify','dash','fill','done'].forEach(v=>$('view-'+v).classList.toggle('hidden',v!==view));}
|
||||||
|
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
|
||||||
|
function msg(el,text,type){el.innerHTML=text?('<div class="msg '+type+'">'+esc(text)+'</div>'):'';}
|
||||||
|
|
||||||
|
async function api(method,path,body,auth){
|
||||||
|
const h={'Content-Type':'application/json'};
|
||||||
|
if(auth&&TOKEN)h['Authorization']='Bearer '+TOKEN;
|
||||||
|
const opt={method,headers:h};
|
||||||
|
if(body)opt.body=JSON.stringify(body);
|
||||||
|
let res,json;
|
||||||
|
try{res=await fetch(API+path,opt);json=await res.json();}
|
||||||
|
catch(e){return{success:false,message:'Errore di connessione. Riprova.',_net:true};}
|
||||||
|
json._status=res.status;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boot: magic-link? token? ──
|
||||||
|
(async function boot(){
|
||||||
|
const params=new URLSearchParams(location.search);
|
||||||
|
const magic=params.get('magic');
|
||||||
|
if(magic){
|
||||||
|
show('loading');
|
||||||
|
const r=await api('POST','/verify-otp',{magic_token:magic},false);
|
||||||
|
history.replaceState({},'',location.pathname); // pulisci URL
|
||||||
|
if(r.success&&r.data&&r.data.token){onLogin(r.data);return;}
|
||||||
|
show('request');
|
||||||
|
msg($('req-msg'),(r.message||'Link non valido o scaduto. Richiedi un nuovo accesso.'),'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(TOKEN){
|
||||||
|
show('loading');
|
||||||
|
const r=await api('GET','/me',null,true);
|
||||||
|
if(r.success&&r.data){renderDash(r.data);return;}
|
||||||
|
TOKEN='';sessionStorage.removeItem('sp_token');
|
||||||
|
}
|
||||||
|
show('request');
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Richiesta OTP ──
|
||||||
|
$('btn-request').onclick=async function(){
|
||||||
|
const email=$('email').value.trim();
|
||||||
|
if(!email||email.indexOf('@')<0){msg($('req-msg'),'Inserisci un indirizzo email valido.','err');return;}
|
||||||
|
this.disabled=true;
|
||||||
|
const r=await api('POST','/request-otp',{email},false);
|
||||||
|
this.disabled=false;
|
||||||
|
// risposta sempre opaca: passiamo comunque alla verifica
|
||||||
|
$('verify-email').textContent=email;
|
||||||
|
sessionStorage.setItem('sp_email',email);
|
||||||
|
show('verify');
|
||||||
|
startResendCountdown();
|
||||||
|
$('otp').focus();
|
||||||
|
};
|
||||||
|
$('email').addEventListener('keydown',e=>{if(e.key==='Enter')$('btn-request').click();});
|
||||||
|
|
||||||
|
function startResendCountdown(){
|
||||||
|
let s=60;const btn=$('btn-resend');btn.disabled=true;
|
||||||
|
if(RESEND_TIMER)clearInterval(RESEND_TIMER);
|
||||||
|
const upd=()=>{btn.textContent=s>0?('Invia di nuovo tra '+s+'s'):'Invia di nuovo l\'email';if(s<=0){btn.disabled=false;clearInterval(RESEND_TIMER);}s--;};
|
||||||
|
upd();RESEND_TIMER=setInterval(upd,1000);
|
||||||
|
}
|
||||||
|
$('btn-resend').onclick=async function(){
|
||||||
|
const email=sessionStorage.getItem('sp_email')||'';
|
||||||
|
if(!email)return;
|
||||||
|
await api('POST','/request-otp',{email},false);
|
||||||
|
startResendCountdown();
|
||||||
|
msg($('verify-msg'),'Nuova email inviata.','ok');
|
||||||
|
};
|
||||||
|
$('btn-other-email').onclick=()=>{show('request');$('email').focus();};
|
||||||
|
|
||||||
|
// ── Verifica OTP ──
|
||||||
|
$('btn-verify').onclick=async function(){
|
||||||
|
const email=sessionStorage.getItem('sp_email')||'';
|
||||||
|
const code=($('otp').value||'').replace(/\D/g,'');
|
||||||
|
if(!code){msg($('verify-msg'),'Inserisci il codice ricevuto via email.','err');return;}
|
||||||
|
this.disabled=true;
|
||||||
|
const r=await api('POST','/verify-otp',{email,code},false);
|
||||||
|
this.disabled=false;
|
||||||
|
if(r.success&&r.data&&r.data.token){onLogin(r.data);return;}
|
||||||
|
// errori specifici con via d'uscita
|
||||||
|
let m=r.message||'Codice non valido.';
|
||||||
|
msg($('verify-msg'),m,'err');
|
||||||
|
$('otp').value='';$('otp').focus();
|
||||||
|
};
|
||||||
|
$('otp').addEventListener('keydown',e=>{if(e.key==='Enter')$('btn-verify').click();});
|
||||||
|
|
||||||
|
function onLogin(data){
|
||||||
|
TOKEN=data.token;sessionStorage.setItem('sp_token',TOKEN);
|
||||||
|
// carica /me per la dashboard completa
|
||||||
|
api('GET','/me',null,true).then(r=>{if(r.success&&r.data)renderDash(r.data);else{show('request');}});
|
||||||
|
}
|
||||||
|
|
||||||
|
$('btn-logout').onclick=()=>{TOKEN='';sessionStorage.removeItem('sp_token');sessionStorage.removeItem('sp_email');show('request');};
|
||||||
|
|
||||||
|
// ── Dashboard ──
|
||||||
|
function renderDash(data){
|
||||||
|
const sup=data.supplier||{};const comm=data.committente||{};
|
||||||
|
$('dash-supplier').textContent=sup.name||'Fornitore';
|
||||||
|
$('dash-committente').textContent=comm.name?('Questionari richiesti da '+comm.name):'';
|
||||||
|
$('brand-title').textContent=comm.name||'Portale Fornitori';
|
||||||
|
const camps=(data.campaigns||[]);
|
||||||
|
const open=camps.filter(c=>c.status==='sent'||c.status==='in_progress');
|
||||||
|
// caso 1 solo questionario aperto → vai diretto
|
||||||
|
if(open.length===1&&camps.length===1){openCampaign(open[0].id);return;}
|
||||||
|
let html='';
|
||||||
|
if(camps.length===0){html='<div class="msg info">Nessun questionario in attesa. Se hai ricevuto una richiesta, contatta il tuo referente.</div>';}
|
||||||
|
camps.forEach(c=>{
|
||||||
|
const b=campBadge(c);
|
||||||
|
const cta=(c.status==='completed')?'Vedi ricevuta':(c.status==='expired'?'Scaduto':(c.status==='in_progress'?'Riprendi':'Compila'));
|
||||||
|
const dis=(c.status==='expired')?'disabled':'';
|
||||||
|
html+='<div class="camp"><div><div style="font-weight:600">Questionario sicurezza NIS2</div><div style="font-size:.8rem;color:var(--gray-500)">'+b+(c.due_at?(' · scade il '+fmtDate(c.due_at)):'')+'</div></div>'+
|
||||||
|
'<button class="btn" style="width:auto;min-height:40px;padding:8px 16px" onclick="openCampaign('+c.id+')" '+dis+'>'+cta+'</button></div>';
|
||||||
|
});
|
||||||
|
$('dash-campaigns').innerHTML=html;
|
||||||
|
show('dash');
|
||||||
|
}
|
||||||
|
function campBadge(c){
|
||||||
|
if(c.status==='completed')return '<span class="badge badge-ok">Inviato</span>';
|
||||||
|
if(c.status==='expired')return '<span class="badge badge-danger">Scaduto</span>';
|
||||||
|
if(c.status==='in_progress')return '<span class="badge badge-warn">In bozza</span>';
|
||||||
|
return '<span class="badge badge-neutral">Da compilare</span>';
|
||||||
|
}
|
||||||
|
function fmtDate(d){try{return new Date(d).toLocaleDateString('it-IT');}catch(e){return d;}}
|
||||||
|
|
||||||
|
// ── Compilazione ──
|
||||||
|
window.openCampaign=async function(id){
|
||||||
|
show('loading');
|
||||||
|
const r=await api('GET','/'+id,null,true);
|
||||||
|
if(!r.success||!r.data){
|
||||||
|
if(r._status===410){alert('Il termine per la compilazione è scaduto.');}
|
||||||
|
const me=await api('GET','/me',null,true);if(me.success)renderDash(me.data);else show('request');return;
|
||||||
|
}
|
||||||
|
const d=r.data;
|
||||||
|
if(d.campaign.status==='completed'){renderDone(d,null);return;}
|
||||||
|
CURRENT={campaignId:id,questions:d.questions||[],answers:d.answers||{},dueAt:d.campaign.due_at,showScore:d.campaign.show_score};
|
||||||
|
$('fill-title').textContent='Questionario per '+((d.committente&&d.committente.name)||'il committente');
|
||||||
|
renderDueBar();
|
||||||
|
renderQuestions();
|
||||||
|
msg($('fill-msg'),'','');
|
||||||
|
show('fill');
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderDueBar(){
|
||||||
|
const due=CURRENT.dueAt;let html='';
|
||||||
|
if(due){const days=Math.ceil((new Date(due)-new Date())/86400000);
|
||||||
|
const cls=days<0?'badge-danger':(days<=7?'badge-warn':'badge-neutral');
|
||||||
|
html='<span class="badge '+cls+'">'+(days<0?'Scaduto il ':'Scade il ')+fmtDate(due)+'</span>';}
|
||||||
|
$('fill-due').innerHTML=html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function answerKey(q){return q.id!=null?String(q.id):('code:'+q.code);}
|
||||||
|
function getAns(q){return CURRENT.answers[answerKey(q)]||{};}
|
||||||
|
|
||||||
|
function renderQuestions(){
|
||||||
|
let html='';
|
||||||
|
CURRENT.questions.forEach((q,i)=>{
|
||||||
|
const a=getAns(q);const val=a.value;
|
||||||
|
html+='<div class="q" data-qi="'+i+'"><div class="q-text">'+esc(q.text)+(q.nis2_ref?'<span class="q-ref">'+esc(q.nis2_ref)+'</span>':'')+(q.is_required?' <span style="color:var(--danger)">*</span>':'')+'</div>';
|
||||||
|
if(q.help_text)html+='<div class="q-help">'+esc(q.help_text)+'</div>';
|
||||||
|
html+=renderInput(q,i,val,a.text);
|
||||||
|
html+='</div>';
|
||||||
|
});
|
||||||
|
$('fill-questions').innerHTML=html;
|
||||||
|
bindInputs();
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInput(q,i,val,text){
|
||||||
|
const name='q'+i;
|
||||||
|
if(q.type==='yes_no_partial'){
|
||||||
|
return optGroup(name,[['yes','Sì'],['partial','Parzialmente'],['no','No'],['not_applicable','Non applicabile']],val,'radio');
|
||||||
|
}
|
||||||
|
if(q.type==='single_choice'){
|
||||||
|
return optGroup(name,(q.options||[]).map(o=>[o,o]),val,'radio');
|
||||||
|
}
|
||||||
|
if(q.type==='multi_choice'){
|
||||||
|
const vals=Array.isArray(val)?val:[];
|
||||||
|
return '<div style="font-size:.75rem;color:var(--gray-400);margin-bottom:6px">Puoi selezionare più opzioni</div>'+optGroup(name,(q.options||[]).map(o=>[o,o]),vals,'checkbox');
|
||||||
|
}
|
||||||
|
if(q.type==='scale_1_5'){
|
||||||
|
const ends=q.options||{};
|
||||||
|
let h=optGroup(name,[['1','1'],['2','2'],['3','3'],['4','4'],['5','5']],val,'radio');
|
||||||
|
h+='<div class="scale-ends"><span>1 = '+esc(ends.min||ends['1']||'minimo')+'</span><span>5 = '+esc(ends.max||ends['5']||'massimo')+'</span></div>';
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
if(q.type==='number'){
|
||||||
|
return '<input type="number" inputmode="numeric" name="'+name+'" value="'+(val!=null?esc(val):'')+'" data-qi="'+i+'">';
|
||||||
|
}
|
||||||
|
if(q.type==='file'){
|
||||||
|
return '<div class="muted">Formati: PDF, JPG, PNG. <input type="file" name="'+name+'" accept=".pdf,.jpg,.jpeg,.png" data-qi="'+i+'"><div data-fileinfo="'+i+'">'+(text?esc(text):'')+'</div></div>';
|
||||||
|
}
|
||||||
|
// text
|
||||||
|
return '<textarea name="'+name+'" data-qi="'+i+'" placeholder="Scrivi qui...">'+(val!=null?esc(val):'')+'</textarea>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function optGroup(name,opts,val,kind){
|
||||||
|
const arr=Array.isArray(val)?val.map(String):[String(val)];
|
||||||
|
let h='<div class="opts">';
|
||||||
|
opts.forEach((o,j)=>{
|
||||||
|
const id=name+'_'+j;const checked=arr.indexOf(String(o[0]))>=0?'checked':'';
|
||||||
|
h+='<div class="opt"><input type="'+kind+'" id="'+id+'" name="'+name+'" value="'+esc(o[0])+'" '+checked+'><label for="'+id+'">'+esc(o[1])+'</label></div>';
|
||||||
|
});
|
||||||
|
return h+'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindInputs(){
|
||||||
|
$('fill-questions').querySelectorAll('input,textarea,select').forEach(el=>{
|
||||||
|
const ev=(el.type==='radio'||el.type==='checkbox')?'change':'input';
|
||||||
|
el.addEventListener(ev,()=>{collectAndAutosave();});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(){
|
||||||
|
const out=[];
|
||||||
|
CURRENT.questions.forEach((q,i)=>{
|
||||||
|
const name='q'+i;let value=null,text=null;
|
||||||
|
if(q.type==='multi_choice'){
|
||||||
|
value=Array.from($('fill-questions').querySelectorAll('input[name="'+name+'"]:checked')).map(e=>e.value);
|
||||||
|
if(value.length===0)value=null;
|
||||||
|
}else if(q.type==='yes_no_partial'||q.type==='single_choice'||q.type==='scale_1_5'){
|
||||||
|
const sel=$('fill-questions').querySelector('input[name="'+name+'"]:checked');
|
||||||
|
value=sel?sel.value:null;
|
||||||
|
}else if(q.type==='number'){
|
||||||
|
const el=$('fill-questions').querySelector('[name="'+name+'"]');value=el&&el.value!==''?el.value:null;
|
||||||
|
}else if(q.type==='file'){
|
||||||
|
const el=$('fill-questions').querySelector('[name="'+name+'"]');text=el&&el.files&&el.files[0]?el.files[0].name:null;
|
||||||
|
}else{
|
||||||
|
const el=$('fill-questions').querySelector('[name="'+name+'"]');text=el&&el.value!==''?el.value:null;
|
||||||
|
}
|
||||||
|
const entry={};if(q.id!=null)entry.question_id=q.id;else entry.question_code=q.code;
|
||||||
|
if(value!=null)entry.value=value;if(text!=null)entry.text=text;
|
||||||
|
// aggiorna cache locale
|
||||||
|
CURRENT.answers[answerKey(q)]={value:value,text:text};
|
||||||
|
if(value!=null||text!=null)out.push(entry);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAndAutosave(){
|
||||||
|
updateProgress();
|
||||||
|
$('fill-savestate').textContent='Salvataggio…';
|
||||||
|
if(SAVE_DEBOUNCE)clearTimeout(SAVE_DEBOUNCE);
|
||||||
|
SAVE_DEBOUNCE=setTimeout(async()=>{
|
||||||
|
const answers=collect();
|
||||||
|
if(answers.length===0){$('fill-savestate').textContent='';return;}
|
||||||
|
const r=await api('PATCH','/'+CURRENT.campaignId+'/answers',{answers},true);
|
||||||
|
$('fill-savestate').textContent=r.success?('Bozza salvata '+new Date().toLocaleTimeString('it-IT').slice(0,5)):'Salvataggio in sospeso';
|
||||||
|
},800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress(){
|
||||||
|
const total=CURRENT.questions.length;let done=0;
|
||||||
|
CURRENT.questions.forEach(q=>{const a=getAns(q);if((a.value!=null&&a.value!=='')||(a.text!=null&&a.text!==''))done++;});
|
||||||
|
$('fill-progress').style.width=(total?Math.round(done/total*100):0)+'%';
|
||||||
|
$('fill-count').textContent=done+' di '+total+' domande con risposta';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('btn-draft').onclick=async function(){
|
||||||
|
const answers=collect();
|
||||||
|
const r=await api('PATCH','/'+CURRENT.campaignId+'/answers',{answers},true);
|
||||||
|
msg($('fill-msg'),r.success?'Bozza salvata.':'Errore nel salvataggio.',r.success?'ok':'err');
|
||||||
|
};
|
||||||
|
|
||||||
|
$('btn-submit').onclick=async function(){
|
||||||
|
const answers=collect();
|
||||||
|
// verifica obbligatorie lato client
|
||||||
|
const missing=[];
|
||||||
|
CURRENT.questions.forEach(q=>{if(q.is_required){const a=getAns(q);if(!((a.value!=null&&a.value!=='')||(a.text!=null&&a.text!=='')))missing.push(q);}});
|
||||||
|
if(missing.length){msg($('fill-msg'),'Mancano '+missing.length+' risposte obbligatorie.','err');return;}
|
||||||
|
if(!confirm('Confermi l\'invio? Dopo l\'invio non potrai modificare le risposte.'))return;
|
||||||
|
this.disabled=true;
|
||||||
|
const r=await api('POST','/'+CURRENT.campaignId+'/submit',{answers},true);
|
||||||
|
this.disabled=false;
|
||||||
|
if(r.success){renderDone({committente:{name:$('brand-title').textContent}},r.data);return;}
|
||||||
|
if(r.data&&r.data.missing){msg($('fill-msg'),'Mancano risposte obbligatorie.','err');return;}
|
||||||
|
msg($('fill-msg'),r.message||'Errore nell\'invio.','err');
|
||||||
|
};
|
||||||
|
$('btn-back-dash').onclick=async function(){const me=await api('GET','/me',null,true);if(me.success)renderDash(me.data);};
|
||||||
|
|
||||||
|
function renderDone(d,result){
|
||||||
|
const comm=(d&&d.committente&&d.committente.name)||'';
|
||||||
|
$('done-msg').textContent='Abbiamo ricevuto le tue risposte'+(comm?(' e le abbiamo inviate a '+comm):'')+'.';
|
||||||
|
let s='';
|
||||||
|
if(result&&result.show_score&&result.score!=null){
|
||||||
|
s='<div class="msg info" style="text-align:center">Punteggio: <strong>'+result.score+'/100</strong></div>';
|
||||||
|
}
|
||||||
|
$('done-score').innerHTML=s;
|
||||||
|
show('done');
|
||||||
|
}
|
||||||
|
$('btn-done-back').onclick=async function(){const me=await api('GET','/me',null,true);if(me.success)renderDash(me.data);else show('request');};
|
||||||
|
|
||||||
|
$('privacy-link').onclick=function(e){e.preventDefault();alert('Il committente è titolare del trattamento dei tuoi dati (nome, email, risposte) per la due-diligence di sicurezza NIS2 (Art. 6.1.f/c GDPR). NIS2 Agile agisce come responsabile (Art. 28). Hai diritto di accesso, rettifica e cancellazione. Per esercitarli contatta il committente.');};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user