[I18N] Modulo Fornitori bilingue IT/EN (#8b) — v1.24.5

- supplier-portal.html (portale esterno OTP): i18n self-contained (dizionario inline + toggle IT/EN, ~79 stringhe), flusso OTP/token/ID/API invariato; domande questionario restano dati (non tradotte).
- supply-chain.html (interno): i18n estesa (5->25 data-i18n + 97 I18n.t).
- i18n.js: +82 chiavi sp.* (38->120). Cache-buster i18n.js su supply-chain.html. node --check OK, pagine 200, parita IT/EN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-21 10:32:18 +02:00
co-authored by Claude Opus 4.8
parent 14c4c2a7d2
commit 05a1436d8c
4 changed files with 502 additions and 182 deletions
+83
View File
@@ -491,6 +491,89 @@ const I18n = (function () {
'sp.import_too_many': { it: 'Troppi record (max 1000).', en: 'Too many records (max 1000).' }, 'sp.import_too_many': { it: 'Troppi record (max 1000).', en: 'Too many records (max 1000).' },
'sp.import_done': { it: 'Completato: creati {created}, aggiornati {updated}, saltati {skipped}.', en: 'Done: {created} created, {updated} updated, {skipped} skipped.' }, 'sp.import_done': { it: 'Completato: creati {created}, aggiornati {updated}, saltati {skipped}.', en: 'Done: {created} created, {updated} updated, {skipped} skipped.' },
'sp.conn_error': { it: 'Errore di connessione.', en: 'Connection error.' }, 'sp.conn_error': { it: 'Errore di connessione.', en: 'Connection error.' },
// ── Supply Chain / Fornitori — i18n estesa pagina interna (2026-06-21) ──
'sp.stat_total': { it: 'Totale Fornitori', en: 'Total Suppliers' },
'sp.stat_critical': { it: 'Critici Non Conformi', en: 'Critical Non-Compliant' },
'sp.stat_expiring': { it: 'Contratti in Scadenza', en: 'Expiring Contracts' },
'sp.stat_avg_score': { it: 'Score Rischio Medio', en: 'Average Risk Score' },
'sp.crit_distribution': { it: 'Distribuzione Criticità', en: 'Criticality Distribution' },
'sp.crit_critical': { it: 'Critico', en: 'Critical' },
'sp.crit_high': { it: 'Alto', en: 'High' },
'sp.crit_medium': { it: 'Medio', en: 'Medium' },
'sp.crit_low': { it: 'Basso', en: 'Low' },
'sp.filter_all_crit': { it: 'Tutte le Criticità', en: 'All Criticalities' },
'sp.filter_all_status': { it: 'Tutti gli Stati', en: 'All Statuses' },
'sp.status_active': { it: 'Attivo', en: 'Active' },
'sp.status_under_review':{ it: 'In Revisione', en: 'Under Review' },
'sp.status_suspended': { it: 'Sospeso', en: 'Suspended' },
'sp.status_terminated': { it: 'Terminato', en: 'Terminated' },
'sp.loading_suppliers': { it: 'Caricamento fornitori...', en: 'Loading suppliers...' },
'sp.loading': { it: 'Caricamento...', en: 'Loading...' },
'sp.no_suppliers': { it: 'Nessun fornitore trovato', en: 'No suppliers found' },
'sp.no_suppliers_hint': { it: 'Aggiungi il tuo primo fornitore per iniziare il monitoraggio della supply chain.', en: 'Add your first supplier to start monitoring the supply chain.' },
'sp.col_name': { it: 'Nome', en: 'Name' },
'sp.col_service_type': { it: 'Tipo Servizio', en: 'Service Type' },
'sp.col_criticality': { it: 'Criticità', en: 'Criticality' },
'sp.col_risk_score': { it: 'Score Rischio', en: 'Risk Score' },
'sp.col_status': { it: 'Stato', en: 'Status' },
'sp.col_last_assessment':{ it: 'Ultima Valutazione', en: 'Last Assessment' },
'sp.col_actions': { it: 'Azioni', en: 'Actions' },
'sp.back_to_list': { it: "Torna all'elenco", en: 'Back to list' },
'sp.compliant': { it: 'Conforme', en: 'Compliant' },
'sp.non_compliant': { it: 'Non Conforme', en: 'Non-Compliant' },
'sp.assess_supplier': { it: 'Valuta Fornitore', en: 'Assess Supplier' },
'sp.send_questionnaire': { it: 'Invia questionario', en: 'Send questionnaire' },
'sp.edit': { it: 'Modifica', en: 'Edit' },
'sp.delete': { it: 'Elimina', en: 'Delete' },
'sp.info_title': { it: 'Informazioni Fornitore', en: 'Supplier Information' },
'sp.field_name': { it: 'Nome', en: 'Name' },
'sp.field_vat': { it: 'Partita IVA', en: 'VAT Number' },
'sp.field_contact_name': { it: 'Referente', en: 'Contact Person' },
'sp.field_email': { it: 'Email', en: 'Email' },
'sp.field_service_type': { it: 'Tipo Servizio', en: 'Service Type' },
'sp.field_contract_start':{ it: 'Inizio Contratto', en: 'Contract Start' },
'sp.field_contract_expiry':{ it: 'Scadenza Contratto', en: 'Contract Expiry' },
'sp.service_desc_title': { it: 'Descrizione Servizio', en: 'Service Description' },
'sp.notes_title': { it: 'Note', en: 'Notes' },
'sp.out_of_100': { it: 'su 100', en: 'out of 100' },
'sp.sec_req_met': { it: 'Requisiti di sicurezza soddisfatti', en: 'Security requirements met' },
'sp.sec_req_not_met': { it: 'Requisiti di sicurezza non soddisfatti', en: 'Security requirements not met' },
'sp.no_assessment_done': { it: 'Nessuna valutazione effettuata', en: 'No assessment performed' },
'sp.assessment_label': { it: 'Valutazione', en: 'Assessment' },
'sp.last_assessment': { it: 'Ultima Valutazione', en: 'Last Assessment' },
'sp.next_assessment': { it: 'Prossima Valutazione', en: 'Next Assessment' },
'sp.sec_requirements': { it: 'Requisiti Sicurezza', en: 'Security Requirements' },
'sp.met': { it: 'Soddisfatti', en: 'Met' },
'sp.not_met': { it: 'Non Soddisfatti', en: 'Not Met' },
'sp.created_at': { it: 'Creato il', en: 'Created on' },
'sp.edit_supplier': { it: 'Modifica Fornitore', en: 'Edit Supplier' },
'sp.new_supplier_modal': { it: 'Nuovo Fornitore', en: 'New Supplier' },
'sp.field_supplier_name':{ it: 'Nome Fornitore', en: 'Supplier Name' },
'sp.field_contact_email':{ it: 'Email Contatto', en: 'Contact Email' },
'sp.ph_company_name': { it: "Nome dell'azienda", en: 'Company name' },
'sp.ph_full_name': { it: 'Nome e cognome', en: 'Full name' },
'sp.ph_service_type': { it: 'es. Cloud Hosting, Sviluppo SW, Consulenza', en: 'e.g. Cloud Hosting, SW Development, Consulting' },
'sp.ph_service_desc': { it: 'Descrivi il servizio fornito...', en: 'Describe the service provided...' },
'sp.ph_notes': { it: 'Note aggiuntive...', en: 'Additional notes...' },
'sp.cancel': { it: 'Annulla', en: 'Cancel' },
'sp.save_changes': { it: 'Salva Modifiche', en: 'Save Changes' },
'sp.add_supplier': { it: 'Aggiungi Fornitore', en: 'Add Supplier' },
'sp.assess_modal_title': { it: 'Valutazione Fornitore', en: 'Supplier Assessment' },
'sp.assess_intro': { it: "Rispondi alle domande per valutare il livello di sicurezza del fornitore. Il punteggio di rischio verrà calcolato automaticamente.", en: 'Answer the questions to assess the supplier security level. The risk score will be calculated automatically.' },
'sp.answer_yes': { it: 'Sì', en: 'Yes' },
'sp.answer_partial': { it: 'Parziale', en: 'Partial' },
'sp.answer_no': { it: 'No', en: 'No' },
'sp.calculated_risk_score':{ it: 'Score Rischio Calcolato', en: 'Calculated Risk Score' },
'sp.save_assessment': { it: 'Salva Valutazione', en: 'Save Assessment' },
'sp.cat_empty': { it: 'Nessuna categoria.', en: 'No categories.' },
'sp.cat_new_title': { it: 'Nuova categoria', en: 'New category' },
'sp.cat_edit': { it: 'Modifica categoria', en: 'Edit category' },
'sp.ph_cat_name': { it: 'es. Cloud Provider', en: 'e.g. Cloud Provider' },
'sp.ph_cat_desc': { it: 'Descrizione opzionale...', en: 'Optional description...' },
'sp.save': { it: 'Salva', en: 'Save' },
'sp.create': { it: 'Crea', en: 'Create' },
'sp.close': { it: 'Chiudi', en: 'Close' },
'sp.tpl_back_plain': { it: 'Torna ai template', en: 'Back to templates' },
}); });
return { init, getLang, setLang, t, applyTranslations, addTranslations }; return { init, getLang, setLang, t, applyTranslations, addTranslations };
+300 -62
View File
@@ -54,6 +54,11 @@ textarea{min-height:88px;resize:vertical}
.camp:last-child{border-bottom:none} .camp:last-child{border-bottom:none}
.save-state{font-size:.78rem;color:var(--gray-400);text-align:right;min-height:18px} .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} .foot{text-align:center;font-size:.72rem;color:var(--gray-400);margin-top:20px;line-height:1.5}
.lang-toggle{display:flex;justify-content:flex-end;gap:0;margin-bottom:8px}
.lang-toggle button{background:transparent;border:1px solid var(--gray-200);color:var(--gray-500);font-size:.78rem;font-weight:700;padding:4px 10px;cursor:pointer;min-height:auto;line-height:1.2}
.lang-toggle button:first-child{border-radius:6px 0 0 6px;border-right:none}
.lang-toggle button:last-child{border-radius:0 6px 6px 0}
.lang-toggle button.active{background:var(--primary);color:#fff;border-color:var(--primary)}
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
@@ -71,9 +76,13 @@ textarea{min-height:88px;resize:vertical}
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
<div class="lang-toggle">
<button type="button" id="lang-it" onclick="spSetLang('it')">IT</button>
<button type="button" id="lang-en" onclick="spSetLang('en')">EN</button>
</div>
<div class="brand"> <div class="brand">
<h1 id="brand-title">Portale Fornitori</h1> <h1 id="brand-title" data-i18n="brand_title">Portale Fornitori</h1>
<p id="brand-sub">Compilazione questionari di sicurezza NIS2</p> <p id="brand-sub" data-i18n="brand_sub">Compilazione questionari di sicurezza NIS2</p>
</div> </div>
<!-- LOADING --> <!-- LOADING -->
@@ -81,29 +90,29 @@ textarea{min-height:88px;resize:vertical}
<!-- RICHIESTA ACCESSO --> <!-- RICHIESTA ACCESSO -->
<div id="view-request" class="card hidden"> <div id="view-request" class="card hidden">
<h2>Accedi al portale</h2> <h2 data-i18n="req_title">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> <p class="sub" data-i18n="req_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> <div id="req-msg"></div>
<label for="email">Email</label> <label for="email" data-i18n="label_email">Email</label>
<input type="email" id="email" inputmode="email" autocomplete="email" placeholder="nome@azienda.it" autofocus> <input type="email" id="email" inputmode="email" autocomplete="email" placeholder="nome@azienda.it" data-i18n-ph="ph_email" autofocus>
<div style="height:12px"></div> <div style="height:12px"></div>
<button class="btn" id="btn-request">Invia link di accesso</button> <button class="btn" id="btn-request" data-i18n="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> <p class="muted"><span data-i18n="req_privacy_pre">Trattiamo la tua email solo per gestire il questionario.</span> <a href="#" id="privacy-link" data-i18n="req_privacy_link">Informativa privacy</a><span data-i18n="req_privacy_post">.</span></p>
</div> </div>
<!-- EMAIL INVIATA / VERIFICA OTP --> <!-- EMAIL INVIATA / VERIFICA OTP -->
<div id="view-verify" class="card hidden"> <div id="view-verify" class="card hidden">
<h2>Controlla la tua email</h2> <h2 data-i18n="verify_title">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> <p class="sub"><span data-i18n="verify_sub_pre">Abbiamo inviato un link di accesso e un codice a</span> <strong id="verify-email"></strong><span data-i18n="verify_sub_post">. Apri l'email e tocca il pulsante, oppure inserisci qui sotto il codice.</span></p>
<div class="msg info">Non la trovi? Controlla la cartella <strong>spam/posta indesiderata</strong>.</div> <div class="msg info"><span data-i18n="verify_spam_pre">Non la trovi? Controlla la cartella</span> <strong data-i18n="verify_spam_strong">spam/posta indesiderata</strong><span data-i18n="verify_spam_post">.</span></div>
<div id="verify-msg"></div> <div id="verify-msg"></div>
<label for="otp">Codice di accesso</label> <label for="otp" data-i18n="label_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="········"> <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> <div style="height:12px"></div>
<button class="btn" id="btn-verify">Entra nel portale</button> <button class="btn" id="btn-verify" data-i18n="btn_verify">Entra nel portale</button>
<div style="height:10px"></div> <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-resend" disabled>Invia di nuovo l'email</button>
<button class="btn-ghost" id="btn-other-email" style="float:right">Usa un'altra email</button> <button class="btn-ghost" id="btn-other-email" style="float:right" data-i18n="btn_other_email">Usa un'altra email</button>
</div> </div>
<!-- DASHBOARD --> <!-- DASHBOARD -->
@@ -112,45 +121,270 @@ textarea{min-height:88px;resize:vertical}
<p class="sub" id="dash-committente"></p> <p class="sub" id="dash-committente"></p>
<div id="dash-campaigns"></div> <div id="dash-campaigns"></div>
<div style="height:14px"></div> <div style="height:14px"></div>
<button class="btn-ghost" id="btn-logout">Esci</button> <button class="btn-ghost" id="btn-logout" data-i18n="btn_logout">Esci</button>
</div> </div>
<!-- COMPILAZIONE --> <!-- COMPILAZIONE -->
<div id="view-fill" class="card hidden"> <div id="view-fill" class="card hidden">
<div class="due-bar"> <div class="due-bar">
<button class="btn-ghost" id="btn-back-dash">&larr; Torna ai questionari</button> <button class="btn-ghost" id="btn-back-dash" data-i18n="btn_back_dash">&larr; Torna ai questionari</button>
<div id="fill-due"></div> <div id="fill-due"></div>
<div class="progress"><div class="progress-fill" id="fill-progress" style="width:0"></div></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 style="font-size:.78rem;color:var(--gray-500)" id="fill-count"></div>
<div class="save-state" id="fill-savestate"></div> <div class="save-state" id="fill-savestate"></div>
</div> </div>
<h2 id="fill-title">Questionario di sicurezza</h2> <h2 id="fill-title" data-i18n="fill_title_default">Questionario di sicurezza</h2>
<div id="fill-questions"></div> <div id="fill-questions"></div>
<div id="fill-msg"></div> <div id="fill-msg"></div>
<div style="height:8px"></div> <div style="height:8px"></div>
<button class="btn" id="btn-submit">Invia definitivo</button> <button class="btn" id="btn-submit" data-i18n="btn_submit">Invia definitivo</button>
<div style="height:8px"></div> <div style="height:8px"></div>
<button class="btn btn-secondary" id="btn-draft">Salva bozza</button> <button class="btn btn-secondary" id="btn-draft" data-i18n="btn_draft">Salva bozza</button>
</div> </div>
<!-- COMPLETATO --> <!-- COMPLETATO -->
<div id="view-done" class="card hidden"> <div id="view-done" class="card hidden">
<div style="text-align:center;font-size:3rem">&#10003;</div> <div style="text-align:center;font-size:3rem">&#10003;</div>
<h2 style="text-align:center">Questionario inviato. Grazie!</h2> <h2 style="text-align:center" data-i18n="done_title">Questionario inviato. Grazie!</h2>
<p class="sub" style="text-align:center" id="done-msg"></p> <p class="sub" style="text-align:center" id="done-msg"></p>
<div id="done-score"></div> <div id="done-score"></div>
<button class="btn-ghost" id="btn-done-back" style="display:block;margin:0 auto">Torna ai questionari</button> <button class="btn-ghost" id="btn-done-back" style="display:block;margin:0 auto" data-i18n="btn_done_back">Torna ai questionari</button>
</div> </div>
<div class="foot"> <div class="foot">
Servizio fornito da <strong>NIS2 Agile</strong> per conto del committente.<br> <span data-i18n="foot_l1_pre">Servizio fornito da</span> <strong>NIS2 Agile</strong> <span data-i18n="foot_l1_post">per conto del committente.</span><br>
Il committente è titolare del trattamento; NIS2 Agile agisce come responsabile (Art. 28 GDPR).<br> <span data-i18n="foot_l2">Il committente è titolare del trattamento; NIS2 Agile agisce come responsabile (Art. 28 GDPR).</span><br>
Se non eri tu a richiedere questo accesso, ignora l'email ricevuta. <span data-i18n="foot_l3">Se non eri tu a richiedere questo accesso, ignora l'email ricevuta.</span>
</div> </div>
</div> </div>
<script> <script>
'use strict'; 'use strict';
// ── i18n self-contained (IT/EN) — solo "chrome" UI; le domande del questionario sono DATI e NON vengono tradotte ──
const SPI18N = {
it: {
// brand
brand_title:'Portale Fornitori',
brand_sub:'Compilazione questionari di sicurezza NIS2',
// richiesta accesso
req_title:'Accedi al portale',
req_sub:"Inserisci l'email a cui hai ricevuto l'invito. Ti invieremo un link di accesso e un codice.",
label_email:'Email',
ph_email:'nome@azienda.it',
btn_request:'Invia link di accesso',
req_privacy_pre:'Trattiamo la tua email solo per gestire il questionario.',
req_privacy_link:'Informativa privacy',
req_privacy_post:'.',
privacy_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.',
// verifica OTP
verify_title:'Controlla la tua email',
verify_sub_pre:'Abbiamo inviato un link di accesso e un codice a',
verify_sub_post:". Apri l'email e tocca il pulsante, oppure inserisci qui sotto il codice.",
verify_spam_pre:'Non la trovi? Controlla la cartella',
verify_spam_strong:'spam/posta indesiderata',
verify_spam_post:'.',
label_otp:'Codice di accesso',
btn_verify:'Entra nel portale',
btn_resend_email:"Invia di nuovo l'email",
resend_in:'Invia di nuovo tra {s}s',
btn_other_email:"Usa un'altra email",
new_email_sent:'Nuova email inviata.',
// errori richiesta/verifica
err_email_invalid:'Inserisci un indirizzo email valido.',
err_magic_invalid:'Link non valido o scaduto. Richiedi un nuovo accesso.',
err_otp_empty:'Inserisci il codice ricevuto via email.',
err_otp_invalid:'Codice non valido.',
// dashboard
btn_logout:'Esci',
dash_supplier_fallback:'Fornitore',
dash_committente:'Questionari richiesti da {name}',
dash_no_campaigns:'Nessun questionario in attesa. Se hai ricevuto una richiesta, contatta il tuo referente.',
camp_title:'Questionario sicurezza NIS2',
camp_due:'scade il {date}',
cta_receipt:'Vedi ricevuta',
cta_expired:'Scaduto',
cta_resume:'Riprendi',
cta_fill:'Compila',
badge_sent:'Inviato',
badge_expired:'Scaduto',
badge_draft:'In bozza',
badge_todo:'Da compilare',
// compilazione
btn_back_dash:'← Torna ai questionari',
btn_submit:'Invia definitivo',
btn_draft:'Salva bozza',
fill_title_default:'Questionario di sicurezza',
fill_title:'Questionario per {name}',
committente_fallback:'il committente',
due_expired_on:'Scaduto il {date}',
due_on:'Scade il {date}',
multi_hint:'Puoi selezionare più opzioni',
opt_yes:'Sì',
opt_partial:'Parzialmente',
opt_no:'No',
opt_na:'Non applicabile',
scale_min:'minimo',
scale_max:'massimo',
file_formats:'Formati: PDF, JPG, PNG.',
text_ph:'Scrivi qui...',
saving:'Salvataggio…',
draft_saved_at:'Bozza salvata {time}',
save_pending:'Salvataggio in sospeso',
progress_count:'{done} di {total} domande con risposta',
draft_saved:'Bozza salvata.',
err_save:'Errore nel salvataggio.',
err_missing_required:'Mancano {n} risposte obbligatorie.',
confirm_submit:"Confermi l'invio? Dopo l'invio non potrai modificare le risposte.",
err_missing_required_generic:'Mancano risposte obbligatorie.',
err_submit:"Errore nell'invio.",
expired_alert:'Il termine per la compilazione è scaduto.',
// completato
done_title:'Questionario inviato. Grazie!',
done_msg:'Abbiamo ricevuto le tue risposte.',
done_msg_sent:'Abbiamo ricevuto le tue risposte e le abbiamo inviate a {name}.',
done_score:'Punteggio: <strong>{score}/100</strong>',
btn_done_back:'Torna ai questionari',
// footer
foot_l1_pre:'Servizio fornito da',
foot_l1_post:'per conto del committente.',
foot_l2:'Il committente è titolare del trattamento; NIS2 Agile agisce come responsabile (Art. 28 GDPR).',
foot_l3:'Se non eri tu a richiedere questo accesso, ignora l\'email ricevuta.',
// generico
err_network:'Errore di connessione. Riprova.'
},
en: {
// brand
brand_title:'Supplier Portal',
brand_sub:'Complete your NIS2 security questionnaires',
// access request
req_title:'Sign in to the portal',
req_sub:"Enter the email address where you received the invitation. We'll send you an access link and a code.",
label_email:'Email',
ph_email:'name@company.com',
btn_request:'Send access link',
req_privacy_pre:'We process your email only to manage the questionnaire.',
req_privacy_link:'Privacy notice',
req_privacy_post:'.',
privacy_alert:'The client is the data controller of your data (name, email, answers) for the NIS2 security due diligence (Art. 6.1.f/c GDPR). NIS2 Agile acts as data processor (Art. 28). You have the right to access, rectification and erasure. To exercise them, contact the client.',
// OTP verification
verify_title:'Check your email',
verify_sub_pre:'We sent an access link and a code to',
verify_sub_post:'. Open the email and tap the button, or enter the code below.',
verify_spam_pre:"Can't find it? Check your",
verify_spam_strong:'spam/junk folder',
verify_spam_post:'.',
label_otp:'Access code',
btn_verify:'Enter the portal',
btn_resend_email:'Resend the email',
resend_in:'Resend in {s}s',
btn_other_email:'Use a different email',
new_email_sent:'New email sent.',
// request/verify errors
err_email_invalid:'Please enter a valid email address.',
err_magic_invalid:'Invalid or expired link. Please request access again.',
err_otp_empty:'Enter the code you received by email.',
err_otp_invalid:'Invalid code.',
// dashboard
btn_logout:'Log out',
dash_supplier_fallback:'Supplier',
dash_committente:'Questionnaires requested by {name}',
dash_no_campaigns:'No questionnaires pending. If you received a request, please contact your reference person.',
camp_title:'NIS2 security questionnaire',
camp_due:'due {date}',
cta_receipt:'View receipt',
cta_expired:'Expired',
cta_resume:'Resume',
cta_fill:'Fill in',
badge_sent:'Submitted',
badge_expired:'Expired',
badge_draft:'Draft',
badge_todo:'To complete',
// filling
btn_back_dash:'← Back to questionnaires',
btn_submit:'Submit final',
btn_draft:'Save draft',
fill_title_default:'Security questionnaire',
fill_title:'Questionnaire for {name}',
committente_fallback:'the client',
due_expired_on:'Expired on {date}',
due_on:'Due {date}',
multi_hint:'You can select multiple options',
opt_yes:'Yes',
opt_partial:'Partially',
opt_no:'No',
opt_na:'Not applicable',
scale_min:'minimum',
scale_max:'maximum',
file_formats:'Formats: PDF, JPG, PNG.',
text_ph:'Type here...',
saving:'Saving…',
draft_saved_at:'Draft saved {time}',
save_pending:'Save pending',
progress_count:'{done} of {total} questions answered',
draft_saved:'Draft saved.',
err_save:'Error while saving.',
err_missing_required:'{n} required answers are missing.',
confirm_submit:'Confirm submission? After submitting you will not be able to change your answers.',
err_missing_required_generic:'Required answers are missing.',
err_submit:'Error during submission.',
expired_alert:'The deadline for completion has passed.',
// completed
done_title:'Questionnaire submitted. Thank you!',
done_msg:'We have received your answers.',
done_msg_sent:'We have received your answers and sent them to {name}.',
done_score:'Score: <strong>{score}/100</strong>',
btn_done_back:'Back to questionnaires',
// footer
foot_l1_pre:'Service provided by',
foot_l1_post:'on behalf of the client.',
foot_l2:'The client is the data controller; NIS2 Agile acts as data processor (Art. 28 GDPR).',
foot_l3:'If you did not request this access, please ignore the email you received.',
// generic
err_network:'Connection error. Please try again.'
}
};
let SP_LANG = sessionStorage.getItem('sp_lang') || (navigator.language||'it').slice(0,2);
if(SP_LANG!=='en') SP_LANG='it';
// spt(key, params) — ritorna la stringa tradotta con sostituzioni semplici {placeholder}
function spt(k,params){
let s = (SPI18N[SP_LANG]&&SPI18N[SP_LANG][k]) || SPI18N.it[k] || k;
if(params) for(const p in params){ s = s.replace(new RegExp('\\{'+p+'\\}','g'), params[p]); }
return s;
}
// applica le traduzioni al testo statico marcato con data-i18n / data-i18n-ph
function spApplyI18n(){
document.querySelectorAll('[data-i18n]').forEach(e=>{ e.textContent = spt(e.getAttribute('data-i18n')); });
document.querySelectorAll('[data-i18n-ph]').forEach(e=>{ e.setAttribute('placeholder', spt(e.getAttribute('data-i18n-ph'))); });
const it=document.getElementById('lang-it'), en=document.getElementById('lang-en');
if(it) it.classList.toggle('active', SP_LANG==='it');
if(en) en.classList.toggle('active', SP_LANG==='en');
document.documentElement.setAttribute('lang', SP_LANG);
}
// toggle lingua: imposta SP_LANG, persiste, riapplica statico + ri-renderizza il dinamico già presente
function spSetLang(lang){
SP_LANG = (lang==='en') ? 'en' : 'it';
sessionStorage.setItem('sp_lang', SP_LANG);
spApplyI18n();
// ri-render delle parti dinamiche eventualmente visibili (le funzioni di render NON sono modificate)
if(typeof SP_LAST_DASH==='object' && SP_LAST_DASH && !document.getElementById('view-dash').classList.contains('hidden')){ renderDash(SP_LAST_DASH); }
if(CURRENT && !document.getElementById('view-fill').classList.contains('hidden')){
if($('fill-title') && SP_LAST_FILL_COMM!=null){ $('fill-title').textContent = SP_LAST_FILL_COMM ? spt('fill_title',{name:SP_LAST_FILL_COMM}) : spt('fill_title',{name:spt('committente_fallback')}); }
renderDueBar(); renderQuestions();
}
if(!document.getElementById('view-done').classList.contains('hidden') && SP_LAST_DONE){ renderDone(SP_LAST_DONE.d, SP_LAST_DONE.result); }
// se il timer "resend" è attivo lo lasciamo aggiornare da solo; se è già scaduto ne aggiorniamo l'etichetta
const rb=$('btn-resend'); if(rb && !rb.disabled){ rb.textContent = spt('btn_resend_email'); }
}
let SP_LAST_DASH = null; // memorizza l'ultimo data della dashboard per il re-render al cambio lingua
let SP_LAST_FILL_COMM = null; // nome committente per il titolo del fill
let SP_LAST_DONE = null; // {d, result} per il re-render della schermata "completato"
const API = '/api/supplier-portal'; const API = '/api/supplier-portal';
let TOKEN = sessionStorage.getItem('sp_token') || ''; let TOKEN = sessionStorage.getItem('sp_token') || '';
let RESEND_TIMER = null; let RESEND_TIMER = null;
@@ -169,13 +403,14 @@ async function api(method,path,body,auth){
if(body)opt.body=JSON.stringify(body); if(body)opt.body=JSON.stringify(body);
let res,json; let res,json;
try{res=await fetch(API+path,opt);json=await 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};} catch(e){return{success:false,message:spt('err_network'),_net:true};}
json._status=res.status; json._status=res.status;
return json; return json;
} }
// ── Boot: magic-link? token? ── // ── Boot: magic-link? token? ──
(async function boot(){ (async function boot(){
spApplyI18n();
const params=new URLSearchParams(location.search); const params=new URLSearchParams(location.search);
const magic=params.get('magic'); const magic=params.get('magic');
if(magic){ if(magic){
@@ -184,7 +419,7 @@ async function api(method,path,body,auth){
history.replaceState({},'',location.pathname); // pulisci URL history.replaceState({},'',location.pathname); // pulisci URL
if(r.success&&r.data&&r.data.token){onLogin(r.data);return;} if(r.success&&r.data&&r.data.token){onLogin(r.data);return;}
show('request'); show('request');
msg($('req-msg'),(r.message||'Link non valido o scaduto. Richiedi un nuovo accesso.'),'err'); msg($('req-msg'),(r.message||spt('err_magic_invalid')),'err');
return; return;
} }
if(TOKEN){ if(TOKEN){
@@ -199,7 +434,7 @@ async function api(method,path,body,auth){
// ── Richiesta OTP ── // ── Richiesta OTP ──
$('btn-request').onclick=async function(){ $('btn-request').onclick=async function(){
const email=$('email').value.trim(); const email=$('email').value.trim();
if(!email||email.indexOf('@')<0){msg($('req-msg'),'Inserisci un indirizzo email valido.','err');return;} if(!email||email.indexOf('@')<0){msg($('req-msg'),spt('err_email_invalid'),'err');return;}
this.disabled=true; this.disabled=true;
const r=await api('POST','/request-otp',{email},false); const r=await api('POST','/request-otp',{email},false);
this.disabled=false; this.disabled=false;
@@ -215,7 +450,7 @@ $('email').addEventListener('keydown',e=>{if(e.key==='Enter')$('btn-request').cl
function startResendCountdown(){ function startResendCountdown(){
let s=60;const btn=$('btn-resend');btn.disabled=true; let s=60;const btn=$('btn-resend');btn.disabled=true;
if(RESEND_TIMER)clearInterval(RESEND_TIMER); 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--;}; const upd=()=>{btn.textContent=s>0?spt('resend_in',{s:s}):spt('btn_resend_email');if(s<=0){btn.disabled=false;clearInterval(RESEND_TIMER);}s--;};
upd();RESEND_TIMER=setInterval(upd,1000); upd();RESEND_TIMER=setInterval(upd,1000);
} }
$('btn-resend').onclick=async function(){ $('btn-resend').onclick=async function(){
@@ -223,7 +458,7 @@ $('btn-resend').onclick=async function(){
if(!email)return; if(!email)return;
await api('POST','/request-otp',{email},false); await api('POST','/request-otp',{email},false);
startResendCountdown(); startResendCountdown();
msg($('verify-msg'),'Nuova email inviata.','ok'); msg($('verify-msg'),spt('new_email_sent'),'ok');
}; };
$('btn-other-email').onclick=()=>{show('request');$('email').focus();}; $('btn-other-email').onclick=()=>{show('request');$('email').focus();};
@@ -231,13 +466,13 @@ $('btn-other-email').onclick=()=>{show('request');$('email').focus();};
$('btn-verify').onclick=async function(){ $('btn-verify').onclick=async function(){
const email=sessionStorage.getItem('sp_email')||''; const email=sessionStorage.getItem('sp_email')||'';
const code=($('otp').value||'').replace(/\D/g,''); const code=($('otp').value||'').replace(/\D/g,'');
if(!code){msg($('verify-msg'),'Inserisci il codice ricevuto via email.','err');return;} if(!code){msg($('verify-msg'),spt('err_otp_empty'),'err');return;}
this.disabled=true; this.disabled=true;
const r=await api('POST','/verify-otp',{email,code},false); const r=await api('POST','/verify-otp',{email,code},false);
this.disabled=false; this.disabled=false;
if(r.success&&r.data&&r.data.token){onLogin(r.data);return;} if(r.success&&r.data&&r.data.token){onLogin(r.data);return;}
// errori specifici con via d'uscita // errori specifici con via d'uscita
let m=r.message||'Codice non valido.'; let m=r.message||spt('err_otp_invalid');
msg($('verify-msg'),m,'err'); msg($('verify-msg'),m,'err');
$('otp').value='';$('otp').focus(); $('otp').value='';$('otp').focus();
}; };
@@ -253,46 +488,48 @@ $('btn-logout').onclick=()=>{TOKEN='';sessionStorage.removeItem('sp_token');sess
// ── Dashboard ── // ── Dashboard ──
function renderDash(data){ function renderDash(data){
SP_LAST_DASH=data; // per il re-render al cambio lingua
const sup=data.supplier||{};const comm=data.committente||{}; const sup=data.supplier||{};const comm=data.committente||{};
$('dash-supplier').textContent=sup.name||'Fornitore'; $('dash-supplier').textContent=sup.name||spt('dash_supplier_fallback');
$('dash-committente').textContent=comm.name?('Questionari richiesti da '+comm.name):''; $('dash-committente').textContent=comm.name?spt('dash_committente',{name:comm.name}):'';
$('brand-title').textContent=comm.name||'Portale Fornitori'; $('brand-title').textContent=comm.name||spt('brand_title');
const camps=(data.campaigns||[]); const camps=(data.campaigns||[]);
const open=camps.filter(c=>c.status==='sent'||c.status==='in_progress'); const open=camps.filter(c=>c.status==='sent'||c.status==='in_progress');
// caso 1 solo questionario aperto → vai diretto // caso 1 solo questionario aperto → vai diretto
if(open.length===1&&camps.length===1){openCampaign(open[0].id);return;} if(open.length===1&&camps.length===1){openCampaign(open[0].id);return;}
let html=''; 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>';} if(camps.length===0){html='<div class="msg info">'+esc(spt('dash_no_campaigns'))+'</div>';}
camps.forEach(c=>{ camps.forEach(c=>{
const b=campBadge(c); const b=campBadge(c);
const cta=(c.status==='completed')?'Vedi ricevuta':(c.status==='expired'?'Scaduto':(c.status==='in_progress'?'Riprendi':'Compila')); const cta=(c.status==='completed')?spt('cta_receipt'):(c.status==='expired'?spt('cta_expired'):(c.status==='in_progress'?spt('cta_resume'):spt('cta_fill')));
const dis=(c.status==='expired')?'disabled':''; 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>'+ html+='<div class="camp"><div><div style="font-weight:600">'+esc(spt('camp_title'))+'</div><div style="font-size:.8rem;color:var(--gray-500)">'+b+(c.due_at?(' · '+esc(spt('camp_due',{date: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>'; '<button class="btn" style="width:auto;min-height:40px;padding:8px 16px" onclick="openCampaign('+c.id+')" '+dis+'>'+esc(cta)+'</button></div>';
}); });
$('dash-campaigns').innerHTML=html; $('dash-campaigns').innerHTML=html;
show('dash'); show('dash');
} }
function campBadge(c){ function campBadge(c){
if(c.status==='completed')return '<span class="badge badge-ok">Inviato</span>'; if(c.status==='completed')return '<span class="badge badge-ok">'+esc(spt('badge_sent'))+'</span>';
if(c.status==='expired')return '<span class="badge badge-danger">Scaduto</span>'; if(c.status==='expired')return '<span class="badge badge-danger">'+esc(spt('badge_expired'))+'</span>';
if(c.status==='in_progress')return '<span class="badge badge-warn">In bozza</span>'; if(c.status==='in_progress')return '<span class="badge badge-warn">'+esc(spt('badge_draft'))+'</span>';
return '<span class="badge badge-neutral">Da compilare</span>'; return '<span class="badge badge-neutral">'+esc(spt('badge_todo'))+'</span>';
} }
function fmtDate(d){try{return new Date(d).toLocaleDateString('it-IT');}catch(e){return d;}} function fmtDate(d){try{return new Date(d).toLocaleDateString(SP_LANG==='en'?'en-GB':'it-IT');}catch(e){return d;}}
// ── Compilazione ── // ── Compilazione ──
window.openCampaign=async function(id){ window.openCampaign=async function(id){
show('loading'); show('loading');
const r=await api('GET','/'+id,null,true); const r=await api('GET','/'+id,null,true);
if(!r.success||!r.data){ if(!r.success||!r.data){
if(r._status===410){alert('Il termine per la compilazione è scaduto.');} if(r._status===410){alert(spt('expired_alert'));}
const me=await api('GET','/me',null,true);if(me.success)renderDash(me.data);else show('request');return; const me=await api('GET','/me',null,true);if(me.success)renderDash(me.data);else show('request');return;
} }
const d=r.data; const d=r.data;
if(d.campaign.status==='completed'){renderDone(d,null);return;} 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}; 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'); SP_LAST_FILL_COMM=(d.committente&&d.committente.name)||''; // per il re-render del titolo al cambio lingua
$('fill-title').textContent=SP_LAST_FILL_COMM?spt('fill_title',{name:SP_LAST_FILL_COMM}):spt('fill_title',{name:spt('committente_fallback')});
renderDueBar(); renderDueBar();
renderQuestions(); renderQuestions();
msg($('fill-msg'),'',''); msg($('fill-msg'),'','');
@@ -303,7 +540,7 @@ function renderDueBar(){
const due=CURRENT.dueAt;let html=''; const due=CURRENT.dueAt;let html='';
if(due){const days=Math.ceil((new Date(due)-new Date())/86400000); if(due){const days=Math.ceil((new Date(due)-new Date())/86400000);
const cls=days<0?'badge-danger':(days<=7?'badge-warn':'badge-neutral'); 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>';} html='<span class="badge '+cls+'">'+esc(days<0?spt('due_expired_on',{date:fmtDate(due)}):spt('due_on',{date:fmtDate(due)}))+'</span>';}
$('fill-due').innerHTML=html; $('fill-due').innerHTML=html;
} }
@@ -327,29 +564,29 @@ function renderQuestions(){
function renderInput(q,i,val,text){ function renderInput(q,i,val,text){
const name='q'+i; const name='q'+i;
if(q.type==='yes_no_partial'){ if(q.type==='yes_no_partial'){
return optGroup(name,[['yes','Sì'],['partial','Parzialmente'],['no','No'],['not_applicable','Non applicabile']],val,'radio'); return optGroup(name,[['yes',spt('opt_yes')],['partial',spt('opt_partial')],['no',spt('opt_no')],['not_applicable',spt('opt_na')]],val,'radio');
} }
if(q.type==='single_choice'){ if(q.type==='single_choice'){
return optGroup(name,(q.options||[]).map(o=>[o,o]),val,'radio'); return optGroup(name,(q.options||[]).map(o=>[o,o]),val,'radio');
} }
if(q.type==='multi_choice'){ if(q.type==='multi_choice'){
const vals=Array.isArray(val)?val:[]; 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'); return '<div style="font-size:.75rem;color:var(--gray-400);margin-bottom:6px">'+esc(spt('multi_hint'))+'</div>'+optGroup(name,(q.options||[]).map(o=>[o,o]),vals,'checkbox');
} }
if(q.type==='scale_1_5'){ if(q.type==='scale_1_5'){
const ends=q.options||{}; const ends=q.options||{};
let h=optGroup(name,[['1','1'],['2','2'],['3','3'],['4','4'],['5','5']],val,'radio'); 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>'; h+='<div class="scale-ends"><span>1 = '+esc(ends.min||ends['1']||spt('scale_min'))+'</span><span>5 = '+esc(ends.max||ends['5']||spt('scale_max'))+'</span></div>';
return h; return h;
} }
if(q.type==='number'){ if(q.type==='number'){
return '<input type="number" inputmode="numeric" name="'+name+'" value="'+(val!=null?esc(val):'')+'" data-qi="'+i+'">'; return '<input type="number" inputmode="numeric" name="'+name+'" value="'+(val!=null?esc(val):'')+'" data-qi="'+i+'">';
} }
if(q.type==='file'){ 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>'; return '<div class="muted">'+esc(spt('file_formats'))+' <input type="file" name="'+name+'" accept=".pdf,.jpg,.jpeg,.png" data-qi="'+i+'"><div data-fileinfo="'+i+'">'+(text?esc(text):'')+'</div></div>';
} }
// text // text
return '<textarea name="'+name+'" data-qi="'+i+'" placeholder="Scrivi qui...">'+(val!=null?esc(val):'')+'</textarea>'; return '<textarea name="'+name+'" data-qi="'+i+'" placeholder="'+esc(spt('text_ph'))+'">'+(val!=null?esc(val):'')+'</textarea>';
} }
function optGroup(name,opts,val,kind){ function optGroup(name,opts,val,kind){
@@ -397,13 +634,13 @@ function collect(){
function collectAndAutosave(){ function collectAndAutosave(){
updateProgress(); updateProgress();
$('fill-savestate').textContent='Salvataggio…'; $('fill-savestate').textContent=spt('saving');
if(SAVE_DEBOUNCE)clearTimeout(SAVE_DEBOUNCE); if(SAVE_DEBOUNCE)clearTimeout(SAVE_DEBOUNCE);
SAVE_DEBOUNCE=setTimeout(async()=>{ SAVE_DEBOUNCE=setTimeout(async()=>{
const answers=collect(); const answers=collect();
if(answers.length===0){$('fill-savestate').textContent='';return;} if(answers.length===0){$('fill-savestate').textContent='';return;}
const r=await api('PATCH','/'+CURRENT.campaignId+'/answers',{answers},true); 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'; $('fill-savestate').textContent=r.success?spt('draft_saved_at',{time:new Date().toLocaleTimeString(SP_LANG==='en'?'en-GB':'it-IT').slice(0,5)}):spt('save_pending');
},800); },800);
} }
@@ -411,13 +648,13 @@ function updateProgress(){
const total=CURRENT.questions.length;let done=0; 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++;}); 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-progress').style.width=(total?Math.round(done/total*100):0)+'%';
$('fill-count').textContent=done+' di '+total+' domande con risposta'; $('fill-count').textContent=spt('progress_count',{done:done,total:total});
} }
$('btn-draft').onclick=async function(){ $('btn-draft').onclick=async function(){
const answers=collect(); const answers=collect();
const r=await api('PATCH','/'+CURRENT.campaignId+'/answers',{answers},true); const r=await api('PATCH','/'+CURRENT.campaignId+'/answers',{answers},true);
msg($('fill-msg'),r.success?'Bozza salvata.':'Errore nel salvataggio.',r.success?'ok':'err'); msg($('fill-msg'),r.success?spt('draft_saved'):spt('err_save'),r.success?'ok':'err');
}; };
$('btn-submit').onclick=async function(){ $('btn-submit').onclick=async function(){
@@ -425,30 +662,31 @@ $('btn-submit').onclick=async function(){
// verifica obbligatorie lato client // verifica obbligatorie lato client
const missing=[]; 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);}}); 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(missing.length){msg($('fill-msg'),spt('err_missing_required',{n:missing.length}),'err');return;}
if(!confirm('Confermi l\'invio? Dopo l\'invio non potrai modificare le risposte.'))return; if(!confirm(spt('confirm_submit')))return;
this.disabled=true; this.disabled=true;
const r=await api('POST','/'+CURRENT.campaignId+'/submit',{answers},true); const r=await api('POST','/'+CURRENT.campaignId+'/submit',{answers},true);
this.disabled=false; this.disabled=false;
if(r.success){renderDone({committente:{name:$('brand-title').textContent}},r.data);return;} 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;} if(r.data&&r.data.missing){msg($('fill-msg'),spt('err_missing_required_generic'),'err');return;}
msg($('fill-msg'),r.message||'Errore nell\'invio.','err'); msg($('fill-msg'),r.message||spt('err_submit'),'err');
}; };
$('btn-back-dash').onclick=async function(){const me=await api('GET','/me',null,true);if(me.success)renderDash(me.data);}; $('btn-back-dash').onclick=async function(){const me=await api('GET','/me',null,true);if(me.success)renderDash(me.data);};
function renderDone(d,result){ function renderDone(d,result){
SP_LAST_DONE={d:d,result:result}; // per il re-render al cambio lingua
const comm=(d&&d.committente&&d.committente.name)||''; const comm=(d&&d.committente&&d.committente.name)||'';
$('done-msg').textContent='Abbiamo ricevuto le tue risposte'+(comm?(' e le abbiamo inviate a '+comm):'')+'.'; $('done-msg').textContent=comm?spt('done_msg_sent',{name:comm}):spt('done_msg');
let s=''; let s='';
if(result&&result.show_score&&result.score!=null){ if(result&&result.show_score&&result.score!=null){
s='<div class="msg info" style="text-align:center">Punteggio: <strong>'+result.score+'/100</strong></div>'; s='<div class="msg info" style="text-align:center">'+spt('done_score',{score:result.score})+'</div>';
} }
$('done-score').innerHTML=s; $('done-score').innerHTML=s;
show('done'); 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');}; $('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.');}; $('privacy-link').onclick=function(e){e.preventDefault();alert(spt('privacy_alert'));};
</script> </script>
</body> </body>
</html> </html>
+118 -119
View File
@@ -399,44 +399,44 @@
<div class="risk-overview" id="risk-overview"> <div class="risk-overview" id="risk-overview">
<div class="risk-card"> <div class="risk-card">
<div class="risk-card-value" id="stat-total">--</div> <div class="risk-card-value" id="stat-total">--</div>
<div class="risk-card-label">Totale Fornitori</div> <div class="risk-card-label" data-i18n="sp.stat_total">Totale Fornitori</div>
</div> </div>
<div class="risk-card"> <div class="risk-card">
<div class="risk-card-value danger" id="stat-critical">--</div> <div class="risk-card-value danger" id="stat-critical">--</div>
<div class="risk-card-label">Critici Non Conformi</div> <div class="risk-card-label" data-i18n="sp.stat_critical">Critici Non Conformi</div>
</div> </div>
<div class="risk-card"> <div class="risk-card">
<div class="risk-card-value warning" id="stat-expiring">--</div> <div class="risk-card-value warning" id="stat-expiring">--</div>
<div class="risk-card-label">Contratti in Scadenza</div> <div class="risk-card-label" data-i18n="sp.stat_expiring">Contratti in Scadenza</div>
</div> </div>
<div class="risk-card"> <div class="risk-card">
<div class="risk-card-value success" id="stat-avg-score">--</div> <div class="risk-card-value success" id="stat-avg-score">--</div>
<div class="risk-card-label">Score Rischio Medio</div> <div class="risk-card-label" data-i18n="sp.stat_avg_score">Score Rischio Medio</div>
</div> </div>
</div> </div>
<!-- Criticality Chart --> <!-- Criticality Chart -->
<div class="card mb-24"> <div class="card mb-24">
<div class="card-header"> <div class="card-header">
<h3>Distribuzione Criticita'</h3> <h3 data-i18n="sp.crit_distribution">Distribuzione Criticita'</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="criticality-chart" id="criticality-chart"> <div class="criticality-chart" id="criticality-chart">
<div class="chart-bar-group"> <div class="chart-bar-group">
<div class="chart-bar" id="chart-bar-critical" style="height:0;background:var(--danger);"><span class="chart-bar-value" id="chart-val-critical">0</span></div> <div class="chart-bar" id="chart-bar-critical" style="height:0;background:var(--danger);"><span class="chart-bar-value" id="chart-val-critical">0</span></div>
<span class="chart-bar-label">Critico</span> <span class="chart-bar-label" data-i18n="sp.crit_critical">Critico</span>
</div> </div>
<div class="chart-bar-group"> <div class="chart-bar-group">
<div class="chart-bar" id="chart-bar-high" style="height:0;background:#f97316;"><span class="chart-bar-value" id="chart-val-high">0</span></div> <div class="chart-bar" id="chart-bar-high" style="height:0;background:#f97316;"><span class="chart-bar-value" id="chart-val-high">0</span></div>
<span class="chart-bar-label">Alto</span> <span class="chart-bar-label" data-i18n="sp.crit_high">Alto</span>
</div> </div>
<div class="chart-bar-group"> <div class="chart-bar-group">
<div class="chart-bar" id="chart-bar-medium" style="height:0;background:var(--warning);"><span class="chart-bar-value" id="chart-val-medium">0</span></div> <div class="chart-bar" id="chart-bar-medium" style="height:0;background:var(--warning);"><span class="chart-bar-value" id="chart-val-medium">0</span></div>
<span class="chart-bar-label">Medio</span> <span class="chart-bar-label" data-i18n="sp.crit_medium">Medio</span>
</div> </div>
<div class="chart-bar-group"> <div class="chart-bar-group">
<div class="chart-bar" id="chart-bar-low" style="height:0;background:var(--secondary);"><span class="chart-bar-value" id="chart-val-low">0</span></div> <div class="chart-bar" id="chart-bar-low" style="height:0;background:var(--secondary);"><span class="chart-bar-value" id="chart-val-low">0</span></div>
<span class="chart-bar-label">Basso</span> <span class="chart-bar-label" data-i18n="sp.crit_low">Basso</span>
</div> </div>
</div> </div>
</div> </div>
@@ -446,18 +446,18 @@
<div id="suppliers-list-section"> <div id="suppliers-list-section">
<div class="filters-bar"> <div class="filters-bar">
<select class="form-select" id="filter-criticality" onchange="applyFilters()" aria-label="Filtra per criticità"> <select class="form-select" id="filter-criticality" onchange="applyFilters()" aria-label="Filtra per criticità">
<option value="">Tutte le Criticita'</option> <option value="" data-i18n="sp.filter_all_crit">Tutte le Criticita'</option>
<option value="critical">Critico</option> <option value="critical" data-i18n="sp.crit_critical">Critico</option>
<option value="high">Alto</option> <option value="high" data-i18n="sp.crit_high">Alto</option>
<option value="medium">Medio</option> <option value="medium" data-i18n="sp.crit_medium">Medio</option>
<option value="low">Basso</option> <option value="low" data-i18n="sp.crit_low">Basso</option>
</select> </select>
<select class="form-select" id="filter-status" onchange="applyFilters()" aria-label="Filtra per stato"> <select class="form-select" id="filter-status" onchange="applyFilters()" aria-label="Filtra per stato">
<option value="">Tutti gli Stati</option> <option value="" data-i18n="sp.filter_all_status">Tutti gli Stati</option>
<option value="active">Attivo</option> <option value="active" data-i18n="sp.status_active">Attivo</option>
<option value="under_review">In Revisione</option> <option value="under_review" data-i18n="sp.status_under_review">In Revisione</option>
<option value="suspended">Sospeso</option> <option value="suspended" data-i18n="sp.status_suspended">Sospeso</option>
<option value="terminated">Terminato</option> <option value="terminated" data-i18n="sp.status_terminated">Terminato</option>
</select> </select>
</div> </div>
@@ -465,7 +465,7 @@
<div class="table-container" id="suppliers-table"> <div class="table-container" id="suppliers-table">
<div class="loading-overlay"> <div class="loading-overlay">
<div class="spinner"></div> <div class="spinner"></div>
<span>Caricamento fornitori...</span> <span data-i18n="sp.loading_suppliers">Caricamento fornitori...</span>
</div> </div>
</div> </div>
</div> </div>
@@ -489,7 +489,7 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260631"></script> <script src="/js/common-bi.js?v=20260631"></script>
<script src="/js/i18n.js?v=20260631"></script> <script src="/js/i18n.js?v=20260621sp"></script>
<script src="/js/help.js?v=20260618p2"></script> <script src="/js/help.js?v=20260618p2"></script>
<script> <script>
// ── Auth & Init ───────────────────────────────────────── // ── Auth & Init ─────────────────────────────────────────
@@ -635,8 +635,8 @@
<svg viewBox="0 0 20 20" fill="currentColor" style="width:48px;height:48px;color:var(--gray-300);"> <svg viewBox="0 0 20 20" fill="currentColor" style="width:48px;height:48px;color:var(--gray-300);">
<path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"/>
</svg> </svg>
<h4>Nessun fornitore trovato</h4> <h4>${I18n.t('sp.no_suppliers')}</h4>
<p>Aggiungi il tuo primo fornitore per iniziare il monitoraggio della supply chain.</p> <p>${I18n.t('sp.no_suppliers_hint')}</p>
</div>`; </div>`;
return; return;
} }
@@ -645,13 +645,13 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Nome</th> <th>${I18n.t('sp.col_name')}</th>
<th>Tipo Servizio</th> <th>${I18n.t('sp.col_service_type')}</th>
<th>Criticita'</th> <th>${I18n.t('sp.col_criticality')}</th>
<th>Score Rischio</th> <th>${I18n.t('sp.col_risk_score')}</th>
<th>Stato</th> <th>${I18n.t('sp.col_status')}</th>
<th>Ultima Valutazione</th> <th>${I18n.t('sp.col_last_assessment')}</th>
<th>Azioni</th> <th>${I18n.t('sp.col_actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody>`; <tbody>`;
@@ -752,28 +752,28 @@
<div> <div>
<div style="margin-bottom:8px;"> <div style="margin-bottom:8px;">
<span class="text-link" onclick="backToList()" style="font-size:0.82rem;"> <span class="text-link" onclick="backToList()" style="font-size:0.82rem;">
&larr; Torna all'elenco &larr; ${I18n.t('sp.back_to_list')}
</span> </span>
</div> </div>
<h3>${escapeHtml(supplier.name)}</h3> <h3>${escapeHtml(supplier.name)}</h3>
<div style="margin-top:8px;display:flex;gap:8px;"> <div style="margin-top:8px;display:flex;gap:8px;">
<span class="badge ${critBadge}">${escapeHtml(critLabel)}</span> <span class="badge ${critBadge}">${escapeHtml(critLabel)}</span>
<span class="badge ${statusBadge}">${escapeHtml(statusLabel)}</span> <span class="badge ${statusBadge}">${escapeHtml(statusLabel)}</span>
${supplier.security_requirements_met ? '<span class="badge badge-success">Conforme</span>' : '<span class="badge badge-danger">Non Conforme</span>'} ${supplier.security_requirements_met ? '<span class="badge badge-success">' + I18n.t('sp.compliant') + '</span>' : '<span class="badge badge-danger">' + I18n.t('sp.non_compliant') + '</span>'}
</div> </div>
</div> </div>
<div class="detail-header-actions"> <div class="detail-header-actions">
<button class="btn btn-primary" onclick="openAssessmentModal(${supplier.id})"> <button class="btn btn-primary" onclick="openAssessmentModal(${supplier.id})">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg> <svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
Valuta Fornitore ${I18n.t('sp.assess_supplier')}
</button> </button>
<button class="btn btn-secondary" onclick="sendSupplierQuestionnaire(${supplier.id}, '${escapeHtml((supplier.name||'').replace(/'/g,"\\'"))}')"> <button class="btn btn-secondary" onclick="sendSupplierQuestionnaire(${supplier.id}, '${escapeHtml((supplier.name||'').replace(/'/g,"\\'"))}')">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/><path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/></svg> <svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/><path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/></svg>
Invia questionario ${I18n.t('sp.send_questionnaire')}
</button> </button>
<button class="btn btn-secondary" onclick="openEditModal(${supplier.id})"> <button class="btn btn-secondary" onclick="openEditModal(${supplier.id})">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg> <svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
Modifica ${I18n.t('sp.edit')}
</button> </button>
</div> </div>
</div> </div>
@@ -782,40 +782,40 @@
<div> <div>
<div class="card" style="margin-bottom:16px;"> <div class="card" style="margin-bottom:16px;">
<div class="card-header"> <div class="card-header">
<h3>Informazioni Fornitore</h3> <h3>${I18n.t('sp.info_title')}</h3>
</div> </div>
<div class="card-body" style="padding:0;"> <div class="card-body" style="padding:0;">
<div class="info-grid"> <div class="info-grid">
<div class="info-item"> <div class="info-item">
<div class="info-item-label">Nome</div> <div class="info-item-label">${I18n.t('sp.field_name')}</div>
<div class="info-item-value">${escapeHtml(supplier.name)}</div> <div class="info-item-value">${escapeHtml(supplier.name)}</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-item-label">Partita IVA</div> <div class="info-item-label">${I18n.t('sp.field_vat')}</div>
<div class="info-item-value">${escapeHtml(supplier.vat_number || '-')}</div> <div class="info-item-value">${escapeHtml(supplier.vat_number || '-')}</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-item-label">Referente</div> <div class="info-item-label">${I18n.t('sp.field_contact_name')}</div>
<div class="info-item-value">${escapeHtml(supplier.contact_name || '-')}</div> <div class="info-item-value">${escapeHtml(supplier.contact_name || '-')}</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-item-label">Email</div> <div class="info-item-label">${I18n.t('sp.field_email')}</div>
<div class="info-item-value">${supplier.contact_email ? '<a href="mailto:' + escapeHtml(supplier.contact_email) + '">' + escapeHtml(supplier.contact_email) + '</a>' : '-'}</div> <div class="info-item-value">${supplier.contact_email ? '<a href="mailto:' + escapeHtml(supplier.contact_email) + '">' + escapeHtml(supplier.contact_email) + '</a>' : '-'}</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-item-label">Tipo Servizio</div> <div class="info-item-label">${I18n.t('sp.field_service_type')}</div>
<div class="info-item-value">${escapeHtml(supplier.service_type || '-')}</div> <div class="info-item-value">${escapeHtml(supplier.service_type || '-')}</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-item-label">Criticita'</div> <div class="info-item-label">${I18n.t('sp.col_criticality')}</div>
<div class="info-item-value"><span class="badge ${critBadge}">${escapeHtml(critLabel)}</span></div> <div class="info-item-value"><span class="badge ${critBadge}">${escapeHtml(critLabel)}</span></div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-item-label">Inizio Contratto</div> <div class="info-item-label">${I18n.t('sp.field_contract_start')}</div>
<div class="info-item-value">${formatDate(supplier.contract_start_date)}</div> <div class="info-item-value">${formatDate(supplier.contract_start_date)}</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-item-label">Scadenza Contratto</div> <div class="info-item-label">${I18n.t('sp.field_contract_expiry')}</div>
<div class="info-item-value">${formatDate(supplier.contract_expiry_date)}</div> <div class="info-item-value">${formatDate(supplier.contract_expiry_date)}</div>
</div> </div>
</div> </div>
@@ -824,7 +824,7 @@
${supplier.service_description ? ` ${supplier.service_description ? `
<div class="card" style="margin-bottom:16px;"> <div class="card" style="margin-bottom:16px;">
<div class="card-header"><h3>Descrizione Servizio</h3></div> <div class="card-header"><h3>${I18n.t('sp.service_desc_title')}</h3></div>
<div class="card-body"> <div class="card-body">
<p style="font-size:0.875rem;color:var(--gray-700);white-space:pre-wrap;">${escapeHtml(supplier.service_description)}</p> <p style="font-size:0.875rem;color:var(--gray-700);white-space:pre-wrap;">${escapeHtml(supplier.service_description)}</p>
</div> </div>
@@ -832,7 +832,7 @@
${supplier.notes ? ` ${supplier.notes ? `
<div class="card"> <div class="card">
<div class="card-header"><h3>Note</h3></div> <div class="card-header"><h3>${I18n.t('sp.notes_title')}</h3></div>
<div class="card-body"> <div class="card-body">
<p style="font-size:0.875rem;color:var(--gray-700);white-space:pre-wrap;">${escapeHtml(supplier.notes)}</p> <p style="font-size:0.875rem;color:var(--gray-700);white-space:pre-wrap;">${escapeHtml(supplier.notes)}</p>
</div> </div>
@@ -842,7 +842,7 @@
<div> <div>
<!-- Risk Score Gauge --> <!-- Risk Score Gauge -->
<div class="meta-card" style="text-align:center;"> <div class="meta-card" style="text-align:center;">
<h4 style="text-align:left;">Score Rischio</h4> <h4 style="text-align:left;">${I18n.t('sp.col_risk_score')}</h4>
<div class="score-ring"> <div class="score-ring">
<svg width="${ringSize}" height="${ringSize}" viewBox="0 0 ${ringSize} ${ringSize}"> <svg width="${ringSize}" height="${ringSize}" viewBox="0 0 ${ringSize} ${ringSize}">
<circle class="score-ring-bg" cx="${ringSize/2}" cy="${ringSize/2}" r="${ringRadius}"/> <circle class="score-ring-bg" cx="${ringSize/2}" cy="${ringSize/2}" r="${ringRadius}"/>
@@ -853,37 +853,37 @@
</svg> </svg>
<div class="score-ring-value"> <div class="score-ring-value">
<div class="score-ring-number" style="color:${scoreColor};">${riskScore !== null ? riskScore : '-'}</div> <div class="score-ring-number" style="color:${scoreColor};">${riskScore !== null ? riskScore : '-'}</div>
<div class="score-ring-label">su 100</div> <div class="score-ring-label">${I18n.t('sp.out_of_100')}</div>
</div> </div>
</div> </div>
<p style="font-size:0.78rem;color:var(--gray-500);margin-top:8px;"> <p style="font-size:0.78rem;color:var(--gray-500);margin-top:8px;">
${riskScore !== null ${riskScore !== null
? (riskScore >= 70 ? 'Requisiti di sicurezza soddisfatti' : 'Requisiti di sicurezza non soddisfatti') ? (riskScore >= 70 ? I18n.t('sp.sec_req_met') : I18n.t('sp.sec_req_not_met'))
: 'Nessuna valutazione effettuata'} : I18n.t('sp.no_assessment_done')}
</p> </p>
</div> </div>
<!-- Assessment Info --> <!-- Assessment Info -->
<div class="meta-card"> <div class="meta-card">
<h4>Valutazione</h4> <h4>${I18n.t('sp.assessment_label')}</h4>
<div class="meta-row"> <div class="meta-row">
<span class="meta-label">Ultima Valutazione</span> <span class="meta-label">${I18n.t('sp.last_assessment')}</span>
<span class="meta-value">${formatDate(supplier.last_assessment_date)}</span> <span class="meta-value">${formatDate(supplier.last_assessment_date)}</span>
</div> </div>
<div class="meta-row"> <div class="meta-row">
<span class="meta-label">Prossima Valutazione</span> <span class="meta-label">${I18n.t('sp.next_assessment')}</span>
<span class="meta-value">${formatDate(supplier.next_assessment_date)}</span> <span class="meta-value">${formatDate(supplier.next_assessment_date)}</span>
</div> </div>
<div class="meta-row"> <div class="meta-row">
<span class="meta-label">Requisiti Sicurezza</span> <span class="meta-label">${I18n.t('sp.sec_requirements')}</span>
<span class="meta-value"> <span class="meta-value">
${supplier.security_requirements_met ${supplier.security_requirements_met
? '<span class="badge badge-success">Soddisfatti</span>' ? '<span class="badge badge-success">' + I18n.t('sp.met') + '</span>'
: '<span class="badge badge-danger">Non Soddisfatti</span>'} : '<span class="badge badge-danger">' + I18n.t('sp.not_met') + '</span>'}
</span> </span>
</div> </div>
<div class="meta-row"> <div class="meta-row">
<span class="meta-label">Creato il</span> <span class="meta-label">${I18n.t('sp.created_at')}</span>
<span class="meta-value">${formatDate(supplier.created_at)}</span> <span class="meta-value">${formatDate(supplier.created_at)}</span>
</div> </div>
</div> </div>
@@ -932,75 +932,75 @@
function showSupplierFormModal(supplier) { function showSupplierFormModal(supplier) {
const isEdit = !!supplier; const isEdit = !!supplier;
const title = isEdit ? 'Modifica Fornitore' : 'Nuovo Fornitore'; const title = isEdit ? I18n.t('sp.edit_supplier') : I18n.t('sp.new_supplier_modal');
const criticalityOptions = Object.entries(CRITICALITY_LABELS).map(([val, label]) => const criticalityOptions = Object.entries(CRITICALITY_LABELS).map(([val, label]) =>
`<option value="${val}" ${supplier && supplier.criticality === val ? 'selected' : ''}>${label}</option>` `<option value="${val}" ${supplier && supplier.criticality === val ? 'selected' : ''}>${label}</option>`
).join(''); ).join('');
const categoryOptions = '<option value="">— Nessuna —</option>' + const categoryOptions = `<option value="">${I18n.t('sp.category_none')}</option>` +
allCategories.map(c => allCategories.map(c =>
`<option value="${c.id}" ${supplier && String(supplier.category_id) === String(c.id) ? 'selected' : ''}>${escapeHtml(c.name)}${Number(c.is_system) === 1 ? '' : ' (personalizzata)'}</option>` `<option value="${c.id}" ${supplier && String(supplier.category_id) === String(c.id) ? 'selected' : ''}>${escapeHtml(c.name)}${Number(c.is_system) === 1 ? '' : ' (' + I18n.t('sp.cat_custom') + ')'}</option>`
).join(''); ).join('');
const content = ` const content = `
<form id="supplier-form" onsubmit="return false;"> <form id="supplier-form" onsubmit="return false;">
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;"> <div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="form-group"> <div class="form-group">
<label for="form-name" class="form-label">Nome Fornitore <span class="required">*</span></label> <label for="form-name" class="form-label">${I18n.t('sp.field_supplier_name')} <span class="required">*</span></label>
<input type="text" class="form-input" id="form-name" value="${isEdit ? escapeHtml(supplier.name) : ''}" required placeholder="Nome dell'azienda"> <input type="text" class="form-input" id="form-name" value="${isEdit ? escapeHtml(supplier.name) : ''}" required placeholder="${I18n.t('sp.ph_company_name')}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="form-vat" class="form-label">Partita IVA</label> <label for="form-vat" class="form-label">${I18n.t('sp.field_vat')}</label>
<input type="text" class="form-input" id="form-vat" value="${isEdit && supplier.vat_number ? escapeHtml(supplier.vat_number) : ''}" placeholder="IT01234567890"> <input type="text" class="form-input" id="form-vat" value="${isEdit && supplier.vat_number ? escapeHtml(supplier.vat_number) : ''}" placeholder="IT01234567890">
</div> </div>
</div> </div>
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;"> <div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="form-group"> <div class="form-group">
<label for="form-contact-name" class="form-label">Referente</label> <label for="form-contact-name" class="form-label">${I18n.t('sp.field_contact_name')}</label>
<input type="text" class="form-input" id="form-contact-name" value="${isEdit && supplier.contact_name ? escapeHtml(supplier.contact_name) : ''}" placeholder="Nome e cognome"> <input type="text" class="form-input" id="form-contact-name" value="${isEdit && supplier.contact_name ? escapeHtml(supplier.contact_name) : ''}" placeholder="${I18n.t('sp.ph_full_name')}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="form-contact-email" class="form-label">Email Contatto</label> <label for="form-contact-email" class="form-label">${I18n.t('sp.field_contact_email')}</label>
<input type="email" class="form-input" id="form-contact-email" value="${isEdit && supplier.contact_email ? escapeHtml(supplier.contact_email) : ''}" placeholder="email@azienda.it"> <input type="email" class="form-input" id="form-contact-email" value="${isEdit && supplier.contact_email ? escapeHtml(supplier.contact_email) : ''}" placeholder="email@azienda.it">
</div> </div>
</div> </div>
<div class="form-row" style="display:grid;grid-template-columns:2fr 1fr;gap:16px;"> <div class="form-row" style="display:grid;grid-template-columns:2fr 1fr;gap:16px;">
<div class="form-group"> <div class="form-group">
<label for="form-service-type" class="form-label">Tipo Servizio <span class="required">*</span></label> <label for="form-service-type" class="form-label">${I18n.t('sp.field_service_type')} <span class="required">*</span></label>
<input type="text" class="form-input" id="form-service-type" value="${isEdit && supplier.service_type ? escapeHtml(supplier.service_type) : ''}" required placeholder="es. Cloud Hosting, Sviluppo SW, Consulenza"> <input type="text" class="form-input" id="form-service-type" value="${isEdit && supplier.service_type ? escapeHtml(supplier.service_type) : ''}" required placeholder="${I18n.t('sp.ph_service_type')}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="form-criticality" class="form-label">Criticita'</label> <label for="form-criticality" class="form-label">${I18n.t('sp.col_criticality')}</label>
<select class="form-select" id="form-criticality"> <select class="form-select" id="form-criticality">
${criticalityOptions} ${criticalityOptions}
</select> </select>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="form-category">Categoria fornitore</label> <label class="form-label" for="form-category">${I18n.t('sp.category_field')}</label>
<select class="form-select" id="form-category"> <select class="form-select" id="form-category">
${categoryOptions} ${categoryOptions}
</select> </select>
<small style="color:var(--gray-500);font-size:0.75rem;">Classifica il fornitore (es. Cloud Provider). Gestisci le categorie dalla tab "Categorie".</small> <small style="color:var(--gray-500);font-size:0.75rem;">${I18n.t('sp.category_hint')}</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="form-service-desc" class="form-label">Descrizione Servizio</label> <label for="form-service-desc" class="form-label">${I18n.t('sp.service_desc_title')}</label>
<textarea class="form-textarea" id="form-service-desc" rows="3" placeholder="Descrivi il servizio fornito...">${isEdit && supplier.service_description ? escapeHtml(supplier.service_description) : ''}</textarea> <textarea class="form-textarea" id="form-service-desc" rows="3" placeholder="${I18n.t('sp.ph_service_desc')}">${isEdit && supplier.service_description ? escapeHtml(supplier.service_description) : ''}</textarea>
</div> </div>
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;"> <div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="form-group"> <div class="form-group">
<label for="form-contract-start" class="form-label">Inizio Contratto</label> <label for="form-contract-start" class="form-label">${I18n.t('sp.field_contract_start')}</label>
<input type="date" class="form-input" id="form-contract-start" value="${isEdit && supplier.contract_start_date ? supplier.contract_start_date.substring(0, 10) : ''}"> <input type="date" class="form-input" id="form-contract-start" value="${isEdit && supplier.contract_start_date ? supplier.contract_start_date.substring(0, 10) : ''}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="form-contract-expiry" class="form-label">Scadenza Contratto</label> <label for="form-contract-expiry" class="form-label">${I18n.t('sp.field_contract_expiry')}</label>
<input type="date" class="form-input" id="form-contract-expiry" value="${isEdit && supplier.contract_expiry_date ? supplier.contract_expiry_date.substring(0, 10) : ''}"> <input type="date" class="form-input" id="form-contract-expiry" value="${isEdit && supplier.contract_expiry_date ? supplier.contract_expiry_date.substring(0, 10) : ''}">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="form-notes" class="form-label">Note</label> <label for="form-notes" class="form-label">${I18n.t('sp.notes_title')}</label>
<textarea class="form-textarea" id="form-notes" rows="3" placeholder="Note aggiuntive...">${isEdit && supplier.notes ? escapeHtml(supplier.notes) : ''}</textarea> <textarea class="form-textarea" id="form-notes" rows="3" placeholder="${I18n.t('sp.ph_notes')}">${isEdit && supplier.notes ? escapeHtml(supplier.notes) : ''}</textarea>
</div> </div>
</form> </form>
`; `;
@@ -1008,8 +1008,8 @@
showModal(title, content, { showModal(title, content, {
size: 'lg', size: 'lg',
footer: ` footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button> <button class="btn btn-secondary" onclick="closeModal()">${I18n.t('sp.cancel')}</button>
<button class="btn btn-primary" onclick="saveSupplier(${isEdit ? supplier.id : 'null'})">${isEdit ? 'Salva Modifiche' : 'Aggiungi Fornitore'}</button> <button class="btn btn-primary" onclick="saveSupplier(${isEdit ? supplier.id : 'null'})">${isEdit ? I18n.t('sp.save_changes') : I18n.t('sp.add_supplier')}</button>
` `
}); });
} }
@@ -1076,9 +1076,9 @@
<div class="assessment-question"> <div class="assessment-question">
<div class="assessment-question-text">${idx + 1}. ${escapeHtml(q.text)}</div> <div class="assessment-question-text">${idx + 1}. ${escapeHtml(q.text)}</div>
<div class="assessment-options"> <div class="assessment-options">
<span class="assessment-option" data-q="${q.id}" data-val="yes" onclick="selectAnswer('${q.id}', 'yes', this)">Si'</span> <span class="assessment-option" data-q="${q.id}" data-val="yes" onclick="selectAnswer('${q.id}', 'yes', this)">${I18n.t('sp.answer_yes')}</span>
<span class="assessment-option" data-q="${q.id}" data-val="partial" onclick="selectAnswer('${q.id}', 'partial', this)">Parziale</span> <span class="assessment-option" data-q="${q.id}" data-val="partial" onclick="selectAnswer('${q.id}', 'partial', this)">${I18n.t('sp.answer_partial')}</span>
<span class="assessment-option" data-q="${q.id}" data-val="no" onclick="selectAnswer('${q.id}', 'no', this)">No</span> <span class="assessment-option" data-q="${q.id}" data-val="no" onclick="selectAnswer('${q.id}', 'no', this)">${I18n.t('sp.answer_no')}</span>
</div> </div>
</div> </div>
`; `;
@@ -1086,23 +1086,22 @@
const content = ` const content = `
<p style="margin-bottom:16px;font-size:0.9rem;color:var(--gray-600);"> <p style="margin-bottom:16px;font-size:0.9rem;color:var(--gray-600);">
Rispondi alle domande per valutare il livello di sicurezza del fornitore. ${I18n.t('sp.assess_intro')}
Il punteggio di rischio verra' calcolato automaticamente.
</p> </p>
<div id="assessment-questions"> <div id="assessment-questions">
${questionsHtml} ${questionsHtml}
</div> </div>
<div class="assessment-score-preview" id="assessment-score-preview"> <div class="assessment-score-preview" id="assessment-score-preview">
<div class="score-label">Score Rischio Calcolato</div> <div class="score-label">${I18n.t('sp.calculated_risk_score')}</div>
<div class="score-val" id="assessment-live-score" style="color:var(--gray-300);">--</div> <div class="score-val" id="assessment-live-score" style="color:var(--gray-300);">--</div>
</div> </div>
`; `;
showModal('Valutazione Fornitore', content, { showModal(I18n.t('sp.assess_modal_title'), content, {
size: 'lg', size: 'lg',
footer: ` footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button> <button class="btn btn-secondary" onclick="closeModal()">${I18n.t('sp.cancel')}</button>
<button class="btn btn-primary" onclick="submitAssessment(${supplierId})">Salva Valutazione</button> <button class="btn btn-primary" onclick="submitAssessment(${supplierId})">${I18n.t('sp.save_assessment')}</button>
` `
}); });
} }
@@ -1225,7 +1224,7 @@
ov.innerHTML = ` ov.innerHTML = `
<div style="background:#fff;border-radius:10px;max-width:640px;width:100%;max-height:90vh;overflow:auto;"> <div style="background:#fff;border-radius:10px;max-width:640px;width:100%;max-height:90vh;overflow:auto;">
<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid #e2e8f0;"> <div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid #e2e8f0;">
<h3 style="margin:0;"><svg class="icon icon-sm" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-upload"></use></svg> Importa fornitori</h3> <h3 style="margin:0;"><svg class="icon icon-sm" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-upload"></use></svg> ${I18n.t('sp.import_title')}</h3>
<button onclick="document.getElementById('importModalDyn').remove()" style="background:none;border:none;font-size:22px;cursor:pointer;line-height:1;">&times;</button> <button onclick="document.getElementById('importModalDyn').remove()" style="background:none;border:none;font-size:22px;cursor:pointer;line-height:1;">&times;</button>
</div> </div>
<div style="padding:20px;"> <div style="padding:20px;">
@@ -1241,8 +1240,8 @@
<div id="importResult" style="margin-top:10px;font-size:14px;"></div> <div id="importResult" style="margin-top:10px;font-size:14px;"></div>
</div> </div>
<div style="display:flex;justify-content:flex-end;gap:10px;padding:16px 20px;border-top:1px solid #e2e8f0;"> <div style="display:flex;justify-content:flex-end;gap:10px;padding:16px 20px;border-top:1px solid #e2e8f0;">
<button class="btn btn-secondary" onclick="document.getElementById('importModalDyn').remove()">Annulla</button> <button class="btn btn-secondary" onclick="document.getElementById('importModalDyn').remove()">${I18n.t('sp.cancel')}</button>
<button class="btn btn-primary" id="importRunBtn" onclick="runSupplierImport()"><svg class="icon icon-sm icon-white" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-upload"></use></svg> Importa</button> <button class="btn btn-primary" id="importRunBtn" onclick="runSupplierImport()"><svg class="icon icon-sm icon-white" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-upload"></use></svg> ${I18n.t('sp.import_run')}</button>
</div> </div>
</div>`; </div>`;
document.body.appendChild(ov); document.body.appendChild(ov);
@@ -1336,11 +1335,11 @@
const rows = allCategories.map(c => { const rows = allCategories.map(c => {
const preset = Number(c.is_system) === 1; const preset = Number(c.is_system) === 1;
const badge = preset const badge = preset
? '<span class="badge badge-neutral" title="Categoria di sistema">Preset</span>' ? '<span class="badge badge-neutral" title="Categoria di sistema">' + I18n.t('sp.cat_preset') + '</span>'
: '<span class="badge badge-info">Personalizzata</span>'; : '<span class="badge badge-info">' + I18n.t('sp.cat_custom') + '</span>';
const actions = preset ? '<span style="color:var(--gray-400);font-size:0.75rem;">non modificabile</span>' : ` const actions = preset ? '<span style="color:var(--gray-400);font-size:0.75rem;">' + I18n.t('sp.cat_not_editable') + '</span>' : `
<button class="btn btn-sm btn-secondary" onclick="editCategory(${c.id})">Modifica</button> <button class="btn btn-sm btn-secondary" onclick="editCategory(${c.id})">${I18n.t('sp.edit')}</button>
<button class="btn btn-sm btn-danger" onclick="removeCategory(${c.id})">Elimina</button>`; <button class="btn btn-sm btn-danger" onclick="removeCategory(${c.id})">${I18n.t('sp.delete')}</button>`;
return `<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 12px;border-bottom:1px solid var(--gray-100);flex-wrap:wrap;"> return `<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 12px;border-bottom:1px solid var(--gray-100);flex-wrap:wrap;">
<div style="flex:1;min-width:200px;"> <div style="flex:1;min-width:200px;">
<div style="font-weight:600;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">${escapeHtml(c.name)} ${badge}</div> <div style="font-weight:600;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">${escapeHtml(c.name)} ${badge}</div>
@@ -1351,13 +1350,13 @@
}).join(''); }).join('');
const content = ` const content = `
<div style="display:flex;justify-content:flex-end;margin-bottom:10px;"> <div style="display:flex;justify-content:flex-end;margin-bottom:10px;">
<button class="btn btn-sm btn-primary" onclick="newCategory()">+ Nuova categoria</button> <button class="btn btn-sm btn-primary" onclick="newCategory()">${I18n.t('sp.cat_new')}</button>
</div> </div>
<div id="categories-list-body">${rows || '<p style="color:var(--gray-500);">Nessuna categoria.</p>'}</div> <div id="categories-list-body">${rows || '<p style="color:var(--gray-500);">' + I18n.t('sp.cat_empty') + '</p>'}</div>
<p style="font-size:0.75rem;color:var(--gray-500);margin-top:12px;">Le categorie <strong>preset</strong> sono fornite dal sistema e non sono modificabili. Puoi creare categorie personalizzate per la tua organizzazione.</p>`; <p style="font-size:0.75rem;color:var(--gray-500);margin-top:12px;">${I18n.t('sp.cat_preset_note')}</p>`;
showModal('Categorie fornitore', content, { showModal(I18n.t('sp.cat_title'), content, {
size: 'lg', size: 'lg',
footer: `<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>` footer: `<button class="btn btn-secondary" onclick="closeModal()">${I18n.t('sp.close')}</button>`
}); });
} }
@@ -1368,17 +1367,17 @@
const isEdit = !!cat; const isEdit = !!cat;
const content = ` const content = `
<div class="form-group"> <div class="form-group">
<label class="form-label" for="cat-name">Nome categoria <span class="required">*</span></label> <label class="form-label" for="cat-name">${I18n.t('sp.cat_name')} <span class="required">*</span></label>
<input type="text" class="form-input" id="cat-name" value="${isEdit ? escapeHtml(cat.name) : ''}" placeholder="es. Cloud Provider"> <input type="text" class="form-input" id="cat-name" value="${isEdit ? escapeHtml(cat.name) : ''}" placeholder="${I18n.t('sp.ph_cat_name')}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="cat-desc">Descrizione</label> <label class="form-label" for="cat-desc">${I18n.t('sp.cat_desc')}</label>
<textarea class="form-textarea" id="cat-desc" rows="3" placeholder="Descrizione opzionale...">${isEdit && cat.description ? escapeHtml(cat.description) : ''}</textarea> <textarea class="form-textarea" id="cat-desc" rows="3" placeholder="${I18n.t('sp.ph_cat_desc')}">${isEdit && cat.description ? escapeHtml(cat.description) : ''}</textarea>
</div>`; </div>`;
showModal(isEdit ? 'Modifica categoria' : 'Nuova categoria', content, { showModal(isEdit ? I18n.t('sp.cat_edit') : I18n.t('sp.cat_new_title'), content, {
footer: ` footer: `
<button class="btn btn-secondary" onclick="openCategoriesModal()">Annulla</button> <button class="btn btn-secondary" onclick="openCategoriesModal()">${I18n.t('sp.cancel')}</button>
<button class="btn btn-primary" onclick="saveCategory(${isEdit ? cat.id : 'null'})">${isEdit ? 'Salva' : 'Crea'}</button>` <button class="btn btn-primary" onclick="saveCategory(${isEdit ? cat.id : 'null'})">${isEdit ? I18n.t('sp.save') : I18n.t('sp.create')}</button>`
}); });
} }
@@ -1425,8 +1424,8 @@
}; };
async function openTemplatesModal() { async function openTemplatesModal() {
showModal('Template questionari', '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento...</span></div>', { showModal(I18n.t('sp.tpl_title'), '<div class="loading-overlay"><div class="spinner"></div><span>' + I18n.t('sp.loading') + '</span></div>', {
size: 'lg', footer: `<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>` size: 'lg', footer: `<button class="btn btn-secondary" onclick="closeModal()">${I18n.t('sp.close')}</button>`
}); });
try { try {
const res = await api.getQuestionnaireTemplates(); const res = await api.getQuestionnaireTemplates();
@@ -1442,18 +1441,18 @@
const b = document.querySelector('#modal-overlay .modal-body'); const b = document.querySelector('#modal-overlay .modal-body');
if (!b) return; if (!b) return;
if (!templates.length) { if (!templates.length) {
b.innerHTML = '<p style="color:var(--gray-500);">Nessun template questionario per questa organizzazione.</p>'; b.innerHTML = '<p style="color:var(--gray-500);">' + I18n.t('sp.tpl_empty') + '</p>';
return; return;
} }
b.innerHTML = templates.map(t => { b.innerHTML = templates.map(t => {
const qc = Number(t.question_count || 0); const qc = Number(t.question_count || 0);
const st = t.status === 'active' ? '<span class="badge badge-success">Attivo</span>' : `<span class="badge badge-neutral">${escapeHtml(t.status || 'bozza')}</span>`; const st = t.status === 'active' ? '<span class="badge badge-success">' + I18n.t('sp.tpl_active') + '</span>' : `<span class="badge badge-neutral">${escapeHtml(t.status || 'bozza')}</span>`;
return `<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 12px;border-bottom:1px solid var(--gray-100);flex-wrap:wrap;"> return `<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 12px;border-bottom:1px solid var(--gray-100);flex-wrap:wrap;">
<div style="flex:1;min-width:200px;"> <div style="flex:1;min-width:200px;">
<div style="font-weight:600;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">${escapeHtml(t.name)} ${Number(t.is_default)===1?'<span class="badge badge-neutral">Default</span>':''} ${st}</div> <div style="font-weight:600;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">${escapeHtml(t.name)} ${Number(t.is_default)===1?'<span class="badge badge-neutral">' + I18n.t('sp.tpl_default') + '</span>':''} ${st}</div>
<div style="font-size:0.78rem;color:var(--gray-500);margin-top:2px;">${t.category_name?escapeHtml(t.category_name)+' · ':''}${qc} domand${qc===1?'a':'e'}${t.current_version?' · v'+escapeHtml(String(t.current_version)):''}</div> <div style="font-size:0.78rem;color:var(--gray-500);margin-top:2px;">${t.category_name?escapeHtml(t.category_name)+' · ':''}${qc} domand${qc===1?'a':'e'}${t.current_version?' · v'+escapeHtml(String(t.current_version)):''}</div>
</div> </div>
<button class="btn btn-sm btn-secondary" onclick="viewTemplate(${t.id})">Vedi domande</button> <button class="btn btn-sm btn-secondary" onclick="viewTemplate(${t.id})">${I18n.t('sp.tpl_view_questions')}</button>
</div>`; </div>`;
}).join(''); }).join('');
} }
@@ -1483,17 +1482,17 @@
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px;padding-left:24px;"> <div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px;padding-left:24px;">
${q.nis2_ref?`<span class="badge badge-info" title="Riferimento normativo">${escapeHtml(q.nis2_ref)}</span>`:''} ${q.nis2_ref?`<span class="badge badge-info" title="Riferimento normativo">${escapeHtml(q.nis2_ref)}</span>`:''}
<span class="badge badge-neutral">${escapeHtml(QTYPE_LABELS[q.question_type]||q.question_type||'')}</span> <span class="badge badge-neutral">${escapeHtml(QTYPE_LABELS[q.question_type]||q.question_type||'')}</span>
${q.weight!=null?`<span class="badge badge-neutral">Peso ${escapeHtml(String(q.weight))}</span>`:''} ${q.weight!=null?`<span class="badge badge-neutral">${I18n.t('sp.tpl_weight')} ${escapeHtml(String(q.weight))}</span>`:''}
${Number(q.is_required)===1?'<span class="badge badge-neutral">Obbligatoria</span>':''} ${Number(q.is_required)===1?'<span class="badge badge-neutral">' + I18n.t('sp.tpl_required') + '</span>':''}
${Number(q.high_criticality_only)===1?'<span class="badge badge-warning">Solo alta criticita</span>':''} ${Number(q.high_criticality_only)===1?'<span class="badge badge-warning">' + I18n.t('sp.tpl_high_crit') + '</span>':''}
</div> </div>
</div>`).join('') : '<p style="color:var(--gray-500);">Nessuna domanda nel template.</p>'; </div>`).join('') : '<p style="color:var(--gray-500);">Nessuna domanda nel template.</p>';
b.innerHTML = ` b.innerHTML = `
<div style="margin-bottom:12px;"><button class="btn btn-sm btn-secondary" onclick="openTemplatesModal()">&larr; Torna ai template</button></div> <div style="margin-bottom:12px;"><button class="btn btn-sm btn-secondary" onclick="openTemplatesModal()">&larr; ${I18n.t('sp.tpl_back_plain')}</button></div>
<h3 style="margin:0 0 4px;">${escapeHtml(tpl.name)}</h3> <h3 style="margin:0 0 4px;">${escapeHtml(tpl.name)}</h3>
<div style="font-size:0.8rem;color:var(--gray-500);margin-bottom:12px;">${qs.length} domand${qs.length===1?'a':'e'}${tpl.current_version?' · versione '+escapeHtml(String(tpl.current_version)):''}${tpl.pass_threshold!=null?' · soglia '+escapeHtml(String(tpl.pass_threshold))+'%':''}</div> <div style="font-size:0.8rem;color:var(--gray-500);margin-bottom:12px;">${qs.length} domand${qs.length===1?'a':'e'}${tpl.current_version?' · versione '+escapeHtml(String(tpl.current_version)):''}${tpl.pass_threshold!=null?' · soglia '+escapeHtml(String(tpl.pass_threshold))+'%':''}</div>
${tpl.description?`<p style="font-size:0.85rem;color:var(--gray-600);margin-bottom:12px;">${escapeHtml(tpl.description)}</p>`:''} ${tpl.description?`<p style="font-size:0.85rem;color:var(--gray-600);margin-bottom:12px;">${escapeHtml(tpl.description)}</p>`:''}
<div style="background:var(--info-bg,#eff6ff);border-radius:6px;padding:10px 12px;margin-bottom:12px;font-size:0.78rem;color:var(--primary);">Sola lettura. L'editor delle domande sara' disponibile in una prossima versione.</div> <div style="background:var(--info-bg,#eff6ff);border-radius:6px;padding:10px 12px;margin-bottom:12px;font-size:0.78rem;color:var(--primary);">${I18n.t('sp.tpl_readonly')}</div>
${qHtml}`; ${qHtml}`;
} }
+1 -1
View File
@@ -1 +1 @@
{"version": "1.24.4", "build": "2026-06-20-v1.24.4", "date": "2026-06-20", "changelog": "Version-provenance sulle segnalazioni (standard feedback-version-provenance): la tabella feedback_reports ora salva client_surface (web-v2), client_version (release reale del prodotto) e client_build; il backend (FeedbackController/FeedbackService) li persiste e il widget di segnalazione (feedback.js) li invia leggendo /version.json. Migrazione 062. Re-ingest nella KB della guida UI V2 corretta (sostituiti i chunk stale)."} {"version": "1.24.5", "build": "2026-06-21-v1.24.5", "date": "2026-06-21", "changelog": "Internazionalizzazione EN del modulo Fornitori: il Portale Fornitori esterno (supplier-portal.html) e' ora bilingue IT/EN con meccanismo self-contained (dizionario inline + toggle lingua, ~79 stringhe) preservando il flusso OTP/token; la pagina interna Supply Chain (supply-chain.html) ha l'i18n estesa (data-i18n + I18n.t, +82 chiavi sp.* in i18n.js, ora 120 totali). Le domande dei questionari restano nei dati (non tradotte)."}