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:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
312
App.tsx
Normal 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
0
Dockerfile
Normal file
25
README.md
25
README.md
@@ -1,11 +1,20 @@
|
||||
<div align="center">
|
||||
|
||||
<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>
|
||||
|
||||
# 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
0
backend/Dockerfile
Normal file
39
backend/db.js
Normal file
39
backend/db.js
Normal 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
47
backend/index.js
Normal 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
21
backend/package.json
Normal 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
38
backend/schema.sql
Normal 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"]');
|
||||
1789
components/AgentDashboard.tsx
Normal file
1789
components/AgentDashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
199
components/AuthScreen.tsx
Normal file
199
components/AuthScreen.tsx
Normal 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
755
components/ClientPortal.tsx
Normal 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
57
components/Toast.tsx
Normal 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
206
constants.ts
Normal 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
55
docker-compose.yml
Normal 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
62
index.html
Normal 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
15
index.tsx
Normal 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
5
metadata.json
Normal 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
19
nginx.conf
Normal 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
28
package.json
Normal 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
143
services/geminiService.ts
Normal 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
29
tsconfig.json
Normal 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
181
types.ts
Normal 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
16
vite.config.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user