626 lines
30 KiB
TypeScript
626 lines
30 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import { Ticket, KBArticle, ChatMessage, TicketPriority, TicketStatus, SurveyResult, Attachment, ClientUser, TicketQueue, AppSettings } 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,
|
|
Download,
|
|
Loader2
|
|
} from 'lucide-react';
|
|
|
|
interface ClientPortalProps {
|
|
currentUser: ClientUser;
|
|
articles: KBArticle[];
|
|
queues: TicketQueue[];
|
|
settings: AppSettings;
|
|
onCreateTicket: (ticket: Omit<Ticket, 'id' | 'createdAt' | 'messages' | 'status'>) => void;
|
|
onReplyTicket: (ticketId: string, message: string, attachments?: Attachment[]) => 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,
|
|
settings,
|
|
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[]>([]);
|
|
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('');
|
|
const [replyAttachments, setReplyAttachments] = useState<Attachment[]>([]);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
|
// Initial Chat Message with Custom Agent Name
|
|
useEffect(() => {
|
|
if (chatMessages.length === 0) {
|
|
setChatMessages([{
|
|
id: '0',
|
|
role: 'assistant',
|
|
content: `Ciao ${currentUser.name}! 👋\nSono ${settings.aiConfig.agentName || 'l\'assistente AI'}. Come posso aiutarti oggi?`,
|
|
timestamp: new Date().toISOString()
|
|
}]);
|
|
}
|
|
}, [currentUser.name, settings.aiConfig.agentName]);
|
|
|
|
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 apiKey = settings.aiConfig.apiKey;
|
|
|
|
// Check if API Key exists before calling service
|
|
let aiResponseText = '';
|
|
if (!apiKey) {
|
|
aiResponseText = "L'assistente AI non è disponibile al momento (Configurazione incompleta).";
|
|
} else {
|
|
aiResponseText = await getSupportResponse(
|
|
apiKey,
|
|
userMsg.content,
|
|
chatMessages.map(m => m.content).slice(-5),
|
|
articles, // AI uses ALL articles (internal and public) for context
|
|
settings.aiConfig.provider,
|
|
settings.aiConfig.model,
|
|
{ agentName: settings.aiConfig.agentName, customPrompt: settings.aiConfig.customPrompt }
|
|
);
|
|
}
|
|
|
|
const aiMsg: ChatMessage = {
|
|
id: (Date.now() + 1).toString(),
|
|
role: 'assistant',
|
|
content: aiResponseText,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
setChatMessages(prev => [...prev, aiMsg]);
|
|
setIsTyping(false);
|
|
};
|
|
|
|
const submitTicket = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setIsUploading(true);
|
|
const attachments: Attachment[] = [];
|
|
|
|
if (ticketFiles && ticketFiles.length > 0) {
|
|
for (let i = 0; i < ticketFiles.length; i++) {
|
|
const file = ticketFiles[i];
|
|
|
|
// Validation check before upload
|
|
const maxSize = (settings.features.maxFileSizeMb || 5) * 1024 * 1024;
|
|
if (file.size > maxSize) {
|
|
showToast(`File ${file.name} troppo grande. Max ${settings.features.maxFileSizeMb}MB`, 'error');
|
|
continue;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
try {
|
|
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
|
if(res.ok) {
|
|
const data = await res.json();
|
|
attachments.push({
|
|
id: data.id,
|
|
name: data.name,
|
|
url: data.url,
|
|
type: data.type
|
|
});
|
|
} else {
|
|
showToast(`Errore caricamento ${file.name}`, 'error');
|
|
}
|
|
} catch(err) {
|
|
console.error(err);
|
|
showToast(`Errore di rete caricamento ${file.name}`, 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
setIsUploading(false);
|
|
setActiveView('dashboard');
|
|
};
|
|
|
|
const submitReply = () => {
|
|
if ((!replyText.trim() && replyAttachments.length === 0) || !selectedTicket) return;
|
|
|
|
// Pass attachments to parent handler
|
|
onReplyTicket(selectedTicket.id, replyText, replyAttachments);
|
|
|
|
setReplyText('');
|
|
setReplyAttachments([]);
|
|
|
|
// Optimistic update for UI smoothness (actual update comes from props change)
|
|
const newMsg: ChatMessage = {
|
|
id: `temp-${Date.now()}`,
|
|
role: 'user',
|
|
content: replyText,
|
|
attachments: replyAttachments,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
setSelectedTicket({
|
|
...selectedTicket,
|
|
messages: [...selectedTicket.messages, newMsg],
|
|
status: selectedTicket.status === TicketStatus.RESOLVED ? TicketStatus.OPEN : selectedTicket.status
|
|
});
|
|
showToast("Risposta inviata", 'success');
|
|
};
|
|
|
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files && e.target.files[0]) {
|
|
const file = e.target.files[0];
|
|
|
|
// Validation
|
|
const maxSize = (settings.features.maxFileSizeMb || 5) * 1024 * 1024;
|
|
if (file.size > maxSize) {
|
|
showToast(`File troppo grande. Max ${settings.features.maxFileSizeMb}MB`, 'error');
|
|
return;
|
|
}
|
|
|
|
const allowed = (settings.features.allowedFileTypes || 'jpg,png,pdf').split(',').map(t => t.trim().toLowerCase());
|
|
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
|
if (!allowed.includes(ext)) {
|
|
showToast(`Estensione non permessa. Ammessi: ${settings.features.allowedFileTypes}`, 'error');
|
|
return;
|
|
}
|
|
|
|
setIsUploading(true);
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
try {
|
|
const res = await fetch('/api/upload', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const data = await res.json();
|
|
if (data.url) {
|
|
setReplyAttachments(prev => [...prev, { id: data.id, name: data.name, url: data.url, type: data.type }]);
|
|
}
|
|
} catch (err) {
|
|
showToast('Errore caricamento file', 'error');
|
|
} finally {
|
|
setIsUploading(false);
|
|
// Reset input
|
|
e.target.value = '';
|
|
}
|
|
}
|
|
};
|
|
|
|
const removeAttachment = (id: string) => {
|
|
setReplyAttachments(prev => prev.filter(a => a.id !== id));
|
|
};
|
|
|
|
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);
|
|
|
|
// Filter only PUBLIC articles for the Client Portal View
|
|
const filteredArticles = articles.filter(a =>
|
|
a.visibility !== 'internal' &&
|
|
(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 Content Logic (Dashboard, Ticket Detail, Create Ticket) ... */}
|
|
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{/* Reusing existing code structure but ensuring we use filteredArticles */}
|
|
|
|
{activeView === 'dashboard' && (
|
|
<div className="space-y-8 animate-fade-in">
|
|
{/* ... Stats & Tickets List (same as before) ... */}
|
|
<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>
|
|
{/* ... other stats ... */}
|
|
<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">
|
|
<div className="lg:col-span-2 space-y-4">
|
|
<h2 className="text-xl font-bold text-gray-800">I tuoi Ticket Attivi</h2>
|
|
{activeTickets.map(ticket => (
|
|
<div key={ticket.id} onClick={() => handleTicketClick(ticket)} className="bg-white p-6 rounded-xl border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
|
<div className="flex justify-between">
|
|
<h3 className="font-bold">{ticket.subject}</h3>
|
|
<span className="text-xs bg-gray-100 px-2 py-1 rounded">{ticket.status}</span>
|
|
</div>
|
|
<p className="text-sm text-gray-500 mt-2">{ticket.description}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="space-y-4">
|
|
<h2 className="text-xl font-bold text-gray-800">Storico</h2>
|
|
{resolvedTickets.slice(0,5).map(ticket => (
|
|
<div key={ticket.id} onClick={() => handleTicketClick(ticket)} className="bg-white p-4 rounded-xl border border-gray-200 hover:bg-gray-50 cursor-pointer text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="font-medium truncate">{ticket.subject}</span>
|
|
<span className="text-xs text-green-600">{ticket.status}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeView === 'ticket_detail' && selectedTicket && (
|
|
<div className="max-w-4xl mx-auto animate-fade-in">
|
|
<button onClick={() => setActiveView('dashboard')} className="mb-4 text-sm text-gray-500 hover:text-gray-900 flex items-center"><ArrowLeft className="w-4 h-4 mr-1"/> Indietro</button>
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
<div className="p-6 border-b border-gray-100 bg-gray-50">
|
|
<h1 className="text-2xl font-bold">{selectedTicket.subject}</h1>
|
|
<div className="flex gap-2 mt-2"><span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">{selectedTicket.status}</span></div>
|
|
</div>
|
|
<div className="p-6 space-y-6">
|
|
<div className="bg-gray-50 p-4 rounded-lg">{selectedTicket.description}</div>
|
|
|
|
{selectedTicket.attachments && selectedTicket.attachments.length > 0 && (
|
|
<div className="bg-blue-50/50 p-4 rounded-lg border border-blue-100">
|
|
<h3 className="text-xs font-bold text-blue-800 uppercase mb-2 flex items-center"><Paperclip className="w-3 h-3 mr-1"/> Allegati Iniziali</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{selectedTicket.attachments.map(att => (
|
|
<a key={att.id} href={att.url} target="_blank" rel="noopener noreferrer" className="flex items-center text-xs bg-white border border-blue-200 text-blue-600 px-3 py-2 rounded-lg hover:bg-blue-50 transition">
|
|
<FileText className="w-3 h-3 mr-2 text-blue-400" /> {att.name}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
<h3 className="font-bold text-gray-700">Messaggi</h3>
|
|
{selectedTicket.messages.map(m => (
|
|
<div key={m.id} className={`p-4 rounded-lg max-w-[80%] ${m.role === 'user' ? 'ml-auto bg-blue-600 text-white' : 'bg-gray-100 text-gray-800'}`}>
|
|
<p className="text-sm">{m.content}</p>
|
|
{m.attachments && m.attachments.length > 0 && (
|
|
<div className={`mt-2 pt-2 border-t ${m.role === 'user' ? 'border-blue-500/30' : 'border-gray-200/50'}`}>
|
|
{m.attachments.map(att => (
|
|
<a key={att.id} href={att.url} target="_blank" rel="noopener noreferrer" className={`flex items-center text-xs hover:underline mt-1 ${m.role === 'user' ? 'text-white' : 'text-blue-600'}`}>
|
|
<Download className="w-3 h-3 mr-1" /> {att.name}
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex flex-wrap gap-2">
|
|
{replyAttachments.map(att => (
|
|
<div key={att.id} className="flex items-center text-xs bg-gray-100 px-2 py-1 rounded-full">
|
|
<span className="max-w-[100px] truncate">{att.name}</span>
|
|
<button onClick={() => removeAttachment(att.id)} className="ml-1 text-gray-500 hover:text-red-500"><X className="w-3 h-3"/></button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<textarea className="flex-1 border p-2 rounded" value={replyText} onChange={e => setReplyText(e.target.value)} placeholder="Scrivi risposta..." />
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
{settings.features.attachmentsEnabled !== false && (
|
|
<label className={`cursor-pointer inline-flex items-center p-2 rounded-lg text-gray-500 hover:bg-gray-100 transition ${isUploading ? 'opacity-50 pointer-events-none' : ''}`}>
|
|
{isUploading ? <Loader2 className="w-5 h-5 animate-spin" /> : <Paperclip className="w-5 h-5" />}
|
|
<input type="file" className="hidden" onChange={handleFileUpload} />
|
|
</label>
|
|
)}
|
|
</div>
|
|
<button onClick={submitReply} disabled={isUploading} className="bg-brand-600 text-white px-4 py-2 rounded font-bold disabled:opacity-50">Invia</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeView === 'create_ticket' && (
|
|
<div className="max-w-2xl mx-auto bg-white p-8 rounded-xl shadow-sm border border-gray-100">
|
|
<h2 className="text-2xl font-bold mb-6">Nuovo Ticket</h2>
|
|
<form onSubmit={submitTicket} className="space-y-4">
|
|
<input type="text" placeholder="Oggetto" className="w-full border p-3 rounded-lg" value={ticketForm.subject} onChange={e => setTicketForm({...ticketForm, subject: e.target.value})} required />
|
|
<textarea placeholder="Descrizione" rows={5} className="w-full border p-3 rounded-lg" value={ticketForm.description} onChange={e => setTicketForm({...ticketForm, description: e.target.value})} required />
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<select className="w-full border p-3 rounded-lg bg-white" value={ticketForm.queue} onChange={e => setTicketForm({...ticketForm, queue: e.target.value})}>
|
|
{queues.map(q => <option key={q.id} value={q.name}>{q.name}</option>)}
|
|
</select>
|
|
<div className="relative">
|
|
<label className={`flex items-center justify-center w-full border p-3 rounded-lg cursor-pointer hover:bg-gray-50 ${isUploading ? 'opacity-50 pointer-events-none' : ''}`}>
|
|
{isUploading ? <Loader2 className="w-5 h-5 animate-spin text-brand-600" /> : <Paperclip className="w-5 h-5 text-gray-500 mr-2" />}
|
|
<span className="text-gray-500 text-sm">{ticketFiles && ticketFiles.length > 0 ? `${ticketFiles.length} file selezionati` : 'Allega file'}</span>
|
|
<input type="file" className="hidden" multiple onChange={(e) => setTicketFiles(e.target.files)} />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<button type="submit" disabled={isUploading} className="w-full bg-brand-600 text-white py-3 rounded-lg font-bold hover:bg-brand-700 disabled:opacity-70 flex items-center justify-center">
|
|
{isUploading ? <Loader2 className="w-5 h-5 animate-spin mr-2" /> : null}
|
|
{isUploading ? 'Caricamento in corso...' : 'Crea Ticket'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{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>
|
|
<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..."
|
|
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 cursor-pointer"
|
|
>
|
|
<span className="text-xs font-bold text-brand-600 bg-brand-50 px-2 py-1 rounded-md uppercase tracking-wide">{article.category}</span>
|
|
<h3 className="text-lg font-bold text-gray-800 my-2">{article.title}</h3>
|
|
<p className="text-gray-600 text-sm line-clamp-3">{article.content}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* KB Modal */}
|
|
{viewingArticle && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
|
<div className="bg-white p-8 rounded-xl max-w-2xl w-full max-h-[80vh] overflow-y-auto relative">
|
|
<button onClick={() => setViewingArticle(null)} className="absolute top-4 right-4"><X/></button>
|
|
<h2 className="text-2xl font-bold mb-4">{viewingArticle.title}</h2>
|
|
<div className="prose">{viewingArticle.content}</div>
|
|
{viewingArticle.type === 'url' && <a href={viewingArticle.url} target="_blank" className="text-blue-600 underline mt-4 block">Vai alla risorsa esterna</a>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</main>
|
|
|
|
{/* FLOATING CHAT WIDGET WITH CUSTOM AVATAR */}
|
|
<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">
|
|
{settings.aiConfig.agentAvatar ? (
|
|
<img src={settings.aiConfig.agentAvatar} alt="Bot" className="w-10 h-10 rounded-full mr-3 border-2 border-white/30" />
|
|
) : (
|
|
<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">{settings.aiConfig.agentName || "Assistente AI"}</h3>
|
|
<p className="text-[10px] text-brand-100 opacity-90">Supporto Istantaneo H24</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={() => setIsChatOpen(false)}><Minus className="w-5 h-5" /></button>
|
|
</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' && (
|
|
settings.aiConfig.agentAvatar ? (
|
|
<img src={settings.aiConfig.agentAvatar} className="w-6 h-6 rounded-full mr-2 mt-1" />
|
|
) : (
|
|
<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 items-center animate-pulse mt-2 ml-1">
|
|
{settings.aiConfig.agentAvatar ? (
|
|
<img src={settings.aiConfig.agentAvatar} className="w-6 h-6 rounded-full mr-2 opacity-70" />
|
|
) : (
|
|
<div className="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center mr-2 text-gray-500 text-[10px]">...</div>
|
|
)}
|
|
<span className="text-xs text-gray-400 italic font-medium">
|
|
{settings.aiConfig.agentName || 'AI'} sta scrivendo...
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div ref={chatEndRef} />
|
|
</div>
|
|
|
|
<div className="p-3 bg-white border-t border-gray-100 flex">
|
|
<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"><Send className="w-5 h-5" /></button>
|
|
</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>
|
|
</div>
|
|
);
|
|
};
|