From 0102f0e285396632f7d2e592e08a3223c7a4e8c1 Mon Sep 17 00:00:00 2001 From: fcarraUniSa Date: Mon, 16 Feb 2026 16:24:31 +0100 Subject: [PATCH] feat: Initialize OmniSupport AI project structure Sets up the basic project structure for OmniSupport AI, including: - Vite build tool configuration. - React and necessary dependencies. - TypeScript configuration. - Basic HTML and root component setup. - Initial type definitions and mock data for core entities like tickets, agents, and queues. - A README with setup instructions. - A .gitignore file for common build artifacts and development files. --- .gitignore | 24 + App.tsx | 312 ++++++ Dockerfile | 0 README.md | 25 +- backend/Dockerfile | 0 backend/db.js | 39 + backend/index.js | 47 + backend/package.json | 21 + backend/schema.sql | 38 + components/AgentDashboard.tsx | 1789 +++++++++++++++++++++++++++++++++ components/AuthScreen.tsx | 199 ++++ components/ClientPortal.tsx | 755 ++++++++++++++ components/Toast.tsx | 57 ++ constants.ts | 206 ++++ docker-compose.yml | 55 + index.html | 62 ++ index.tsx | 15 + metadata.json | 5 + nginx.conf | 19 + package.json | 28 + services/geminiService.ts | 143 +++ tsconfig.json | 29 + types.ts | 181 ++++ vite.config.ts | 16 + 24 files changed, 4057 insertions(+), 8 deletions(-) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 Dockerfile create mode 100644 backend/Dockerfile create mode 100644 backend/db.js create mode 100644 backend/index.js create mode 100644 backend/package.json create mode 100644 backend/schema.sql create mode 100644 components/AgentDashboard.tsx create mode 100644 components/AuthScreen.tsx create mode 100644 components/ClientPortal.tsx create mode 100644 components/Toast.tsx create mode 100644 constants.ts create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 nginx.conf create mode 100644 package.json create mode 100644 services/geminiService.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..341ede4 --- /dev/null +++ b/App.tsx @@ -0,0 +1,312 @@ + +import React, { useState } from 'react'; +import { AgentDashboard } from './components/AgentDashboard'; +import { ClientPortal } from './components/ClientPortal'; +import { AuthScreen } from './components/AuthScreen'; +import { ToastContainer, ToastMessage, ToastType } from './components/Toast'; +import { INITIAL_KB, INITIAL_QUEUES, INITIAL_SETTINGS, INITIAL_TICKETS, MOCK_AGENTS, MOCK_CLIENT_USERS, MOCK_SURVEYS } from './constants'; +import { Agent, AppSettings, AppState, ClientUser, KBArticle, Ticket, TicketStatus, SurveyResult, TicketQueue, ChatMessage } from './types'; + +const App: React.FC = () => { + const [state, setState] = useState({ + tickets: INITIAL_TICKETS, + articles: INITIAL_KB, + agents: MOCK_AGENTS, + queues: INITIAL_QUEUES, + surveys: MOCK_SURVEYS, + clientUsers: MOCK_CLIENT_USERS, + settings: INITIAL_SETTINGS, + currentUser: null, + userRole: 'guest' + }); + + const [toasts, setToasts] = useState([]); + + const showToast = (message: string, type: ToastType = 'info') => { + const id = Date.now().toString(); + setToasts(prev => [...prev, { id, message, type }]); + }; + + const removeToast = (id: string) => { + setToasts(prev => prev.filter(t => t.id !== id)); + }; + + // --- Auth Management --- + const handleClientLogin = (email: string, pass: string): boolean => { + const user = state.clientUsers.find(u => u.email === email && u.password === pass); + if (user) { + setState(prev => ({ ...prev, currentUser: user, userRole: 'client' })); + showToast(`Bentornato, ${user.name}!`, 'success'); + return true; + } + return false; + }; + + const handleAgentLogin = (email: string, pass: string): boolean => { + const agent = state.agents.find(a => a.email === email && a.password === pass); + if (agent) { + // Set the specific role from the agent object (agent, supervisor, superadmin) + setState(prev => ({ ...prev, currentUser: agent, userRole: agent.role })); + showToast(`Accesso effettuato come ${agent.role}`, 'success'); + return true; + } + return false; + }; + + const handleClientRegister = (name: string, email: string, pass: string, company: string) => { + const newUser: ClientUser = { + id: `u${Date.now()}`, + name, + email, + password: pass, + company, + status: 'active' + }; + setState(prev => ({ + ...prev, + clientUsers: [...prev.clientUsers, newUser], + currentUser: newUser, + userRole: 'client' + })); + showToast("Registrazione completata con successo!", 'success'); + }; + + const handleLogout = () => { + setState(prev => ({ ...prev, currentUser: null, userRole: 'guest' })); + showToast("Logout effettuato", 'info'); + }; + + // --- Ticket Management --- + const createTicket = (ticketData: Omit) => { + const newTicket: Ticket = { + ...ticketData, + id: `T-${1000 + state.tickets.length + 1}`, + createdAt: new Date().toISOString(), + status: TicketStatus.OPEN, + messages: [], + attachments: ticketData.attachments || [] + }; + setState(prev => ({ ...prev, tickets: [newTicket, ...prev.tickets] })); + showToast("Ticket creato correttamente", 'success'); + }; + + const replyToTicket = (ticketId: string, message: string) => { + const newMessage: ChatMessage = { + id: `m-${Date.now()}`, + role: 'user', + content: message, + timestamp: new Date().toISOString() + }; + + setState(prev => ({ + ...prev, + tickets: prev.tickets.map(t => + t.id === ticketId + ? { ...t, messages: [...t.messages, newMessage], status: t.status === TicketStatus.RESOLVED ? TicketStatus.OPEN : t.status } + : t + ) + })); + // Toast handled in component for better UX or here + }; + + const updateTicketStatus = (id: string, status: TicketStatus) => { + setState(prev => ({ + ...prev, + tickets: prev.tickets.map(t => t.id === id ? { ...t, status } : t) + })); + showToast(`Stato ticket aggiornato a ${status}`, 'info'); + }; + + const updateTicketAgent = (id: string, agentId: string) => { + setState(prev => ({ + ...prev, + tickets: prev.tickets.map(t => t.id === id ? { ...t, assignedAgentId: agentId } : t) + })); + showToast("Agente assegnato con successo", 'success'); + }; + + // --- KB Management --- + const addArticle = (article: KBArticle) => { + if (!state.settings.features.kbEnabled) { + showToast("La funzionalità Knowledge Base è disabilitata.", 'error'); + return; + } + if (state.articles.length >= state.settings.features.maxKbArticles) { + showToast(`Limite massimo di articoli (${state.settings.features.maxKbArticles}) raggiunto.`, 'error'); + return; + } + + // Check for AI Quota if manually triggering AI generation logic + if (article.source === 'ai') { + if (!state.settings.features.aiKnowledgeAgentEnabled) { + showToast("L'Agente AI per la Knowledge Base è disabilitato.", 'error'); + return; + } + const aiCount = state.articles.filter(a => a.source === 'ai').length; + if (aiCount >= state.settings.features.maxAiGeneratedArticles) { + showToast(`Limite creazione articoli AI (${state.settings.features.maxAiGeneratedArticles}) raggiunto.`, 'error'); + return; + } + } + + setState(prev => ({ + ...prev, + articles: [article, ...prev.articles] + })); + showToast("Articolo aggiunto con successo", 'success'); + }; + + const updateArticle = (updatedArticle: KBArticle) => { + setState(prev => ({ + ...prev, + articles: prev.articles.map(a => a.id === updatedArticle.id ? updatedArticle : a) + })); + showToast("Articolo aggiornato", 'success'); + }; + + // --- Survey Management --- + const submitSurvey = (surveyData: Omit) => { + const newSurvey: SurveyResult = { + ...surveyData, + id: `s${Date.now()}`, + timestamp: new Date().toISOString() + }; + setState(prev => ({ + ...prev, + surveys: [...prev.surveys, newSurvey] + })); + showToast("Grazie per il tuo feedback!", 'success'); + }; + + // --- Settings Management --- + const addAgent = (agent: Agent) => { + // Quota Validation + if (agent.role === 'supervisor') { + const supervisorCount = state.agents.filter(a => a.role === 'supervisor').length; + if (supervisorCount >= state.settings.features.maxSupervisors) { + showToast(`Limite Supervisor (${state.settings.features.maxSupervisors}) raggiunto.`, 'error'); + return; + } + } else if (agent.role === 'agent') { + const agentCount = state.agents.filter(a => a.role === 'agent').length; + if (agentCount >= state.settings.features.maxAgents) { + showToast(`Limite Agenti (${state.settings.features.maxAgents}) raggiunto.`, 'error'); + return; + } + } + + setState(prev => ({ ...prev, agents: [...prev.agents, agent] })); + showToast("Nuovo agente aggiunto", 'success'); + }; + + const updateAgent = (agent: Agent) => { + setState(prev => ({ ...prev, agents: prev.agents.map(a => a.id === agent.id ? agent : a) })); + showToast("Dati agente aggiornati", 'success'); + }; + + const removeAgent = (id: string) => { + setState(prev => ({ ...prev, agents: prev.agents.filter(a => a.id !== id) })); + showToast("Agente rimosso", 'info'); + }; + + const addClientUser = (user: ClientUser) => { + setState(prev => ({ ...prev, clientUsers: [...prev.clientUsers, user] })); + showToast("Utente aggiunto", 'success'); + }; + + const updateClientUser = (user: ClientUser) => { + setState(prev => ({ ...prev, clientUsers: prev.clientUsers.map(u => u.id === user.id ? user : u) })); + showToast("Utente aggiornato", 'success'); + }; + + const removeClientUser = (id: string) => { + setState(prev => ({ ...prev, clientUsers: prev.clientUsers.filter(u => u.id !== id) })); + showToast("Utente rimosso", 'info'); + }; + + const updateSettings = (newSettings: AppSettings) => { + setState(prev => ({ ...prev, settings: newSettings })); + // Toast triggered in component + }; + + // --- Queue Management --- + const addQueue = (queue: TicketQueue) => { + setState(prev => ({ ...prev, queues: [...prev.queues, queue] })); + showToast("Coda creata", 'success'); + }; + + const removeQueue = (id: string) => { + setState(prev => ({ ...prev, queues: prev.queues.filter(q => q.id !== id) })); + showToast("Coda rimossa", 'info'); + }; + + + // Render Logic + if (!state.currentUser) { + return ( + <> + + + + ); + } + + // Filter Tickets for Agent: Only show tickets from assigned queues + const isAgentOrSupervisor = state.userRole === 'agent' || state.userRole === 'supervisor'; + const agentTickets = isAgentOrSupervisor + ? state.tickets.filter(t => (state.currentUser as Agent).queues.includes(t.queue)) + : state.tickets; // Superadmin sees all + + return ( +
+ + {state.userRole === 'client' ? ( + t.customerName === state.currentUser?.name)} + queues={state.queues} + onCreateTicket={createTicket} + onReplyTicket={replyToTicket} + onSubmitSurvey={submitSurvey} + onLogout={handleLogout} + showToast={showToast} + /> + ) : ( + + )} + +
+ ); +}; + +export default App; diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 2241000..2124aed 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@
- GHBanner - -

