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