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.
This commit is contained in:
fcarraUniSa
2026-02-16 16:24:31 +01:00
parent cfee03e670
commit 0102f0e285
24 changed files with 4057 additions and 8 deletions

24
.gitignore vendored Normal file
View File

@@ -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?

312
App.tsx Normal file
View File

@@ -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<AppState>({
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<ToastMessage[]>([]);
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<Ticket, 'id' | 'createdAt' | 'messages' | 'status'>) => {
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<SurveyResult, 'id' | 'timestamp'>) => {
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 (
<>
<AuthScreen
settings={state.settings}
onClientLogin={handleClientLogin}
onAgentLogin={handleAgentLogin}
onClientRegister={handleClientRegister}
/>
<ToastContainer toasts={toasts} removeToast={removeToast} />
</>
);
}
// 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 (
<div className="min-h-screen bg-gray-50 text-gray-900 font-sans" style={{ '--brand-color': state.settings.branding.primaryColor } as React.CSSProperties}>
{state.userRole === 'client' ? (
<ClientPortal
currentUser={state.currentUser as ClientUser}
articles={state.settings.features.kbEnabled ? state.articles : []}
tickets={state.tickets.filter(t => t.customerName === state.currentUser?.name)}
queues={state.queues}
onCreateTicket={createTicket}
onReplyTicket={replyToTicket}
onSubmitSurvey={submitSurvey}
onLogout={handleLogout}
showToast={showToast}
/>
) : (
<AgentDashboard
currentUser={state.currentUser as Agent}
tickets={agentTickets}
articles={state.settings.features.kbEnabled ? state.articles : []}
agents={state.agents}
queues={state.queues}
surveys={state.surveys}
clientUsers={state.clientUsers}
settings={state.settings}
updateTicketStatus={updateTicketStatus}
updateTicketAgent={updateTicketAgent}
addArticle={addArticle}
updateArticle={updateArticle}
addAgent={addAgent}
updateAgent={updateAgent}
removeAgent={removeAgent}
addClientUser={addClientUser}
updateClientUser={updateClientUser}
removeClientUser={removeClientUser}
updateSettings={updateSettings}
addQueue={addQueue}
removeQueue={removeQueue}
onLogout={handleLogout}
showToast={showToast}
/>
)}
<ToastContainer toasts={toasts} removeToast={removeToast} />
</div>
);
};
export default App;

0
Dockerfile Normal file
View File

View File

@@ -1,11 +1,20 @@
<div align="center"> <div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" /> <img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
<h1>Built with AI Studio</h2>
<p>The fastest path from prompt to production with Gemini.</p>
<a href="https://aistudio.google.com/apps">Start building</a>
</div> </div>
# 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`

0
backend/Dockerfile Normal file
View File

39
backend/db.js Normal file
View File

@@ -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;
}
};

47
backend/index.js Normal file
View File

@@ -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
});

21
backend/package.json Normal file
View File

@@ -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"
}
}

38
backend/schema.sql Normal file
View File

@@ -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"]');

File diff suppressed because it is too large Load Diff

199
components/AuthScreen.tsx Normal file
View File

@@ -0,0 +1,199 @@
import React, { useState } from 'react';
import { Agent, ClientUser, AppSettings } from '../types';
import { Lock, User, Mail, Briefcase, ArrowRight, AlertCircle } from 'lucide-react';
interface AuthScreenProps {
settings: AppSettings;
onClientLogin: (email: string, password: string) => boolean;
onAgentLogin: (email: string, password: string) => boolean;
onClientRegister: (name: string, email: string, password: string, company: string) => void;
}
export const AuthScreen: React.FC<AuthScreenProps> = ({ settings, onClientLogin, onAgentLogin, onClientRegister }) => {
const [activeTab, setActiveTab] = useState<'client' | 'agent'>('client');
const [isRegistering, setIsRegistering] = useState(false);
const [error, setError] = useState<string | null>(null);
// Form States
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [company, setCompany] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
let success = false;
if (activeTab === 'agent') {
success = onAgentLogin(email, password);
} else {
if (isRegistering) {
onClientRegister(name, email, password, company);
success = true; // Assume success for mock registration
} else {
success = onClientLogin(email, password);
}
}
if (!success) {
setError("Credenziali non valide. Per favore controlla email e password.");
}
};
const handleTabChange = (tab: 'client' | 'agent') => {
setActiveTab(tab);
setIsRegistering(false);
setError(null);
setEmail('');
setPassword('');
};
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center items-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100">
<div className="bg-white p-8 pb-0 text-center">
<div
className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 text-white font-bold text-2xl"
style={{ backgroundColor: settings.branding.primaryColor }}
>
{settings.branding.appName.substring(0, 2)}
</div>
<h2 className="text-2xl font-bold text-gray-900">{settings.branding.appName}</h2>
<p className="text-gray-500 mt-2 text-sm">Piattaforma di Supporto & Assistenza</p>
</div>
<div className="flex border-b border-gray-200 mt-8">
<button
onClick={() => handleTabChange('client')}
className={`flex-1 py-4 text-sm font-medium transition ${activeTab === 'client' ? 'border-b-2 text-gray-900' : 'text-gray-400 hover:text-gray-600'}`}
style={activeTab === 'client' ? { borderColor: settings.branding.primaryColor } : {}}
>
Cliente
</button>
<button
onClick={() => handleTabChange('agent')}
className={`flex-1 py-4 text-sm font-medium transition ${activeTab === 'agent' ? 'border-b-2 text-gray-900' : 'text-gray-400 hover:text-gray-600'}`}
style={activeTab === 'agent' ? { borderColor: settings.branding.primaryColor } : {}}
>
Agente / Staff
</button>
</div>
<div className="p-8">
<h3 className="text-lg font-bold text-gray-800 mb-6">
{activeTab === 'agent' ? 'Accesso Area Riservata' : (isRegistering ? 'Crea un nuovo account' : 'Accedi al tuo account')}
</h3>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center text-red-700 text-sm animate-pulse">
<AlertCircle className="w-5 h-5 mr-2 flex-shrink-0" />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{isRegistering && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nome Completo</label>
<div className="relative">
<User className="absolute left-3 top-2.5 text-gray-400 w-5 h-5" />
<input
type="text"
required
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="Mario Rossi"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<div className="relative">
<Mail className="absolute left-3 top-2.5 text-gray-400 w-5 h-5" />
<input
type="email"
required
className={`w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 bg-white text-gray-900 ${error ? 'border-red-300 focus:ring-red-200' : 'border-gray-300 focus:ring-blue-500'}`}
placeholder="nome@esempio.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(null); }}
/>
</div>
</div>
{isRegistering && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Azienda (Opzionale)</label>
<div className="relative">
<Briefcase className="absolute left-3 top-2.5 text-gray-400 w-5 h-5" />
<input
type="text"
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="La tua azienda"
value={company}
onChange={(e) => setCompany(e.target.value)}
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<div className="relative">
<Lock className="absolute left-3 top-2.5 text-gray-400 w-5 h-5" />
<input
type="password"
required
className={`w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 bg-white text-gray-900 ${error ? 'border-red-300 focus:ring-red-200' : 'border-gray-300 focus:ring-blue-500'}`}
placeholder="••••••••"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(null); }}
/>
</div>
</div>
<button
type="submit"
className="w-full py-3 rounded-lg text-white font-bold shadow-md hover:opacity-90 transition flex justify-center items-center mt-6"
style={{ backgroundColor: settings.branding.primaryColor }}
>
{activeTab === 'agent' ? 'Entra in Dashboard' : (isRegistering ? 'Registrati' : 'Accedi')}
<ArrowRight className="ml-2 w-4 h-4" />
</button>
</form>
{activeTab === 'client' && (
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
{isRegistering ? 'Hai già un account?' : 'Non hai un account?'}
<button
type="button"
onClick={() => { setIsRegistering(!isRegistering); setError(null); }}
className="font-bold ml-1 hover:underline"
style={{ color: settings.branding.primaryColor }}
>
{isRegistering ? 'Accedi' : 'Registrati ora'}
</button>
</p>
</div>
)}
{activeTab === 'agent' && (
<div className="mt-6 text-center bg-blue-50 p-2 rounded text-xs text-blue-700">
Demo: Usa <b>mario@omni.ai</b> / <b>admin</b>
</div>
)}
{activeTab === 'client' && !isRegistering && (
<div className="mt-6 text-center bg-gray-50 p-2 rounded text-xs text-gray-700">
Demo: Usa <b>luca@client.com</b> / <b>user</b>
</div>
)}
</div>
</div>
</div>
);
};

