nis2-agile/docs/supplier-portal/UX_MINI_SPEC.md
DevEnv nis2-agile aa2db4c6c2 [DOCS] Design modulo questionari fornitori + portale OTP + AI consulente normativo
Pacchetto di design completo (nessun codice applicato, nessuna migrazione eseguita):
- DESIGN aggiornato con 5 review agenti + 3 decisioni utente + pilastro AI consulente (sez. 12-14)
- docs/supplier-portal/template-nis2-base.questions.json: 26 domande GV.SC (Allegato 2 ACN) con nis2_ref/vuln_flag e fonti certe verbatim
- docs/supplier-portal/AI_CONSULENTE_NORMATIVO.md: corpus normativo aggiornato + persona consulente (modello TRPG)
- docs/supplier-portal/UX_MINI_SPEC.md: mini-spec portale fornitore (stati/copy/autosave/mobile/a11y/editor no-code)
- docs/sql/032-035: migrazioni idempotenti proposte (modulo, suppliers, portale auth, migrazione 027->campaigns)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 09:53:07 +02:00

9.5 KiB
Raw Blame History

Mini-spec UX — Portale fornitore + Editor lato azienda (§6)

Deliverable agente design 2026-05-31. Chiude i finding UX F1F25 di DESIGN_MODULO_QUESTIONARI_FORNITORI §12. Copy + comportamento + acceptance criteria. Utente di riferimento del portale: referente PMI fornitrice, NON tecnico, da smartphone, 1 volta ogni 6-12 mesi, senza supporto IT. Ogni scelta privilegia chiarezza, target ≥44px, recupero da errore sempre possibile. Convenzioni riusate (verificate): --primary:#06B6D4; progress bar di assessment.html (updateProgressBar, id progress-pct/-bar-fill/-answered/-total, i18n assessment.progress); debounce, showNotification, _loadFirmBranding() (GET /api/branding/current), initIdleTimeout() overlay countdown — in common.js; badge badge-success|warning|danger|neutral; sendSupplierQuestionnaire+prompt rigenera-link in supply-chain.html; baseline + footer GDPR + noindex,nofollow in supplier-assessment.html. Endpoint (forma router §12): POST /api/supplier-portal/request-otp, verify-otp, GET /me, GET /{id}, POST /{id}/submit, PATCH /{id}/answers. i18n: namespace sp.* (+ sp.editor.*) in i18n.js; ogni stringa IT ha controparte EN.

1. Portale fornitore — supplier-portal.html

Principi: una colonna max 560px, branding del committente in header (NIS2 Agile solo footer), zero gergo ("codice"/"link"/"questionario", mai "token/OTP/JWT/campaign"), selettore lingua IT/EN in alto a destra, noindex,nofollow, nessun link all'app interna.

