add ticketing system
Deploy Nexus Portal to HestiaCP (FTP) / build-and-deploy (push) Successful in 14s
Deploy Nexus Portal to HestiaCP (FTP) / build-and-deploy (push) Successful in 14s
This commit is contained in:
@@ -0,0 +1,374 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { getClientTickets, createTicket, getHelpdesks, getTicketDetails, replyTicket } from '../../services/api'; // Ajout des routes de détails et réponses
|
||||||
|
import { LifeBuoy, Plus, MessageSquare, Clock, CheckCircle2, Send, AlertCircle, Loader, Lock } from 'lucide-react';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COMPOSANT 0 : MODAL DE NOTIFICATION (Design System)
|
||||||
|
// ============================================================================
|
||||||
|
const NotificationModal = ({ notification, onClose }) => {
|
||||||
|
if (!notification) return null;
|
||||||
|
const isError = notification.type === 'error';
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
|
||||||
|
<div className={`bg-gray-900 border ${isError ? 'border-red-500/50 shadow-red-500/10' : 'border-cyan-500/50 shadow-cyan-500/10'} rounded-lg shadow-2xl max-w-sm w-full p-6 text-gray-200`}>
|
||||||
|
<h4 className={`text-lg font-mono font-bold tracking-wider mb-2 ${isError ? 'text-red-400' : 'text-cyan-400'}`}>
|
||||||
|
{notification.title.toUpperCase()}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-400 mb-6">{notification.message}</p>
|
||||||
|
<button onClick={onClose} className={`w-full font-mono py-2 rounded text-xs font-bold transition ${isError ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-cyan-500 hover:bg-cyan-400 text-gray-950'}`}>
|
||||||
|
COMPRIS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COMPOSANT PRINCIPAL : SUPPORT TICKETS
|
||||||
|
// ============================================================================
|
||||||
|
export default function Support() {
|
||||||
|
const [tickets, setTickets] = useState([]);
|
||||||
|
const [helpdesks, setHelpdesks] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isLoadingConversation, setIsLoadingConversation] = useState(false); // Loader spécifique pour le fil de discussion
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// États pour les formulaires
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [replyMessage, setReplyMessage] = useState(''); // Stocke le texte de la réponse
|
||||||
|
const [selectedHelpdesk, setSelectedHelpdesk] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// États pour les modaux
|
||||||
|
const [customAlert, setCustomAlert] = useState(null);
|
||||||
|
const [isCreatingTicket, setIsCreatingTicket] = useState(false);
|
||||||
|
const [activeTicket, setActiveTicket] = useState(null);
|
||||||
|
|
||||||
|
const triggerAlert = (title, message, type = "info") => {
|
||||||
|
setCustomAlert({ title, message, type });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chargement synchrone des tickets et des helpdesks
|
||||||
|
const loadSupportData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const ticketsData = await getClientTickets();
|
||||||
|
if (ticketsData && ticketsData.list) {
|
||||||
|
setTickets(ticketsData.list.map(tkt => ({
|
||||||
|
id: `TKT-${tkt.id}`,
|
||||||
|
db_id: tkt.id,
|
||||||
|
subject: tkt.subject,
|
||||||
|
department: tkt.helpdesk?.name || 'Support Technique',
|
||||||
|
status: (tkt.status === 'open' || tkt.status === 'closed') ? tkt.status : 'pending',
|
||||||
|
lastUpdate: tkt.updated_at || tkt.created_at || 'Récemment',
|
||||||
|
messages: []
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hdeskPairs = await getHelpdesks();
|
||||||
|
if (hdeskPairs) {
|
||||||
|
const formattedDesks = Object.entries(hdeskPairs).map(([id, name]) => ({ id, name }));
|
||||||
|
setHelpdesks(formattedDesks);
|
||||||
|
const nexusDesk = formattedDesks.find(hd => hd.name.toLowerCase().includes('nexus'));
|
||||||
|
if (nexusDesk) {
|
||||||
|
setSelectedHelpdesk(nexusDesk.id);
|
||||||
|
} else if (formattedDesks.length > 0) {
|
||||||
|
setSelectedHelpdesk(formattedDesks[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Impossible de synchroniser le centre de support.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSupportData();
|
||||||
|
}, [loadSupportData]);
|
||||||
|
|
||||||
|
// ACTION CLIC : Charger les messages du ticket depuis FOSSBilling
|
||||||
|
const handleOpenTicket = async (ticket) => {
|
||||||
|
try {
|
||||||
|
setIsLoadingConversation(true);
|
||||||
|
// On ouvre immédiatement le modal avec une liste de messages vide pour la fluidité
|
||||||
|
setActiveTicket({ ...ticket, messages: [] });
|
||||||
|
|
||||||
|
const details = await getTicketDetails(ticket.db_id);
|
||||||
|
|
||||||
|
if (details && details.messages) {
|
||||||
|
// FOSSBilling renvoie l'auteur dans msg.author.role ('client', 'staff', 'admin')
|
||||||
|
const formattedMessages = details.messages.map(msg => ({
|
||||||
|
sender: msg.author?.role === 'client' ? 'client' : 'staff',
|
||||||
|
text: msg.content,
|
||||||
|
date: msg.created_at || 'Récemment'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Injection dynamique des messages dans le modal actif
|
||||||
|
setActiveTicket(prev => prev ? { ...prev, messages: formattedMessages } : null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
triggerAlert("Erreur Réseau", "Impossible de récupérer l'historique : " + err.message, "error");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingConversation(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ACTION RÉPONSE : Envoyer un message dans le thread actuel
|
||||||
|
const handleReplySubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!replyMessage.trim() || !activeTicket) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await replyTicket(activeTicket.db_id, replyMessage);
|
||||||
|
setReplyMessage(''); // Nettoyer l'input
|
||||||
|
|
||||||
|
// Rechargement instantané du fil de discussion pour afficher le message soumis
|
||||||
|
const details = await getTicketDetails(activeTicket.db_id);
|
||||||
|
if (details && details.messages) {
|
||||||
|
const formattedMessages = details.messages.map(msg => ({
|
||||||
|
sender: msg.author?.role === 'client' ? 'client' : 'staff',
|
||||||
|
text: msg.content,
|
||||||
|
date: msg.created_at || 'Récemment'
|
||||||
|
}));
|
||||||
|
setActiveTicket(prev => prev ? { ...prev, messages: formattedMessages } : null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
triggerAlert("Échec d'envoi", "Votre réponse n'a pas pu être transmise : " + err.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Soumission du nouveau ticket
|
||||||
|
const handleCreateTicket = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!subject.trim() || !message.trim() || !selectedHelpdesk) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await createTicket(subject, message, selectedHelpdesk);
|
||||||
|
setIsCreatingTicket(false);
|
||||||
|
setSubject('');
|
||||||
|
setMessage('');
|
||||||
|
loadSupportData();
|
||||||
|
triggerAlert("Ticket Ouvert", "Votre demande a bien été enregistrée sur le Service Desk.", "success");
|
||||||
|
} catch (err) {
|
||||||
|
triggerAlert("Échec", "Erreur lors de la création du ticket : " + err.message, "error");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusConfig = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'open': return { color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/20', label: 'SUPPORT', icon: <MessageSquare className="w-3 h-3 mr-1" /> };
|
||||||
|
case 'pending': return { color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/20', label: 'EN ATTENTE', icon: <Clock className="w-3 h-3 mr-1" /> };
|
||||||
|
case 'closed': return { color: 'text-gray-400', bg: 'bg-gray-500/10', border: 'border-gray-500/20', label: 'RÉSOLU', icon: <CheckCircle2 className="w-3 h-3 mr-1" /> };
|
||||||
|
default: return { color: 'text-gray-400', bg: 'bg-gray-800', border: 'border-gray-700', label: status.toUpperCase() };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-6xl p-6 mx-auto">
|
||||||
|
|
||||||
|
{/* HEADER */}
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 gap-4">
|
||||||
|
<div>
|
||||||
|
<header>
|
||||||
|
<h1 className="text-3xl font-black text-white tracking-wider flex items-center gap-3">
|
||||||
|
<LifeBuoy className="w-8 h-8 text-cyan-400" />
|
||||||
|
CENTRE DE <span className="text-cyan-400">SUPPORT</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-2">Canal de communication sécurisé avec les ingénieurs GISE.</p>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreatingTicket(true)}
|
||||||
|
className="bg-cyan-500 hover:bg-cyan-400 text-gray-900 px-5 py-2.5 rounded-lg font-bold text-sm transition flex items-center font-mono tracking-widest shadow-[0_0_15px_rgba(0,229,255,0.2)]"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" /> NOUVEAU TICKET
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CHARGEMENT & ERREURS GLOBALES */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center space-x-3 text-cyan-400 mb-8 font-mono">
|
||||||
|
<Loader className="w-6 h-6 animate-spin" />
|
||||||
|
<span>Déchiffrement de la matrice de support...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center space-x-3 text-red-400 bg-red-400/10 border border-red-400 p-4 rounded-lg mb-8">
|
||||||
|
<AlertCircle className="w-6 h-6" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GRILLE DES TICKETS */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{tickets.length === 0 ? (
|
||||||
|
<div className="col-span-full text-gray-600 text-sm border border-dashed border-gray-800 rounded-xl p-10 text-center font-mono">
|
||||||
|
Aucun ticket de support actif. L'infrastructure est nominale.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tickets.map(ticket => {
|
||||||
|
const conf = getStatusConfig(ticket.status);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ticket.id}
|
||||||
|
onClick={() => handleOpenTicket(ticket)} // Modification ici : Appel de la fonction de chargement au lieu du setter direct
|
||||||
|
className="bg-gray-900 border border-gray-800 rounded-xl p-5 hover:border-cyan-400/50 hover:bg-gray-800/50 transition-all cursor-pointer group flex flex-col justify-between min-h-[160px]"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<span className="text-gray-500 font-mono text-xs">{ticket.id}</span>
|
||||||
|
<span className={`flex items-center px-2 py-1 rounded text-[10px] font-bold border tracking-wider ${conf.color} ${conf.bg} ${conf.border}`}>
|
||||||
|
{conf.icon} {conf.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-white font-bold text-lg mb-1 group-hover:text-cyan-400 transition-colors line-clamp-2">
|
||||||
|
{ticket.subject}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-xs text-gray-500 font-mono border-t border-gray-800 pt-3 mt-4">
|
||||||
|
<span>Département: {ticket.department}</span>
|
||||||
|
<span className="truncate max-w-[120px]">MàJ: {ticket.lastUpdate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MODAL 1 : CRÉATION DE TICKET */}
|
||||||
|
{isCreatingTicket && (
|
||||||
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-gray-900 border border-cyan-500/30 rounded-lg shadow-2xl max-w-lg w-full p-6 text-gray-200">
|
||||||
|
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
|
||||||
|
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-2">
|
||||||
|
<Plus className="w-5 h-5 text-cyan-400" /> OUVRIR UNE REQUÊTE
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => setIsCreatingTicket(false)} className="text-gray-400 hover:text-white transition text-xl">✖</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreateTicket} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Sujet de la demande</label>
|
||||||
|
<input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} required placeholder="Ex: Problème d'accès sur l'API" className="w-full bg-black border border-gray-800 text-white p-3 rounded text-sm focus:border-cyan-500 outline-none transition" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">
|
||||||
|
Département (Cible Réseau)
|
||||||
|
</label>
|
||||||
|
{/* CHAMP VERROUILLÉ (Read-Only) */}
|
||||||
|
<div className="w-full bg-gray-950 border border-gray-800 text-gray-500 p-3 rounded text-sm font-mono flex items-center justify-between select-none">
|
||||||
|
<span>
|
||||||
|
{helpdesks.find(h => h.id === selectedHelpdesk)?.name || 'Service Desk Nexus'}
|
||||||
|
</span>
|
||||||
|
<Lock className="w-4 h-4 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-600 mt-1">
|
||||||
|
Routage automatique vers les ingénieurs d'infrastructure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Description détaillée</label>
|
||||||
|
<textarea value={message} onChange={(e) => setMessage(e.target.value)} required rows="4" placeholder="Décrivez votre problème technique ici..." className="w-full bg-black border border-gray-800 text-white p-3 rounded text-sm focus:border-cyan-500 outline-none transition resize-none"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={isSubmitting} className="w-full bg-cyan-600 hover:bg-cyan-500 text-white font-bold font-mono tracking-widest uppercase py-3 rounded mt-2 transition shadow-lg shadow-cyan-500/20 disabled:opacity-50">
|
||||||
|
{isSubmitting ? 'CHIFFREMENT ET TRANSMISSION...' : 'TRANSMETTRE AU SUPPORT NEXUS'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MODAL 2 : FIL DE DISCUSSION INTERACTIF (THREAD NATIVE) */}
|
||||||
|
{activeTicket && (
|
||||||
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-2 sm:p-4">
|
||||||
|
<div className="bg-gray-950 border border-gray-800 rounded-lg shadow-2xl max-w-3xl w-full h-[85vh] flex flex-col">
|
||||||
|
|
||||||
|
{/* Thread Header */}
|
||||||
|
<div className="bg-gray-900 border-b border-gray-800 p-4 sm:p-6 flex justify-between items-start rounded-t-lg">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<span className="text-cyan-500 font-mono text-sm">{activeTicket.id}</span>
|
||||||
|
<span className="bg-gray-800 text-gray-300 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border border-gray-700">
|
||||||
|
{activeTicket.department}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-white line-clamp-1">{activeTicket.subject}</h3>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setActiveTicket(null)} className="text-gray-400 hover:text-white transition text-xl bg-black/50 p-2 rounded-lg">✖</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thread Body (Messages avec état de chargement) */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6 scrollbar-thin scrollbar-thumb-gray-800">
|
||||||
|
{isLoadingConversation ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-cyan-400 gap-2 font-mono text-xs">
|
||||||
|
<Loader className="w-6 h-6 animate-spin" />
|
||||||
|
<span>Téléchargement des paquets de discussion sécurisés...</span>
|
||||||
|
</div>
|
||||||
|
) : activeTicket.messages.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-600 font-mono text-xs pt-10">
|
||||||
|
Aucun message trouvé dans ce fil.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activeTicket.messages.map((msg, idx) => (
|
||||||
|
<div key={idx} className={`flex flex-col ${msg.sender === 'client' ? 'items-end' : 'items-start'}`}>
|
||||||
|
<div className="flex items-baseline gap-2 mb-1 px-1">
|
||||||
|
<span className={`text-[10px] font-bold uppercase tracking-widest ${msg.sender === 'client' ? 'text-gray-500' : 'text-cyan-400'}`}>
|
||||||
|
{msg.sender === 'client' ? 'VOUS' : 'INGÉNIEUR GISE'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-600 font-mono">{msg.date}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`p-4 rounded-xl max-w-[85%] text-sm leading-relaxed ${msg.sender === 'client'
|
||||||
|
? 'bg-gray-800 text-gray-200 rounded-tr-none'
|
||||||
|
: 'bg-cyan-900/20 border border-cyan-800/30 text-cyan-50 rounded-tl-none'
|
||||||
|
}`}>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thread Footer (Formulaire de réponse branché) */}
|
||||||
|
{activeTicket.status !== 'closed' ? (
|
||||||
|
<form onSubmit={handleReplySubmit} className="bg-gray-900 border-t border-gray-800 p-4 rounded-b-lg">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
rows="2"
|
||||||
|
value={replyMessage}
|
||||||
|
onChange={(e) => setReplyMessage(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Taper une réponse sécurisée..."
|
||||||
|
className="flex-1 bg-black border border-gray-800 text-white p-3 rounded-lg text-sm focus:border-cyan-500 outline-none transition resize-none"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-cyan-600 hover:bg-cyan-500 text-white px-6 rounded-lg font-bold font-mono tracking-widest transition flex flex-col items-center justify-center gap-1 shadow-lg shadow-cyan-500/20"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
<span className="text-[10px]">ENVOYER</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-900 border-t border-gray-800 p-4 rounded-b-lg text-center font-mono text-sm text-gray-500">
|
||||||
|
[ CE TICKET EST VERROUILLÉ ET ARCHIVÉ ]
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* VRAI MODAL DE NOTIFICATION */}
|
||||||
|
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user