mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-17 04:06:44 +02:00
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:
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} />
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user