Built with AI Studio

- -

The fastest path from prompt to production with Gemini.

- - Start building -
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1RjYd9BtQ40pbPgd_S4vluuBb7QpAPDaC + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/backend/db.js b/backend/db.js new file mode 100644 index 0000000..ae0dea3 --- /dev/null +++ b/backend/db.js @@ -0,0 +1,39 @@ + +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const pool = mysql.createPool({ + host: process.env.DB_HOST || 'db', + port: process.env.DB_PORT || 3306, + user: process.env.DB_USER || 'omni_user', + password: process.env.DB_PASSWORD || 'omni_pass', + database: process.env.DB_NAME || 'omnisupport', + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 +}); + +export const query = async (sql, params) => { + try { + const [results] = await pool.execute(sql, params); + return results; + } catch (error) { + console.error('Database query error:', error); + throw error; + } +}; + +export const checkConnection = async () => { + try { + const connection = await pool.getConnection(); + await connection.ping(); + connection.release(); + console.log('✅ Connected to MySQL Database'); + return true; + } catch (error) { + console.error('❌ Database connection failed:', error.message); + return false; + } +}; diff --git a/backend/index.js b/backend/index.js new file mode 100644 index 0000000..2d346f6 --- /dev/null +++ b/backend/index.js @@ -0,0 +1,47 @@ + +import express from 'express'; +import cors from 'cors'; +import { checkConnection, query } from './db.js'; + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(cors()); +app.use(express.json()); + +// Health Check & DB Init trigger +app.get('/api/health', async (req, res) => { + const dbStatus = await checkConnection(); + res.json({ + status: 'ok', + database: dbStatus ? 'connected' : 'disconnected', + timestamp: new Date().toISOString() + }); +}); + +// --- API ENDPOINTS EXAMPLES (To replace Mock Data) --- + +// Get All Agents +app.get('/api/agents', async (req, res) => { + try { + const agents = await query('SELECT * FROM agents'); + res.json(agents); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// Get Tickets +app.get('/api/tickets', async (req, res) => { + try { + const tickets = await query('SELECT * FROM tickets ORDER BY created_at DESC'); + res.json(tickets); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.listen(PORT, () => { + console.log(`🚀 Backend Server running on port ${PORT}`); + checkConnection(); // Initial check +}); diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..76beb4b --- /dev/null +++ b/backend/package.json @@ -0,0 +1,21 @@ + +{ + "name": "omnisupport-backend", + "version": "1.0.0", + "description": "Backend API for OmniSupport AI", + "main": "index.js", + "type": "module", + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js" + }, + "dependencies": { + "express": "^4.18.2", + "mysql2": "^3.9.2", + "cors": "^2.8.5", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } +} diff --git a/backend/schema.sql b/backend/schema.sql new file mode 100644 index 0000000..5a10e3a --- /dev/null +++ b/backend/schema.sql @@ -0,0 +1,38 @@ + +CREATE TABLE IF NOT EXISTS agents ( + id VARCHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + role ENUM('superadmin', 'supervisor', 'agent') DEFAULT 'agent', + avatar TEXT, + queues JSON, + skills JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS client_users ( + id VARCHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + company VARCHAR(255), + status ENUM('active', 'inactive') DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS tickets ( + id VARCHAR(36) PRIMARY KEY, + subject VARCHAR(255) NOT NULL, + description TEXT, + status ENUM('APERTO', 'IN LAVORAZIONE', 'RISOLTO', 'CHIUSO') DEFAULT 'APERTO', + priority ENUM('Bassa', 'Media', 'Alta', 'Critica') DEFAULT 'Media', + customer_name VARCHAR(255), + assigned_agent_id VARCHAR(36), + queue VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Insert Default Superadmin if not exists +INSERT IGNORE INTO agents (id, name, email, password, role, queues) VALUES +('a0', 'Super Admin', 'fcarra79@gmail.com', 'Mr10921.', 'superadmin', '["General", "Tech Support", "Billing"]'); diff --git a/components/AgentDashboard.tsx b/components/AgentDashboard.tsx new file mode 100644 index 0000000..5184d6a --- /dev/null +++ b/components/AgentDashboard.tsx @@ -0,0 +1,1789 @@ + +import React, { useState, useRef, useEffect } from 'react'; +import { Ticket, KBArticle, Agent, TicketStatus, TicketPriority, SurveyResult, AppSettings, ClientUser, TicketQueue, EmailTemplate, EmailTrigger, EmailAudience, AgentAvatarConfig, AgentRole, AiProvider } from '../types'; +import { generateNewKBArticle } from '../services/geminiService'; +import { ToastType } from './Toast'; +import { + LayoutDashboard, + BookOpen, + Users, + Sparkles, + CheckCircle, + Clock, + Edit3, + Plus, + ExternalLink, + FileText, + BarChart3, + TrendingUp, + MessageCircle, + Star, + Settings, + Trash2, + Mail, + Palette, + Shield, + Save, + LogOut, + Paperclip, + Layers, + X, + Camera, + Move, + Check, + Zap, + Copy, + Activity, + UserPlus, + Loader2, + Lock, + Cpu, + AlertTriangle, + RotateCcw, + Bot, + Key +} from 'lucide-react'; + +interface AgentDashboardProps { + currentUser: Agent; + tickets: Ticket[]; + articles: KBArticle[]; + agents: Agent[]; + queues: TicketQueue[]; + surveys?: SurveyResult[]; + clientUsers: ClientUser[]; + settings: AppSettings; + updateTicketStatus: (id: string, status: TicketStatus) => void; + updateTicketAgent: (id: string, agentId: string) => void; + addArticle: (article: KBArticle) => void; + updateArticle: (article: KBArticle) => void; + addAgent: (agent: Agent) => void; + updateAgent: (agent: Agent) => void; + removeAgent: (id: string) => void; + addClientUser: (user: ClientUser) => void; + updateClientUser: (user: ClientUser) => void; + removeClientUser: (id: string) => void; + updateSettings: (settings: AppSettings) => void; + addQueue: (queue: TicketQueue) => void; + removeQueue: (id: string) => void; + onLogout: () => void; + showToast: (message: string, type: ToastType) => void; +} + +// --- SUB-COMPONENT: Avatar Editor --- +interface AvatarEditorProps { + initialImage: string; + initialConfig?: AgentAvatarConfig; + onSave: (image: string, config: AgentAvatarConfig) => void; +} + +const AvatarEditor: React.FC = ({ initialImage, initialConfig, onSave }) => { + const [image, setImage] = useState(initialImage); + const [config, setConfig] = useState(initialConfig || { x: 50, y: 50, scale: 1 }); + const [isDragging, setIsDragging] = useState(false); + const dragStart = useRef<{x: number, y: number}>({ x: 0, y: 0 }); + + // Update local state when props change (for editing) + useEffect(() => { + setImage(initialImage); + if(initialConfig) setConfig(initialConfig); + }, [initialImage, initialConfig]); + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + const url = URL.createObjectURL(e.target.files[0]); + setImage(url); + setConfig({ x: 50, y: 50, scale: 1 }); // Reset config for new image + } + }; + + const handleMouseDown = (e: React.MouseEvent) => { + setIsDragging(true); + dragStart.current = { x: e.clientX, y: e.clientY }; + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDragging) return; + const deltaX = (e.clientX - dragStart.current.x) * 0.5; // Sensitivity + const deltaY = (e.clientY - dragStart.current.y) * 0.5; + + setConfig(prev => ({ + ...prev, + x: Math.min(100, Math.max(0, prev.x - deltaX * 0.2)), // Invert direction for natural feel or keep normal + y: Math.min(100, Math.max(0, prev.y - deltaY * 0.2)) + })); + + dragStart.current = { x: e.clientX, y: e.clientY }; + }; + + const handleMouseUp = () => setIsDragging(false); + + return ( +
+
+ {image ? ( + Avatar + ) : ( +
+ )} +
+ +
+
+ +
+
+ +
+ + {image && ( +
+ Zoom + setConfig({...config, scale: parseFloat(e.target.value)})} + className="flex-1 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer" + /> +
+ )} + +

