1775 lines
97 KiB
TypeScript
1775 lines
97 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import { Ticket, KBArticle, Agent, TicketStatus, TicketPriority, SurveyResult, AppSettings, ClientUser, TicketQueue, EmailTemplate, EmailTrigger, EmailAudience, AgentAvatarConfig, AgentRole, AiProvider } from '../types';
|
|
import { generateNewKBArticle } from '../services/geminiService';
|
|
import { ToastType } from './Toast';
|
|
import {
|
|
LayoutDashboard,
|
|
BookOpen,
|
|
Users,
|
|
Sparkles,
|
|
CheckCircle,
|
|
Clock,
|
|
Edit3,
|
|
Plus,
|
|
ExternalLink,
|
|
FileText,
|
|
BarChart3,
|
|
TrendingUp,
|
|
MessageCircle,
|
|
Star,
|
|
Settings,
|
|
Trash2,
|
|
Mail,
|
|
Palette,
|
|
Shield,
|
|
Save,
|
|
LogOut,
|
|
Paperclip,
|
|
Layers,
|
|
X,
|
|
Camera,
|
|
Move,
|
|
Check,
|
|
Zap,
|
|
Copy,
|
|
Activity,
|
|
UserPlus,
|
|
Loader2,
|
|
Lock,
|
|
Cpu,
|
|
AlertTriangle,
|
|
RotateCcw,
|
|
Bot,
|
|
Key
|
|
} from 'lucide-react';
|
|
|
|
interface AgentDashboardProps {
|
|
currentUser: Agent;
|
|
tickets: Ticket[];
|
|
articles: KBArticle[];
|
|
agents: Agent[];
|
|
queues: TicketQueue[];
|
|
surveys?: SurveyResult[];
|
|
clientUsers: ClientUser[];
|
|
settings: AppSettings;
|
|
updateTicketStatus: (id: string, status: TicketStatus) => void;
|
|
updateTicketAgent: (id: string, agentId: string) => void;
|
|
addArticle: (article: KBArticle) => void;
|
|
updateArticle: (article: KBArticle) => void;
|
|
addAgent: (agent: Agent) => void;
|
|
updateAgent: (agent: Agent) => void;
|
|
removeAgent: (id: string) => void;
|
|
addClientUser: (user: ClientUser) => void;
|
|
updateClientUser: (user: ClientUser) => void;
|
|
removeClientUser: (id: string) => void;
|
|
updateSettings: (settings: AppSettings) => void;
|
|
addQueue: (queue: TicketQueue) => void;
|
|
removeQueue: (id: string) => void;
|
|
onLogout: () => void;
|
|
showToast: (message: string, type: ToastType) => void;
|
|
}
|
|
|
|
// --- SUB-COMPONENT: Avatar Editor ---
|
|
interface AvatarEditorProps {
|
|
initialImage: string;
|
|
initialConfig?: AgentAvatarConfig;
|
|
onSave: (image: string, config: AgentAvatarConfig) => void;
|
|
}
|
|
|
|
const AvatarEditor: React.FC<AvatarEditorProps> = ({ initialImage, initialConfig, onSave }) => {
|
|
const [image, setImage] = useState(initialImage);
|
|
const [config, setConfig] = useState<AgentAvatarConfig>(initialConfig || { x: 50, y: 50, scale: 1 });
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const dragStart = useRef<{x: number, y: number}>({ x: 0, y: 0 });
|
|
|
|
// Update local state when props change (for editing)
|
|
useEffect(() => {
|
|
setImage(initialImage);
|
|
if(initialConfig) setConfig(initialConfig);
|
|
}, [initialImage, initialConfig]);
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files && e.target.files[0]) {
|
|
const url = URL.createObjectURL(e.target.files[0]);
|
|
setImage(url);
|
|
setConfig({ x: 50, y: 50, scale: 1 }); // Reset config for new image
|
|
}
|
|
};
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
setIsDragging(true);
|
|
dragStart.current = { x: e.clientX, y: e.clientY };
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
if (!isDragging) return;
|
|
const deltaX = (e.clientX - dragStart.current.x) * 0.5; // Sensitivity
|
|
const deltaY = (e.clientY - dragStart.current.y) * 0.5;
|
|
|
|
setConfig(prev => ({
|
|
...prev,
|
|
x: Math.min(100, Math.max(0, prev.x - deltaX * 0.2)), // Invert direction for natural feel or keep normal
|
|
y: Math.min(100, Math.max(0, prev.y - deltaY * 0.2))
|
|
}));
|
|
|
|
dragStart.current = { x: e.clientX, y: e.clientY };
|
|
};
|
|
|
|
const handleMouseUp = () => setIsDragging(false);
|
|
|
|
return (
|
|
<div className="flex flex-col items-center p-4 bg-gray-50 rounded-lg border border-dashed border-gray-300">
|
|
<div
|
|
className="w-32 h-32 rounded-full overflow-hidden border-4 border-white shadow-lg cursor-move relative bg-gray-200 mb-4"
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
>
|
|
{image ? (
|
|
<img
|
|
src={image}
|
|
alt="Avatar"
|
|
className="w-full h-full object-cover"
|
|
style={{
|
|
objectPosition: `${config.x}% ${config.y}%`,
|
|
transform: `scale(${config.scale})`
|
|
}}
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-gray-400"><Users className="w-8 h-8" /></div>
|
|
)}
|
|
<div className="absolute inset-0 bg-black bg-opacity-0 hover:bg-opacity-10 transition pointer-events-none flex items-center justify-center">
|
|
<Move className="text-white opacity-0 hover:opacity-100 drop-shadow-md" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full space-y-3">
|
|
<div className="flex justify-center">
|
|
<label className="cursor-pointer bg-white border border-gray-300 px-3 py-1.5 rounded-md text-sm font-medium hover:bg-gray-50 flex items-center">
|
|
<Camera className="w-4 h-4 mr-2 text-gray-500" />
|
|
Carica Foto
|
|
<input type="file" className="hidden" accept="image/*" onChange={handleFileChange} />
|
|
</label>
|
|
</div>
|
|
|
|
{image && (
|
|
<div className="flex items-center space-x-2">
|
|
<span className="text-xs text-gray-500">Zoom</span>
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="3"
|
|
step="0.1"
|
|
value={config.scale}
|
|
onChange={(e) => setConfig({...config, scale: parseFloat(e.target.value)})}
|
|
className="flex-1 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-[10px] text-gray-400 text-center">Trascina l'immagine per centrarla</p>
|
|
|
|
<button
|
|
type="button" // Prevent form submission
|
|
onClick={() => onSave(image, config)}
|
|
className="w-full bg-blue-600 text-white py-1.5 rounded text-sm font-bold hover:bg-blue-700"
|
|
>
|
|
Conferma Avatar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// --- MAIN COMPONENT ---
|
|
export const AgentDashboard: React.FC<AgentDashboardProps> = ({
|
|
currentUser,
|
|
tickets,
|
|
articles,
|
|
agents,
|
|
queues,
|
|
surveys = [],
|
|
clientUsers,
|
|
settings,
|
|
updateTicketStatus,
|
|
updateTicketAgent,
|
|
addArticle,
|
|
updateArticle,
|
|
addAgent,
|
|
updateAgent,
|
|
removeAgent,
|
|
addClientUser,
|
|
updateClientUser,
|
|
removeClientUser,
|
|
updateSettings,
|
|
addQueue,
|
|
removeQueue,
|
|
onLogout,
|
|
showToast
|
|
}) => {
|
|
const [view, setView] = useState<'tickets' | 'kb' | 'ai' | 'analytics' | 'settings'>('tickets');
|
|
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
|
|
|
|
// ROLE BASED PERMISSIONS
|
|
const canManageGlobalSettings = currentUser.role === 'superadmin';
|
|
const canManageTeam = currentUser.role === 'superadmin' || currentUser.role === 'supervisor';
|
|
const canAccessSettings = canManageTeam || canManageGlobalSettings;
|
|
|
|
// Initialize correct tab based on permissions
|
|
const [settingsTab, setSettingsTab] = useState<'general' | 'system' | 'ai' | 'users' | 'agents' | 'queues' | 'email'>('users');
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (view === 'settings') {
|
|
if (canManageGlobalSettings) setSettingsTab('system'); // Default to system for superadmin
|
|
else if (canManageTeam) setSettingsTab('users');
|
|
}
|
|
}, [view, canManageGlobalSettings, canManageTeam]);
|
|
|
|
|
|
// KB Editor State
|
|
const [isEditingKB, setIsEditingKB] = useState(false);
|
|
const [newArticle, setNewArticle] = useState<Partial<KBArticle>>({ type: 'article', category: 'General' });
|
|
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
|
|
|
|
// AI State
|
|
const [isAiAnalyzing, setIsAiAnalyzing] = useState(false);
|
|
const [aiSuggestion, setAiSuggestion] = useState<{ title: string; content: string; category: string } | null>(null);
|
|
|
|
// Forms State for Settings
|
|
const [newAgentForm, setNewAgentForm] = useState<Partial<Agent>>({ name: '', email: '', password: '', skills: [], queues: [], role: 'agent', avatar: '', avatarConfig: {x: 50, y: 50, scale: 1} });
|
|
const [editingAgent, setEditingAgent] = useState<Agent | null>(null);
|
|
|
|
const [newUserForm, setNewUserForm] = useState<Partial<ClientUser>>({ name: '', email: '', status: 'active', company: '' });
|
|
const [editingUser, setEditingUser] = useState<ClientUser | null>(null);
|
|
|
|
const [newQueueForm, setNewQueueForm] = useState<Partial<TicketQueue>>({ name: '', description: '' });
|
|
const [tempSettings, setTempSettings] = useState<AppSettings>(settings);
|
|
|
|
// Email Template Editor State
|
|
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
|
const [isTestingSmtp, setIsTestingSmtp] = useState(false);
|
|
|
|
const selectedTicket = tickets.find(t => t.id === selectedTicketId);
|
|
|
|
// Stats for Quotas
|
|
const currentAgents = agents.filter(a => a.role === 'agent').length;
|
|
const currentSupervisors = agents.filter(a => a.role === 'supervisor').length;
|
|
const currentArticles = articles.length;
|
|
const currentAiArticles = articles.filter(a => a.source === 'ai').length;
|
|
|
|
const isKbFull = currentArticles >= settings.features.maxKbArticles;
|
|
const isAgentQuotaFull = currentAgents >= settings.features.maxAgents;
|
|
const isSupervisorQuotaFull = currentSupervisors >= settings.features.maxSupervisors;
|
|
|
|
// Filter Agents for Assignment based on Role
|
|
const getAssignableAgents = (ticketQueue: string) => {
|
|
// Superadmin and Supervisor can assign to anyone
|
|
if (canManageTeam) return agents;
|
|
// Agents can only assign to agents in the same queue
|
|
return agents.filter(a => a.queues.includes(ticketQueue));
|
|
};
|
|
|
|
|
|
// Helper function to fetch URL content via proxy
|
|
const fetchUrlContent = async (url: string): Promise<string> => {
|
|
const parseContent = (html: string) => {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, 'text/html');
|
|
|
|
const scripts = doc.querySelectorAll('script, style, nav, footer, header, svg, noscript, iframe');
|
|
scripts.forEach(node => node.remove());
|
|
|
|
let text = doc.body?.textContent || "";
|
|
text = text.replace(/\s+/g, ' ').trim();
|
|
|
|
return text.substring(0, 5000);
|
|
};
|
|
|
|
try {
|
|
const res1 = await fetch(`https://api.allorigins.win/get?url=${encodeURIComponent(url)}`);
|
|
if (res1.ok) {
|
|
const data = await res1.json();
|
|
if (data.contents) return parseContent(data.contents);
|
|
}
|
|
} catch (e) {
|
|
console.warn("Primary scraping failed, trying fallback...");
|
|
}
|
|
|
|
try {
|
|
const res2 = await fetch(`https://corsproxy.io/?${encodeURIComponent(url)}`);
|
|
if (res2.ok) {
|
|
const html = await res2.text();
|
|
return parseContent(html);
|
|
}
|
|
} catch (e) {
|
|
console.error("Scraping error:", e);
|
|
}
|
|
|
|
return "";
|
|
};
|
|
|
|
// Handlers
|
|
const handleAiAnalysis = async () => {
|
|
if (!settings.features.aiKnowledgeAgentEnabled) {
|
|
showToast("L'Agente Knowledge AI è disabilitato dall'amministratore.", 'error');
|
|
return;
|
|
}
|
|
if (currentAiArticles >= settings.features.maxAiGeneratedArticles) {
|
|
showToast("Quota creazione articoli AI raggiunta. Impossibile generare nuovi suggerimenti.", 'error');
|
|
return;
|
|
}
|
|
|
|
setIsAiAnalyzing(true);
|
|
setAiSuggestion(null);
|
|
const suggestion = await generateNewKBArticle(tickets, articles);
|
|
setAiSuggestion(suggestion);
|
|
setIsAiAnalyzing(false);
|
|
};
|
|
|
|
const saveAiArticle = () => {
|
|
if (aiSuggestion) {
|
|
addArticle({
|
|
id: `kb-${Date.now()}`,
|
|
title: aiSuggestion.title,
|
|
content: aiSuggestion.content,
|
|
category: aiSuggestion.category,
|
|
type: 'article',
|
|
source: 'ai',
|
|
lastUpdated: new Date().toISOString().split('T')[0]
|
|
});
|
|
setAiSuggestion(null);
|
|
setView('kb');
|
|
}
|
|
};
|
|
|
|
const handleSaveArticle = async () => {
|
|
if (newArticle.title && (newArticle.content || newArticle.url)) {
|
|
let finalContent = newArticle.content || '';
|
|
|
|
if (newArticle.type === 'url' && newArticle.url) {
|
|
setIsFetchingUrl(true);
|
|
const scrapedText = await fetchUrlContent(newArticle.url);
|
|
setIsFetchingUrl(false);
|
|
if (scrapedText) {
|
|
finalContent = `[CONTENUTO PAGINA WEB SCARICATO]\n\n${scrapedText}\n\n[NOTE MANUALI]\n${newArticle.content || ''}`;
|
|
} else {
|
|
finalContent = newArticle.content || 'Contenuto non recuperabile automaticamente. Fare riferimento al link.';
|
|
}
|
|
}
|
|
|
|
const articleToSave: KBArticle = {
|
|
id: newArticle.id || `kb-${Date.now()}`,
|
|
title: newArticle.title,
|
|
content: finalContent,
|
|
category: newArticle.category || 'General',
|
|
type: newArticle.type || 'article',
|
|
url: newArticle.url,
|
|
source: newArticle.source || 'manual',
|
|
lastUpdated: new Date().toISOString().split('T')[0]
|
|
};
|
|
|
|
if (newArticle.id) {
|
|
updateArticle(articleToSave);
|
|
} else {
|
|
addArticle(articleToSave);
|
|
}
|
|
|
|
setIsEditingKB(false);
|
|
setNewArticle({ type: 'article', category: 'General' });
|
|
}
|
|
};
|
|
|
|
const handleAvatarSaved = (image: string, config: AgentAvatarConfig, isEditing: boolean) => {
|
|
if (isEditing && editingAgent) {
|
|
setEditingAgent({ ...editingAgent, avatar: image, avatarConfig: config });
|
|
} else {
|
|
setNewAgentForm({ ...newAgentForm, avatar: image, avatarConfig: config });
|
|
}
|
|
};
|
|
|
|
const handleAddAgent = () => {
|
|
if(newAgentForm.name && newAgentForm.email && newAgentForm.queues && newAgentForm.queues.length > 0) {
|
|
addAgent({
|
|
id: `a${Date.now()}`,
|
|
name: newAgentForm.name,
|
|
email: newAgentForm.email,
|
|
password: newAgentForm.password || 'password',
|
|
role: newAgentForm.role || 'agent',
|
|
avatar: newAgentForm.avatar || 'https://via.placeholder.com/200',
|
|
avatarConfig: newAgentForm.avatarConfig,
|
|
skills: newAgentForm.skills || ['General'],
|
|
queues: newAgentForm.queues
|
|
} as Agent);
|
|
setNewAgentForm({ name: '', email: '', password: '', role: 'agent', skills: [], queues: [], avatar: '', avatarConfig: {x: 50, y: 50, scale: 1} });
|
|
} else {
|
|
showToast("Compila nome, email e seleziona almeno una coda.", 'error');
|
|
}
|
|
};
|
|
|
|
const handleUpdateAgent = () => {
|
|
if (editingAgent && newAgentForm.name && newAgentForm.email) {
|
|
const updatedAgent: Agent = {
|
|
...editingAgent,
|
|
name: newAgentForm.name!,
|
|
email: newAgentForm.email!,
|
|
password: newAgentForm.password || editingAgent.password,
|
|
role: newAgentForm.role as AgentRole,
|
|
avatar: newAgentForm.avatar || editingAgent.avatar,
|
|
avatarConfig: newAgentForm.avatarConfig,
|
|
queues: newAgentForm.queues || [],
|
|
skills: newAgentForm.skills || []
|
|
};
|
|
|
|
updateAgent(updatedAgent);
|
|
setEditingAgent(null);
|
|
setNewAgentForm({ name: '', email: '', password: '', role: 'agent', skills: [], queues: [], avatar: '', avatarConfig: {x: 50, y: 50, scale: 1} });
|
|
}
|
|
};
|
|
|
|
const handleEditAgentClick = (agent: Agent) => {
|
|
setEditingAgent(agent);
|
|
setNewAgentForm({
|
|
name: agent.name,
|
|
email: agent.email,
|
|
password: agent.password,
|
|
role: agent.role,
|
|
avatar: agent.avatar,
|
|
avatarConfig: agent.avatarConfig,
|
|
skills: agent.skills,
|
|
queues: agent.queues
|
|
});
|
|
};
|
|
|
|
const cancelEditAgent = () => {
|
|
setEditingAgent(null);
|
|
setNewAgentForm({ name: '', email: '', password: '', role: 'agent', skills: [], queues: [], avatar: '', avatarConfig: {x: 50, y: 50, scale: 1} });
|
|
};
|
|
|
|
const toggleQueueInForm = (queueName: string, isEditing: boolean) => {
|
|
const currentQueues = newAgentForm.queues || [];
|
|
const newQueues = currentQueues.includes(queueName)
|
|
? currentQueues.filter(q => q !== queueName)
|
|
: [...currentQueues, queueName];
|
|
setNewAgentForm({ ...newAgentForm, queues: newQueues });
|
|
};
|
|
|
|
// --- USER MANAGEMENT HANDLERS ---
|
|
const handleAddUser = () => {
|
|
if(newUserForm.name && newUserForm.email) {
|
|
addClientUser({
|
|
id: `u${Date.now()}`,
|
|
name: newUserForm.name,
|
|
email: newUserForm.email,
|
|
company: newUserForm.company,
|
|
status: newUserForm.status || 'active',
|
|
password: 'user' // Default password
|
|
} as ClientUser);
|
|
setNewUserForm({ name: '', email: '', status: 'active', company: '' });
|
|
}
|
|
};
|
|
|
|
const handleUpdateUser = () => {
|
|
if (editingUser && newUserForm.name && newUserForm.email) {
|
|
updateClientUser({
|
|
...editingUser,
|
|
name: newUserForm.name,
|
|
email: newUserForm.email,
|
|
company: newUserForm.company,
|
|
status: newUserForm.status as 'active' | 'inactive'
|
|
});
|
|
setEditingUser(null);
|
|
setNewUserForm({ name: '', email: '', status: 'active', company: '' });
|
|
}
|
|
};
|
|
|
|
const handleEditUserClick = (user: ClientUser) => {
|
|
setEditingUser(user);
|
|
setNewUserForm({
|
|
name: user.name,
|
|
email: user.email,
|
|
company: user.company,
|
|
status: user.status
|
|
});
|
|
};
|
|
|
|
const cancelEditUser = () => {
|
|
setEditingUser(null);
|
|
setNewUserForm({ name: '', email: '', status: 'active', company: '' });
|
|
};
|
|
|
|
const handleSendPasswordReset = (email: string) => {
|
|
// Simulate sending email
|
|
showToast(`Link di reset password inviato a ${email}`, 'success');
|
|
};
|
|
|
|
const handleAddQueue = () => {
|
|
if (newQueueForm.name) {
|
|
addQueue({
|
|
id: `q-${Date.now()}`,
|
|
name: newQueueForm.name,
|
|
description: newQueueForm.description
|
|
} as TicketQueue);
|
|
setNewQueueForm({ name: '', description: '' });
|
|
}
|
|
};
|
|
|
|
// Email & SMTP Handlers
|
|
const handleTestSmtp = () => {
|
|
setIsTestingSmtp(true);
|
|
setTimeout(() => {
|
|
setIsTestingSmtp(false);
|
|
showToast(`Test Connessione SMTP Riuscito! Host: ${tempSettings.smtp.host}`, 'success');
|
|
}, 1500);
|
|
};
|
|
|
|
const handleSaveTemplate = () => {
|
|
if (editingTemplate) {
|
|
let updatedTemplates = [...tempSettings.emailTemplates];
|
|
if (editingTemplate.id === 'new') {
|
|
updatedTemplates.push({ ...editingTemplate, id: `t${Date.now()}` });
|
|
} else {
|
|
updatedTemplates = updatedTemplates.map(t => t.id === editingTemplate.id ? editingTemplate : t);
|
|
}
|
|
setTempSettings({ ...tempSettings, emailTemplates: updatedTemplates });
|
|
setEditingTemplate(null);
|
|
}
|
|
};
|
|
|
|
const handleDeleteTemplate = (id: string) => {
|
|
setTempSettings({
|
|
...tempSettings,
|
|
emailTemplates: tempSettings.emailTemplates.filter(t => t.id !== id)
|
|
});
|
|
};
|
|
|
|
const handleSaveSettings = () => {
|
|
setIsSaving(true);
|
|
// Simulate API call
|
|
setTimeout(() => {
|
|
updateSettings(tempSettings);
|
|
setIsSaving(false);
|
|
showToast("Impostazioni salvate con successo!", 'success');
|
|
}, 800);
|
|
};
|
|
|
|
// Analytics Helpers
|
|
const avgRating = surveys.length > 0 ? (surveys.reduce((acc, curr) => acc + curr.rating, 0) / surveys.length).toFixed(1) : 'N/A';
|
|
const totalTickets = tickets.length;
|
|
const resolvedTickets = tickets.filter(t => t.status === TicketStatus.RESOLVED).length;
|
|
const resolutionRate = totalTickets > 0 ? ((resolvedTickets / totalTickets) * 100).toFixed(0) : 0;
|
|
|
|
const techCount = tickets.filter(t => t.queue === 'Tech Support').length;
|
|
const billingCount = tickets.filter(t => t.queue === 'Billing').length;
|
|
const generalCount = tickets.filter(t => t.queue === 'General').length;
|
|
const maxQueue = Math.max(techCount, billingCount, generalCount);
|
|
|
|
// Template Variables Legend
|
|
const getTemplateVariables = (trigger?: EmailTrigger) => {
|
|
const common = ['{app_name}', '{ticket_id}', '{ticket_subject}', '{customer_name}', '{queue_name}'];
|
|
if (trigger === EmailTrigger.STATUS_CHANGED) return [...common, '{status}', '{old_status}'];
|
|
if (trigger === EmailTrigger.AGENT_ASSIGNED) return [...common, '{agent_name}', '{agent_email}'];
|
|
if (trigger === EmailTrigger.SURVEY_REQUEST) return [...common, '{survey_link}'];
|
|
if (trigger === EmailTrigger.NEW_REPLY) return [...common, '{last_message}', '{reply_link}'];
|
|
return [...common, '{ticket_priority}', '{ticket_description}'];
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-screen bg-gray-100">
|
|
{/* Sidebar */}
|
|
<div className="w-64 bg-slate-900 text-white flex flex-col flex-shrink-0">
|
|
<div className="p-6">
|
|
<h2 className="text-2xl font-bold tracking-tight">{settings.branding.appName}</h2>
|
|
<p className="text-slate-400 text-sm">{currentUser.role === 'superadmin' ? 'Super Admin' : currentUser.role === 'supervisor' ? 'Supervisor Workspace' : 'Agent Workspace'}</p>
|
|
</div>
|
|
<nav className="flex-1 px-4 space-y-2">
|
|
<button
|
|
onClick={() => setView('tickets')}
|
|
className={`flex items-center w-full px-4 py-3 rounded-lg transition ${view === 'tickets' ? 'bg-brand-600 text-white' : 'text-slate-300 hover:bg-slate-800'}`}
|
|
style={view === 'tickets' ? { backgroundColor: settings.branding.primaryColor } : {}}
|
|
>
|
|
<LayoutDashboard className="w-5 h-5 mr-3" />
|
|
Ticket
|
|
</button>
|
|
<button
|
|
onClick={() => setView('kb')}
|
|
className={`flex items-center w-full px-4 py-3 rounded-lg transition ${view === 'kb' ? 'bg-brand-600 text-white' : 'text-slate-300 hover:bg-slate-800'}`}
|
|
style={view === 'kb' ? { backgroundColor: settings.branding.primaryColor } : {}}
|
|
>
|
|
<BookOpen className="w-5 h-5 mr-3" />
|
|
Knowledge Base
|
|
</button>
|
|
<button
|
|
onClick={() => setView('ai')}
|
|
className={`flex items-center w-full px-4 py-3 rounded-lg transition ${view === 'ai' ? 'bg-purple-600 text-white' : 'text-slate-300 hover:bg-slate-800'}`}
|
|
>
|
|
<Sparkles className="w-5 h-5 mr-3" />
|
|
AI Knowledge Agent
|
|
</button>
|
|
<button
|
|
onClick={() => setView('analytics')}
|
|
className={`flex items-center w-full px-4 py-3 rounded-lg transition ${view === 'analytics' ? 'bg-indigo-600 text-white' : 'text-slate-300 hover:bg-slate-800'}`}
|
|
>
|
|
<BarChart3 className="w-5 h-5 mr-3" />
|
|
Analytics
|
|
</button>
|
|
{canAccessSettings && (
|
|
<button
|
|
onClick={() => setView('settings')}
|
|
className={`flex items-center w-full px-4 py-3 rounded-lg transition ${view === 'settings' ? 'bg-brand-600 text-white' : 'text-slate-300 hover:bg-slate-800'}`}
|
|
style={view === 'settings' ? { backgroundColor: settings.branding.primaryColor } : {}}
|
|
>
|
|
<Settings className="w-5 h-5 mr-3" />
|
|
Impostazioni
|
|
</button>
|
|
)}
|
|
</nav>
|
|
<div className="p-4 border-t border-slate-800">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center">
|
|
<div className="w-8 h-8 rounded-full overflow-hidden mr-3 bg-gray-600 border border-slate-600">
|
|
<img
|
|
src={currentUser.avatar || 'https://via.placeholder.com/200'}
|
|
alt={currentUser.name}
|
|
className="w-full h-full object-cover"
|
|
style={currentUser.avatarConfig ? {
|
|
objectPosition: `${currentUser.avatarConfig.x}% ${currentUser.avatarConfig.y}%`,
|
|
transform: `scale(${currentUser.avatarConfig.scale})`
|
|
} : {}}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">{currentUser.name}</p>
|
|
<p className="text-xs text-slate-400 truncate w-24" title={currentUser.queues.join(', ')}>{currentUser.queues.join(', ')}</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={onLogout} className="text-slate-400 hover:text-white"><LogOut className="w-4 h-4" /></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex-1 overflow-auto p-8">
|
|
|
|
{/* SETTINGS VIEW - PERMISSION GATED */}
|
|
{view === 'settings' && canAccessSettings && (
|
|
<div className="max-w-6xl mx-auto flex bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden min-h-[600px]">
|
|
{/* Sidebar Settings Tabs */}
|
|
<div className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col flex-shrink-0">
|
|
<div className="p-4 border-b border-gray-200">
|
|
<h3 className="font-bold text-gray-700">Impostazioni</h3>
|
|
</div>
|
|
<nav className="flex-1 p-2 space-y-1">
|
|
{canManageGlobalSettings && (
|
|
<>
|
|
<button onClick={() => setSettingsTab('system')} className={`w-full px-4 py-3 text-sm font-medium flex items-center rounded-lg transition-colors ${settingsTab === 'system' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-600 hover:bg-gray-100'}`}>
|
|
<Cpu className="w-4 h-4 mr-3" /> Sistema & Quote
|
|
</button>
|
|
<button onClick={() => setSettingsTab('general')} className={`w-full px-4 py-3 text-sm font-medium flex items-center rounded-lg transition-colors ${settingsTab === 'general' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-600 hover:bg-gray-100'}`}>
|
|
<Palette className="w-4 h-4 mr-3" /> Branding
|
|
</button>
|
|
</>
|
|
)}
|
|
{(canManageTeam) && (
|
|
<>
|
|
<button onClick={() => setSettingsTab('ai')} className={`w-full px-4 py-3 text-sm font-medium flex items-center rounded-lg transition-colors ${settingsTab === 'ai' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-600 hover:bg-gray-100'}`}>
|
|
<Bot className="w-4 h-4 mr-3" /> Configurazione AI
|
|
</button>
|
|
<button onClick={() => setSettingsTab('users')} className={`w-full px-4 py-3 text-sm font-medium flex items-center rounded-lg transition-colors ${settingsTab === 'users' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-600 hover:bg-gray-100'}`}>
|
|
<Users className="w-4 h-4 mr-3" /> Utenti Frontend
|
|
</button>
|
|
<button onClick={() => setSettingsTab('agents')} className={`w-full px-4 py-3 text-sm font-medium flex items-center rounded-lg transition-colors ${settingsTab === 'agents' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-600 hover:bg-gray-100'}`}>
|
|
<Shield className="w-4 h-4 mr-3" /> Agenti Reali
|
|
</button>
|
|
<button onClick={() => setSettingsTab('queues')} className={`w-full px-4 py-3 text-sm font-medium flex items-center rounded-lg transition-colors ${settingsTab === 'queues' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-600 hover:bg-gray-100'}`}>
|
|
<Layers className="w-4 h-4 mr-3" /> Gestione Code
|
|
</button>
|
|
</>
|
|
)}
|
|
{canManageGlobalSettings && (
|
|
<button onClick={() => setSettingsTab('email')} className={`w-full px-4 py-3 text-sm font-medium flex items-center rounded-lg transition-colors ${settingsTab === 'email' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-600 hover:bg-gray-100'}`}>
|
|
<Mail className="w-4 h-4 mr-3" /> Email & SMTP
|
|
</button>
|
|
)}
|
|
</nav>
|
|
</div>
|
|
|
|
<div className="flex-1 p-8 overflow-y-auto">
|
|
|
|
{/* SYSTEM TAB - SUPERADMIN ONLY */}
|
|
{settingsTab === 'system' && canManageGlobalSettings && (
|
|
<div className="space-y-8 animate-fade-in">
|
|
<h2 className="text-xl font-bold text-gray-800 mb-6">Limiti e Quote di Sistema</h2>
|
|
|
|
<div className="bg-slate-50 p-6 rounded-xl border border-slate-200">
|
|
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center">
|
|
<BookOpen className="w-5 h-5 mr-2 text-slate-600" />
|
|
Knowledge Base
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div className="flex items-center justify-between p-4 bg-white rounded-lg border border-slate-200">
|
|
<div>
|
|
<p className="font-medium text-slate-700">Stato Modulo KB</p>
|
|
<p className="text-xs text-slate-500">Abilita o disabilita l'accesso alla KB</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input type="checkbox" className="sr-only peer" checked={tempSettings.features.kbEnabled} onChange={e => setTempSettings({...tempSettings, features: {...tempSettings.features, kbEnabled: e.target.checked}})} />
|
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
|
</label>
|
|
</div>
|
|
<div className="p-4 bg-white rounded-lg border border-slate-200">
|
|
<label className="block font-medium text-slate-700 mb-1">Max Articoli</label>
|
|
<input type="number" className="w-full border border-slate-300 rounded px-3 py-2 bg-white text-gray-900" value={tempSettings.features.maxKbArticles} onChange={e => setTempSettings({...tempSettings, features: {...tempSettings.features, maxKbArticles: parseInt(e.target.value)}})} />
|
|
<p className="text-xs text-slate-500 mt-1">Utilizzo corrente: {currentArticles}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-slate-50 p-6 rounded-xl border border-slate-200">
|
|
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center">
|
|
<Users className="w-5 h-5 mr-2 text-slate-600" />
|
|
Personale
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div className="p-4 bg-white rounded-lg border border-slate-200">
|
|
<label className="block font-medium text-slate-700 mb-1">Max Supervisors</label>
|
|
<input type="number" className="w-full border border-slate-300 rounded px-3 py-2 bg-white text-gray-900" value={tempSettings.features.maxSupervisors} onChange={e => setTempSettings({...tempSettings, features: {...tempSettings.features, maxSupervisors: parseInt(e.target.value)}})} />
|
|
<p className="text-xs text-slate-500 mt-1">Utilizzo corrente: {currentSupervisors}</p>
|
|
</div>
|
|
<div className="p-4 bg-white rounded-lg border border-slate-200">
|
|
<label className="block font-medium text-slate-700 mb-1">Max Agenti</label>
|
|
<input type="number" className="w-full border border-slate-300 rounded px-3 py-2 bg-white text-gray-900" value={tempSettings.features.maxAgents} onChange={e => setTempSettings({...tempSettings, features: {...tempSettings.features, maxAgents: parseInt(e.target.value)}})} />
|
|
<p className="text-xs text-slate-500 mt-1">Utilizzo corrente: {currentAgents}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-slate-50 p-6 rounded-xl border border-slate-200">
|
|
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center">
|
|
<Sparkles className="w-5 h-5 mr-2 text-purple-600" />
|
|
AI Knowledge Agent
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div className="flex items-center justify-between p-4 bg-white rounded-lg border border-slate-200">
|
|
<div>
|
|
<p className="font-medium text-slate-700">Stato Agente AI</p>
|
|
<p className="text-xs text-slate-500">Auto-generazione articoli da ticket</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input type="checkbox" className="sr-only peer" checked={tempSettings.features.aiKnowledgeAgentEnabled} onChange={e => setTempSettings({...tempSettings, features: {...tempSettings.features, aiKnowledgeAgentEnabled: e.target.checked}})} />
|
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
|
</label>
|
|
</div>
|
|
<div className="p-4 bg-white rounded-lg border border-slate-200">
|
|
<label className="block font-medium text-slate-700 mb-1">Quota Articoli AI</label>
|
|
<input type="number" className="w-full border border-slate-300 rounded px-3 py-2 bg-white text-gray-900" value={tempSettings.features.maxAiGeneratedArticles} onChange={e => setTempSettings({...tempSettings, features: {...tempSettings.features, maxAiGeneratedArticles: parseInt(e.target.value)}})} />
|
|
<p className="text-xs text-slate-500 mt-1">Generati finora: {currentAiArticles}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* AI CONFIG TAB - NEW */}
|
|
{settingsTab === 'ai' && canManageTeam && (
|
|
<div className="space-y-6 max-w-2xl animate-fade-in">
|
|
<h2 className="text-xl font-bold text-gray-800 mb-6">Integrazione AI</h2>
|
|
<div className="bg-purple-50 p-4 rounded-lg border border-purple-100 mb-6 flex items-start">
|
|
<Bot className="w-6 h-6 text-purple-600 mr-3 mt-1" />
|
|
<div>
|
|
<h3 className="font-bold text-purple-900">Configurazione Provider AI</h3>
|
|
<p className="text-sm text-purple-700">Scegli il motore di intelligenza artificiale per l'assistente chat e l'analisi della KB. Puoi usare provider cloud o self-hosted.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Provider AI</label>
|
|
<select
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 bg-white text-gray-900"
|
|
value={tempSettings.aiConfig.provider}
|
|
onChange={(e) => setTempSettings({...tempSettings, aiConfig: {...tempSettings.aiConfig, provider: e.target.value as AiProvider}})}
|
|
>
|
|
<option value="gemini">Google Gemini</option>
|
|
<option value="openai">OpenAI (GPT-4/3.5)</option>
|
|
<option value="anthropic">Anthropic Claude</option>
|
|
<option value="deepseek">DeepSeek</option>
|
|
<option value="ollama">Ollama (Self-Hosted/Local)</option>
|
|
<option value="huggingface">HuggingFace (Free Tier)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Modello</label>
|
|
<input
|
|
type="text"
|
|
placeholder={tempSettings.aiConfig.provider === 'gemini' ? "gemini-1.5-pro" : "gpt-4o"}
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 bg-white text-gray-900"
|
|
value={tempSettings.aiConfig.model}
|
|
onChange={(e) => setTempSettings({...tempSettings, aiConfig: {...tempSettings.aiConfig, model: e.target.value}})}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Base URL
|
|
<span className="text-gray-400 font-normal ml-1 text-xs">
|
|
{(tempSettings.aiConfig.provider === 'ollama' || tempSettings.aiConfig.provider === 'openai') ? '(Richiesto per Custom/Ollama)' : '(Opzionale)'}
|
|
</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder={tempSettings.aiConfig.provider === 'ollama' ? "http://localhost:11434" : "https://api.openai.com/v1"}
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 bg-white text-gray-900"
|
|
value={tempSettings.aiConfig.baseUrl || ''}
|
|
onChange={(e) => setTempSettings({...tempSettings, aiConfig: {...tempSettings.aiConfig, baseUrl: e.target.value}})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{settingsTab === 'general' && canManageGlobalSettings && (
|
|
<div className="space-y-6 max-w-lg">
|
|
<h2 className="text-xl font-bold text-gray-800 mb-6">Personalizzazione Branding</h2>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Nome Applicazione</label>
|
|
<input
|
|
type="text"
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 bg-white text-gray-900"
|
|
value={tempSettings.branding.appName}
|
|
onChange={(e) => setTempSettings({...tempSettings, branding: {...tempSettings.branding, appName: e.target.value}})}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Colore Primario</label>
|
|
<div className="flex items-center space-x-3">
|
|
<input
|
|
type="color"
|
|
className="h-10 w-20 border border-gray-300 rounded cursor-pointer bg-white"
|
|
value={tempSettings.branding.primaryColor}
|
|
onChange={(e) => setTempSettings({...tempSettings, branding: {...tempSettings.branding, primaryColor: e.target.value}})}
|
|
/>
|
|
<span className="text-gray-500 text-sm">{tempSettings.branding.primaryColor}</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">URL Logo</label>
|
|
<input
|
|
type="text"
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 bg-white text-gray-900"
|
|
value={tempSettings.branding.logoUrl}
|
|
onChange={(e) => setTempSettings({...tempSettings, branding: {...tempSettings.branding, logoUrl: e.target.value}})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{settingsTab === 'users' && canManageTeam && (
|
|
<div>
|
|
<h2 className="text-xl font-bold text-gray-800 mb-6">Gestione Utenti Frontend</h2>
|
|
|
|
<div className={`flex justify-between items-end mb-6 p-4 rounded-lg border transition-colors ${editingUser ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-200'}`}>
|
|
<div className="flex-1 mr-4">
|
|
<h3 className={`text-sm font-bold mb-3 ${editingUser ? 'text-blue-800' : 'text-gray-700'}`}>{editingUser ? 'Modifica Utente' : 'Aggiungi Nuovo Utente'}</h3>
|
|
<div className="grid grid-cols-4 gap-3">
|
|
<input type="text" placeholder="Nome" className="border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-900" value={newUserForm.name} onChange={e => setNewUserForm({...newUserForm, name: e.target.value})} />
|
|
<input type="email" placeholder="Email" className="border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-900" value={newUserForm.email} onChange={e => setNewUserForm({...newUserForm, email: e.target.value})} />
|
|
<input type="text" placeholder="Azienda" className="border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-900" value={newUserForm.company} onChange={e => setNewUserForm({...newUserForm, company: e.target.value})} />
|
|
{editingUser && (
|
|
<select
|
|
className="border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-900"
|
|
value={newUserForm.status}
|
|
onChange={e => setNewUserForm({...newUserForm, status: e.target.value as 'active'|'inactive'})}
|
|
>
|
|
<option value="active">Attivo</option>
|
|
<option value="inactive">Inattivo</option>
|
|
</select>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex space-x-2">
|
|
{editingUser && (
|
|
<button onClick={cancelEditUser} className="bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded text-sm font-medium hover:bg-gray-50">
|
|
Annulla
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={editingUser ? handleUpdateUser : handleAddUser}
|
|
className={`px-4 py-2 rounded text-sm font-bold text-white shadow-sm ${editingUser ? 'bg-blue-600 hover:bg-blue-700' : 'bg-green-600 hover:bg-green-700'}`}
|
|
>
|
|
{editingUser ? 'Salva Modifiche' : 'Aggiungi'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<table className="min-w-full text-left">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 text-gray-500 text-sm">
|
|
<th className="py-2 px-4">Nome</th>
|
|
<th className="py-2 px-4">Email</th>
|
|
<th className="py-2 px-4">Azienda</th>
|
|
<th className="py-2 px-4">Stato</th>
|
|
<th className="py-2 px-4">Azioni</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{clientUsers.map(user => (
|
|
<tr key={user.id} className={`border-b border-gray-100 hover:bg-gray-50 transition ${editingUser?.id === user.id ? 'bg-blue-50' : ''}`}>
|
|
<td className="py-3 px-4 font-medium text-gray-900">{user.name}</td>
|
|
<td className="py-3 px-4 text-gray-600">{user.email}</td>
|
|
<td className="py-3 px-4 text-gray-600">{user.company || '-'}</td>
|
|
<td className="py-3 px-4"><span className={`px-2 py-1 rounded text-xs font-bold ${user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>{user.status}</span></td>
|
|
<td className="py-3 px-4 flex items-center space-x-2">
|
|
<button onClick={() => handleEditUserClick(user)} className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded" title="Modifica"><Edit3 className="w-4 h-4" /></button>
|
|
<button onClick={() => handleSendPasswordReset(user.email)} className="p-1.5 text-gray-400 hover:text-amber-600 hover:bg-amber-50 rounded" title="Invia Reset Password"><Key className="w-4 h-4" /></button>
|
|
<button onClick={() => removeClientUser(user.id)} className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded" title="Rimuovi"><Trash2 className="w-4 h-4" /></button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
{settingsTab === 'agents' && canManageTeam && (
|
|
<div>
|
|
<h2 className="text-xl font-bold text-gray-800 mb-6">Gestione Team di Supporto</h2>
|
|
{/* Add/Edit Agent Form */}
|
|
<div className={`mb-6 p-4 rounded-lg border ${editingAgent ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-200'}`}>
|
|
<div className="flex justify-between items-center mb-3">
|
|
<h3 className={`text-sm font-bold ${editingAgent ? 'text-blue-800' : 'text-gray-700'}`}>
|
|
{editingAgent ? 'Modifica Agente' : 'Aggiungi Nuovo Agente'}
|
|
</h3>
|
|
{!editingAgent && (
|
|
<div className="text-xs text-gray-500 space-x-3">
|
|
<span className={`${isAgentQuotaFull ? 'text-red-500 font-bold' : ''}`}>Agenti: {currentAgents}/{settings.features.maxAgents}</span>
|
|
<span className={`${isSupervisorQuotaFull ? 'text-red-500 font-bold' : ''}`}>Supervisor: {currentSupervisors}/{settings.features.maxSupervisors}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-6 items-start">
|
|
{/* Avatar Editor Component */}
|
|
<AvatarEditor
|
|
initialImage={newAgentForm.avatar || ''}
|
|
initialConfig={newAgentForm.avatarConfig}
|
|
onSave={(img, config) => handleAvatarSaved(img, config, !!editingAgent)}
|
|
/>
|
|
|
|
<div className="flex-1 space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<input type="text" placeholder="Nome Agente" className="border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-900" value={newAgentForm.name} onChange={e => setNewAgentForm({...newAgentForm, name: e.target.value})} />
|
|
<input type="email" placeholder="Email (Username)" className="border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-900" value={newAgentForm.email} onChange={e => setNewAgentForm({...newAgentForm, email: e.target.value})} />
|
|
<input type="password" placeholder={editingAgent ? "Lascia vuoto per non cambiare" : "Password"} className="border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-900" value={newAgentForm.password} onChange={e => setNewAgentForm({...newAgentForm, password: e.target.value})} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1">Ruolo:</label>
|
|
<select
|
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-900 mb-2"
|
|
value={newAgentForm.role}
|
|
onChange={e => setNewAgentForm({...newAgentForm, role: e.target.value as AgentRole})}
|
|
>
|
|
<option value="agent" disabled={!editingAgent && isAgentQuotaFull}>Agente {!editingAgent && isAgentQuotaFull ? '(Quota Piena)' : ''}</option>
|
|
<option value="supervisor" disabled={!editingAgent && isSupervisorQuotaFull}>Supervisor {!editingAgent && isSupervisorQuotaFull ? '(Quota Piena)' : ''}</option>
|
|
{canManageGlobalSettings && <option value="superadmin">Superadmin</option>}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1">Assegna Code:</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{queues.map(q => (
|
|
<label key={q.id} className="inline-flex items-center bg-white border border-gray-300 px-3 py-1 rounded-full cursor-pointer hover:bg-gray-50 select-none">
|
|
<input
|
|
type="checkbox"
|
|
className="mr-2"
|
|
checked={newAgentForm.queues?.includes(q.name)}
|
|
onChange={() => toggleQueueInForm(q.name, !!editingAgent)}
|
|
/>
|
|
<span className="text-sm">{q.name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end pt-2 gap-2">
|
|
{editingAgent && (
|
|
<button onClick={cancelEditAgent} className="text-gray-500 px-3 py-2 text-sm font-medium hover:text-gray-700 flex items-center bg-white border border-gray-300 rounded">
|
|
<RotateCcw className="w-4 h-4 mr-1" /> Annulla
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={editingAgent ? handleUpdateAgent : handleAddAgent}
|
|
disabled={!editingAgent && ((newAgentForm.role === 'agent' && isAgentQuotaFull) || (newAgentForm.role === 'supervisor' && isSupervisorQuotaFull))}
|
|
className={`text-white px-4 py-2 rounded text-sm font-bold disabled:opacity-50 disabled:cursor-not-allowed ${editingAgent ? 'bg-blue-600 hover:bg-blue-700' : 'bg-green-600 hover:bg-green-700'}`}
|
|
>
|
|
{editingAgent ? 'Salva Modifiche' : 'Aggiungi Agente'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{agents.map(agent => (
|
|
<div key={agent.id} className={`border rounded-lg p-4 shadow-sm transition ${editingAgent?.id === agent.id ? 'border-blue-400 bg-blue-50 ring-1 ring-blue-400' : 'border-gray-200 bg-white'}`}>
|
|
<div className="flex justify-between items-start mb-2">
|
|
<div className="flex items-center">
|
|
<div className="w-10 h-10 rounded-full overflow-hidden mr-3 bg-gray-100 border border-gray-200">
|
|
<img
|
|
src={agent.avatar || 'https://via.placeholder.com/200'}
|
|
alt={agent.name}
|
|
className="w-full h-full object-cover"
|
|
style={agent.avatarConfig ? {
|
|
objectPosition: `${agent.avatarConfig.x}% ${agent.avatarConfig.y}%`,
|
|
transform: `scale(${agent.avatarConfig.scale})`
|
|
} : {}}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<h4 className="font-bold text-gray-800 text-sm flex items-center">
|
|
{agent.name}
|
|
{agent.role === 'superadmin' && <Lock className="w-3 h-3 ml-1 text-red-500" />}
|
|
{agent.role === 'supervisor' && <Shield className="w-3 h-3 ml-1 text-blue-500" />}
|
|
</h4>
|
|
<p className="text-xs text-gray-500">{agent.email}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex space-x-1">
|
|
<button onClick={() => handleEditAgentClick(agent)} className="text-gray-400 hover:text-blue-600"><Edit3 className="w-4 h-4" /></button>
|
|
<button onClick={() => removeAgent(agent.id)} className="text-gray-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-2">
|
|
<p className="text-xs font-bold text-gray-400 uppercase mb-1">Code Assegnate</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{agent.queues.map(q => (
|
|
<span key={q} className="text-[10px] bg-gray-100 text-gray-600 px-2 py-0.5 rounded border border-gray-200">{q}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{settingsTab === 'queues' && canManageTeam && (
|
|
<div>
|
|
<h2 className="text-xl font-bold text-gray-800 mb-6">Code di Smistamento</h2>
|
|
<div className="flex justify-between items-end mb-6 bg-gray-50 p-4 rounded-lg border border-gray-200">
|
|
<div className="grid grid-cols-2 gap-3 flex-1 mr-4">
|
|
<input
|
|
type="text"
|
|
placeholder="Nome Coda (es. Tech Support)"
|
|
className="border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-900"
|
|
value={newQueueForm.name}
|
|
onChange={e => setNewQueueForm({...newQueueForm, name: e.target.value})}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Descrizione"
|
|
className="border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-900"
|
|
value={newQueueForm.description}
|
|
onChange={e => setNewQueueForm({...newQueueForm, description: e.target.value})}
|
|
/>
|
|
</div>
|
|
<button onClick={handleAddQueue} className="bg-green-600 text-white px-4 py-2 rounded text-sm font-bold hover:bg-green-700">Aggiungi Coda</button>
|
|
</div>
|
|
|
|
<table className="min-w-full text-left">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 text-gray-500 text-sm">
|
|
<th className="py-2 px-4">Nome Coda</th>
|
|
<th className="py-2 px-4">Descrizione</th>
|
|
<th className="py-2 px-4">Agenti Assegnati</th>
|
|
<th className="py-2 px-4">Azioni</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{queues.map(queue => (
|
|
<tr key={queue.id} className="border-b border-gray-100 hover:bg-gray-50">
|
|
<td className="py-3 px-4 font-medium text-gray-900">{queue.name}</td>
|
|
<td className="py-3 px-4 text-gray-600">{queue.description || '-'}</td>
|
|
<td className="py-3 px-4 text-gray-600 text-sm">
|
|
{agents.filter(a => a.queues.includes(queue.name)).length} agenti
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<button onClick={() => removeQueue(queue.id)} className="text-red-500 hover:text-red-700"><Trash2 className="w-4 h-4" /></button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
{settingsTab === 'email' && canManageGlobalSettings && (
|
|
<div className="space-y-8">
|
|
<h2 className="text-xl font-bold text-gray-800 mb-6">Notifiche & Email</h2>
|
|
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 flex justify-between items-center">
|
|
<div>
|
|
<h3 className="font-bold text-gray-800">Configurazione SMTP</h3>
|
|
<p className="text-sm text-gray-500">Imposta il server di posta in uscita.</p>
|
|
</div>
|
|
<button
|
|
onClick={handleTestSmtp}
|
|
disabled={isTestingSmtp}
|
|
className="flex items-center space-x-2 bg-white border border-gray-300 text-gray-700 px-3 py-1.5 rounded-lg text-sm font-medium hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
{isTestingSmtp ? <div className="animate-spin w-4 h-4 border-2 border-gray-600 border-t-transparent rounded-full"></div> : <Zap className="w-4 h-4 text-amber-500" />}
|
|
<span>{isTestingSmtp ? 'Test in corso...' : 'Test Configurazione'}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-500 mb-1">Host</label>
|
|
<input type="text" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm bg-white text-gray-900" value={tempSettings.smtp.host} onChange={e => setTempSettings({...tempSettings, smtp: {...tempSettings.smtp, host: e.target.value}})} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-500 mb-1">Porta</label>
|
|
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm bg-white text-gray-900" value={tempSettings.smtp.port} onChange={e => setTempSettings({...tempSettings, smtp: {...tempSettings.smtp, port: parseInt(e.target.value)}})} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-500 mb-1">Username</label>
|
|
<input type="text" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm bg-white text-gray-900" value={tempSettings.smtp.user} onChange={e => setTempSettings({...tempSettings, smtp: {...tempSettings.smtp, user: e.target.value}})} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-500 mb-1">Password</label>
|
|
<input type="password" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm bg-white text-gray-900" value={tempSettings.smtp.pass} onChange={e => setTempSettings({...tempSettings, smtp: {...tempSettings.smtp, pass: e.target.value}})} />
|
|
</div>
|
|
</div>
|
|
|
|
<hr className="border-gray-200" />
|
|
|
|
{/* Template Manager */}
|
|
{!editingTemplate ? (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="font-bold text-gray-800">Modelli Email</h3>
|
|
<button
|
|
onClick={() => setEditingTemplate({ id: 'new', name: '', trigger: EmailTrigger.TICKET_CREATED, audience: EmailAudience.CLIENT, subject: '', body: '', isActive: true })}
|
|
className="text-sm bg-blue-50 text-blue-600 px-3 py-1.5 rounded-lg font-medium hover:bg-blue-100 flex items-center"
|
|
>
|
|
<Plus className="w-4 h-4 mr-1" /> Nuovo Modello
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{tempSettings.emailTemplates.map(template => (
|
|
<div key={template.id} className="border border-gray-200 rounded-lg p-4 bg-white hover:bg-gray-50 transition flex justify-between items-center">
|
|
<div>
|
|
<div className="flex items-center space-x-2 mb-1">
|
|
<h4 className="font-bold text-gray-800 text-sm">{template.name}</h4>
|
|
<span className={`text-[10px] px-2 py-0.5 rounded font-bold uppercase ${template.audience === EmailAudience.STAFF ? 'bg-purple-100 text-purple-700' : 'bg-green-100 text-green-700'}`}>
|
|
{template.audience}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-gray-500">Trigger: <span className="font-mono bg-gray-100 px-1 rounded">{template.trigger}</span></p>
|
|
</div>
|
|
<div className="flex space-x-2">
|
|
<button onClick={() => setEditingTemplate(template)} className="p-2 text-gray-400 hover:text-blue-600 bg-white border border-gray-200 rounded"><Edit3 className="w-4 h-4" /></button>
|
|
<button onClick={() => handleDeleteTemplate(template.id)} className="p-2 text-gray-400 hover:text-red-600 bg-white border border-gray-200 rounded"><Trash2 className="w-4 h-4" /></button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* Template Editor */
|
|
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6">
|
|
<div className="flex justify-between items-center mb-6 border-b border-gray-200 pb-4">
|
|
<h3 className="font-bold text-gray-800">{editingTemplate.id === 'new' ? 'Nuovo Modello' : 'Modifica Modello'}</h3>
|
|
<button onClick={() => setEditingTemplate(null)} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
|
</div>
|
|
|
|
<div className="flex gap-6">
|
|
<div className="flex-1 space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1">Nome Template</label>
|
|
<input type="text" className="w-full border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-900" value={editingTemplate.name} onChange={e => setEditingTemplate({...editingTemplate, name: e.target.value})} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1">Trigger (Attivazione)</label>
|
|
<select className="w-full border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-900" value={editingTemplate.trigger} onChange={e => setEditingTemplate({...editingTemplate, trigger: e.target.value as EmailTrigger})}>
|
|
{Object.values(EmailTrigger).map(t => <option key={t} value={t}>{t.replace('_', ' ').toUpperCase()}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1">Destinatario</label>
|
|
<div className="flex space-x-4 mt-2">
|
|
<label className="flex items-center text-sm"><input type="radio" className="mr-2" checked={editingTemplate.audience === EmailAudience.CLIENT} onChange={() => setEditingTemplate({...editingTemplate, audience: EmailAudience.CLIENT})} /> Cliente</label>
|
|
<label className="flex items-center text-sm"><input type="radio" className="mr-2" checked={editingTemplate.audience === EmailAudience.STAFF} onChange={() => setEditingTemplate({...editingTemplate, audience: EmailAudience.STAFF})} /> Staff</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1">Oggetto Email</label>
|
|
<input type="text" className="w-full border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-900" value={editingTemplate.subject} onChange={e => setEditingTemplate({...editingTemplate, subject: e.target.value})} />
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1">Corpo del Messaggio</label>
|
|
<textarea rows={8} className="w-full border border-gray-300 rounded px-3 py-2 text-sm font-mono text-gray-700 bg-white" value={editingTemplate.body} onChange={e => setEditingTemplate({...editingTemplate, body: e.target.value})} />
|
|
</div>
|
|
|
|
<div className="flex justify-end pt-2">
|
|
<button onClick={handleSaveTemplate} className="bg-blue-600 text-white px-6 py-2 rounded font-bold hover:bg-blue-700">Salva Modello</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Variables Sidebar */}
|
|
<div className="w-64 bg-white rounded-lg border border-gray-200 p-4 h-fit">
|
|
<h4 className="text-xs font-bold text-gray-500 uppercase mb-3">Variabili Disponibili</h4>
|
|
<div className="space-y-2">
|
|
{getTemplateVariables(editingTemplate.trigger).map(variable => (
|
|
<div key={variable} className="flex justify-between items-center bg-gray-50 p-2 rounded border border-gray-100 group hover:bg-blue-50 cursor-pointer" onClick={() => {
|
|
setEditingTemplate({...editingTemplate, body: editingTemplate.body + ' ' + variable})
|
|
}}>
|
|
<code className="text-xs text-blue-600 font-mono">{variable}</code>
|
|
<Copy className="w-3 h-3 text-gray-400 opacity-0 group-hover:opacity-100" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-[10px] text-gray-400 mt-3 text-center">Clicca per aggiungere al corpo</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
)}
|
|
|
|
{settingsTab !== 'users' && settingsTab !== 'agents' && settingsTab !== 'queues' && (
|
|
<div className="mt-8 flex justify-end border-t border-gray-200 pt-6">
|
|
<button
|
|
onClick={handleSaveSettings}
|
|
disabled={isSaving}
|
|
className="bg-brand-600 text-white px-6 py-3 rounded-lg font-bold flex items-center hover:bg-brand-700 shadow-lg disabled:opacity-70 disabled:cursor-wait transition-all"
|
|
>
|
|
{isSaving ? <Loader2 className="w-5 h-5 mr-2 animate-spin" /> : <Save className="w-5 h-5 mr-2" />}
|
|
{isSaving ? 'Salvataggio...' : 'Salva Impostazioni'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ... (Rest of the component remains largely the same, no changes needed for toast integration in other tabs as they use props) ... */}
|
|
{view === 'tickets' && (
|
|
// ... existing tickets view ...
|
|
<div className="flex h-full gap-6">
|
|
<div className="w-1/3 bg-white rounded-xl shadow-sm overflow-hidden flex flex-col">
|
|
<div className="p-4 border-b border-gray-100 bg-gray-50 font-semibold text-gray-700">
|
|
Ticket Coda ({tickets.length})
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto">
|
|
{tickets.length === 0 && (
|
|
<div className="p-4 text-center text-gray-400 text-sm">
|
|
Nessun ticket in coda per le tue competenze.
|
|
</div>
|
|
)}
|
|
{tickets.map(ticket => (
|
|
<div
|
|
key={ticket.id}
|
|
onClick={() => setSelectedTicketId(ticket.id)}
|
|
className={`p-4 border-b border-gray-100 cursor-pointer hover:bg-blue-50 transition relative group ${selectedTicketId === ticket.id ? 'bg-blue-50 border-l-4 border-brand-500' : ''}`}
|
|
>
|
|
<div className="absolute right-2 top-2 hidden group-hover:flex flex-col gap-1 z-10">
|
|
{/* Agent Quick Assign */}
|
|
<div className="relative" onClick={e => e.stopPropagation()}>
|
|
<select
|
|
className="appearance-none w-full bg-white border border-gray-200 text-xs rounded shadow-sm py-1 pl-6 pr-2 cursor-pointer hover:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
value={ticket.assignedAgentId || ''}
|
|
onChange={(e) => updateTicketAgent(ticket.id, e.target.value)}
|
|
>
|
|
<option value="">Non assegnato</option>
|
|
{getAssignableAgents(ticket.queue).map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
|
</select>
|
|
<UserPlus className="w-3 h-3 absolute left-1.5 top-1.5 text-gray-400 pointer-events-none" />
|
|
</div>
|
|
|
|
{/* Status Quick Change */}
|
|
<div className="relative" onClick={e => e.stopPropagation()}>
|
|
<select
|
|
className={`appearance-none w-full border text-xs rounded shadow-sm py-1 pl-6 pr-2 cursor-pointer focus:outline-none focus:ring-1 focus:ring-blue-500 ${
|
|
ticket.status === TicketStatus.RESOLVED ? 'bg-green-50 border-green-200 text-green-700' :
|
|
ticket.status === TicketStatus.OPEN ? 'bg-blue-50 border-blue-200 text-blue-700' :
|
|
'bg-white border-gray-200 text-gray-700'
|
|
}`}
|
|
value={ticket.status}
|
|
onChange={(e) => updateTicketStatus(ticket.id, e.target.value as TicketStatus)}
|
|
>
|
|
{Object.values(TicketStatus).map(s => <option key={s} value={s}>{s}</option>)}
|
|
</select>
|
|
<div className="absolute left-1.5 top-1.5 pointer-events-none text-current opacity-70">
|
|
<Activity className="w-3 h-3" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-start mb-1">
|
|
<span className="font-bold text-gray-800 text-sm">{ticket.id}</span>
|
|
<span className={`text-[10px] px-2 py-0.5 rounded-full font-bold uppercase ${
|
|
ticket.priority === TicketPriority.HIGH || ticket.priority === TicketPriority.CRITICAL ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
|
|
}`}>{ticket.priority}</span>
|
|
</div>
|
|
<h4 className="text-sm font-medium text-gray-900 truncate mb-1 pr-6">{ticket.subject}</h4>
|
|
<p className="text-xs text-gray-500">{ticket.customerName} • {ticket.status}</p>
|
|
<div className="flex items-center justify-between mt-1">
|
|
{ticket.attachments && ticket.attachments.length > 0 && (
|
|
<div className="flex items-center text-xs text-gray-400">
|
|
<Paperclip className="w-3 h-3 mr-1" />
|
|
{ticket.attachments.length}
|
|
</div>
|
|
)}
|
|
<span className="text-[10px] bg-gray-100 text-gray-500 px-1 rounded">{ticket.queue}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 bg-white rounded-xl shadow-sm p-6 overflow-y-auto">
|
|
{selectedTicket ? (
|
|
<div>
|
|
<div className="flex justify-between items-start mb-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-900">{selectedTicket.subject}</h2>
|
|
<div className="flex items-center space-x-3 mt-2 text-sm text-gray-500">
|
|
<span>{selectedTicket.customerName}</span>
|
|
<span>•</span>
|
|
<span>{selectedTicket.createdAt.split('T')[0]}</span>
|
|
<span>•</span>
|
|
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-700">{selectedTicket.queue}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex space-x-2">
|
|
<select
|
|
value={selectedTicket.assignedAgentId || ''}
|
|
onChange={(e) => updateTicketAgent(selectedTicket.id, e.target.value)}
|
|
className="border border-gray-300 rounded-md text-sm px-2 py-1 bg-white text-gray-900"
|
|
>
|
|
<option value="">Non assegnato</option>
|
|
{getAssignableAgents(selectedTicket.queue).map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
|
</select>
|
|
<select
|
|
value={selectedTicket.status}
|
|
onChange={(e) => updateTicketStatus(selectedTicket.id, e.target.value as TicketStatus)}
|
|
className="border border-gray-300 rounded-md text-sm px-2 py-1 bg-white text-gray-900"
|
|
>
|
|
{Object.values(TicketStatus).map(s => <option key={s} value={s}>{s}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 p-4 rounded-lg mb-6 border border-gray-100">
|
|
<p className="text-gray-800">{selectedTicket.description}</p>
|
|
</div>
|
|
|
|
{/* Attachments Section */}
|
|
{selectedTicket.attachments && selectedTicket.attachments.length > 0 && (
|
|
<div className="mb-6">
|
|
<h3 className="font-semibold text-gray-700 text-sm mb-2 flex items-center">
|
|
<Paperclip className="w-4 h-4 mr-1" /> Allegati
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{selectedTicket.attachments.map(att => (
|
|
<div key={att.id} className="flex items-center p-2 bg-gray-50 border border-gray-200 rounded text-sm text-blue-600 hover:underline cursor-pointer">
|
|
<FileText className="w-4 h-4 mr-2 text-gray-500" />
|
|
{att.name}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-4 mb-6">
|
|
<h3 className="font-semibold text-gray-700">Cronologia Messaggi</h3>
|
|
{selectedTicket.messages.length === 0 ? (
|
|
<p className="text-sm text-gray-400 italic">Nessun messaggio.</p>
|
|
) : (
|
|
selectedTicket.messages.map(m => (
|
|
<div key={m.id} className={`p-3 rounded-lg max-w-[80%] ${m.role === 'assistant' ? 'ml-auto bg-brand-50 border border-brand-100' : 'bg-white border border-gray-200'}`}>
|
|
<p className="text-xs text-gray-500 mb-1 font-semibold">{m.role === 'assistant' ? 'Agente' : 'Cliente'} <span className="font-normal opacity-70 ml-2">{m.timestamp.split('T')[1].substring(0,5)}</span></p>
|
|
<p className="text-sm text-gray-800">{m.content}</p>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-auto pt-4 border-t border-gray-100">
|
|
<textarea className="w-full border border-gray-300 rounded-lg p-3 text-sm focus:ring-2 focus:ring-brand-500 focus:outline-none bg-white text-gray-900" placeholder="Scrivi una risposta interna o pubblica..." rows={3}></textarea>
|
|
<div className="flex justify-end mt-2">
|
|
<button className="bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-700">Rispondi</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-gray-400">Seleziona un ticket per vedere i dettagli</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{view === 'kb' && (
|
|
<div className="bg-white rounded-xl shadow-sm p-6 min-h-full">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h2 className="text-2xl font-bold text-gray-800">Gestione Knowledge Base</h2>
|
|
{settings.features.kbEnabled ? (
|
|
<button
|
|
onClick={() => {
|
|
setNewArticle({ type: 'article', category: 'General' });
|
|
setIsEditingKB(true);
|
|
}}
|
|
disabled={isKbFull}
|
|
className="bg-brand-600 text-white px-4 py-2 rounded-lg flex items-center hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
{isKbFull ? 'Limite Raggiunto' : 'Nuovo Articolo'}
|
|
</button>
|
|
) : (
|
|
<span className="text-red-500 font-bold flex items-center bg-red-50 px-3 py-1 rounded">
|
|
<AlertTriangle className="w-4 h-4 mr-2" /> KB Disabilitata
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{isEditingKB && (
|
|
<div className="bg-gray-50 p-6 rounded-xl border border-gray-200 mb-8 animate-fade-in">
|
|
<h3 className="font-bold text-gray-700 mb-4">{newArticle.id ? 'Modifica Elemento' : 'Nuovo Elemento'}</h3>
|
|
{/* ... (Existing KB Form Content remains the same) ... */}
|
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Titolo</label>
|
|
<input
|
|
type="text"
|
|
className="w-full border border-gray-300 rounded px-3 py-2 bg-white text-gray-900"
|
|
value={newArticle.title || ''}
|
|
onChange={e => setNewArticle({...newArticle, title: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Categoria</label>
|
|
<input
|
|
type="text"
|
|
className="w-full border border-gray-300 rounded px-3 py-2 bg-white text-gray-900"
|
|
value={newArticle.category || ''}
|
|
onChange={e => setNewArticle({...newArticle, category: e.target.value})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Tipo</label>
|
|
<div className="flex space-x-4">
|
|
<label className="flex items-center">
|
|
<input type="radio" name="type" checked={newArticle.type === 'article'} onChange={() => setNewArticle({...newArticle, type: 'article'})} className="mr-2" />
|
|
Articolo
|
|
</label>
|
|
<label className="flex items-center">
|
|
<input type="radio" name="type" checked={newArticle.type === 'url'} onChange={() => setNewArticle({...newArticle, type: 'url'})} className="mr-2" />
|
|
Link Esterno (URL)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{newArticle.type === 'article' ? (
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Contenuto (Supporta Markdown)</label>
|
|
<textarea
|
|
rows={6}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 bg-white text-gray-900"
|
|
value={newArticle.content || ''}
|
|
onChange={e => setNewArticle({...newArticle, content: e.target.value})}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">URL Fonte</label>
|
|
<div className="flex space-x-2">
|
|
<input
|
|
type="url"
|
|
className="w-full border border-gray-300 rounded px-3 py-2 bg-white text-gray-900"
|
|
value={newArticle.url || ''}
|
|
onChange={e => setNewArticle({...newArticle, url: e.target.value})}
|
|
placeholder="https://example.com"
|
|
/>
|
|
</div>
|
|
<div className="mt-2 text-xs text-gray-500">
|
|
Inserisci l'URL completo. Il sistema scaricherà automaticamente il contenuto della pagina.
|
|
</div>
|
|
<div className="mt-2">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Note Manuali (Opzionale)</label>
|
|
<textarea
|
|
rows={3}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 bg-white text-gray-900"
|
|
value={newArticle.content || ''}
|
|
onChange={e => setNewArticle({...newArticle, content: e.target.value})}
|
|
placeholder="Aggiungi qui eventuali riassunti o note extra..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end space-x-3">
|
|
<button onClick={() => { setIsEditingKB(false); setNewArticle({ type: 'article', category: 'General' }); }} className="px-4 py-2 text-gray-600 hover:text-gray-800">Annulla</button>
|
|
<button
|
|
onClick={handleSaveArticle}
|
|
disabled={isFetchingUrl}
|
|
className="px-4 py-2 bg-brand-600 text-white rounded hover:bg-brand-700 flex items-center disabled:opacity-70"
|
|
>
|
|
{isFetchingUrl && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
{isFetchingUrl ? 'Scaricamento...' : 'Salva'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full text-left">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 text-gray-500 text-sm">
|
|
<th className="py-3 px-4">Titolo</th>
|
|
<th className="py-3 px-4">Categoria</th>
|
|
<th className="py-3 px-4">Tipo</th>
|
|
<th className="py-3 px-4">Ultimo Agg.</th>
|
|
<th className="py-3 px-4">Azioni</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{articles.map(article => (
|
|
<tr key={article.id} className="border-b border-gray-100 hover:bg-gray-50">
|
|
<td className="py-3 px-4 font-medium text-gray-800">{article.title}</td>
|
|
<td className="py-3 px-4"><span className="bg-gray-100 text-gray-600 text-xs px-2 py-1 rounded">{article.category}</span></td>
|
|
<td className="py-3 px-4 text-sm text-gray-500 flex items-center">
|
|
{article.type === 'url' ? <ExternalLink className="w-4 h-4 mr-1 text-blue-500" /> : <FileText className="w-4 h-4 mr-1 text-gray-400" />}
|
|
{article.type === 'url' ? 'Link Esterno' : 'Articolo'}
|
|
{article.source === 'ai' && <span className="ml-2 text-[10px] bg-purple-100 text-purple-700 px-1 rounded flex items-center"><Sparkles className="w-3 h-3 mr-1"/> AI</span>}
|
|
</td>
|
|
<td className="py-3 px-4 text-sm text-gray-500">{article.lastUpdated}</td>
|
|
<td className="py-3 px-4">
|
|
<button
|
|
onClick={() => {
|
|
setNewArticle(article);
|
|
setIsEditingKB(true);
|
|
}}
|
|
className="text-brand-600 hover:text-brand-800"
|
|
>
|
|
<Edit3 className="w-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ... (AI and Analytics Views remain same) ... */}
|
|
{view === 'ai' && (
|
|
// ... existing AI view ...
|
|
<div className="max-w-3xl mx-auto">
|
|
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 rounded-2xl p-8 text-white shadow-lg mb-8">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h2 className="text-3xl font-bold mb-2">Knowledge Agent AI</h2>
|
|
<p className="text-purple-100 max-w-xl">
|
|
Questo agente analizza automaticamente i ticket "Risolti" per trovare problemi comuni non ancora documentati nella Knowledge Base.
|
|
</p>
|
|
</div>
|
|
<Sparkles className="w-16 h-16 text-purple-300 opacity-50" />
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
{settings.features.aiKnowledgeAgentEnabled ? (
|
|
<button
|
|
onClick={handleAiAnalysis}
|
|
disabled={isAiAnalyzing}
|
|
className="bg-white text-purple-700 px-6 py-3 rounded-xl font-bold hover:bg-purple-50 transition shadow-lg flex items-center disabled:opacity-70"
|
|
>
|
|
{isAiAnalyzing ? (
|
|
<>
|
|
<div className="animate-spin h-5 w-5 border-2 border-purple-700 border-t-transparent rounded-full mr-3"></div>
|
|
Analisi in corso...
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle className="w-5 h-5 mr-2" />
|
|
Scansiona Ticket Risolti
|
|
</>
|
|
)}
|
|
</button>
|
|
) : (
|
|
<div className="bg-white/20 p-4 rounded-lg flex items-center text-sm font-medium">
|
|
<AlertTriangle className="w-5 h-5 mr-3 text-yellow-300" />
|
|
Funzionalità disabilitata dall'amministratore.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* ... rest of AI view ... */}
|
|
{aiSuggestion ? (
|
|
<div className="bg-white rounded-2xl shadow-md border border-purple-100 overflow-hidden animate-fade-in-up">
|
|
<div className="bg-purple-50 p-4 border-b border-purple-100 flex justify-between items-center">
|
|
<h3 className="text-purple-800 font-bold flex items-center">
|
|
<Sparkles className="w-4 h-4 mr-2" />
|
|
Nuovo Contenuto Suggerito
|
|
</h3>
|
|
<span className="text-xs bg-purple-200 text-purple-800 px-2 py-1 rounded-full">Lacuna Rilevata</span>
|
|
</div>
|
|
<div className="p-6">
|
|
<div className="mb-4">
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wide">Titolo Suggerito</label>
|
|
<h4 className="text-xl font-bold text-gray-900 mt-1">{aiSuggestion.title}</h4>
|
|
</div>
|
|
<div className="mb-4">
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wide">Categoria</label>
|
|
<span className="block mt-1 text-sm text-gray-700">{aiSuggestion.category}</span>
|
|
</div>
|
|
<div className="mb-6">
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wide">Contenuto Bozza</label>
|
|
<div className="mt-2 p-4 bg-gray-50 rounded-lg border border-gray-100 text-sm text-gray-700 font-mono whitespace-pre-wrap">
|
|
{aiSuggestion.content}
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end space-x-4">
|
|
<button
|
|
onClick={() => setAiSuggestion(null)}
|
|
className="px-4 py-2 text-gray-500 hover:text-gray-700 font-medium"
|
|
>
|
|
Scarta
|
|
</button>
|
|
<button
|
|
onClick={saveAiArticle}
|
|
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-bold shadow-md"
|
|
>
|
|
Approva e Aggiungi alla KB
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
!isAiAnalyzing && (
|
|
<div className="text-center text-gray-400 mt-12">
|
|
<Clock className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
|
<p>Nessun suggerimento attivo. Avvia una scansione per trovare nuove lacune.</p>
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
)}
|
|
{view === 'analytics' && (
|
|
// ... existing analytics view ...
|
|
<div className="max-w-6xl mx-auto space-y-6">
|
|
<h2 className="text-2xl font-bold text-gray-800 mb-6">Dashboard Analitica</h2>
|
|
|
|
{/* Key Metrics Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
|
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div className="p-2 bg-blue-50 rounded-lg text-blue-600"><TicketStatusIcon /></div>
|
|
<span className="text-xs font-semibold text-green-500 flex items-center"><TrendingUp className="w-3 h-3 mr-1" /> +12%</span>
|
|
</div>
|
|
<h3 className="text-slate-500 text-sm font-medium">Volume Ticket</h3>
|
|
<p className="text-3xl font-bold text-gray-900">{totalTickets}</p>
|
|
</div>
|
|
|
|
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div className="p-2 bg-amber-50 rounded-lg text-amber-600"><Star className="w-5 h-5" /></div>
|
|
</div>
|
|
<h3 className="text-slate-500 text-sm font-medium">CSAT (Soddisfazione)</h3>
|
|
<p className="text-3xl font-bold text-gray-900">{avgRating}/5</p>
|
|
</div>
|
|
|
|
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div className="p-2 bg-green-50 rounded-lg text-green-600"><CheckCircle className="w-5 h-5" /></div>
|
|
</div>
|
|
<h3 className="text-slate-500 text-sm font-medium">Tasso Risoluzione</h3>
|
|
<p className="text-3xl font-bold text-gray-900">{resolutionRate}%</p>
|
|
</div>
|
|
|
|
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div className="p-2 bg-purple-50 rounded-lg text-purple-600"><MessageCircle className="w-5 h-5" /></div>
|
|
</div>
|
|
<h3 className="text-slate-500 text-sm font-medium">Chat AI</h3>
|
|
<p className="text-3xl font-bold text-gray-900">{surveys.filter(s => s.source === 'chat').length} <span className="text-sm text-gray-400 font-normal">sessioni valutate</span></p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Queue Distribution */}
|
|
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
|
<h3 className="font-bold text-gray-800 mb-6">Distribuzione Code</h3>
|
|
<div className="space-y-4">
|
|
<div className="group">
|
|
<div className="flex justify-between text-sm mb-1">
|
|
<span>Tech Support</span>
|
|
<span className="font-bold">{techCount}</span>
|
|
</div>
|
|
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
|
|
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${(techCount/maxQueue)*100}%` }}></div>
|
|
</div>
|
|
</div>
|
|
<div className="group">
|
|
<div className="flex justify-between text-sm mb-1">
|
|
<span>Billing</span>
|
|
<span className="font-bold">{billingCount}</span>
|
|
</div>
|
|
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
|
|
<div className="h-full bg-green-500 rounded-full" style={{ width: `${(billingCount/maxQueue)*100}%` }}></div>
|
|
</div>
|
|
</div>
|
|
<div className="group">
|
|
<div className="flex justify-between text-sm mb-1">
|
|
<span>General</span>
|
|
<span className="font-bold">{generalCount}</span>
|
|
</div>
|
|
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
|
|
<div className="h-full bg-gray-500 rounded-full" style={{ width: `${(generalCount/maxQueue)*100}%` }}></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent Feedback */}
|
|
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex flex-col">
|
|
<h3 className="font-bold text-gray-800 mb-4">Feedback Recenti</h3>
|
|
<div className="flex-1 overflow-y-auto pr-2 space-y-3 max-h-[250px]">
|
|
{surveys.slice().reverse().map(survey => (
|
|
<div key={survey.id} className="p-3 bg-gray-50 rounded-lg border border-gray-100">
|
|
<div className="flex justify-between mb-1">
|
|
<div className="flex text-amber-400">
|
|
{[...Array(5)].map((_, i) => (
|
|
<Star key={i} className={`w-3 h-3 ${i < survey.rating ? 'fill-current' : 'text-gray-200'}`} />
|
|
))}
|
|
</div>
|
|
<span className="text-[10px] uppercase font-bold text-gray-400 bg-white px-2 rounded border border-gray-200">{survey.source}</span>
|
|
</div>
|
|
<p className="text-sm text-gray-700 italic">"{survey.comment || 'Nessun commento'}"</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const TicketStatusIcon = () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/></svg>
|
|
);
|