import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useToast } from "@/hooks/use-toast"; import { apiRequest } from "@/lib/queryClient"; import { useStore } from "@/contexts/StoreContext"; import { Plus, Search, Filter, Wrench, Calendar, ClipboardList, Edit, Trash2, Building, User, MessageCircle, AlertTriangle, CheckCircle, Clock, Ban, Settings } from "lucide-react"; import { safeFormat } from "@/lib/dateUtils"; import type { SavTicketWithRelations, Supplier, Group, InsertSavTicket } from "@shared/schema"; const statusConfig = { nouveau: { label: "Nouveau", color: "bg-blue-100 text-blue-800", icon: AlertTriangle }, en_cours: { label: "En cours", color: "bg-yellow-100 text-yellow-800", icon: Clock }, attente_pieces: { label: "Attente pièces", color: "bg-orange-100 text-orange-800", icon: Clock }, attente_echange: { label: "Attente échange", color: "bg-purple-100 text-purple-800", icon: Clock }, resolu: { label: "Résolu", color: "bg-green-100 text-green-800", icon: CheckCircle }, ferme: { label: "Fermé", color: "bg-gray-100 text-gray-800", icon: Ban } }; const priorityConfig = { faible: { label: "Faible", color: "bg-gray-100 text-gray-800" }, normale: { label: "Normale", color: "bg-blue-100 text-blue-800" }, haute: { label: "Haute", color: "bg-orange-100 text-orange-800" }, critique: { label: "Critique", color: "bg-red-100 text-red-800" } }; export default function SavTickets() { const { user } = useAuthUnified(); const { toast } = useToast(); const queryClient = useQueryClient(); const { selectedStoreId, storeInitialized } = useStore(); const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [priorityFilter, setPriorityFilter] = useState("all"); const [supplierFilter, setSupplierFilter] = useState("all"); const [showCreateModal, setShowCreateModal] = useState(false); const [showDetailModal, setShowDetailModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); const [selectedTicket, setSelectedTicket] = useState(null); const [newComment, setNewComment] = useState(""); const [tempPriority, setTempPriority] = useState(""); const [tempStatus, setTempStatus] = useState(""); const [formData, setFormData] = useState({ supplierId: "", groupId: "", productGencode: "", productReference: "", productDesignation: "", problemType: "defectueux", problemDescription: "", priority: "normale", clientName: "", clientPhone: "" }); // Build query parameters with store filtering const queryParams = new URLSearchParams(); if (statusFilter !== 'all') queryParams.append('status', statusFilter); if (priorityFilter !== 'all') queryParams.append('priority', priorityFilter); if (supplierFilter !== 'all') queryParams.append('supplierId', supplierFilter); // Add store filtering for admin users if (selectedStoreId && user?.role === 'admin') { queryParams.append('groupId', selectedStoreId.toString()); } const ticketsUrl = `/api/sav/tickets${queryParams.toString() ? '?' + queryParams.toString() : ''}`; const { data: ticketsData = [], isLoading } = useQuery({ queryKey: [ticketsUrl, selectedStoreId], enabled: !!user && (user.role !== 'admin' || storeInitialized), staleTime: 1000 * 60 * 5, // 5 minutes }); const { data: suppliersData = [] } = useQuery({ queryKey: ['/api/suppliers'], staleTime: 1000 * 60 * 10, // 10 minutes }); const { data: groupsData = [] } = useQuery({ queryKey: ['/api/groups'], staleTime: 1000 * 60 * 10, // 10 minutes }); const { data: statsData } = useQuery<{ totalTickets: number; newTickets: number; inProgressTickets: number; resolvedTickets: number; criticalTickets: number; }>({ queryKey: ['/api/sav/stats', selectedStoreId], queryFn: async () => { const params = new URLSearchParams(); if (selectedStoreId && user?.role === 'admin') { params.append('groupId', selectedStoreId.toString()); } const response = await fetch(`/api/sav/stats?${params.toString()}`, { credentials: 'include' }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }, enabled: !!user && (user.role !== 'admin' || storeInitialized), staleTime: 1000 * 60 * 2, // 2 minutes }); // Query pour récupérer les détails actuels du ticket sélectionné const { data: currentTicketDetails } = useQuery({ queryKey: [`/api/sav/tickets/${selectedTicket?.id}`], enabled: !!selectedTicket?.id && showDetailModal, staleTime: 0, // Toujours frais pour voir les changements en temps réel }); // Utiliser les détails actuels ou fallback sur selectedTicket const displayedTicket = currentTicketDetails || selectedTicket; // Create ticket mutation const createTicketMutation = useMutation({ mutationFn: async (data: InsertSavTicket) => { const response = await fetch('/api/sav/tickets', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || 'Erreur lors de la création du ticket'); } return response.json(); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['/api/sav/tickets'] }); queryClient.invalidateQueries({ queryKey: ['/api/sav/stats'] }); setShowCreateModal(false); setFormData({ supplierId: "", groupId: "", productGencode: "", productReference: "", productDesignation: "", problemType: "defectueux", problemDescription: "", priority: "normale", clientName: "", clientPhone: "" }); toast({ title: "Ticket créé", description: "Le ticket SAV a été créé avec succès.", }); }, onError: (error: any) => { console.error('Error creating ticket:', error); toast({ title: "Erreur", description: error?.message || "Impossible de créer le ticket.", variant: "destructive", }); }, }); // Add comment mutation const addCommentMutation = useMutation({ mutationFn: async (data: { ticketId: number; comment: string }) => { const response = await apiRequest(`/api/sav/tickets/${data.ticketId}/history`, "POST", { description: data.comment // ✅ Utilise 'description' selon le schéma API }); return response; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['/api/sav/tickets'] }); // ✅ Invalider aussi les détails du ticket pour rafraîchir l'historique if (selectedTicket) { queryClient.invalidateQueries({ queryKey: [`/api/sav/tickets/${selectedTicket.id}`] }); } setNewComment(""); toast({ title: "Commentaire ajouté", description: "Le commentaire a été ajouté avec succès.", }); }, onError: (error: any) => { toast({ title: "Erreur", description: error?.message || "Impossible d'ajouter le commentaire.", variant: "destructive", }); }, }); // Update ticket status/priority mutation const updateTicketMutation = useMutation({ mutationFn: async (data: { ticketId: number; status?: string; priority?: string; resolutionDescription?: string }) => { const response = await apiRequest(`/api/sav/tickets/${data.ticketId}`, "PATCH", { status: data.status, priority: data.priority, resolutionDescription: data.resolutionDescription, }); return response; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['/api/sav/tickets'] }); queryClient.invalidateQueries({ queryKey: ['/api/sav/stats'] }); if (selectedTicket) { queryClient.invalidateQueries({ queryKey: [`/api/sav/tickets/${selectedTicket.id}`] }); } setTempStatus(""); setTempPriority(""); toast({ title: "Ticket mis à jour", description: "Le statut du ticket a été modifié avec succès.", }); }, onError: (error: any) => { toast({ title: "Erreur", description: error?.message || "Impossible de modifier le ticket.", variant: "destructive", }); }, }); // Delete ticket mutation const deleteTicketMutation = useMutation({ mutationFn: async (ticketId: number) => { const response = await apiRequest(`/api/sav/tickets/${ticketId}`, "DELETE"); return response; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['/api/sav/tickets'] }); queryClient.invalidateQueries({ queryKey: ['/api/sav/stats'] }); setShowDeleteModal(false); toast({ title: "Ticket supprimé", description: "Le ticket a été supprimé avec succès.", }); }, onError: (error: any) => { toast({ title: "Erreur", description: error?.message || "Impossible de supprimer le ticket.", variant: "destructive", }); }, }); // Check permissions AFTER all hooks are called const canView = ['admin', 'directeur', 'manager', 'employee'].includes(user?.role || ''); const canCreate = ['admin', 'directeur', 'manager'].includes(user?.role || ''); // Employee can only view const canModify = ['admin', 'directeur', 'manager'].includes(user?.role || ''); const canDelete = ['admin', 'directeur'].includes(user?.role || ''); // Get user's available groups for auto-assignment const getUserGroups = () => { if (!user || user.role === 'admin') { console.log('🎫 [CLIENT] Admin user - can access all groups:', groupsData?.length || 0); return groupsData; // Admin can access all groups } // For other users, get their assigned groups const userGroups = (user as any).userGroups || []; const filteredGroups = groupsData.filter(group => userGroups.some((ug: any) => ug.groupId === group.id) ); console.log('🎫 [CLIENT] Non-admin user groups:', { userRole: user.role, userUsername: user.username, allUserGroups: userGroups, filteredGroups: filteredGroups.map(g => ({ id: g.id, name: g.name })) }); return filteredGroups; }; const availableGroups = getUserGroups(); // Handle form submission const handleCreateTicket = () => { if (!formData.supplierId || !formData.productGencode || !formData.productDesignation || !formData.problemDescription) { toast({ title: "Erreur de validation", description: "Veuillez remplir tous les champs obligatoires.", variant: "destructive", }); return; } // Auto-assign group based on user role and available groups let selectedGroupId = formData.groupId ? parseInt(formData.groupId) : null; console.log('🎫 [CLIENT] User groups analysis:', { userRole: user?.role, formDataGroupId: formData.groupId, availableGroups: availableGroups.map(g => ({ id: g.id, name: g.name })), selectedGroupId }); if (!selectedGroupId && availableGroups.length > 0) { // Auto-assign first available group selectedGroupId = availableGroups[0].id; console.log('🎫 [CLIENT] Auto-assigned group:', selectedGroupId, availableGroups[0].name); } const ticketData: InsertSavTicket = { supplierId: parseInt(formData.supplierId), groupId: selectedGroupId || 1, // Default group if none available productGencode: formData.productGencode, productReference: formData.productReference || undefined, productDesignation: formData.productDesignation, problemType: formData.problemType as "defectueux" | "pieces_manquantes" | "non_conforme" | "autre", problemDescription: formData.problemDescription, priority: formData.priority as "faible" | "normale" | "haute" | "critique", status: "nouveau" as const, createdBy: "1", // Will be set by the backend from session clientName: formData.clientName || undefined, clientPhone: formData.clientPhone || undefined, }; console.log('🎫 [CLIENT] Creating ticket with data:', { ...ticketData, userInfo: { role: user?.role, username: user?.username, availableGroups: availableGroups.length } }); createTicketMutation.mutate(ticketData); }; // Handle viewing ticket details const handleViewTicket = (ticket: SavTicketWithRelations) => { setSelectedTicket(ticket); setShowDetailModal(true); }; // Query pour récupérer les détails complets du ticket avec historique const { data: ticketDetails } = useQuery({ queryKey: [`/api/sav/tickets/${selectedTicket?.id}`], enabled: !!selectedTicket && showDetailModal, staleTime: 0, // Toujours récupérer les derniers commentaires }); // Function to check if ticket has recent comments (last 24h) const hasRecentComments = (ticket: SavTicketWithRelations) => { if (!ticket.history || ticket.history.length === 0) return false; const oneDayAgo = new Date(); oneDayAgo.setDate(oneDayAgo.getDate() - 1); return ticket.history.some(entry => entry.action === 'comment' && entry.createdAt && new Date(entry.createdAt) > oneDayAgo ); }; // Handle editing ticket const handleEditTicket = (ticket: SavTicketWithRelations) => { setSelectedTicket(ticket); // Pre-fill form with ticket data setFormData({ supplierId: ticket.supplierId.toString(), groupId: ticket.groupId.toString(), productGencode: ticket.productGencode, productReference: ticket.productReference || "", productDesignation: ticket.productDesignation, problemType: ticket.problemType, problemDescription: ticket.problemDescription, priority: ticket.priority, clientName: ticket.clientName || "", clientPhone: ticket.clientPhone || "" }); setShowEditModal(true); }; // Handle deleting ticket const handleDeleteTicket = (ticket: SavTicketWithRelations) => { setSelectedTicket(ticket); setShowDeleteModal(true); }; // Handle permission check without early return if (!canView) { return (

Accès restreint
Vous n'avez pas les permissions nécessaires pour accéder au module SAV.

); } // Filter tickets by search term const filteredTickets = ticketsData.filter(ticket => { if (!searchTerm) return true; const searchLower = searchTerm.toLowerCase(); return ( ticket.ticketNumber.toLowerCase().includes(searchLower) || ticket.clientName?.toLowerCase().includes(searchLower) || ticket.problemDescription?.toLowerCase().includes(searchLower) || ticket.productDesignation?.toLowerCase().includes(searchLower) || ticket.supplier?.name.toLowerCase().includes(searchLower) ); }); return (
{/* Header */}

Service Après-Vente

Gestion des tickets SAV et suivi des réparations

{canCreate && ( Créer un nouveau ticket SAV Remplissez les informations pour créer un nouveau ticket de service après-vente.
{/* Section Informations Produit */} Informations Produit Détails du produit concerné par le SAV
setFormData({...formData, productGencode: e.target.value})} placeholder="Code-barres du produit" />
setFormData({...formData, productReference: e.target.value})} placeholder="Référence du produit" />
setFormData({...formData, productDesignation: e.target.value})} placeholder="Nom/désignation du produit" />
{availableGroups.length > 1 && (
)}
{availableGroups.length === 1 && (
{availableGroups[0].name}
)} {availableGroups.length === 0 && (
Aucun magasin assigné. Contactez un administrateur.
)}
{/* Section Informations Client */} Informations Client Informations de contact du client (optionnel)
setFormData({...formData, clientName: e.target.value})} placeholder="Nom du client" />
setFormData({...formData, clientPhone: e.target.value})} placeholder="Numéro de téléphone" />
{/* Section Problème et Priorité */} Problème et Priorité Description du problème rencontré