755
components/ClientPortal.tsx Normal file
View File

@@ -0,0 +1,755 @@
import React, { useState, useRef, useEffect } from 'react';
import { Ticket, KBArticle, ChatMessage, TicketPriority, TicketStatus, SurveyResult, Attachment, ClientUser, TicketQueue } from '../types';
import { getSupportResponse } from '../services/geminiService';
import { ToastType } from './Toast';
import {
Search,
Send,
FileText,
AlertCircle,
MessageSquare,
Star,
X,
Paperclip,
LogOut,
Home,
PlusCircle,
Clock,
CheckCircle,
ChevronRight,
ArrowLeft,
MoreVertical,
Minus,
ExternalLink,
BookOpen
} from 'lucide-react';
interface ClientPortalProps {
currentUser: ClientUser;
articles: KBArticle[];
queues: TicketQueue[];
onCreateTicket: (ticket: Omit<Ticket, 'id' | 'createdAt' | 'messages' | 'status'>) => void;
onReplyTicket: (ticketId: string, message: string) => void;
onSubmitSurvey: (survey: Omit<SurveyResult, 'id' | 'timestamp'>) => void;
tickets?: Ticket[];
onLogout: () => void;
showToast: (message: string, type: ToastType) => void;
}
export const ClientPortal: React.FC<ClientPortalProps> = ({
currentUser,
articles,
queues,
onCreateTicket,
onReplyTicket,
onSubmitSurvey,
tickets = [],
onLogout,
showToast
}) => {
const [activeView, setActiveView] = useState<'dashboard' | 'create_ticket' | 'ticket_detail' | 'kb'>('dashboard');
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [searchQuery, setSearchQuery] = useState('');
// KB Modal State
const [viewingArticle, setViewingArticle] = useState<KBArticle | null>(null);
// Chat Widget State
const [isChatOpen, setIsChatOpen] = useState(false);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([{
id: '0',
role: 'assistant',
content: `Ciao ${currentUser.name}! 👋\nSono l'assistente AI. Come posso aiutarti oggi?`,
timestamp: new Date().toISOString()
}]);
const [inputMessage, setInputMessage] = useState('');
const [isTyping, setIsTyping] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
// Survey State
const [showSurvey, setShowSurvey] = useState(false);
const [surveyData, setSurveyData] = useState({ rating: 0, comment: '', context: '' as 'chat' | 'ticket', refId: '' });
// Ticket Creation Form
const [ticketForm, setTicketForm] = useState({
subject: '',
description: '',
queue: queues.length > 0 ? queues[0].name : 'General'
});
const [ticketFiles, setTicketFiles] = useState<FileList | null>(null);
// Ticket Reply State
const [replyText, setReplyText] = useState('');
// --- Logic ---
useEffect(() => {
if (isChatOpen) chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chatMessages, isChatOpen]);
const handleSendMessage = async () => {
if (!inputMessage.trim()) return;
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: inputMessage,
timestamp: new Date().toISOString()
};
setChatMessages(prev => [...prev, userMsg]);
setInputMessage('');
setIsTyping(true);
const aiResponseText = await getSupportResponse(
userMsg.content,
chatMessages.map(m => m.content).slice(-5),
articles
);
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: aiResponseText,
timestamp: new Date().toISOString()
};
setChatMessages(prev => [...prev, aiMsg]);
setIsTyping(false);
};
const submitTicket = (e: React.FormEvent) => {
e.preventDefault();
const attachments: Attachment[] = [];
if (ticketFiles) {
for (let i = 0; i < ticketFiles.length; i++) {
const file = ticketFiles[i];
attachments.push({
id: `att-${Date.now()}-${i}`,
name: file.name,
type: file.type,
url: URL.createObjectURL(file)
});
}
}
onCreateTicket({
subject: ticketForm.subject,
description: ticketForm.description,
customerName: currentUser.name,
priority: TicketPriority.MEDIUM,
queue: ticketForm.queue,
attachments: attachments
});
setTicketForm({ subject: '', description: '', queue: queues.length > 0 ? queues[0].name : 'General' });
setTicketFiles(null);
setActiveView('dashboard');
};
const submitReply = () => {
if (!replyText.trim() || !selectedTicket) return;
onReplyTicket(selectedTicket.id, replyText);
setReplyText('');
// Optimistic update for UI smoothness (actual update comes from props change)
const newMsg: ChatMessage = { id: `temp-${Date.now()}`, role: 'user', content: replyText, timestamp: new Date().toISOString() };
setSelectedTicket({
...selectedTicket,
messages: [...selectedTicket.messages, newMsg],
status: selectedTicket.status === TicketStatus.RESOLVED ? TicketStatus.OPEN : selectedTicket.status
});
showToast("Risposta inviata", 'success');
};
const submitSurvey = () => {
if (surveyData.rating === 0) {
showToast("Per favore seleziona una valutazione.", 'error');
return;
}
onSubmitSurvey({
rating: surveyData.rating,
comment: surveyData.comment,
source: surveyData.context,
referenceId: surveyData.refId || undefined
});
setShowSurvey(false);
if (surveyData.context === 'chat') {
setChatMessages([{ id: '0', role: 'assistant', content: `Ciao ${currentUser.name}! Come posso aiutarti oggi?`, timestamp: new Date().toISOString() }]);
setIsChatOpen(false);
}
};
const handleTicketClick = (ticket: Ticket) => {
setSelectedTicket(ticket);
setActiveView('ticket_detail');
};
const activeTickets = tickets.filter(t => t.status !== TicketStatus.RESOLVED && t.status !== TicketStatus.CLOSED);
const resolvedTickets = tickets.filter(t => t.status === TicketStatus.RESOLVED || t.status === TicketStatus.CLOSED);
const filteredArticles = articles.filter(a =>
a.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
a.content.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="min-h-screen bg-gray-50 flex flex-col font-sans">
{/* Top Navigation */}
<nav className="bg-white border-b border-gray-200 sticky top-0 z-30 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center cursor-pointer" onClick={() => setActiveView('dashboard')}>
<div className="w-8 h-8 rounded-lg bg-brand-600 flex items-center justify-center text-white font-bold mr-2">
OS
</div>
<span className="font-bold text-xl text-gray-800 tracking-tight">OmniSupport</span>
</div>
<div className="hidden sm:ml-10 sm:flex sm:space-x-8">
<button
onClick={() => setActiveView('dashboard')}
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${activeView === 'dashboard' ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}
>
Dashboard
</button>
<button
onClick={() => setActiveView('kb')}
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${activeView === 'kb' ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}
>
Knowledge Base
</button>
</div>
</div>
<div className="flex items-center">
<button
onClick={() => setActiveView('create_ticket')}
className="bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-700 shadow-sm transition mr-4 flex items-center"
>
<PlusCircle className="w-4 h-4 mr-2" />
Nuovo Ticket
</button>
<div className="ml-3 relative flex items-center cursor-pointer group">
<div className="h-8 w-8 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-bold border border-gray-300">
{currentUser.name.substring(0,2).toUpperCase()}
</div>
<button onClick={onLogout} className="ml-4 text-gray-400 hover:text-red-500">
<LogOut className="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>
</nav>
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* DASHBOARD VIEW */}
{activeView === 'dashboard' && (
<div className="space-y-8 animate-fade-in">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Ticket Aperti</p>
<p className="text-3xl font-bold text-gray-900">{activeTickets.filter(t => t.status === TicketStatus.OPEN).length}</p>
</div>
<div className="p-3 bg-blue-50 rounded-full text-blue-600">
<AlertCircle className="w-6 h-6" />
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">In Lavorazione</p>
<p className="text-3xl font-bold text-gray-900">{activeTickets.filter(t => t.status === TicketStatus.IN_PROGRESS).length}</p>
</div>
<div className="p-3 bg-amber-50 rounded-full text-amber-600">
<Clock className="w-6 h-6" />
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Risolti Totali</p>
<p className="text-3xl font-bold text-gray-900">{resolvedTickets.length}</p>
</div>
<div className="p-3 bg-green-50 rounded-full text-green-600">
<CheckCircle className="w-6 h-6" />
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Active Tickets List */}
<div className="lg:col-span-2 space-y-4">
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold text-gray-800">I tuoi Ticket Attivi</h2>
<span className="text-sm text-gray-500">{activeTickets.length} in corso</span>
</div>
{activeTickets.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
<CheckCircle className="w-12 h-12 text-green-200 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900">Tutto tranquillo!</h3>
<p className="text-gray-500 mt-1">Non hai ticket aperti al momento.</p>
</div>
) : (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="divide-y divide-gray-100">
{activeTickets.map(ticket => (
<div
key={ticket.id}
onClick={() => handleTicketClick(ticket)}
className="p-6 hover:bg-gray-50 transition cursor-pointer flex items-center justify-between group"
>
<div className="flex-1">
<div className="flex items-center space-x-3 mb-1">
<span className="text-sm font-mono text-gray-500">#{ticket.id}</span>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
ticket.status === TicketStatus.OPEN ? 'bg-blue-100 text-blue-800' : 'bg-amber-100 text-amber-800'
}`}>
{ticket.status}
</span>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">{ticket.queue}</span>
</div>
<h3 className="text-base font-semibold text-gray-900 group-hover:text-brand-600 transition">{ticket.subject}</h3>
<p className="text-sm text-gray-500 mt-1 line-clamp-1">{ticket.description}</p>
</div>
<ChevronRight className="w-5 h-5 text-gray-300 group-hover:text-gray-400" />
</div>
))}
</div>
</div>
)}
</div>
{/* Resolved History */}
<div className="space-y-4">
<h2 className="text-xl font-bold text-gray-800">Storico Recente</h2>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{resolvedTickets.length === 0 ? (
<div className="p-8 text-center text-gray-400 text-sm">Nessun ticket nello storico.</div>
) : (
<div className="divide-y divide-gray-100">
{resolvedTickets.slice(0, 5).map(ticket => (
<div key={ticket.id} onClick={() => handleTicketClick(ticket)} className="p-4 hover:bg-gray-50 cursor-pointer transition">
<div className="flex justify-between items-start">
<div>
<p className="text-sm font-medium text-gray-900 line-clamp-1">{ticket.subject}</p>
<p className="text-xs text-gray-500 mt-0.5">{ticket.createdAt.split('T')[0]}</p>
</div>
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-green-100 text-green-800">
{ticket.status}
</span>
</div>
</div>
))}
</div>
)}
{resolvedTickets.length > 5 && (
<div className="p-3 bg-gray-50 text-center border-t border-gray-100">
<button className="text-xs font-medium text-brand-600 hover:text-brand-800">Visualizza tutti</button>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* TICKET DETAIL VIEW */}
{activeView === 'ticket_detail' && selectedTicket && (
<div className="max-w-4xl mx-auto animate-fade-in">
<button onClick={() => setActiveView('dashboard')} className="flex items-center text-gray-500 hover:text-gray-900 mb-6 transition">
<ArrowLeft className="w-4 h-4 mr-2" /> Torna alla Dashboard
</button>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{/* Header */}
<div className="p-6 border-b border-gray-200 flex justify-between items-start bg-gray-50">
<div>
<div className="flex items-center space-x-3 mb-2">
<span className="font-mono text-gray-500 text-sm">#{selectedTicket.id}</span>
<span className={`px-2 py-1 rounded-full text-xs font-bold uppercase ${
selectedTicket.status === TicketStatus.RESOLVED ? 'bg-green-100 text-green-700' :
selectedTicket.status === TicketStatus.OPEN ? 'bg-blue-100 text-blue-700' : 'bg-amber-100 text-amber-700'
}`}>
{selectedTicket.status}
</span>
<span className="text-xs text-gray-500 flex items-center">
<Clock className="w-3 h-3 mr-1" /> {selectedTicket.createdAt.split('T')[0]}
</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">{selectedTicket.subject}</h1>
<p className="text-sm text-gray-500 mt-1">Coda: <span className="font-medium">{selectedTicket.queue}</span></p>
</div>
{selectedTicket.status === TicketStatus.RESOLVED && (
<button
onClick={() => { setSurveyData({rating: 0, comment: '', context: 'ticket', refId: selectedTicket.id}); setShowSurvey(true); }}
className="bg-amber-100 text-amber-700 px-4 py-2 rounded-lg text-sm font-bold hover:bg-amber-200 transition flex items-center"
>
<Star className="w-4 h-4 mr-2" /> Valuta
</button>
)}
</div>
{/* Description */}
<div className="p-8 border-b border-gray-100">
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-wide mb-3">Descrizione Problema</h3>
<div className="text-gray-800 leading-relaxed bg-gray-50 p-4 rounded-lg border border-gray-100">
{selectedTicket.description}
</div>
{selectedTicket.attachments && selectedTicket.attachments.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{selectedTicket.attachments.map(att => (
<a key={att.id} href="#" className="flex items-center px-3 py-2 bg-white border border-gray-200 rounded-lg text-sm text-blue-600 hover:border-blue-300 transition">
<Paperclip className="w-4 h-4 mr-2 text-gray-400" />
{att.name}
</a>
))}
</div>
)}
</div>
{/* Conversation */}
<div className="p-8 bg-white space-y-6">
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-wide mb-4">Cronologia Conversazione</h3>
{selectedTicket.messages.length === 0 && (
<p className="text-center text-gray-400 italic py-4">Nessuna risposta ancora. Un agente ti risponderà presto.</p>
)}
{selectedTicket.messages.map(msg => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] p-4 rounded-xl text-sm ${
msg.role === 'user'
? 'bg-blue-600 text-white rounded-tr-none shadow-md'
: 'bg-gray-100 text-gray-800 rounded-tl-none border border-gray-200'
}`}>
<div className="flex justify-between items-center mb-1 opacity-80 text-xs">
<span className="font-bold mr-4">{msg.role === 'user' ? 'Tu' : 'Supporto'}</span>
<span>{msg.timestamp.split('T')[1].substring(0,5)}</span>
</div>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
</div>
))}
</div>
{/* Reply Box */}
<div className="p-6 bg-gray-50 border-t border-gray-200">
<h3 className="text-sm font-bold text-gray-700 mb-3">Rispondi</h3>
<textarea
className="w-full border border-gray-300 rounded-lg p-4 focus:ring-2 focus:ring-brand-500 focus:outline-none shadow-sm mb-3 bg-white text-gray-900"
rows={3}
placeholder="Scrivi qui la tua risposta..."
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
/>
<div className="flex justify-end">
<button
onClick={submitReply}
disabled={!replyText.trim()}
className="bg-brand-600 text-white px-6 py-2.5 rounded-lg font-bold hover:bg-brand-700 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<Send className="w-4 h-4 mr-2" /> Invia Risposta
</button>
</div>
</div>
</div>
</div>
)}
{/* KB VIEW */}
{activeView === 'kb' && (
<div className="max-w-5xl mx-auto animate-fade-in">
<div className="text-center py-10">
<h1 className="text-3xl font-bold text-gray-900 mb-4">Knowledge Base</h1>
<p className="text-gray-500 mb-8 max-w-2xl mx-auto">Trova risposte rapide alle domande più comuni, guide tecniche e documentazione.</p>
<div className="relative max-w-xl mx-auto">
<Search className="absolute left-4 top-3.5 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Cerca articoli, guide..."
className="w-full pl-12 pr-4 py-3 rounded-full border border-gray-300 focus:outline-none focus:ring-2 focus:ring-brand-500 shadow-sm text-lg bg-white text-gray-900"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{filteredArticles.map(article => (
<div
key={article.id}
onClick={() => setViewingArticle(article)}
className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition group cursor-pointer hover:border-brand-200"
>
<div className="flex justify-between items-start mb-4">
<span className="text-xs font-bold text-brand-600 bg-brand-50 px-2 py-1 rounded-md uppercase tracking-wide">
{article.category}
</span>
{article.type === 'url' && <ExternalLink className="w-4 h-4 text-gray-300" />}
</div>
<h3 className="text-lg font-bold text-gray-800 mb-2 group-hover:text-brand-600 transition">{article.title}</h3>
<p className="text-gray-600 text-sm line-clamp-3 mb-4">
{article.content}
</p>
{article.type === 'url' ? (
<span className="text-brand-600 text-sm font-medium hover:underline flex items-center">
Apri Link <ChevronRight className="w-4 h-4 ml-1" />
</span>
) : (
<span className="text-brand-600 text-sm font-medium group-hover:underline flex items-center">
Leggi Articolo <ChevronRight className="w-4 h-4 ml-1" />
</span>
)}
</div>
))}
</div>
</div>
)}
{/* KB Article Modal */}
{viewingArticle && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 backdrop-blur-sm animate-fade-in">
<div className="bg-white rounded-xl w-full max-w-2xl max-h-[85vh] shadow-2xl flex flex-col">
<div className="p-6 border-b border-gray-100 flex justify-between items-start bg-gray-50 rounded-t-xl">
<div>
<span className="text-xs font-bold text-brand-600 bg-brand-50 px-2 py-1 rounded-md uppercase tracking-wide mb-2 inline-block">
{viewingArticle.category}
</span>
<h2 className="text-xl font-bold text-gray-900">{viewingArticle.title}</h2>
{viewingArticle.type === 'url' && (
<a href={viewingArticle.url} target="_blank" rel="noreferrer" className="text-sm text-blue-600 hover:underline flex items-center mt-1">
{viewingArticle.url} <ExternalLink className="w-3 h-3 ml-1" />
</a>
)}
</div>
<button onClick={() => setViewingArticle(null)} className="p-2 bg-white rounded-full text-gray-400 hover:text-gray-700 hover:bg-gray-100 border border-gray-200 shadow-sm transition">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-8 overflow-y-auto">
<div className="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap">
{viewingArticle.content}
</div>
</div>
<div className="p-4 border-t border-gray-100 bg-gray-50 rounded-b-xl flex justify-between items-center text-sm text-gray-500">
<span>Ultimo aggiornamento: {viewingArticle.lastUpdated}</span>
<button onClick={() => setViewingArticle(null)} className="px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 font-medium">Chiudi</button>
</div>
</div>
</div>
)}
{/* CREATE TICKET VIEW */}
{activeView === 'create_ticket' && (
<div className="max-w-2xl mx-auto animate-fade-in">
<button onClick={() => setActiveView('dashboard')} className="flex items-center text-gray-500 hover:text-gray-900 mb-6 transition">
<ArrowLeft className="w-4 h-4 mr-2" /> Annulla e Torna indietro
</button>
<div className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100">
<h2 className="text-2xl font-bold text-gray-800 mb-6">Nuova Richiesta di Supporto</h2>
<form onSubmit={submitTicket} className="space-y-6">
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">Di cosa hai bisogno?</label>
<select
className="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white text-gray-900"
value={ticketForm.queue}
onChange={e => setTicketForm({...ticketForm, queue: e.target.value})}
>
{queues.map(q => (
<option key={q.id} value={q.name}>{q.name} - {q.description}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">Oggetto</label>
<input
required
type="text"
placeholder="Breve sintesi del problema"
className="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white text-gray-900"
value={ticketForm.subject}
onChange={e => setTicketForm({...ticketForm, subject: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">Descrizione Dettagliata</label>
<textarea
required
rows={5}
placeholder="Descrivi i passaggi per riprodurre il problema..."
className="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white text-gray-900"
value={ticketForm.description}
onChange={e => setTicketForm({...ticketForm, description: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">Allegati</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center text-center hover:bg-gray-50 transition cursor-pointer relative">
<input
type="file"
multiple
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={(e) => setTicketFiles(e.target.files)}
/>
<div className="bg-blue-50 p-3 rounded-full mb-3">
<Paperclip className="w-6 h-6 text-brand-600" />
</div>
<p className="text-sm font-medium text-gray-900">
{ticketFiles && ticketFiles.length > 0
? <span className="text-green-600">{ticketFiles.length} file pronti per l'upload</span>
: 'Clicca per caricare file'}
</p>
<p className="text-xs text-gray-500 mt-1">Supporta immagini e PDF</p>
</div>
</div>
<button type="submit" className="w-full bg-brand-600 text-white font-bold py-4 rounded-xl hover:bg-brand-700 transition shadow-md">
Invia Richiesta
</button>
</form>
</div>
</div>
)}
</main>
{/* FLOATING CHAT WIDGET */}
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
{isChatOpen && (
<div className="mb-4 bg-white w-96 rounded-2xl shadow-2xl border border-gray-200 overflow-hidden flex flex-col h-[500px] animate-slide-up origin-bottom-right">
<div className="bg-gradient-to-r from-brand-600 to-brand-700 p-4 flex justify-between items-center text-white shadow-md">
<div className="flex items-center">
<div className="bg-white/20 p-2 rounded-lg mr-3">
<MessageSquare className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-sm">Assistente AI</h3>
<p className="text-[10px] text-brand-100 opacity-90">Supporto Istantaneo H24</p>
</div>
</div>
<div className="flex items-center space-x-1">
<button onClick={() => { setSurveyData({rating: 0, comment: '', context: 'chat', refId: ''}); setShowSurvey(true); }} className="p-1 hover:bg-white/20 rounded">
<Star className="w-4 h-4" />
</button>
<button onClick={() => setIsChatOpen(false)} className="p-1 hover:bg-white/20 rounded">
<Minus className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 bg-gray-50 space-y-3">
{chatMessages.map(msg => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
{msg.role === 'assistant' && (
<div className="w-6 h-6 rounded-full bg-brand-100 flex items-center justify-center mr-2 mt-1 flex-shrink-0 text-brand-700 text-[10px] font-bold">AI</div>
)}
<div className={`max-w-[85%] p-3 text-sm rounded-2xl shadow-sm ${
msg.role === 'user'
? 'bg-brand-600 text-white rounded-tr-none'
: 'bg-white text-gray-800 rounded-tl-none border border-gray-100'
}`}>
{msg.content}
</div>
</div>
))}
{isTyping && (
<div className="flex justify-start">
<div className="w-6 h-6 rounded-full bg-brand-100 flex items-center justify-center mr-2 mt-1"></div>
<div className="bg-white p-3 rounded-2xl rounded-tl-none border border-gray-100 shadow-sm flex space-x-1 items-center">
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" style={{animationDelay: '0.4s'}}></div>
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
<div className="p-3 bg-white border-t border-gray-100">
<div className="flex items-center bg-gray-50 rounded-full px-4 py-2 border border-gray-200 focus-within:ring-2 focus-within:ring-brand-500 focus-within:border-transparent transition">
<input
type="text"
className="flex-1 bg-transparent border-none focus:outline-none text-sm text-gray-900"
placeholder="Scrivi un messaggio..."
value={inputMessage}
onChange={e => setInputMessage(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSendMessage()}
/>
<button
onClick={handleSendMessage}
disabled={!inputMessage.trim() || isTyping}
className="ml-2 text-brand-600 hover:text-brand-800 disabled:opacity-50"
>
<Send className="w-5 h-5" />
</button>
</div>
</div>
</div>
)}
<button
onClick={() => setIsChatOpen(!isChatOpen)}
className={`w-14 h-14 rounded-full shadow-lg flex items-center justify-center transition transform hover:scale-105 ${isChatOpen ? 'bg-gray-800 rotate-90' : 'bg-brand-600 hover:bg-brand-700'} text-white`}
>
{isChatOpen ? <X className="w-6 h-6" /> : <MessageSquare className="w-7 h-7" />}
</button>
</div>
{/* Survey Modal */}
{showSurvey && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60] p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-sm shadow-2xl animate-scale-in">
<div className="text-center mb-6">
<h3 className="text-xl font-bold text-gray-800">Valuta l'esperienza</h3>
<p className="text-gray-500 text-sm mt-1">Il tuo parere conta per noi!</p>
</div>
<div className="flex justify-center space-x-3 mb-6">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => setSurveyData(s => ({ ...s, rating: star }))}
className={`transition transform hover:scale-110 p-1`}
>
<Star className={`w-8 h-8 ${surveyData.rating >= star ? 'fill-amber-400 text-amber-400' : 'text-gray-200'}`} />
</button>
))}
</div>
<textarea
className="w-full border border-gray-300 rounded-lg p-3 text-sm focus:ring-2 focus:ring-brand-500 mb-4 bg-white text-gray-900 resize-none"
rows={3}
placeholder="Scrivi un commento (opzionale)..."
value={surveyData.comment}
onChange={e => setSurveyData(s => ({...s, comment: e.target.value}))}
/>
<div className="flex space-x-3">
<button
onClick={() => setShowSurvey(false)}
className="flex-1 py-2.5 text-gray-500 font-medium hover:bg-gray-100 rounded-xl transition"
>
Salta
</button>
<button
onClick={submitSurvey}
className="flex-1 py-2.5 bg-brand-600 text-white font-bold rounded-xl hover:bg-brand-700 transition shadow-lg shadow-brand-200"
>
Invia
</button>
</div>
</div>
</div>
)}
</div>
);
};

57
components/Toast.tsx Normal file
View File

@@ -0,0 +1,57 @@
import React, { useEffect } from 'react';
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
export type ToastType = 'success' | 'error' | 'info';
export interface ToastMessage {
id: string;
type: ToastType;
message: string;
}
interface ToastProps {
toasts: ToastMessage[];
removeToast: (id: string) => void;
}
export const ToastContainer: React.FC<ToastProps> = ({ toasts, removeToast }) => {
return (
<div className="fixed bottom-6 right-6 z-[100] flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} removeToast={removeToast} />
))}
</div>
);
};
const ToastItem: React.FC<{ toast: ToastMessage; removeToast: (id: string) => void }> = ({ toast, removeToast }) => {
useEffect(() => {
const timer = setTimeout(() => {
removeToast(toast.id);
}, 4000);
return () => clearTimeout(timer);
}, [toast.id, removeToast]);
const styles = {
success: 'bg-green-600 text-white',
error: 'bg-red-600 text-white',
info: 'bg-blue-600 text-white',
};
const icons = {
success: <CheckCircle className="w-5 h-5" />,
error: <AlertCircle className="w-5 h-5" />,
info: <Info className="w-5 h-5" />,
};
return (
<div className={`${styles[toast.type]} pointer-events-auto flex items-center p-4 rounded-lg shadow-lg min-w-[300px] animate-slide-up transition-all transform hover:scale-105`}>
<div className="mr-3">{icons[toast.type]}</div>
<div className="flex-1 text-sm font-medium">{toast.message}</div>
<button onClick={() => removeToast(toast.id)} className="ml-4 opacity-70 hover:opacity-100">
<X className="w-4 h-4" />
</button>
</div>
);
};

