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