Stati:

  1. Loading: spinner; se GET /me/branding >8s → stato errore rete.
  2. Richiesta accesso: input email (type=email inputmode=email autocomplete=email autofocus, label visibile), CTA "Invia link di accesso", micro-copy GDPR con link informativa. Risposta sempre opaca (anti-enumerazione) → va sempre allo stato "email inviata".
  3. Email inviata: "Controlla la tua email" + avviso spam + mittente; campo OTP fallback; reinvio con countdown 60s (raddoppia dal 3° invio); via d'uscita "Usa un'altra email".
  4. Verifica OTP — errori specifici (F2/F3): messaggi umani con via d'uscita per: codice errato ("…hai ancora {n} tentativi", contatore reale da supplier_otp.attempts), scaduto ("→ Invia un nuovo codice"), lockout ("bloccato per {minuti} min", persistente lato DB), link già usato ("→ Invia un nuovo link"), errore rete ("Riprova"). Mai codici HTTP/"token".
  5. Gerarchia magic-link/OTP (F1/F4): nel portale e nell'email il pulsante magic-link è primario, OTP fallback. No doppio-consumo: l'apertura del link (prefetch mail mobile) NON consuma il token; consumo solo al click esplicito "Entra nel portale". AC: aprire 2× e poi cliccare deve funzionare.
  6. Dashboard: se 1 solo questionario aperto → vai diretto alla compilazione. Se multipli → card con badge stato + scadenza in chiaro + CTA "Compila/Riprendi/Vedi ricevuta"; ordinamento da-compilare prima per scadenza.
  7. Compilazione: header con badge scadenza sticky + progress bar + domande single-page (vedi §4).
  8. Vuoto: "Nessun questionario in attesa" + suggerimento contattare committente.
  9. Completato/Ricevuta: "Questionario inviato. Grazie!" + "Scarica PDF" + (Caso B) "Torna ai questionari". Score mostrato solo se show_score_to_supplier=true (default OFF, F23) → rimuovere/condizionare lo score sempre-mostrato dell'attuale supplier-assessment.html.
  10. Errore rete: non distruttivo, risposte salvate localmente, "Riprova".
  11. Sessione in scadenza (4h): overlay countdown (riuso initIdleTimeout), "Continua" estende; alla scadenza "Le tue risposte sono salvate. Richiedi un nuovo link per riprendere". Ripresa dall'ultima domanda dopo nuovo OTP.

2. Autosave (F11 — priorità assoluta)

  • Stato in_progress alla prima risposta. Salvataggio per-domanda via PATCH /{id}/answers con debounce(fn,800).
  • Doppio livello: localStorage immediato (resilienza offline) + PATCH server (cross-device). Server = source of truth; rinvia PATCH falliti.
  • Indicatore discreto: "Salvataggio…" / "Bozza salvata {ora}" / "Salvataggio in sospeso — riproveremo".
  • Ripresa: banner "Bentornato. Hai risposto a {x} di {y} domande" + "Riprendi" → scroll alla prima domanda senza risposta.
  • "Salva bozza" (ghost, ridondante ma rassicurante) vs "Invia definitivo" (CTA primaria, conferma in modale "dopo l'invio non potrai modificare", disabilitata finché le obbligatorie non sono complete; se mancano → scroll alla prima + "{k} risposte obbligatorie mancanti").
  • AC: chiudere/riaprire (anche altro device dopo nuovo OTP) → risposte presenti, cursore sull'ultima non risposta; submit blocca PATCH successivi (409 "già inviato").

3. Email invito/OTP (F6)

Inviata via template /api/emails/send (NON send-raw → OTP fuori da email_log). Branding committente, una sola CTA. Contenuto minimo: (1) oggetto con nome committente; (2) motivo 1 frase ("Come fornitore di {Committente}…richiesto dalla normativa NIS2"); (3) tempo stimato (~{n} min); (4) scadenza in chiaro (solo se due_at); (5) CTA singola magic-link "Compila il questionario"; (6) OTP fallback "Oppure inserisci questo codice: 123 456 (15 min)"; (7) validità "15 minuti e una sola volta"; (8) footer GDPR Art.13/14 (titolare=committente, NIS2 Agile=responsabile Art.28, link informativa, "se non eri tu, ignora"). AC: OTP mai nei log; 1 sola CTA; nome committente in oggetto+corpo; scadenza solo se valorizzata.

4. Compilazione — ergonomia per tipo

Chrome: progress bar (riuso updateProgressBar) + "{answered} di {total}"; badge scadenza sticky (neutral >7gg / warning ≤7gg / danger scaduto). Per tipo:

  • yes_no_partial: 3 bottoni grandi (radio nativi sotto) "Sì/Parzialmente/No".
  • scale_1_5: 5 bottoni con etichette agli estremi sempre visibili (da options min/max). Mai scala nuda.
  • single_choice: radio nativi verticali. multi_choice: checkbox + hint "Puoi selezionare più opzioni".
  • text: textarea + esempio placeholder. number: inputmode=numeric.
  • file: formati/dimensioni dichiarati prima del tap; accept + capture (foto documenti); feedback upload (%/check/rimuovi); errori formato/dimensione con suggerimento; async, sopravvive ad autosave (file_ref).
  • help_text: sempre visibile sotto la domanda (mai tooltip/hover — non funziona su touch).
  • "Non applicabile" / "Non so" dove sensato: conta come risposta per la progress bar, distinto dal "No" nello scoring.