206
constants.ts Normal file
View File

@@ -0,0 +1,206 @@
import { Agent, KBArticle, Ticket, TicketPriority, TicketStatus, SurveyResult, AppSettings, ClientUser, TicketQueue, EmailTrigger, EmailAudience, AiProvider } from "./types";
export const INITIAL_QUEUES: TicketQueue[] = [
{ id: 'q1', name: 'General', description: 'Richieste generiche e informazioni' },
{ id: 'q2', name: 'Tech Support', description: 'Problemi tecnici hardware e software' },
{ id: 'q3', name: 'Billing', description: 'Fatturazione, pagamenti e rimborsi' }
];
export const MOCK_AGENTS: Agent[] = [
{
id: 'a0',
name: 'Super Admin',
email: 'fcarra79@gmail.com',
password: 'Mr10921.',
role: 'superadmin',
avatar: 'https://ui-avatars.com/api/?name=Super+Admin&background=0D8ABC&color=fff',
avatarConfig: { x: 50, y: 50, scale: 1 },
skills: ['All'],
queues: ['General', 'Tech Support', 'Billing']
},
{
id: 'a1',
name: 'Mario Rossi',
email: 'mario@omni.ai',
password: 'admin',
role: 'agent',
avatar: 'https://picsum.photos/id/1005/200/200',
avatarConfig: { x: 50, y: 50, scale: 1 },
skills: ['Technical', 'Linux'],
queues: ['Tech Support', 'General']
},
{
id: 'a2',
name: 'Giulia Bianchi',
email: 'giulia@omni.ai',
password: 'admin',
role: 'supervisor',
avatar: 'https://picsum.photos/id/1011/200/200',
avatarConfig: { x: 50, y: 50, scale: 1 },
skills: ['Billing', 'Refunds'],
queues: ['Billing']
},
];
export const MOCK_CLIENT_USERS: ClientUser[] = [
{ id: 'u1', name: 'Luca Verdi', email: 'luca@client.com', password: 'user', company: 'Acme Corp', status: 'active' },
{ id: 'u2', name: 'Anna Neri', email: 'anna@client.com', password: 'user', company: 'Globex', status: 'active' },
{ id: 'u3', name: 'Giorgio Gialli', email: 'giorgio.g@example.com', password: 'user', status: 'inactive' },
];
export const INITIAL_SETTINGS: AppSettings = {
branding: {
appName: 'OmniSupport AI',
primaryColor: '#0284c7', // brand-600
logoUrl: 'https://via.placeholder.com/150'
},
features: {
kbEnabled: true,
maxKbArticles: 50,
maxSupervisors: 2,
aiKnowledgeAgentEnabled: true,
maxAiGeneratedArticles: 10,
maxAgents: 10
},
aiConfig: {
provider: AiProvider.GEMINI,
apiKey: '',
model: 'gemini-3-flash-preview',
isActive: true
},
smtp: {
host: 'smtp.example.com',
port: 587,
user: 'notifications@omnisupport.ai',
pass: 'password',
secure: true,
fromEmail: 'noreply@omnisupport.ai'
},
emailTemplates: [
{
id: 't1',
name: 'Conferma Apertura Ticket',
trigger: EmailTrigger.TICKET_CREATED,
audience: EmailAudience.CLIENT,
subject: 'Ticket #{ticket_id} Creato - {ticket_subject}',
body: 'Ciao {customer_name},\n\nAbbiamo ricevuto la tua richiesta. Un agente prenderà in carico il ticket #{ticket_id} al più presto.\n\nGrazie,\nIl team di {app_name}',
isActive: true
},
{
id: 't2',
name: 'Notifica Nuovo Ticket (Staff)',
trigger: EmailTrigger.TICKET_CREATED,
audience: EmailAudience.STAFF,
subject: '[NUOVO] Ticket #{ticket_id} in {queue_name}',
body: 'Ciao,\n\nÈ stato aperto un nuovo ticket.\nCliente: {customer_name}\nOggetto: {ticket_subject}\nPriorità: {ticket_priority}\n\nAccedi alla dashboard per gestirlo.',
isActive: true
},
{
id: 't3',
name: 'Aggiornamento Stato',
trigger: EmailTrigger.STATUS_CHANGED,
audience: EmailAudience.CLIENT,
subject: 'Aggiornamento Ticket #{ticket_id}: {status}',
body: 'Ciao {customer_name},\n\nLo stato del tuo ticket #{ticket_id} è cambiato in: {status}.\n\nAccedi al portale per vedere i dettagli.',
isActive: true
},
{
id: 't4',
name: 'Richiesta Feedback',
trigger: EmailTrigger.SURVEY_REQUEST,
audience: EmailAudience.CLIENT,
subject: 'Come è andata con il ticket #{ticket_id}?',
body: 'Ciao {customer_name},\n\nIl tuo ticket è stato risolto. Ti andrebbe di valutare il servizio?\n\nClicca qui per lasciare un feedback: {survey_link}',
isActive: true
}
]
};
export const INITIAL_KB: KBArticle[] = [
{
id: 'kb1',
title: 'Come reimpostare la password',
content: 'Per reimpostare la password, vai su Impostazioni > Sicurezza e clicca su "Cambia Password". Ti verrà inviata una mail di conferma.',
category: 'Account',
type: 'article',
source: 'manual',
lastUpdated: '2023-10-15'
},
{
id: 'kb2',
title: 'Configurazione Email su Outlook',
content: '1. Apri Outlook. 2. File > Aggiungi Account. 3. Inserisci la tua mail. 4. Seleziona IMAP. Server in entrata: imap.example.com porta 993.',
category: 'Tecnico',
type: 'article',
source: 'manual',
lastUpdated: '2023-11-02'
},
{
id: 'kb3',
title: 'Documentazione API Ufficiale',
content: 'Riferimento esterno alla documentazione.',
url: 'https://example.com/api-docs',
category: 'Sviluppo',
type: 'url',
source: 'manual',
lastUpdated: '2023-09-20'
}
];
export const INITIAL_TICKETS: Ticket[] = [
{
id: 'T-1001',
subject: 'Problema connessione VPN',
description: 'Non riesco a connettermi alla VPN aziendale da casa.',
status: TicketStatus.OPEN,
priority: TicketPriority.HIGH,
queue: 'Tech Support',
customerName: 'Luca Verdi',
createdAt: '2023-12-01T09:00:00Z',
messages: [],
attachments: []
},
{
id: 'T-1002',
subject: 'Errore Fattura Dicembre',
description: 'La fattura riporta un importo errato rispetto al contratto.',
status: TicketStatus.RESOLVED,
priority: TicketPriority.MEDIUM,
assignedAgentId: 'a2',
queue: 'Billing',
customerName: 'Anna Neri',
createdAt: '2023-11-28T14:30:00Z',
messages: [
{ id: 'm1', role: 'user', content: 'La fattura è di 50€ invece di 30€.', timestamp: '2023-11-28T14:30:00Z'},
{ id: 'm2', role: 'assistant', content: 'Ciao Anna, ho verificato. C\'era un errore nel calcolo dell\'IVA. Ho emesso una nota di credito.', timestamp: '2023-11-28T15:00:00Z'},
{ id: 'm3', role: 'user', content: 'Grazie mille, risolto.', timestamp: '2023-11-28T15:10:00Z'}
],
attachments: [
{ id: 'att1', name: 'fattura_errata.pdf', type: 'application/pdf', url: '#' }
]
},
{
id: 'T-1003',
subject: 'Come configurare 2FA',
description: 'Vorrei attivare la doppia autenticazione ma non trovo l\'opzione.',
status: TicketStatus.RESOLVED,
priority: TicketPriority.LOW,
assignedAgentId: 'a1',
queue: 'Tech Support',
customerName: 'Giorgio Gialli',
createdAt: '2023-11-25T10:00:00Z',
messages: [
{ id: 'm4', role: 'user', content: 'Dove trovo il 2FA?', timestamp: '2023-11-25T10:00:00Z'},
{ id: 'm5', role: 'assistant', content: 'Ciao Giorgio. Devi scaricare Google Authenticator. Poi vai nel tuo profilo, clicca su "Sicurezza Avanzata" e scansiona il QR code.', timestamp: '2023-11-25T10:15:00Z'}
],
attachments: []
}
];
export const MOCK_SURVEYS: SurveyResult[] = [
{ id: 's1', rating: 5, comment: 'Ottimo servizio, molto veloce!', source: 'ticket', referenceId: 'T-1002', timestamp: '2023-11-28T16:00:00Z' },
{ id: 's2', rating: 4, comment: 'Buono, ma l\'attesa è stata lunghetta.', source: 'chat', timestamp: '2023-12-02T10:30:00Z' },
{ id: 's3', rating: 5, comment: 'AI molto intelligente, ha risolto subito.', source: 'chat', timestamp: '2023-12-03T09:15:00Z' },
{ id: 's4', rating: 2, comment: 'Non ha capito la mia domanda.', source: 'chat', timestamp: '2023-12-01T14:20:00Z' }
];