Trascina l'immagine per centrarla

+ + +
+
+ ); +}; + +// --- MAIN COMPONENT --- +export const AgentDashboard: React.FC = ({ + currentUser, + tickets, + articles, + agents, + queues, + surveys = [], + clientUsers, + settings, + updateTicketStatus, + updateTicketAgent, + addArticle, + updateArticle, + addAgent, + updateAgent, + removeAgent, + addClientUser, + updateClientUser, + removeClientUser, + updateSettings, + addQueue, + removeQueue, + onLogout, + showToast +}) => { + const [view, setView] = useState<'tickets' | 'kb' | 'ai' | 'analytics' | 'settings'>('tickets'); + const [selectedTicketId, setSelectedTicketId] = useState(null); + + // ROLE BASED PERMISSIONS + const canManageGlobalSettings = currentUser.role === 'superadmin'; + const canManageTeam = currentUser.role === 'superadmin' || currentUser.role === 'supervisor'; + const canAccessSettings = canManageTeam || canManageGlobalSettings; + + // Initialize correct tab based on permissions + const [settingsTab, setSettingsTab] = useState<'general' | 'system' | 'ai' | 'users' | 'agents' | 'queues' | 'email'>('users'); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (view === 'settings') { + if (canManageGlobalSettings) setSettingsTab('system'); // Default to system for superadmin + else if (canManageTeam) setSettingsTab('users'); + } + }, [view, canManageGlobalSettings, canManageTeam]); + + + // KB Editor State + const [isEditingKB, setIsEditingKB] = useState(false); + const [newArticle, setNewArticle] = useState>({ type: 'article', category: 'General' }); + const [isFetchingUrl, setIsFetchingUrl] = useState(false); + + // AI State + const [isAiAnalyzing, setIsAiAnalyzing] = useState(false); + const [aiSuggestion, setAiSuggestion] = useState<{ title: string; content: string; category: string } | null>(null); + + // Forms State for Settings + const [newAgentForm, setNewAgentForm] = useState>({ name: '', email: '', password: '', skills: [], queues: [], role: 'agent', avatar: '', avatarConfig: {x: 50, y: 50, scale: 1} }); + const [editingAgent, setEditingAgent] = useState(null); + + const [newUserForm, setNewUserForm] = useState>({ name: '', email: '', status: 'active', company: '' }); + const [editingUser, setEditingUser] = useState(null); + + const [newQueueForm, setNewQueueForm] = useState>({ name: '', description: '' }); + const [tempSettings, setTempSettings] = useState(settings); + + // Email Template Editor State + const [editingTemplate, setEditingTemplate] = useState(null); + const [isTestingSmtp, setIsTestingSmtp] = useState(false); + + const selectedTicket = tickets.find(t => t.id === selectedTicketId); + + // Stats for Quotas + const currentAgents = agents.filter(a => a.role === 'agent').length; + const currentSupervisors = agents.filter(a => a.role === 'supervisor').length; + const currentArticles = articles.length; + const currentAiArticles = articles.filter(a => a.source === 'ai').length; + + const isKbFull = currentArticles >= settings.features.maxKbArticles; + const isAgentQuotaFull = currentAgents >= settings.features.maxAgents; + const isSupervisorQuotaFull = currentSupervisors >= settings.features.maxSupervisors; + + // Filter Agents for Assignment based on Role + const getAssignableAgents = (ticketQueue: string) => { + // Superadmin and Supervisor can assign to anyone + if (canManageTeam) return agents; + // Agents can only assign to agents in the same queue + return agents.filter(a => a.queues.includes(ticketQueue)); + }; + + + // Helper function to fetch URL content via proxy + const fetchUrlContent = async (url: string): Promise => { + const parseContent = (html: string) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + const scripts = doc.querySelectorAll('script, style, nav, footer, header, svg, noscript, iframe'); + scripts.forEach(node => node.remove()); + + let text = doc.body?.textContent || ""; + text = text.replace(/\s+/g, ' ').trim(); + + return text.substring(0, 5000); + }; + + try { + const res1 = await fetch(`https://api.allorigins.win/get?url=${encodeURIComponent(url)}`); + if (res1.ok) { + const data = await res1.json(); + if (data.contents) return parseContent(data.contents); + } + } catch (e) { + console.warn("Primary scraping failed, trying fallback..."); + } + + try { + const res2 = await fetch(`https://corsproxy.io/?${encodeURIComponent(url)}`); + if (res2.ok) { + const html = await res2.text(); + return parseContent(html); + } + } catch (e) { + console.error("Scraping error:", e); + } + + return ""; + }; + + // Handlers + const handleAiAnalysis = async () => { + if (!settings.features.aiKnowledgeAgentEnabled) { + showToast("L'Agente Knowledge AI è disabilitato dall'amministratore.", 'error'); + return; + } + if (currentAiArticles >= settings.features.maxAiGeneratedArticles) { + showToast("Quota creazione articoli AI raggiunta. Impossibile generare nuovi suggerimenti.", 'error'); + return; + } + + setIsAiAnalyzing(true); + setAiSuggestion(null); + const suggestion = await generateNewKBArticle(tickets, articles); + setAiSuggestion(suggestion); + setIsAiAnalyzing(false); + }; + + const saveAiArticle = () => { + if (aiSuggestion) { + addArticle({ + id: `kb-${Date.now()}`, + title: aiSuggestion.title, + content: aiSuggestion.content, + category: aiSuggestion.category, + type: 'article', + source: 'ai', + lastUpdated: new Date().toISOString().split('T')[0] + }); + setAiSuggestion(null); + setView('kb'); + } + }; + + const handleSaveArticle = async () => { + if (newArticle.title && (newArticle.content || newArticle.url)) { + let finalContent = newArticle.content || ''; + + if (newArticle.type === 'url' && newArticle.url) { + setIsFetchingUrl(true); + const scrapedText = await fetchUrlContent(newArticle.url); + setIsFetchingUrl(false); + if (scrapedText) { + finalContent = `[CONTENUTO PAGINA WEB SCARICATO]\n\n${scrapedText}\n\n[NOTE MANUALI]\n${newArticle.content || ''}`; + } else { + finalContent = newArticle.content || 'Contenuto non recuperabile automaticamente. Fare riferimento al link.'; + } + } + + const articleToSave: KBArticle = { + id: newArticle.id || `kb-${Date.now()}`, + title: newArticle.title, + content: finalContent, + category: newArticle.category || 'General', + type: newArticle.type || 'article', + url: newArticle.url, + source: newArticle.source || 'manual', + lastUpdated: new Date().toISOString().split('T')[0] + }; + + if (newArticle.id) { + updateArticle(articleToSave); + } else { + addArticle(articleToSave); + } + + setIsEditingKB(false); + setNewArticle({ type: 'article', category: 'General' }); + } + }; + + const handleAvatarSaved = (image: string, config: AgentAvatarConfig, isEditing: boolean) => { + if (isEditing && editingAgent) { + setEditingAgent({ ...editingAgent, avatar: image, avatarConfig: config }); + } else { + setNewAgentForm({ ...newAgentForm, avatar: image, avatarConfig: config }); + } + }; + + const handleAddAgent = () => { + if(newAgentForm.name && newAgentForm.email && newAgentForm.queues && newAgentForm.queues.length > 0) { + addAgent({ + id: `a${Date.now()}`, + name: newAgentForm.name, + email: newAgentForm.email, + password: newAgentForm.password || 'password', + role: newAgentForm.role || 'agent', + avatar: newAgentForm.avatar || 'https://via.placeholder.com/200', + avatarConfig: newAgentForm.avatarConfig, + skills: newAgentForm.skills || ['General'], + queues: newAgentForm.queues + } as Agent); + setNewAgentForm({ name: '', email: '', password: '', role: 'agent', skills: [], queues: [], avatar: '', avatarConfig: {x: 50, y: 50, scale: 1} }); + } else { + showToast("Compila nome, email e seleziona almeno una coda.", 'error'); + } + }; + + const handleUpdateAgent = () => { + if (editingAgent && newAgentForm.name && newAgentForm.email) { + const updatedAgent: Agent = { + ...editingAgent, + name: newAgentForm.name!, + email: newAgentForm.email!, + password: newAgentForm.password || editingAgent.password, + role: newAgentForm.role as AgentRole, + avatar: newAgentForm.avatar || editingAgent.avatar, + avatarConfig: newAgentForm.avatarConfig, + queues: newAgentForm.queues || [], + skills: newAgentForm.skills || [] + }; + + updateAgent(updatedAgent); + setEditingAgent(null); + setNewAgentForm({ name: '', email: '', password: '', role: 'agent', skills: [], queues: [], avatar: '', avatarConfig: {x: 50, y: 50, scale: 1} }); + } + }; + + const handleEditAgentClick = (agent: Agent) => { + setEditingAgent(agent); + setNewAgentForm({ + name: agent.name, + email: agent.email, + password: agent.password, + role: agent.role, + avatar: agent.avatar, + avatarConfig: agent.avatarConfig, + skills: agent.skills, + queues: agent.queues + }); + }; + + const cancelEditAgent = () => { + setEditingAgent(null); + setNewAgentForm({ name: '', email: '', password: '', role: 'agent', skills: [], queues: [], avatar: '', avatarConfig: {x: 50, y: 50, scale: 1} }); + }; + + const toggleQueueInForm = (queueName: string, isEditing: boolean) => { + const currentQueues = newAgentForm.queues || []; + const newQueues = currentQueues.includes(queueName) + ? currentQueues.filter(q => q !== queueName) + : [...currentQueues, queueName]; + setNewAgentForm({ ...newAgentForm, queues: newQueues }); + }; + + // --- USER MANAGEMENT HANDLERS --- + const handleAddUser = () => { + if(newUserForm.name && newUserForm.email) { + addClientUser({ + id: `u${Date.now()}`, + name: newUserForm.name, + email: newUserForm.email, + company: newUserForm.company, + status: newUserForm.status || 'active', + password: 'user' // Default password + } as ClientUser); + setNewUserForm({ name: '', email: '', status: 'active', company: '' }); + } + }; + + const handleUpdateUser = () => { + if (editingUser && newUserForm.name && newUserForm.email) { + updateClientUser({ + ...editingUser, + name: newUserForm.name, + email: newUserForm.email, + company: newUserForm.company, + status: newUserForm.status as 'active' | 'inactive' + }); + setEditingUser(null); + setNewUserForm({ name: '', email: '', status: 'active', company: '' }); + } + }; + + const handleEditUserClick = (user: ClientUser) => { + setEditingUser(user); + setNewUserForm({ + name: user.name, + email: user.email, + company: user.company, + status: user.status + }); + }; + + const cancelEditUser = () => { + setEditingUser(null); + setNewUserForm({ name: '', email: '', status: 'active', company: '' }); + }; + + const handleSendPasswordReset = (email: string) => { + // Simulate sending email + showToast(`Link di reset password inviato a ${email}`, 'success'); + }; + + const handleAddQueue = () => { + if (newQueueForm.name) { + addQueue({ + id: `q-${Date.now()}`, + name: newQueueForm.name, + description: newQueueForm.description + } as TicketQueue); + setNewQueueForm({ name: '', description: '' }); + } + }; + + // Email & SMTP Handlers + const handleTestSmtp = () => { + setIsTestingSmtp(true); + setTimeout(() => { + setIsTestingSmtp(false); + showToast(`Test Connessione SMTP Riuscito! Host: ${tempSettings.smtp.host}`, 'success'); + }, 1500); + }; + + const handleSaveTemplate = () => { + if (editingTemplate) { + let updatedTemplates = [...tempSettings.emailTemplates]; + if (editingTemplate.id === 'new') { + updatedTemplates.push({ ...editingTemplate, id: `t${Date.now()}` }); + } else { + updatedTemplates = updatedTemplates.map(t => t.id === editingTemplate.id ? editingTemplate : t); + } + setTempSettings({ ...tempSettings, emailTemplates: updatedTemplates }); + setEditingTemplate(null); + } + }; + + const handleDeleteTemplate = (id: string) => { + setTempSettings({ + ...tempSettings, + emailTemplates: tempSettings.emailTemplates.filter(t => t.id !== id) + }); + }; + + const handleSaveSettings = () => { + setIsSaving(true); + // Simulate API call + setTimeout(() => { + updateSettings(tempSettings); + setIsSaving(false); + showToast("Impostazioni salvate con successo!", 'success'); + }, 800); + }; + + // Analytics Helpers + const avgRating = surveys.length > 0 ? (surveys.reduce((acc, curr) => acc + curr.rating, 0) / surveys.length).toFixed(1) : 'N/A'; + const totalTickets = tickets.length; + const resolvedTickets = tickets.filter(t => t.status === TicketStatus.RESOLVED).length; + const resolutionRate = totalTickets > 0 ? ((resolvedTickets / totalTickets) * 100).toFixed(0) : 0; + + const techCount = tickets.filter(t => t.queue === 'Tech Support').length; + const billingCount = tickets.filter(t => t.queue === 'Billing').length; + const generalCount = tickets.filter(t => t.queue === 'General').length; + const maxQueue = Math.max(techCount, billingCount, generalCount); + + // Template Variables Legend + const getTemplateVariables = (trigger?: EmailTrigger) => { + const common = ['{app_name}', '{ticket_id}', '{ticket_subject}', '{customer_name}', '{queue_name}']; + if (trigger === EmailTrigger.STATUS_CHANGED) return [...common, '{status}', '{old_status}']; + if (trigger === EmailTrigger.AGENT_ASSIGNED) return [...common, '{agent_name}', '{agent_email}']; + if (trigger === EmailTrigger.SURVEY_REQUEST) return [...common, '{survey_link}']; + if (trigger === EmailTrigger.NEW_REPLY) return [...common, '{last_message}', '{reply_link}']; + return [...common, '{ticket_priority}', '{ticket_description}']; + }; + + return ( +
+ {/* Sidebar */} +
+
+

