Add new service for managing customer support tickets

Introduce the Service Après-Vente (SAV) module with API endpoints for ticket creation, retrieval, and filtering, integrated with the sidebar navigation and utilizing React Query for frontend state management.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 31f9bd88-0285-4787-81b3-31ceeea5a08b
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1957c339-2757-4d1f-8e92-e9f71a1ce58e/31f9bd88-0285-4787-81b3-31ceeea5a08b/Dkpn0ry
This commit is contained in:
michaelschal committed 2025-08-14 08:54:02 +00:00
1 parent da4a46156e
commit d678b3fe7a
6 files changed
+1402 -2

No files matched your search

@@ -21,6 +21,7 @@ import BackupManager from "@/pages/BackupManager";
import Utilities from "@/pages/Utilities";
import Tasks from "@/pages/Tasks";
import TasksSimplified from "@/pages/TasksSimplified";
import SavTickets from "@/pages/SavTickets";
import Layout from "@/components/Layout";
function RouterProduction() {
@@ -94,6 +95,7 @@ function RouterProduction() {
<Route path="/dlc" component={DlcPage} />
<Route path="/utilities" component={Utilities} />
<Route path="/tasks" component={Tasks} />
<Route path="/sav" component={SavTickets} />
{/* Routes de compatibilité - redirection vers utilities */}
<Route path="/backup" component={Utilities} />
+8 -1
View File
@@ -22,7 +22,8 @@ import {
ChevronLeft,
ChevronRight,
X,
Settings
Settings,
Wrench
} from "lucide-react";
export default function Sidebar() {
@@ -123,6 +124,12 @@ export default function Sidebar() {
icon: ListTodo,
roles: ["admin", "directeur", "manager", "employee"]
},
{
path: "/sav",
label: "SAV",
icon: Wrench,
roles: ["admin", "directeur", "manager", "employee"]
},
];
const adminItems = [
+415
View File
@@ -0,0 +1,415 @@
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 { useAuthUnified } from "@/hooks/useAuthUnified";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { usePermissions } from "@shared/permissions";
import {
Plus,
Search,
Filter,
Wrench,
Calendar,
Eye,
Edit,
Trash2,
Building,
User,
MessageCircle,
AlertTriangle,
CheckCircle,
Clock,
Ban
} from "lucide-react";
import { safeFormat } from "@/lib/dateUtils";
import type { SavTicketWithRelations, Supplier } 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 permissions = usePermissions(user?.role);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [priorityFilter, setPriorityFilter] = useState("all");
const [supplierFilter, setSupplierFilter] = useState("all");
// Redirection pour les employés sans permission
const canView = ['admin', 'directeur', 'manager', 'employee'].includes(user?.role || '');
const canModify = ['admin', 'directeur', 'manager'].includes(user?.role || '');
const canDelete = ['admin', 'directeur'].includes(user?.role || '');
if (!canView) {
return (
<div className="p-4 sm:p-6">
<div className="bg-orange-50 border-l-4 border-orange-400 p-4 rounded-r-lg">
<div className="flex">
<div className="flex-shrink-0">
<Wrench className="h-5 w-5 text-orange-400" />
</div>
<div className="ml-3">
<p className="text-sm text-orange-700">
<strong>Accès restreint</strong><br />
Vous n'avez pas les permissions nécessaires pour accéder au module SAV.
</p>
</div>
</div>
</div>
</div>
);
}
// Build query parameters
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);
const ticketsUrl = `/api/sav/tickets${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
const { data: ticketsData = [], isLoading } = useQuery<SavTicketWithRelations[]>({
queryKey: [ticketsUrl],
staleTime: 1000 * 60 * 5, // 5 minutes
});
const { data: suppliersData = [] } = useQuery<Supplier[]>({
queryKey: ['/api/suppliers'],
staleTime: 1000 * 60 * 10, // 10 minutes
});
const { data: statsData } = useQuery<{
totalTickets: number;
newTickets: number;
inProgressTickets: number;
resolvedTickets: number;
criticalTickets: number;
}>({
queryKey: ['/api/sav/stats'],
staleTime: 1000 * 60 * 2, // 2 minutes
});
// 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 (
<div className="p-4 sm:p-6 space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Service Après-Vente</h1>
<p className="text-gray-600 mt-1">Gestion des tickets SAV et suivi des réparations</p>
</div>
{canModify && (
<Button className="w-full sm:w-auto">
<Plus className="h-4 w-4 mr-2" />
Nouveau Ticket
</Button>
)}
</div>
{/* Stats Cards */}
{statsData && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total</CardTitle>
<Wrench className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{statsData.totalTickets}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Nouveaux</CardTitle>
<AlertTriangle className="h-4 w-4 text-blue-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{statsData.newTickets}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">En cours</CardTitle>
<Clock className="h-4 w-4 text-yellow-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">{statsData.inProgressTickets}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Résolus</CardTitle>
<CheckCircle className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{statsData.resolvedTickets}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Critiques</CardTitle>
<AlertTriangle className="h-4 w-4 text-red-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{statsData.criticalTickets}</div>
</CardContent>
</Card>
</div>
)}
{/* Filters */}
<Card>
<CardContent className="p-4 sm:p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
type="text"
placeholder="Rechercher..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="Statut" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les statuts</SelectItem>
{Object.entries(statusConfig).map(([key, config]) => (
<SelectItem key={key} value={key}>{config.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger>
<SelectValue placeholder="Priorité" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes priorités</SelectItem>
{Object.entries(priorityConfig).map(([key, config]) => (
<SelectItem key={key} value={key}>{config.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={supplierFilter} onValueChange={setSupplierFilter}>
<SelectTrigger>
<SelectValue placeholder="Fournisseur" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous fournisseurs</SelectItem>
{suppliersData.map((supplier) => (
<SelectItem key={supplier.id} value={supplier.id.toString()}>
{supplier.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => {
setSearchTerm("");
setStatusFilter("all");
setPriorityFilter("all");
setSupplierFilter("all");
}}
>
<Filter className="h-4 w-4 mr-2" />
Réinitialiser
</Button>
</div>
</CardContent>
</Card>
{/* Tickets List */}
<Card>
<CardHeader>
<CardTitle>Tickets SAV ({filteredTickets.length})</CardTitle>
<CardDescription>
Liste des tickets de service après-vente
</CardDescription>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-gray-500">Chargement des tickets...</p>
</div>
) : filteredTickets.length === 0 ? (
<div className="p-8 text-center">
<Wrench className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">Aucun ticket trouvé</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Ticket
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Client
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Produit
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Problème
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Priorité
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Fournisseur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredTickets.map((ticket) => {
const StatusIcon = statusConfig[ticket.status as keyof typeof statusConfig]?.icon || Clock;
return (
<tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Wrench className="h-4 w-4 text-gray-400 mr-2" />
<span className="text-sm font-medium text-gray-900">
{ticket.ticketNumber}
</span>
</div>
</td>
<td className="px-6 py-4">
<div>
<div className="text-sm font-medium text-gray-900">
{ticket.clientName || 'N/A'}
</div>
{ticket.clientPhone && (
<div className="text-sm text-gray-500">
{ticket.clientPhone}
</div>
)}
</div>
</td>
<td className="px-6 py-4">
<div>
{ticket.productDesignation && (
<div className="text-sm font-medium text-gray-900">
{ticket.productDesignation}
</div>
)}
{ticket.productGencode && (
<div className="text-sm text-gray-500">
{ticket.productGencode}
</div>
)}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900 max-w-xs truncate">
{ticket.problemDescription}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<StatusIcon className="h-4 w-4 mr-2" />
<Badge className={statusConfig[ticket.status as keyof typeof statusConfig]?.color}>
{statusConfig[ticket.status as keyof typeof statusConfig]?.label}
</Badge>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<Badge className={priorityConfig[ticket.priority as keyof typeof priorityConfig]?.color}>
{priorityConfig[ticket.priority as keyof typeof priorityConfig]?.label}
</Badge>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Building className="h-4 w-4 text-gray-400 mr-2" />
<span className="text-sm text-gray-900">
{ticket.supplier?.name}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center">
<Calendar className="h-4 w-4 mr-1" />
{safeFormat(ticket.createdAt, 'dd/MM/yyyy')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
{canModify && (
<>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm">
<MessageCircle className="h-4 w-4" />
</Button>
</>
)}
{canDelete && (
<Button variant="ghost" size="sm" className="text-red-600 hover:text-red-900">
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}