55
docker-compose.yml Normal file
View File

@@ -0,0 +1,55 @@
version: '3.8'
services:
frontend:
build:
context: .
dockerfile: Dockerfile
ports:
- "80:80"
depends_on:
- backend
networks:
- omni-network
backend:
build:
context: ./backend
dockerfile: Dockerfile
environment:
- DB_HOST=${DB_HOST:-db}
- DB_USER=${DB_USER:-omni_user}
- DB_PASSWORD=${DB_PASSWORD:-omni_pass}
- DB_NAME=${DB_NAME:-omnisupport}
- API_KEY=${API_KEY}
ports:
- "3000:3000"
depends_on:
- db
networks:
- omni-network
db:
image: mysql:8.0
command: --default-authentication-plugin=mysql_native_password
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root_pass}
MYSQL_DATABASE: ${DB_NAME:-omnisupport}
MYSQL_USER: ${DB_USER:-omni_user}
MYSQL_PASSWORD: ${DB_PASSWORD:-omni_pass}
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
- ./backend/schema.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- omni-network
volumes:
db_data:
networks:
omni-network:
driver: bridge

62
index.html Normal file
View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OmniSupport AI</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
colors: {
brand: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
900: '#0c4a6e',
}
}
}
}
}
</script>
<style>
body { font-family: 'Inter', sans-serif; background-color: #f3f4f6; }
/* Custom scrollbar for chat */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
<script type="importmap">
{
"imports": {
"@google/genai": "https://esm.sh/@google/genai@^1.40.0",
"react/": "https://esm.sh/react@^19.2.4/",
"react": "https://esm.sh/react@^19.2.4",
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
"lucide-react": "https://esm.sh/lucide-react@^0.563.0",
"vite": "https://esm.sh/vite@^7.3.1",
"@vitejs/plugin-react": "https://esm.sh/@vitejs/plugin-react@^5.1.4",
"dotenv": "https://esm.sh/dotenv@^17.3.1",
"mysql2/": "https://esm.sh/mysql2@^3.17.1/",
"express": "https://esm.sh/express@^5.2.1",
"cors": "https://esm.sh/cors@^2.8.6"
}
}
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>

15
index.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
metadata.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "OmniSupport AI",
"description": "A complete customer support platform featuring a client portal with AI assistance and a backend for ticket and knowledge base management.",
"requestFramePermissions": []
}