{settings.branding.appName}

+

{currentUser.role === 'superadmin' ? 'Super Admin' : currentUser.role === 'supervisor' ? 'Supervisor Workspace' : 'Agent Workspace'}

+
+ +
+
+
+
+ {currentUser.name} +
+
+

{currentUser.name}

+

{currentUser.queues.join(', ')}

+
+
+ +
+
+
+ + {/* Main Content */} +
+ + {/* SETTINGS VIEW - PERMISSION GATED */} + {view === 'settings' && canAccessSettings && ( +
+ {/* Sidebar Settings Tabs */} +
+
+

Impostazioni

+
+ +
+ +
+ + {/* SYSTEM TAB - SUPERADMIN ONLY */} + {settingsTab === 'system' && canManageGlobalSettings && ( +
+

Limiti e Quote di Sistema

+ +
+

+ + Knowledge Base +

+
+
+
+

Stato Modulo KB

+

Abilita o disabilita l'accesso alla KB

+
+ +
+
+ + setTempSettings({...tempSettings, features: {...tempSettings.features, maxKbArticles: parseInt(e.target.value)}})} /> +

Utilizzo corrente: {currentArticles}

+
+
+
+ +
+

+ + Personale +

+
+
+ + setTempSettings({...tempSettings, features: {...tempSettings.features, maxSupervisors: parseInt(e.target.value)}})} /> +

Utilizzo corrente: {currentSupervisors}

+
+
+ + setTempSettings({...tempSettings, features: {...tempSettings.features, maxAgents: parseInt(e.target.value)}})} /> +

Utilizzo corrente: {currentAgents}

+
+
+
+ +
+

+ + AI Knowledge Agent +

+
+
+
+

Stato Agente AI

+

Auto-generazione articoli da ticket

+
+ +
+
+ + setTempSettings({...tempSettings, features: {...tempSettings.features, maxAiGeneratedArticles: parseInt(e.target.value)}})} /> +

