upgrade ticket 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:
@@ -1,6 +1,7 @@
|
|||||||
// src/layouts/AppLayout.jsx
|
// src/layouts/AppLayout.jsx
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||||
|
import { getClientTickets } from '../services/api';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// COMPOSANT : MODAL DE CONFIRMATION DE DÉCONNEXION
|
// COMPOSANT : MODAL DE CONFIRMATION DE DÉCONNEXION
|
||||||
@@ -60,6 +61,25 @@ const ConfirmLogoutModal = ({ isOpen, onClose, onConfirm }) => {
|
|||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [showLogoutModal, setShowLogoutModal] = useState(false);
|
const [showLogoutModal, setShowLogoutModal] = useState(false);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
|
const checkUnreadTickets = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getClientTickets();
|
||||||
|
if (data && data.list) {
|
||||||
|
// On cherche les tickets qui ont le statut 'on_hold'
|
||||||
|
const unread = data.list.filter(t => t.status === 'on_hold' || t.unread).length;
|
||||||
|
setUnreadCount(unread);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Erreur silencieuse : on ne pollue pas l'écran si le réseau saute
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
checkUnreadTickets(); // Premier check immédiat
|
||||||
|
const intervalId = setInterval(checkUnreadTickets, 120000); // Check toutes les 2 minutes
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Ouvre simplement le modal
|
// Ouvre simplement le modal
|
||||||
const handleLogoutClick = () => {
|
const handleLogoutClick = () => {
|
||||||
@@ -154,9 +174,48 @@ export default function AppLayout() {
|
|||||||
<div style={{ padding: '0 25px', marginTop: '25px', marginBottom: '10px', color: '#444', fontSize: '0.7rem', fontWeight: 'bold' }}>
|
<div style={{ padding: '0 25px', marginTop: '25px', marginBottom: '10px', color: '#444', fontSize: '0.7rem', fontWeight: 'bold' }}>
|
||||||
// GESTION
|
// GESTION
|
||||||
</div>
|
</div>
|
||||||
<NavLink style={getNavLinkStyle} to="/billing">Facturation</NavLink>
|
{/*<NavLink style={getNavLinkStyle} to="/billing" locked>Facturation</NavLink>*/}
|
||||||
<NavLink style={getNavLinkStyle} to="/support">Tickets Support</NavLink>
|
<NavLink style={getNavLinkStyle} to="/support">
|
||||||
<NavLink style={getNavLinkStyle} to="/settings">Profil & Sécurité</NavLink>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||||
|
<span>Tickets Support</span>
|
||||||
|
|
||||||
|
{/* LE BADGE DE NOTIFICATION */}
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span style={{
|
||||||
|
backgroundColor: '#00E5FF',
|
||||||
|
color: '#000',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 0 10px rgba(0,229,255,0.6)'
|
||||||
|
}}>
|
||||||
|
{unreadCount} NEW
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</NavLink>
|
||||||
|
{/*<NavLink style={getNavLinkStyle} to="/settings">Profil & Sécurité</NavLink>*/}
|
||||||
|
{/* === MODULES VERROUILLÉS (WORK IN PROGRESS) === */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '15px 25px', color: '#444', borderLeft: '4px solid transparent',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '1px', fontSize: '0.9rem',
|
||||||
|
cursor: 'not-allowed', userSelect: 'none'
|
||||||
|
}}>
|
||||||
|
<span>Facturation</span>
|
||||||
|
<span style={{ fontSize: '0.6rem', color: '#333', border: '1px solid #333', padding: '2px 5px', borderRadius: '3px', fontWeight: 'bold' }}>PROCHAINEMENT</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '15px 25px', color: '#444', borderLeft: '4px solid transparent',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '1px', fontSize: '0.9rem',
|
||||||
|
cursor: 'not-allowed', userSelect: 'none'
|
||||||
|
}}>
|
||||||
|
<span>Profil & Sécurité</span>
|
||||||
|
<span style={{ fontSize: '0.6rem', color: '#333', border: '1px solid #333', padding: '2px 5px', borderRadius: '3px', fontWeight: 'bold' }}>PROCHAINEMENT</span>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Bas de la Sidebar (Déconnexion) */}
|
{/* Bas de la Sidebar (Déconnexion) */}
|
||||||
@@ -179,7 +238,7 @@ export default function AppLayout() {
|
|||||||
{/* ========================================== */}
|
{/* ========================================== */}
|
||||||
<main style={mainContentStyle}>
|
<main style={mainContentStyle}>
|
||||||
{/* Le composant <Outlet /> injecte le contenu de la page demandée sans recharger la sidebar */}
|
{/* Le composant <Outlet /> injecte le contenu de la page demandée sans recharger la sidebar */}
|
||||||
<Outlet />
|
<Outlet context={{ refreshNavbarTickets: checkUnreadTickets }} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* MODAL DE DÉCONNEXION */}
|
{/* MODAL DE DÉCONNEXION */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { getClientTickets, createTicket, getHelpdesks, getTicketDetails, replyTicket } from '../../services/api'; // Ajout des routes de détails et réponses
|
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';
|
import { LifeBuoy, Plus, MessageSquare, Clock, CheckCircle2, Send, AlertCircle, Loader, Lock } from 'lucide-react';
|
||||||
|
|
||||||
@@ -27,6 +28,8 @@ const NotificationModal = ({ notification, onClose }) => {
|
|||||||
// COMPOSANT PRINCIPAL : SUPPORT TICKETS
|
// COMPOSANT PRINCIPAL : SUPPORT TICKETS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
export default function Support() {
|
export default function Support() {
|
||||||
|
const { refreshNavbarTickets } = useOutletContext();
|
||||||
|
|
||||||
const [tickets, setTickets] = useState([]);
|
const [tickets, setTickets] = useState([]);
|
||||||
const [helpdesks, setHelpdesks] = useState([]);
|
const [helpdesks, setHelpdesks] = useState([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -45,6 +48,16 @@ export default function Support() {
|
|||||||
const [isCreatingTicket, setIsCreatingTicket] = useState(false);
|
const [isCreatingTicket, setIsCreatingTicket] = useState(false);
|
||||||
const [activeTicket, setActiveTicket] = useState(null);
|
const [activeTicket, setActiveTicket] = useState(null);
|
||||||
|
|
||||||
|
const messagesEndRef = useRef(null);
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTicket && activeTicket.messages && !isLoadingConversation) {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}, [activeTicket?.messages, isLoadingConversation]);
|
||||||
|
|
||||||
const triggerAlert = (title, message, type = "info") => {
|
const triggerAlert = (title, message, type = "info") => {
|
||||||
setCustomAlert({ title, message, type });
|
setCustomAlert({ title, message, type });
|
||||||
};
|
};
|
||||||
@@ -60,7 +73,7 @@ export default function Support() {
|
|||||||
db_id: tkt.id,
|
db_id: tkt.id,
|
||||||
subject: tkt.subject,
|
subject: tkt.subject,
|
||||||
department: tkt.helpdesk?.name || 'Support Technique',
|
department: tkt.helpdesk?.name || 'Support Technique',
|
||||||
status: (tkt.status === 'open' || tkt.status === 'closed') ? tkt.status : 'pending',
|
status: ['open', 'closed', 'on_hold'].includes(tkt.status) ? tkt.status : 'pending',
|
||||||
lastUpdate: tkt.updated_at || tkt.created_at || 'Récemment',
|
lastUpdate: tkt.updated_at || tkt.created_at || 'Récemment',
|
||||||
messages: []
|
messages: []
|
||||||
})));
|
})));
|
||||||
@@ -122,6 +135,7 @@ export default function Support() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await replyTicket(activeTicket.db_id, replyMessage);
|
await replyTicket(activeTicket.db_id, replyMessage);
|
||||||
|
loadSupportData();
|
||||||
setReplyMessage(''); // Nettoyer l'input
|
setReplyMessage(''); // Nettoyer l'input
|
||||||
|
|
||||||
// Rechargement instantané du fil de discussion pour afficher le message soumis
|
// Rechargement instantané du fil de discussion pour afficher le message soumis
|
||||||
@@ -134,6 +148,7 @@ export default function Support() {
|
|||||||
}));
|
}));
|
||||||
setActiveTicket(prev => prev ? { ...prev, messages: formattedMessages } : null);
|
setActiveTicket(prev => prev ? { ...prev, messages: formattedMessages } : null);
|
||||||
}
|
}
|
||||||
|
if (refreshNavbarTickets) refreshNavbarTickets();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
triggerAlert("Échec d'envoi", "Votre réponse n'a pas pu être transmise : " + err.message, "error");
|
triggerAlert("Échec d'envoi", "Votre réponse n'a pas pu être transmise : " + err.message, "error");
|
||||||
}
|
}
|
||||||
@@ -151,6 +166,7 @@ export default function Support() {
|
|||||||
setSubject('');
|
setSubject('');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
loadSupportData();
|
loadSupportData();
|
||||||
|
if (refreshNavbarTickets) refreshNavbarTickets();
|
||||||
triggerAlert("Ticket Ouvert", "Votre demande a bien été enregistrée sur le Service Desk.", "success");
|
triggerAlert("Ticket Ouvert", "Votre demande a bien été enregistrée sur le Service Desk.", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
triggerAlert("Échec", "Erreur lors de la création du ticket : " + err.message, "error");
|
triggerAlert("Échec", "Erreur lors de la création du ticket : " + err.message, "error");
|
||||||
@@ -162,6 +178,7 @@ export default function Support() {
|
|||||||
const getStatusConfig = (status) => {
|
const getStatusConfig = (status) => {
|
||||||
switch (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 '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 'on_hold': return { color: 'text-yellow-400', bg: 'bg-yellow-500/10', border: 'border-yellow-500/20', label: 'RÉPONSE REÇUE', icon: <AlertCircle 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 '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" /> };
|
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() };
|
default: return { color: 'text-gray-400', bg: 'bg-gray-800', border: 'border-gray-700', label: status.toUpperCase() };
|
||||||
@@ -224,7 +241,17 @@ export default function Support() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-start mb-3">
|
<div className="flex justify-between items-start mb-3">
|
||||||
<span className="text-gray-500 font-mono text-xs">{ticket.id}</span>
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-gray-500 font-mono text-xs">{ticket.id}</span>
|
||||||
|
|
||||||
|
{/* LE RADAR : Pastille clignotante en cas de réponse */}
|
||||||
|
{ticket.status === 'on_hold' && (
|
||||||
|
<span className="flex h-2.5 w-2.5 relative" title="Nouveau message en attente de lecture">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-cyan-500 shadow-[0_0_8px_#00E5FF]"></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<span className={`flex items-center px-2 py-1 rounded text-[10px] font-bold border tracking-wider ${conf.color} ${conf.bg} ${conf.border}`}>
|
<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}
|
{conf.icon} {conf.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -327,11 +354,12 @@ export default function Support() {
|
|||||||
<span className="text-[10px] text-gray-600 font-mono">{msg.date}</span>
|
<span className="text-[10px] text-gray-600 font-mono">{msg.date}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`p-4 rounded-xl max-w-[85%] text-sm leading-relaxed ${msg.sender === 'client'
|
<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-gray-800 text-gray-200 rounded-tr-none'
|
||||||
: 'bg-cyan-900/20 border border-cyan-800/30 text-cyan-50 rounded-tl-none'
|
: 'bg-cyan-900/20 border border-cyan-800/30 text-cyan-50 rounded-tl-none'
|
||||||
}`}>
|
}`}>
|
||||||
{msg.text}
|
{msg.text}
|
||||||
</div>
|
</div>
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user