19
nginx.conf Normal file
View File

@@ -0,0 +1,19 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "omnisupport-ai",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@google/genai": "^0.1.1",
"lucide-react": "^0.344.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.2",
"vite": "^5.1.5"
}
}

143
services/geminiService.ts Normal file
View File

@@ -0,0 +1,143 @@
import { GoogleGenAI } from "@google/genai";
import { KBArticle, Ticket, TicketStatus } from "../types";
const apiKey = process.env.API_KEY || '';
// Initialize properly with named parameter
const ai = new GoogleGenAI({ apiKey });
/**
* Agent 1: Customer Support Chat
* Uses the KB to answer questions.
* SUPPORTS MULTI-LANGUAGE via Prompt Engineering.
*/
export const getSupportResponse = async (
userQuery: string,
chatHistory: string[],
knowledgeBase: KBArticle[]
): Promise<string> => {
if (!apiKey) return "API Key mancante. Configura l'ambiente.";
// Prepare Context from KB
const kbContext = knowledgeBase.map(a => {
// Note: We now include 'content' even for URLs because we scrape the text into it.
if (a.type === 'url') {
return `Fonte Esterna [${a.category}]: ${a.title} - URL: ${a.url}\nContenuto Estratto: ${a.content}`;
}
return `Articolo [${a.category}]: ${a.title}\nContenuto: ${a.content}`;
}).join('\n\n');
const systemInstruction = `
Sei "OmniSupport AI", un assistente clienti virtuale globale.
IL TUO COMPITO:
Rispondere alle domande dei clienti basandoti ESCLUSIVAMENTE sulla seguente Base di Conoscenza (KB) fornita in ITALIANO.
GESTIONE LINGUA (IMPORTANTE):
1. Rileva automaticamente la lingua utilizzata dall'utente nel suo ultimo messaggio.
2. Anche se la KB è in Italiano, devi tradurre mentalmente la richiesta, cercare la risposta nella KB Italiana, e poi RISPONDERE NELLA LINGUA DELL'UTENTE.
3. Esempio: Se l'utente scrive in Inglese "How do I reset my password?", tu cerchi "Come reimpostare la password" nella KB e rispondi in Inglese.
BASE DI CONOSCENZA (ITALIANO):
${kbContext}
REGOLE:
1. Se la risposta è nella KB, forniscila.
2. Se l'articolo è una fonte web (URL), usa il "Contenuto Estratto" per rispondere e fornisci anche il link originale all'utente.
3. Se la risposta NON si trova nella KB, ammettilo gentilmente (nella lingua dell'utente) e consiglia di aprire un ticket.
4. Sii cortese, professionale e sintetico.
`;
try {
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: [
...chatHistory.map(msg => ({ role: 'user', parts: [{ text: msg }] })),
{ role: 'user', parts: [{ text: userQuery }] }
],
config: {
systemInstruction: systemInstruction,
temperature: 0.3,
}
});
return response.text || "I apologize, I cannot process your request at the moment / Mi dispiace, non riesco a rispondere al momento.";
} catch (error) {
console.error("Gemini Error:", error);
return "Service Error / Errore del servizio.";
}
};
/**
* Agent 2: Knowledge Extraction
* Scans resolved tickets to find gaps in KB and drafts new articles.
*/
export const generateNewKBArticle = async (
resolvedTickets: Ticket[],
existingArticles: KBArticle[]
): Promise<{ title: string; content: string; category: string } | null> => {
if (!apiKey) return null;
// Filter only resolved tickets
const relevantTickets = resolvedTickets.filter(t => t.status === TicketStatus.RESOLVED);
if (relevantTickets.length === 0) return null;
// Aggregate ticket conversations
const transcripts = relevantTickets.map(t => {
const convo = t.messages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n');
return `TICKET ID: ${t.id}\nOGGETTO: ${t.subject}\nCONVERSAZIONE:\n${convo}\n---`;
}).join('\n');
const existingTitles = existingArticles.map(a => a.title).join(', ');
const prompt = `
Sei un Knowledge Manager AI esperto.
Analizza i seguenti ticket risolti e confrontali con gli articoli esistenti nella Knowledge Base.
ARTICOLI ESISTENTI: ${existingTitles}
TICKET RISOLTI RECENTI:
${transcripts}
OBIETTIVO:
1. Identifica un problema ricorrente o una soluzione tecnica presente nei ticket risolti MA NON coperta dagli articoli esistenti.
2. Se trovi una lacuna, scrivi un NUOVO articolo di Knowledge Base per colmarla.
3. Restituisci il risultato ESCLUSIVAMENTE in formato JSON.
SCHEMA JSON RICHIESTO:
{
"foundGap": boolean,
"title": "Titolo del nuovo articolo",
"content": "Contenuto dettagliato in formato Markdown",
"category": "Categoria suggerita (es. Tecnico, Amministrazione, Account)"
}
Se non trovi nulla di rilevante o nuovo, imposta "foundGap" a false.
`;
try {
const response = await ai.models.generateContent({
model: 'gemini-3-pro-preview', // Pro model for better reasoning
contents: prompt,
config: {
responseMimeType: "application/json"
}
});
const text = response.text;
if (!text) return null;
const result = JSON.parse(text);
if (result.foundGap) {
return {
title: result.title,
content: result.content,
category: result.category
};
}
return null;
} catch (error) {
console.error("Knowledge Agent Error:", error);
return null;
}
};

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