Generati finora: {currentAiArticles}

+
+
+
+
+ )} + + {/* AI CONFIG TAB - NEW */} + {settingsTab === 'ai' && canManageTeam && ( +
+

Integrazione AI

+
+ +
+

Configurazione Provider AI

+

Scegli il motore di intelligenza artificiale per l'assistente chat e l'analisi della KB. Puoi usare provider cloud o self-hosted.

+
+
+ +
+
+ + +
+ +
+ + setTempSettings({...tempSettings, aiConfig: {...tempSettings.aiConfig, apiKey: e.target.value}})} + /> +
+ +
+
+ + setTempSettings({...tempSettings, aiConfig: {...tempSettings.aiConfig, model: e.target.value}})} + /> +
+
+ + setTempSettings({...tempSettings, aiConfig: {...tempSettings.aiConfig, baseUrl: e.target.value}})} + /> +
+
+
+
+ )} + + {settingsTab === 'general' && canManageGlobalSettings && ( +
+

Personalizzazione Branding

+
+ + setTempSettings({...tempSettings, branding: {...tempSettings.branding, appName: e.target.value}})} + /> +
+
+ +
+ setTempSettings({...tempSettings, branding: {...tempSettings.branding, primaryColor: e.target.value}})} + /> + {tempSettings.branding.primaryColor} +
+
+
+ + setTempSettings({...tempSettings, branding: {...tempSettings.branding, logoUrl: e.target.value}})} + /> +
+
+ )} + + {settingsTab === 'users' && canManageTeam && ( +
+

Gestione Utenti Frontend

+ +
+
+

{editingUser ? 'Modifica Utente' : 'Aggiungi Nuovo Utente'}

+
+ setNewUserForm({...newUserForm, name: e.target.value})} /> + setNewUserForm({...newUserForm, email: e.target.value})} /> + setNewUserForm({...newUserForm, company: e.target.value})} /> + {editingUser && ( + + )} +
+
+
+ {editingUser && ( + + )} + +
+
+ + + + + + + + + + + + + {clientUsers.map(user => ( + + + + + + + + ))} + +
NomeEmailAziendaStatoAzioni
{user.name}{user.email}{user.company || '-'}{user.status} + + + +
+
+ )} + {settingsTab === 'agents' && canManageTeam && ( +
+

Gestione Team di Supporto

+ {/* Add/Edit Agent Form */} +
+
+

+ {editingAgent ? 'Modifica Agente' : 'Aggiungi Nuovo Agente'} +

+ {!editingAgent && ( +
+ Agenti: {currentAgents}/{settings.features.maxAgents} + Supervisor: {currentSupervisors}/{settings.features.maxSupervisors} +
+ )} +
+ +
+ {/* Avatar Editor Component */} + handleAvatarSaved(img, config, !!editingAgent)} + /> + +
+
+ setNewAgentForm({...newAgentForm, name: e.target.value})} /> + setNewAgentForm({...newAgentForm, email: e.target.value})} /> + setNewAgentForm({...newAgentForm, password: e.target.value})} /> +
+
+ + +
+
+ +
+ {queues.map(q => ( + + ))} +
+
+ +
+ {editingAgent && ( + + )} + +
+
+
+
+ +
+ {agents.map(agent => ( +
+
+
+
+ {agent.name} +
+
+

+ {agent.name} + {agent.role === 'superadmin' && } + {agent.role === 'supervisor' && } +

+

{agent.email}

+
+
+
+ + +
+
+
+

Code Assegnate

+
+ {agent.queues.map(q => ( + {q} + ))} +
+
+
+ ))} +
+
+ )} + {settingsTab === 'queues' && canManageTeam && ( +
+

Code di Smistamento

+
+
+ setNewQueueForm({...newQueueForm, name: e.target.value})} + /> + setNewQueueForm({...newQueueForm, description: e.target.value})} + /> +
+ +
+ + + + + + + + + + + + {queues.map(queue => ( + + + + + + + ))} + +
Nome CodaDescrizioneAgenti AssegnatiAzioni
{queue.name}{queue.description || '-'} + {agents.filter(a => a.queues.includes(queue.name)).length} agenti + + +
+
+ )} + {settingsTab === 'email' && canManageGlobalSettings && ( +
+

Notifiche & Email

+
+
+

Configurazione SMTP

+

Imposta il server di posta in uscita.

+
+ +
+ +
+
+ + setTempSettings({...tempSettings, smtp: {...tempSettings.smtp, host: e.target.value}})} /> +
+
+ + setTempSettings({...tempSettings, smtp: {...tempSettings.smtp, port: parseInt(e.target.value)}})} /> +
+
+ + setTempSettings({...tempSettings, smtp: {...tempSettings.smtp, user: e.target.value}})} /> +
+
+ + setTempSettings({...tempSettings, smtp: {...tempSettings.smtp, pass: e.target.value}})} /> +
+
+ +
+ + {/* Template Manager */} + {!editingTemplate ? ( +
+
+

Modelli Email

+ +
+ +
+ {tempSettings.emailTemplates.map(template => ( +
+
+
+

{template.name}

+ + {template.audience} + +
+

Trigger: {template.trigger}

+
+
+ + +
+
+ ))} +
+
+ ) : ( + /* Template Editor */ +
+
+

{editingTemplate.id === 'new' ? 'Nuovo Modello' : 'Modifica Modello'}

+ +
+ +
+
+
+ + setEditingTemplate({...editingTemplate, name: e.target.value})} /> +
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + setEditingTemplate({...editingTemplate, subject: e.target.value})} /> +
+ +
+ + +
+ +
+
+
+ ) : ( +
Seleziona un ticket per vedere i dettagli
+ )} +
+
+ )} + + {view === 'kb' && ( +
+
+

Gestione Knowledge Base

+ {settings.features.kbEnabled ? ( + + ) : ( + + KB Disabilitata + + )} +
+ + {isEditingKB && ( +
+

{newArticle.id ? 'Modifica Elemento' : 'Nuovo Elemento'}

+ {/* ... (Existing KB Form Content remains the same) ... */} +
+
+ + setNewArticle({...newArticle, title: e.target.value})} + /> +
+
+ + setNewArticle({...newArticle, category: e.target.value})} + /> +
+
+ +
+ +
+ + +
+
+ + {newArticle.type === 'article' ? ( +
+ +