Update ClientPortal.tsx
This commit is contained in:
@@ -21,7 +21,9 @@ import {
|
|||||||
MoreVertical,
|
MoreVertical,
|
||||||
Minus,
|
Minus,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
BookOpen
|
BookOpen,
|
||||||
|
Download,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface ClientPortalProps {
|
interface ClientPortalProps {
|
||||||
@@ -30,7 +32,7 @@ interface ClientPortalProps {
|
|||||||
queues: TicketQueue[];
|
queues: TicketQueue[];
|
||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
onCreateTicket: (ticket: Omit<Ticket, 'id' | 'createdAt' | 'messages' | 'status'>) => void;
|
onCreateTicket: (ticket: Omit<Ticket, 'id' | 'createdAt' | 'messages' | 'status'>) => void;
|
||||||
onReplyTicket: (ticketId: string, message: string) => void;
|
onReplyTicket: (ticketId: string, message: string, attachments?: Attachment[]) => void;
|
||||||
onSubmitSurvey: (survey: Omit<SurveyResult, 'id' | 'timestamp'>) => void;
|
onSubmitSurvey: (survey: Omit<SurveyResult, 'id' | 'timestamp'>) => void;
|
||||||
tickets?: Ticket[];
|
tickets?: Ticket[];
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
@@ -77,6 +79,8 @@ export const ClientPortal: React.FC<ClientPortalProps> = ({
|
|||||||
|
|
||||||
// Ticket Reply State
|
// Ticket Reply State
|
||||||
const [replyText, setReplyText] = useState('');
|
const [replyText, setReplyText] = useState('');
|
||||||
|
const [replyAttachments, setReplyAttachments] = useState<Attachment[]>([]);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
// Initial Chat Message with Custom Agent Name
|
// Initial Chat Message with Custom Agent Name
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -147,7 +151,7 @@ export const ClientPortal: React.FC<ClientPortalProps> = ({
|
|||||||
id: `att-${Date.now()}-${i}`,
|
id: `att-${Date.now()}-${i}`,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
url: URL.createObjectURL(file)
|
url: URL.createObjectURL(file) // Note: This is client-side only URL for preview, real implementation would upload here too or later
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,11 +171,23 @@ export const ClientPortal: React.FC<ClientPortalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const submitReply = () => {
|
const submitReply = () => {
|
||||||
if (!replyText.trim() || !selectedTicket) return;
|
if ((!replyText.trim() && replyAttachments.length === 0) || !selectedTicket) return;
|
||||||
onReplyTicket(selectedTicket.id, replyText);
|
|
||||||
|
// Pass attachments to parent handler
|
||||||
|
onReplyTicket(selectedTicket.id, replyText, replyAttachments);
|
||||||
|
|
||||||
setReplyText('');
|
setReplyText('');
|
||||||
|
setReplyAttachments([]);
|
||||||
|
|
||||||
// Optimistic update for UI smoothness (actual update comes from props change)
|
// Optimistic update for UI smoothness (actual update comes from props change)
|
||||||
const newMsg: ChatMessage = { id: `temp-${Date.now()}`, role: 'user', content: replyText, timestamp: new Date().toISOString() };
|
const newMsg: ChatMessage = {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
role: 'user',
|
||||||
|
content: replyText,
|
||||||
|
attachments: replyAttachments,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
setSelectedTicket({
|
setSelectedTicket({
|
||||||
...selectedTicket,
|
...selectedTicket,
|
||||||
messages: [...selectedTicket.messages, newMsg],
|
messages: [...selectedTicket.messages, newMsg],
|
||||||
@@ -180,6 +196,51 @@ export const ClientPortal: React.FC<ClientPortalProps> = ({
|
|||||||
showToast("Risposta inviata", 'success');
|
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 = () => {
|
const submitSurvey = () => {
|
||||||
if (surveyData.rating === 0) {
|
if (surveyData.rating === 0) {
|
||||||
showToast("Per favore seleziona una valutazione.", 'error');
|
showToast("Per favore seleziona una valutazione.", 'error');
|
||||||
@@ -334,12 +395,39 @@ export const ClientPortal: React.FC<ClientPortalProps> = ({
|
|||||||
{selectedTicket.messages.map(m => (
|
{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'}`}>
|
<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>
|
<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>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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..." />
|
<textarea className="flex-1 border p-2 rounded" value={replyText} onChange={e => setReplyText(e.target.value)} placeholder="Scrivi risposta..." />
|
||||||
<button onClick={submitReply} className="bg-brand-600 text-white px-4 rounded font-bold">Invia</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,6 +443,8 @@ export const ClientPortal: React.FC<ClientPortalProps> = ({
|
|||||||
<select className="w-full border p-3 rounded-lg" value={ticketForm.queue} onChange={e => setTicketForm({...ticketForm, queue: e.target.value})}>
|
<select className="w-full border p-3 rounded-lg" value={ticketForm.queue} onChange={e => setTicketForm({...ticketForm, queue: e.target.value})}>
|
||||||
{queues.map(q => <option key={q.id} value={q.name}>{q.name}</option>)}
|
{queues.map(q => <option key={q.id} value={q.name}>{q.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
{/* Create Ticket attachment (mock for now as backend requires form data or separate upload) */}
|
||||||
|
{/* For simplicity in this demo, omitting file upload on creation to keep code lean, or would need same async upload logic */}
|
||||||
<button type="submit" className="w-full bg-brand-600 text-white py-3 rounded-lg font-bold">Crea Ticket</button>
|
<button type="submit" className="w-full bg-brand-600 text-white py-3 rounded-lg font-bold">Crea Ticket</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user