nis2-agile/public/js/feedback.js
DevEnv nis2-agile 1382530189 [FEAT] Sistema Segnalazioni & Risoluzione AI (feedback)
Adattato da alltax.it — il sistema più maturo testato con utenti reali.

Backend:
- FeedbackController: 6 endpoint (submit, mine, list, show, update, resolve)
- FeedbackService: createReport + classifyWithAI + broadcastResolution
- AIService::classifyFeedback() — 10s timeout, 500 token, JSON puro
- EmailService::sendFeedbackResolved() — broadcast email org
- DB migration 014: tabella feedback_reports

Frontend:
- feedback.js: FAB rosso #EF4444, modal 2 fasi (form → AI → password gate)
- Tab "Le mie segnalazioni" con badge status
- Auto-init su tutte le pagine autenticate (common.js::checkAuth)
- api.js: 6 metodi client; style.css: stili completi

Worker:
- scripts/feedback-worker.php: cron ogni 30 min
  → docker exec nis2-agile-devenv + Claude Code CLI
  → risoluzione autonoma con POST /api/feedback/{id}/resolve

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 08:51:52 +01:00

519 lines
21 KiB
JavaScript

/**
* NIS2 Agile — Sistema Segnalazioni & Risoluzione AI
*
* Adattato da alltax.it/docs/sistema-segnalazioni-standard.html
*
* Componenti:
* - FAB (Floating Action Button) rosso bottom-right
* - Modal fase 1: inserimento segnalazione
* - Modal fase 2: risposta AI + conferma risoluzione / password gate
* - Tab "Le mie segnalazioni"
*/
(function () {
'use strict';
// ── Config ────────────────────────────────────────────────────────────────
const RESOLVE_LABEL_YES = 'Sì, problema risolto';
const RESOLVE_LABEL_NO = 'No, il problema persiste';
const TIPO_LABELS = {
bug: '🐛 Bug / Errore',
ux: '🎨 Miglioramento interfaccia',
funzionalita: '✨ Nuova funzionalità',
domanda: '❓ Domanda / Supporto',
altro: '📌 Altro',
};
const PRIORITA_LABELS = {
alta: '🔴 Alta',
media: '🟡 Media',
bassa: '🟢 Bassa',
};
const STATUS_LABELS = {
aperto: { label: 'Aperto', color: '#64748b' },
in_lavorazione:{ label: 'In lavorazione', color: '#f59e0b' },
risolto: { label: 'Risolto', color: '#22c55e' },
chiuso: { label: 'Chiuso', color: '#3b82f6' },
};
// ── State ─────────────────────────────────────────────────────────────────
let _currentReportId = null;
let _attachmentBase64 = null;
let _myReports = [];
// ── Init ──────────────────────────────────────────────────────────────────
/**
* Inizializza il sistema di feedback.
* Chiamato da common.js dopo la verifica autenticazione.
*/
window.initFeedbackFab = function () {
if (!localStorage.getItem('nis2_access_token')) return;
if (document.getElementById('feedback-fab')) return; // già presente
_injectFab();
_injectModal();
_bindEvents();
};
// ── FAB ───────────────────────────────────────────────────────────────────
function _injectFab() {
const fab = document.createElement('button');
fab.id = 'feedback-fab';
fab.type = 'button';
fab.title = 'Segnala un problema o suggerisci un miglioramento';
fab.innerHTML = '<i class="fas fa-comment-alt"></i>';
fab.setAttribute('aria-label', 'Segnala problema');
document.body.appendChild(fab);
}
// ── Modal HTML ────────────────────────────────────────────────────────────
function _injectModal() {
const wrapper = document.createElement('div');
wrapper.id = 'feedback-modal-wrapper';
wrapper.innerHTML = _buildModalHtml();
document.body.appendChild(wrapper);
}
function _buildModalHtml() {
const tipoOptions = Object.entries(TIPO_LABELS)
.map(([v, l]) => `<option value="${v}">${l}</option>`)
.join('');
const prioritaOptions = Object.entries(PRIORITA_LABELS)
.map(([v, l]) => `<option value="${v}">${l}</option>`)
.join('');
return `
<div id="feedback-overlay" class="feedback-overlay" role="dialog" aria-modal="true" aria-labelledby="feedback-modal-title">
<div class="feedback-modal">
<!-- Header -->
<div class="feedback-modal-header">
<div class="feedback-modal-tabs">
<button class="feedback-tab active" data-tab="new">
<i class="fas fa-plus-circle"></i> Nuova segnalazione
</button>
<button class="feedback-tab" data-tab="mine">
<i class="fas fa-list"></i> Le mie segnalazioni
</button>
</div>
<button id="feedback-close" class="feedback-close" aria-label="Chiudi">
<i class="fas fa-times"></i>
</button>
</div>
<!-- FASE 1: Inserimento -->
<div id="feedback-phase-1" class="feedback-tab-pane">
<form id="feedback-form" autocomplete="off">
<div class="feedback-form-row">
<label class="feedback-label" for="feedback-tipo">Tipo <span class="feedback-required">*</span></label>
<select id="feedback-tipo" class="feedback-select" required>
${tipoOptions}
</select>
</div>
<div class="feedback-form-row">
<label class="feedback-label" for="feedback-priorita">Priorità percepita <span class="feedback-required">*</span></label>
<select id="feedback-priorita" class="feedback-select" required>
${prioritaOptions}
</select>
</div>
<div class="feedback-form-row">
<label class="feedback-label" for="feedback-descrizione">
Descrizione <span class="feedback-required">*</span>
<span class="feedback-hint">Sii specifico: cosa hai fatto, cosa ti aspettavi, cosa è successo</span>
</label>
<textarea id="feedback-descrizione" class="feedback-textarea" rows="5"
placeholder="Es: Dopo aver salvato un nuovo rischio, la pagina torna vuota invece di mostrare il rischio appena inserito…"
required minlength="10" maxlength="2000"></textarea>
<div class="feedback-charcount"><span id="feedback-charcount">0</span>/2000</div>
</div>
<div class="feedback-form-row">
<label class="feedback-label" for="feedback-attachment">
Screenshot (opzionale)
</label>
<div id="feedback-drop-area" class="feedback-drop-area">
<i class="fas fa-image"></i>
<span>Trascina un'immagine o <label for="feedback-file-input" class="feedback-file-label">scegli file</label></span>
<input type="file" id="feedback-file-input" accept="image/*" style="display:none">
<div id="feedback-preview" class="feedback-preview" style="display:none">
<img id="feedback-preview-img" src="" alt="preview">
<button type="button" id="feedback-remove-img" class="feedback-remove-img" title="Rimuovi">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<input type="hidden" id="feedback-page-url">
<div id="feedback-phase1-error" class="feedback-error" style="display:none"></div>
<div class="feedback-form-actions">
<button type="button" id="feedback-cancel" class="btn-ghost">Annulla</button>
<button type="submit" id="feedback-submit" class="btn-primary-red">
<span id="feedback-submit-text"><i class="fas fa-paper-plane"></i> Invia segnalazione</span>
<span id="feedback-submit-loading" style="display:none">
<i class="fas fa-circle-notch fa-spin"></i> Analisi AI in corso…
</span>
</button>
</div>
</form>
</div>
<!-- FASE 2: Risposta AI -->
<div id="feedback-phase-2" class="feedback-tab-pane" style="display:none">
<div class="feedback-ai-badge">
<i class="fas fa-robot"></i> Analisi AI completata
</div>
<div id="feedback-ai-response" class="feedback-ai-box"></div>
<p class="feedback-resolve-question">Il problema è stato risolto grazie a questo suggerimento?</p>
<div class="feedback-resolve-actions">
<button id="feedback-resolve-yes" class="btn-success">
<i class="fas fa-check"></i> ${RESOLVE_LABEL_YES}
</button>
<button id="feedback-resolve-no" class="btn-ghost">
<i class="fas fa-times"></i> ${RESOLVE_LABEL_NO}
</button>
</div>
<!-- Password gate (nascosto di default) -->
<div id="feedback-password-gate" style="display:none" class="feedback-password-gate">
<label class="feedback-label" for="feedback-resolve-password">
Conferma con password di verifica
</label>
<div class="feedback-password-row">
<input type="password" id="feedback-resolve-password" class="feedback-input"
placeholder="Password di conferma" autocomplete="off">
<button id="feedback-resolve-confirm" class="btn-success">
<i class="fas fa-check-circle"></i> Conferma
</button>
</div>
<div id="feedback-password-error" class="feedback-error" style="display:none"></div>
</div>
<div id="feedback-resolved-ok" class="feedback-resolved-ok" style="display:none">
<i class="fas fa-check-circle"></i>
<strong>Grazie!</strong> La segnalazione è stata marcata come risolta.
</div>
</div>
<!-- Tab: Le mie segnalazioni -->
<div id="feedback-tab-mine" class="feedback-tab-pane" style="display:none">
<div id="feedback-mine-list">
<div class="feedback-loading"><i class="fas fa-circle-notch fa-spin"></i> Caricamento…</div>
</div>
</div>
</div>
</div>`;
}
// ── Events ────────────────────────────────────────────────────────────────
function _bindEvents() {
// Apri modal
document.getElementById('feedback-fab').addEventListener('click', _openModal);
// Chiudi
document.getElementById('feedback-close').addEventListener('click', _closeModal);
document.getElementById('feedback-cancel')?.addEventListener('click', _closeModal);
document.getElementById('feedback-overlay').addEventListener('click', (e) => {
if (e.target.id === 'feedback-overlay') _closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') _closeModal();
});
// Tabs
document.querySelectorAll('.feedback-tab').forEach(btn => {
btn.addEventListener('click', () => _switchTab(btn.dataset.tab));
});
// Textarea charcount
document.getElementById('feedback-descrizione').addEventListener('input', function () {
document.getElementById('feedback-charcount').textContent = this.value.length;
});
// File upload
const fileInput = document.getElementById('feedback-file-input');
const dropArea = document.getElementById('feedback-drop-area');
fileInput.addEventListener('change', () => _handleFile(fileInput.files[0]));
dropArea.addEventListener('dragover', (e) => {
e.preventDefault();
dropArea.classList.add('feedback-drop-active');
});
dropArea.addEventListener('dragleave', () => dropArea.classList.remove('feedback-drop-active'));
dropArea.addEventListener('drop', (e) => {
e.preventDefault();
dropArea.classList.remove('feedback-drop-active');
const f = e.dataTransfer.files[0];
if (f && f.type.startsWith('image/')) _handleFile(f);
});
document.getElementById('feedback-remove-img').addEventListener('click', _removeAttachment);
// Submit form
document.getElementById('feedback-form').addEventListener('submit', _onSubmit);
// Fase 2 actions
document.getElementById('feedback-resolve-yes').addEventListener('click', _onResolveYes);
document.getElementById('feedback-resolve-no').addEventListener('click', _onResolveNo);
document.getElementById('feedback-resolve-confirm').addEventListener('click', _onResolveConfirm);
}
// ── Modal open/close ──────────────────────────────────────────────────────
function _openModal() {
document.getElementById('feedback-overlay').classList.add('active');
document.getElementById('feedback-page-url').value = window.location.href;
document.getElementById('feedback-descrizione').focus();
}
function _closeModal() {
document.getElementById('feedback-overlay').classList.remove('active');
// Reset stato dopo breve delay
setTimeout(_resetModal, 300);
}
function _resetModal() {
_currentReportId = null;
_attachmentBase64 = null;
document.getElementById('feedback-form').reset();
document.getElementById('feedback-charcount').textContent = '0';
document.getElementById('feedback-phase1-error').style.display = 'none';
_removeAttachment();
_showPhase(1);
_switchTab('new');
}
function _switchTab(tab) {
document.querySelectorAll('.feedback-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
document.getElementById('feedback-phase-1').style.display = tab === 'new' ? '' : 'none';
document.getElementById('feedback-phase-2').style.display = 'none';
document.getElementById('feedback-tab-mine').style.display = tab === 'mine' ? '' : 'none';
if (tab === 'mine') _loadMyReports();
}
function _showPhase(n) {
document.getElementById('feedback-phase-1').style.display = n === 1 ? '' : 'none';
document.getElementById('feedback-phase-2').style.display = n === 2 ? '' : 'none';
}
// ── File handling ─────────────────────────────────────────────────────────
function _handleFile(file) {
if (!file) return;
if (file.size > 1.5 * 1024 * 1024) {
_showPhaseError('Immagine troppo grande (max 1.5 MB).');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
_attachmentBase64 = e.target.result;
document.getElementById('feedback-preview-img').src = e.target.result;
document.getElementById('feedback-preview').style.display = '';
};
reader.readAsDataURL(file);
}
function _removeAttachment() {
_attachmentBase64 = null;
document.getElementById('feedback-file-input').value = '';
document.getElementById('feedback-preview').style.display = 'none';
document.getElementById('feedback-preview-img').src = '';
}
// ── Submit ────────────────────────────────────────────────────────────────
async function _onSubmit(e) {
e.preventDefault();
const tipo = document.getElementById('feedback-tipo').value;
const priorita = document.getElementById('feedback-priorita').value;
const descrizione = document.getElementById('feedback-descrizione').value.trim();
const pageUrl = document.getElementById('feedback-page-url').value;
if (descrizione.length < 10) {
_showPhaseError('Descrizione troppo breve (minimo 10 caratteri).');
return;
}
_setSubmitting(true);
const payload = {
tipo,
priorita,
descrizione,
page_url: pageUrl,
attachment: _attachmentBase64 || null,
};
try {
const res = await api.post('/feedback/submit', payload);
if (!res.success) {
_showPhaseError(res.message || 'Errore durante l\'invio.');
_setSubmitting(false);
return;
}
const report = res.data;
_currentReportId = report.id;
// Fase 2: mostra risposta AI
const aiText = report.ai_risposta
|| 'La segnalazione è stata ricevuta. Il team tecnico la prenderà in carico al più presto.';
document.getElementById('feedback-ai-response').textContent = aiText;
document.getElementById('feedback-resolved-ok').style.display = 'none';
document.getElementById('feedback-password-gate').style.display = 'none';
document.getElementById('feedback-resolve-yes').style.display = '';
document.getElementById('feedback-resolve-no').style.display = '';
_showPhase(2);
} catch (err) {
_showPhaseError('Errore di rete. Riprova.');
} finally {
_setSubmitting(false);
}
}
function _setSubmitting(loading) {
document.getElementById('feedback-submit-text').style.display = loading ? 'none' : '';
document.getElementById('feedback-submit-loading').style.display = loading ? '' : 'none';
document.getElementById('feedback-submit').disabled = loading;
}
function _showPhaseError(msg) {
const el = document.getElementById('feedback-phase1-error');
el.textContent = msg;
el.style.display = '';
}
// ── Fase 2 actions ────────────────────────────────────────────────────────
function _onResolveYes() {
document.getElementById('feedback-password-gate').style.display = '';
document.getElementById('feedback-resolve-password').focus();
}
function _onResolveNo() {
// Ticket resta aperto — chiudi modal
_closeModal();
if (window.showNotification) {
showNotification('Segnalazione registrata. Il team la prenderà in carico.', 'info');
}
}
async function _onResolveConfirm() {
const password = document.getElementById('feedback-resolve-password').value;
const errEl = document.getElementById('feedback-password-error');
if (!password) {
errEl.textContent = 'Inserisci la password di conferma.';
errEl.style.display = '';
return;
}
errEl.style.display = 'none';
const btn = document.getElementById('feedback-resolve-confirm');
btn.disabled = true;
try {
const res = await api.post(`/feedback/${_currentReportId}/resolve`, { password });
if (!res.success) {
errEl.textContent = res.message || 'Password non corretta.';
errEl.style.display = '';
return;
}
document.getElementById('feedback-password-gate').style.display = 'none';
document.getElementById('feedback-resolve-yes').style.display = 'none';
document.getElementById('feedback-resolve-no').style.display = 'none';
document.getElementById('feedback-resolved-ok').style.display = '';
setTimeout(_closeModal, 2500);
} catch (err) {
errEl.textContent = 'Errore di rete. Riprova.';
errEl.style.display = '';
} finally {
btn.disabled = false;
}
}
// ── Le mie segnalazioni ───────────────────────────────────────────────────
async function _loadMyReports() {
const container = document.getElementById('feedback-mine-list');
container.innerHTML = '<div class="feedback-loading"><i class="fas fa-circle-notch fa-spin"></i> Caricamento…</div>';
try {
const res = await api.get('/feedback/mine');
_myReports = res.data || [];
if (!_myReports.length) {
container.innerHTML = '<p class="feedback-empty">Nessuna segnalazione ancora. Usala per segnalare problemi o suggerire miglioramenti!</p>';
return;
}
container.innerHTML = _myReports.map(_renderReportRow).join('');
} catch (err) {
container.innerHTML = '<p class="feedback-error">Errore nel caricamento. Riprova.</p>';
}
}
function _renderReportRow(report) {
const statusInfo = STATUS_LABELS[report.status] || { label: report.status, color: '#64748b' };
const tipoLabel = TIPO_LABELS[report.tipo] || report.tipo;
const date = new Date(report.created_at).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' });
const aiBox = report.ai_risposta
? `<div class="feedback-mine-ai">${_esc(report.ai_risposta)}</div>`
: '';
return `
<div class="feedback-mine-row">
<div class="feedback-mine-meta">
<span class="feedback-mine-tipo">${tipoLabel}</span>
<span class="feedback-badge" style="background:${statusInfo.color}20;color:${statusInfo.color};border-color:${statusInfo.color}40">
${statusInfo.label}
</span>
<span class="feedback-mine-date">${date}</span>
</div>
<p class="feedback-mine-desc">${_esc(report.descrizione.substring(0, 150))}${report.descrizione.length > 150 ? '…' : ''}</p>
${aiBox}
</div>`;
}
// ── Helpers ───────────────────────────────────────────────────────────────
function _esc(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
})();