[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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3b51b5bd95
commit
1382530189
@@ -1967,3 +1967,423 @@ tbody tr.clickable:active {
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
Sistema Segnalazioni & Risoluzione AI (feedback.js)
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* FAB */
|
||||
#feedback-fab {
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
right: 28px;
|
||||
z-index: 8000;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(239,68,68,.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
transition: background .2s, transform .15s, box-shadow .2s;
|
||||
}
|
||||
#feedback-fab:hover {
|
||||
background: #dc2626;
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 6px 20px rgba(239,68,68,.5);
|
||||
}
|
||||
#feedback-fab:active {
|
||||
transform: scale(.96);
|
||||
}
|
||||
|
||||
/* Overlay */
|
||||
.feedback-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
background: rgba(15,23,42,.6);
|
||||
backdrop-filter: blur(3px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.feedback-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.feedback-modal {
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(239,68,68,.2);
|
||||
border-radius: 14px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,.4);
|
||||
color: #f8fafc;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
/* Modal header */
|
||||
.feedback-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 0;
|
||||
border-bottom: 1px solid rgba(239,68,68,.12);
|
||||
gap: 8px;
|
||||
}
|
||||
.feedback-modal-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.feedback-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
font-size: .82rem;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color .2s, border-color .2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.feedback-tab.active {
|
||||
color: #ef4444;
|
||||
border-bottom-color: #ef4444;
|
||||
}
|
||||
.feedback-tab:hover {
|
||||
color: #f8fafc;
|
||||
}
|
||||
.feedback-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: color .2s, background .2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.feedback-close:hover {
|
||||
color: #f8fafc;
|
||||
background: rgba(255,255,255,.06);
|
||||
}
|
||||
|
||||
/* Tab panes */
|
||||
.feedback-tab-pane {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
.feedback-form-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.feedback-label {
|
||||
display: block;
|
||||
font-size: .8rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: .02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.feedback-required {
|
||||
color: #ef4444;
|
||||
}
|
||||
.feedback-hint {
|
||||
font-weight: 400;
|
||||
font-size: .75rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: #64748b;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.feedback-select,
|
||||
.feedback-textarea,
|
||||
.feedback-input {
|
||||
width: 100%;
|
||||
background: #0f172a;
|
||||
border: 1px solid rgba(239,68,68,.2);
|
||||
border-radius: 8px;
|
||||
color: #f8fafc;
|
||||
padding: 10px 12px;
|
||||
font-size: .875rem;
|
||||
font-family: inherit;
|
||||
transition: border-color .2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.feedback-select:focus,
|
||||
.feedback-textarea:focus,
|
||||
.feedback-input:focus {
|
||||
outline: none;
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 2px rgba(239,68,68,.15);
|
||||
}
|
||||
.feedback-textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
.feedback-charcount {
|
||||
text-align: right;
|
||||
font-size: .72rem;
|
||||
color: #64748b;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Drag-drop area */
|
||||
.feedback-drop-area {
|
||||
border: 2px dashed rgba(239,68,68,.3);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: .8rem;
|
||||
cursor: pointer;
|
||||
transition: border-color .2s, background .2s;
|
||||
position: relative;
|
||||
}
|
||||
.feedback-drop-area:hover,
|
||||
.feedback-drop-active {
|
||||
border-color: #ef4444;
|
||||
background: rgba(239,68,68,.04);
|
||||
}
|
||||
.feedback-drop-area i {
|
||||
font-size: 1.4rem;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: #475569;
|
||||
}
|
||||
.feedback-file-label {
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.feedback-preview {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.feedback-preview img {
|
||||
max-height: 120px;
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(239,68,68,.3);
|
||||
}
|
||||
.feedback-remove-img {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: .65rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Errors */
|
||||
.feedback-error {
|
||||
background: rgba(239,68,68,.1);
|
||||
border: 1px solid rgba(239,68,68,.3);
|
||||
border-radius: 6px;
|
||||
color: #fca5a5;
|
||||
padding: 10px 12px;
|
||||
font-size: .82rem;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Form actions */
|
||||
.feedback-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(239,68,68,.1);
|
||||
}
|
||||
.btn-primary-red {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
font-size: .875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background .2s;
|
||||
}
|
||||
.btn-primary-red:hover:not(:disabled) { background: #dc2626; }
|
||||
.btn-primary-red:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.btn-ghost {
|
||||
background: none;
|
||||
color: #94a3b8;
|
||||
border: 1px solid rgba(255,255,255,.1);
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: .875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color .2s, border-color .2s;
|
||||
}
|
||||
.btn-ghost:hover { color: #f8fafc; border-color: rgba(255,255,255,.2); }
|
||||
.btn-success {
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
font-size: .875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background .2s;
|
||||
}
|
||||
.btn-success:hover:not(:disabled) { background: #15803d; }
|
||||
.btn-success:disabled { opacity: .6; cursor: not-allowed; }
|
||||
|
||||
/* Fase 2: risposta AI */
|
||||
.feedback-ai-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(239,68,68,.12);
|
||||
border: 1px solid rgba(239,68,68,.25);
|
||||
color: #fca5a5;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: .78rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.feedback-ai-box {
|
||||
background: rgba(6,182,212,.06);
|
||||
border: 1px solid rgba(6,182,212,.2);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.6;
|
||||
font-size: .875rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.feedback-resolve-question {
|
||||
color: #94a3b8;
|
||||
font-size: .85rem;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.feedback-resolve-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.feedback-password-gate {
|
||||
margin-top: 16px;
|
||||
padding: 14px;
|
||||
background: rgba(239,68,68,.06);
|
||||
border: 1px solid rgba(239,68,68,.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.feedback-password-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.feedback-password-row .feedback-input {
|
||||
flex: 1;
|
||||
}
|
||||
.feedback-resolved-ok {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba(22,163,74,.12);
|
||||
border: 1px solid rgba(22,163,74,.3);
|
||||
border-radius: 8px;
|
||||
color: #86efac;
|
||||
padding: 14px;
|
||||
margin-top: 16px;
|
||||
font-size: .9rem;
|
||||
}
|
||||
.feedback-resolved-ok i {
|
||||
font-size: 1.3rem;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* "Le mie segnalazioni" tab */
|
||||
.feedback-loading {
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: .875rem;
|
||||
}
|
||||
.feedback-empty {
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
font-size: .875rem;
|
||||
}
|
||||
.feedback-mine-row {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,.05);
|
||||
}
|
||||
.feedback-mine-row:last-child { border-bottom: none; }
|
||||
.feedback-mine-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.feedback-mine-tipo {
|
||||
font-size: .78rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.feedback-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: .72rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid;
|
||||
}
|
||||
.feedback-mine-date {
|
||||
font-size: .72rem;
|
||||
color: #475569;
|
||||
margin-left: auto;
|
||||
}
|
||||
.feedback-mine-desc {
|
||||
color: #cbd5e1;
|
||||
font-size: .85rem;
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.feedback-mine-ai {
|
||||
background: rgba(6,182,212,.05);
|
||||
border-left: 3px solid rgba(6,182,212,.3);
|
||||
padding: 8px 10px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
color: #94a3b8;
|
||||
font-size: .78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 480px) {
|
||||
#feedback-fab { bottom: 20px; right: 20px; }
|
||||
.feedback-modal { border-radius: 10px; }
|
||||
.feedback-resolve-actions { flex-direction: column; }
|
||||
.feedback-password-row { flex-direction: column; }
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ $controllerMap = [
|
||||
'cross-analysis' => 'CrossAnalysisController',
|
||||
'contact' => 'ContactController', // legacy
|
||||
'mktg-lead' => 'MktgLeadController', // standard condiviso TRPG/NIS2
|
||||
'feedback' => 'FeedbackController', // segnalazioni & risoluzione AI
|
||||
];
|
||||
|
||||
if (!isset($controllerMap[$controllerName])) {
|
||||
@@ -381,6 +382,16 @@ $actionMap = [
|
||||
'mktg-lead' => [
|
||||
'POST:submit' => 'submit',
|
||||
],
|
||||
|
||||
// ── FeedbackController — segnalazioni & risoluzione AI ──
|
||||
'feedback' => [
|
||||
'POST:submit' => 'submit',
|
||||
'GET:mine' => 'mine',
|
||||
'GET:list' => 'list',
|
||||
'GET:{id}' => 'show',
|
||||
'PUT:{id}' => 'update',
|
||||
'POST:{id}/resolve' => 'resolve',
|
||||
],
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -301,6 +301,17 @@ class NIS2API {
|
||||
updateCapa(capaId, data) { return this.put(`/ncr/capa/${capaId}`, data); }
|
||||
createNCRsFromAssessment(assessmentId) { return this.post('/ncr/from-assessment', { assessment_id: assessmentId }); }
|
||||
getNCRStats() { return this.get('/ncr/stats'); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Feedback & Segnalazioni
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
submitFeedback(data) { return this.post('/feedback/submit', data); }
|
||||
getMyFeedback() { return this.get('/feedback/mine'); }
|
||||
listFeedback(params = {}) { return this.get('/feedback/list?' + new URLSearchParams(params)); }
|
||||
getFeedback(id) { return this.get(`/feedback/${id}`); }
|
||||
updateFeedback(id, data) { return this.put(`/feedback/${id}`, data); }
|
||||
resolveFeedback(id, password) { return this.post(`/feedback/${id}/resolve`, { password }); }
|
||||
}
|
||||
|
||||
// Singleton globale
|
||||
|
||||
@@ -458,6 +458,10 @@ function checkAuth() {
|
||||
_idleInitialized = true;
|
||||
initIdleTimeout();
|
||||
}
|
||||
// Inizializza FAB segnalazioni su tutte le pagine autenticate
|
||||
if (typeof initFeedbackFab === 'function') {
|
||||
initFeedbackFab();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user