5. Ricevuta, multi-referente, score

  • Ricevuta (F22): email conferma al referente + PDF risposte scaricabile (da email e da §1.9). PDF = intestazione committente+fornitore, data, domande+risposte+allegati, riferimento normativo. Niente score se OFF. Snapshot immutabile → rigenerabile identico.
  • Multi-referente/delega (F24): "Inoltra a un collega" → nuovo accesso OTP per quell'email, stessa campagna/supplier_id, autosave condiviso, traccia answered_by. Delegato vede solo quella campagna (no IDOR), stessi limiti OTP.
  • Score (F23): show_score_to_supplier per template/campagna, default false. OFF = solo conferma invio. Toggle nell'editor "Mostra il punteggio al fornitore".

6. Editor lato azienda (F16) — no-code in supply-chain.html

Tab "Categorie" / "Template questionari" / "Campagne-scadenze". Mappatura tecnico→no-code:

  • slug/code: auto-generati, nascosti. order_index: drag-and-drop (+ frecce ↑/↓ accessibili).
  • options JSON: editor a righe ("+ Aggiungi opzione"); scale = 2 campi "Etichetta minimo/massimo".
  • reminder_offsets: chip "14/7/1 giorni prima". recurrence_months: dropdown "Una tantum/Ogni 6/12/24 mesi/Personalizzato".
  • weight: dropdown "Bassa/Media/Alta". type: etichette umane. required: toggle. nis2_ref: chip informativo non editabile. show_score_to_supplier: toggle (default OFF).
  • Anteprima "come lo vede il fornitore": stesso componente di rendering della compilazione (single source).
  • Versioning (F17): salvare un template con campagne attive → modale "crea nuova versione v{x+1}, si applica solo alle campagne future; le {n} in corso restano su v{x}". Snapshot in questionnaire_template_versions.
  • Cruscotto campagne: semaforo (badge), colonne Fornitore/Questionario/Stato/Scadenza/Avanzamento/Azioni. Azioni rapide: Sollecita, Copia link (fallback manuale), Vedi risposte. Bulk: sollecita/avvia/esporta selezionati, con conferma e conteggio. Rispetta rate-limit reminder.

7. Accessibilità (F21) + mobile-first (F10)

Elementi nativi (button, input radio/checkbox con label); OTP inputmode=numeric autocomplete=one-time-code pattern=[0-9]* maxlength=6; email inputmode=email autocomplete=email. ARIA: aria-live=polite per autosave/progress, role=alert per errori; focus order logico, focus torna sul campo dopo errore OTP; modali con focus trap + Esc + aria-modal. Drag-drop con alternativa tastiera. Contrasto ≥WCAG AA, stato non solo da colore. Target ≥44×44px, single-column, no hover-only. AC: flusso completo navigabile da sola tastiera e da screen reader; OTP autofill; nessun target <44px a 360px.

8. i18n (F20)

Namespace sp.*. Lingua portale: (1) impostata dal cliente su campagna (language it|en|auto), (2) selezionabile dal fornitore (persistita in localStorage), (3) fallback navigator.language → IT. Email nella lingua della campagna. Chrome (header/footer/badge/errori/pulsanti) bilingue; i contenuti domande restano nella lingua in cui l'azienda li ha scritti (il selettore non auto-traduce i contenuti). AC: cambio lingua immediato senza reload; campaign.language=en → portale ed email in EN; nessuna stringa IT hardcoded in modalità EN.