181
types.ts Normal file
View File

@@ -0,0 +1,181 @@
export enum TicketStatus {
OPEN = 'APERTO',
IN_PROGRESS = 'IN LAVORAZIONE',
RESOLVED = 'RISOLTO',
CLOSED = 'CHIUSO'
}
export enum TicketPriority {
LOW = 'Bassa',
MEDIUM = 'Media',
HIGH = 'Alta',
CRITICAL = 'Critica'
}
export interface Attachment {
id: string;
name: string;
url: string; // Mock URL
type: string;
}
export interface TicketQueue {
id: string;
name: string;
description?: string;
}
export interface AgentAvatarConfig {
x: number; // Percentage offset X
y: number; // Percentage offset Y
scale: number; // Zoom level (1 = 100%)
}
export type AgentRole = 'superadmin' | 'supervisor' | 'agent';
export interface Agent {
id: string;
name: string;
email: string;
password?: string;
role: AgentRole; // Added Role
avatar: string;
avatarConfig?: AgentAvatarConfig; // Added positioning config
skills: string[];
queues: string[];
}
export interface ClientUser {
id: string;
name: string;
email: string;
password?: string; // Mock password
company?: string;
status: 'active' | 'inactive';
}
export interface Ticket {
id: string;
subject: string;
description: string;
status: TicketStatus;
priority: TicketPriority;
assignedAgentId?: string;
queue: string; // e.g., 'Technical', 'Billing'
createdAt: string;
customerName: string;
messages: ChatMessage[];
attachments: Attachment[]; // Added attachments
}
export interface KBArticle {
id: string;
title: string;
content: string; // HTML or Markdown
category: string;
type: 'article' | 'url';
url?: string;
source?: 'manual' | 'ai'; // Track if created by AI for quotas
lastUpdated: string;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: string;
}
export interface SurveyResult {
id: string;
rating: number; // 1-5
comment?: string;
source: 'chat' | 'ticket';
referenceId?: string; // ticketId
timestamp: string;
}
export interface BrandingConfig {
appName: string;
primaryColor: string;
logoUrl: string;
}
export interface SmtpConfig {
host: string;
port: number;
user: string;
pass: string;
secure: boolean;
fromEmail: string;
}
export enum EmailTrigger {
TICKET_CREATED = 'ticket_created',
STATUS_CHANGED = 'status_changed',
AGENT_ASSIGNED = 'agent_assigned',
NEW_REPLY = 'new_reply',
SURVEY_REQUEST = 'survey_request'
}
export enum EmailAudience {
CLIENT = 'client',
STAFF = 'staff'
}
export interface EmailTemplate {
id: string;
name: string;
trigger: EmailTrigger;
audience: EmailAudience;
subject: string;
body: string;
isActive: boolean;
}
export interface FeatureConfig {
kbEnabled: boolean;
maxKbArticles: number;
maxSupervisors: number;
aiKnowledgeAgentEnabled: boolean;
maxAiGeneratedArticles: number;
maxAgents: number;
}
export enum AiProvider {
GEMINI = 'gemini',
OPENAI = 'openai',
ANTHROPIC = 'anthropic',
DEEPSEEK = 'deepseek',
OLLAMA = 'ollama', // Self-hosted
HUGGINGFACE = 'huggingface' // Free tier available
}
export interface AiConfig {
provider: AiProvider;
apiKey: string;
model: string;
baseUrl?: string; // For self-hosted/custom endpoints
isActive: boolean;
}
export interface AppSettings {
branding: BrandingConfig;
smtp: SmtpConfig;
emailTemplates: EmailTemplate[];
features: FeatureConfig;
aiConfig: AiConfig; // New AI Configuration
}
export interface AppState {
tickets: Ticket[];
articles: KBArticle[];
agents: Agent[];
queues: TicketQueue[]; // Added Queues
surveys: SurveyResult[];
clientUsers: ClientUser[];
settings: AppSettings;
currentUser: ClientUser | Agent | null; // Changed to object or null
userRole: 'client' | AgentRole | 'guest'; // Updated to include specific agent roles
}

16
vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://backend:3000',
changeOrigin: true,
}
}
}
});