mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-12 01:37:40 +02:00
Merge pull request #113 from R0m1k3/1.1
Add new service for managing customer support tickets
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
+278
-1
@@ -34,7 +34,9 @@ import {
|
||||
insertDlcProductSchema,
|
||||
insertDlcProductFrontendSchema,
|
||||
insertTaskSchema,
|
||||
insertNocodbConfigSchema
|
||||
insertNocodbConfigSchema,
|
||||
insertSavTicketSchema,
|
||||
insertSavTicketHistorySchema
|
||||
} from "@shared/schema";
|
||||
import { hasPermission } from "@shared/permissions";
|
||||
import { z } from "zod";
|
||||
@@ -2406,6 +2408,281 @@ RÉSUMÉ DU SCAN
|
||||
}
|
||||
});
|
||||
|
||||
// SAV (Service Après-Vente) routes
|
||||
app.get('/api/sav/tickets', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.claims ? req.user.claims.sub : req.user.id;
|
||||
const user = await storage.getUserWithGroups(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
// Get user's group IDs for filtering
|
||||
let groupIds: number[] = [];
|
||||
if (user.role === 'admin') {
|
||||
// Admin can see all tickets
|
||||
groupIds = [];
|
||||
} else {
|
||||
// Other roles see only their assigned groups
|
||||
const userGroups = (user as any).userGroups;
|
||||
groupIds = userGroups ? userGroups.map((ug: any) => ug.groupId) : [];
|
||||
}
|
||||
|
||||
// Parse query filters
|
||||
const filters = {
|
||||
groupIds: groupIds.length > 0 ? groupIds : undefined,
|
||||
status: req.query.status as string,
|
||||
supplierId: req.query.supplierId ? parseInt(req.query.supplierId) : undefined,
|
||||
priority: req.query.priority as string,
|
||||
startDate: req.query.startDate as string,
|
||||
endDate: req.query.endDate as string,
|
||||
};
|
||||
|
||||
const tickets = await storage.getSavTickets(filters);
|
||||
res.json(tickets);
|
||||
} catch (error) {
|
||||
console.error("Error fetching SAV tickets:", error);
|
||||
res.status(500).json({ message: "Failed to fetch tickets" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/sav/tickets/:id', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.claims ? req.user.claims.sub : req.user.id;
|
||||
const user = await storage.getUserWithGroups(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
const ticketId = parseInt(req.params.id);
|
||||
const ticket = await storage.getSavTicket(ticketId);
|
||||
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ message: "Ticket not found" });
|
||||
}
|
||||
|
||||
// Check if user can access this ticket
|
||||
if (user.role !== 'admin') {
|
||||
const userGroups = (user as any).userGroups;
|
||||
const userGroupIds = userGroups ? userGroups.map((ug: any) => ug.groupId) : [];
|
||||
if (!userGroupIds.includes(ticket.groupId)) {
|
||||
return res.status(403).json({ message: "Access denied to this ticket" });
|
||||
}
|
||||
}
|
||||
|
||||
res.json(ticket);
|
||||
} catch (error) {
|
||||
console.error("Error fetching SAV ticket:", error);
|
||||
res.status(500).json({ message: "Failed to fetch ticket" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/sav/tickets', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.claims ? req.user.claims.sub : req.user.id;
|
||||
const user = await storage.getUserWithGroups(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
// Check permissions: admin, manager, directeur can create
|
||||
if (!['admin', 'manager', 'directeur'].includes(user.role)) {
|
||||
return res.status(403).json({ message: "Insufficient permissions to create tickets" });
|
||||
}
|
||||
|
||||
// Get user's groups for automatic assignment
|
||||
let availableGroupIds: number[] = [];
|
||||
if (user.role === 'admin') {
|
||||
const allGroups = await storage.getGroups();
|
||||
availableGroupIds = allGroups.map(g => g.id);
|
||||
} else {
|
||||
const userGroups = (user as any).userGroups;
|
||||
availableGroupIds = userGroups ? userGroups.map((ug: any) => ug.groupId) : [];
|
||||
}
|
||||
|
||||
if (availableGroupIds.length === 0) {
|
||||
return res.status(400).json({ message: "No groups available for ticket creation" });
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const ticketData = insertSavTicketSchema.parse({
|
||||
...req.body,
|
||||
groupId: req.body.groupId || availableGroupIds[0], // Use first available group if not specified
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
// Validate that the specified group is accessible to the user
|
||||
if (!availableGroupIds.includes(ticketData.groupId)) {
|
||||
return res.status(403).json({ message: "Access denied to the specified group" });
|
||||
}
|
||||
|
||||
const ticket = await storage.createSavTicket(ticketData);
|
||||
res.status(201).json(ticket);
|
||||
} catch (error) {
|
||||
console.error("Error creating SAV ticket:", error);
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ message: "Validation error", errors: error.errors });
|
||||
}
|
||||
res.status(500).json({ message: "Failed to create ticket" });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/sav/tickets/:id', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.claims ? req.user.claims.sub : req.user.id;
|
||||
const user = await storage.getUserWithGroups(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
// Check permissions: admin, manager, directeur can modify
|
||||
if (!['admin', 'manager', 'directeur'].includes(user.role)) {
|
||||
return res.status(403).json({ message: "Insufficient permissions to modify tickets" });
|
||||
}
|
||||
|
||||
const ticketId = parseInt(req.params.id);
|
||||
const existingTicket = await storage.getSavTicket(ticketId);
|
||||
|
||||
if (!existingTicket) {
|
||||
return res.status(404).json({ message: "Ticket not found" });
|
||||
}
|
||||
|
||||
// Check if user can access this ticket
|
||||
if (user.role !== 'admin') {
|
||||
const userGroups = (user as any).userGroups;
|
||||
const userGroupIds = userGroups ? userGroups.map((ug: any) => ug.groupId) : [];
|
||||
if (!userGroupIds.includes(existingTicket.groupId)) {
|
||||
return res.status(403).json({ message: "Access denied to modify this ticket" });
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and validate partial update data
|
||||
const updateData = insertSavTicketSchema.partial().parse(req.body);
|
||||
const updatedTicket = await storage.updateSavTicket(ticketId, updateData);
|
||||
|
||||
// Add history entry for status change if status was modified
|
||||
if (req.body.status && req.body.status !== existingTicket.status) {
|
||||
await storage.addSavTicketHistory({
|
||||
ticketId: ticketId,
|
||||
action: 'status_change',
|
||||
description: `Statut changé de "${existingTicket.status}" vers "${req.body.status}"`,
|
||||
createdBy: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(updatedTicket);
|
||||
} catch (error) {
|
||||
console.error("Error updating SAV ticket:", error);
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ message: "Validation error", errors: error.errors });
|
||||
}
|
||||
res.status(500).json({ message: "Failed to update ticket" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/sav/tickets/:id', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.claims ? req.user.claims.sub : req.user.id;
|
||||
const user = await storage.getUser(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
// Check permissions: only admin and directeur can delete
|
||||
if (!['admin', 'directeur'].includes(user.role)) {
|
||||
return res.status(403).json({ message: "Insufficient permissions to delete tickets" });
|
||||
}
|
||||
|
||||
const ticketId = parseInt(req.params.id);
|
||||
const existingTicket = await storage.getSavTicket(ticketId);
|
||||
|
||||
if (!existingTicket) {
|
||||
return res.status(404).json({ message: "Ticket not found" });
|
||||
}
|
||||
|
||||
await storage.deleteSavTicket(ticketId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error("Error deleting SAV ticket:", error);
|
||||
res.status(500).json({ message: "Failed to delete ticket" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/sav/tickets/:id/history', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.claims ? req.user.claims.sub : req.user.id;
|
||||
const user = await storage.getUserWithGroups(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
// Check permissions: admin, manager, directeur can add comments
|
||||
if (!['admin', 'manager', 'directeur'].includes(user.role)) {
|
||||
return res.status(403).json({ message: "Insufficient permissions to add comments" });
|
||||
}
|
||||
|
||||
const ticketId = parseInt(req.params.id);
|
||||
const ticket = await storage.getSavTicket(ticketId);
|
||||
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ message: "Ticket not found" });
|
||||
}
|
||||
|
||||
// Check if user can access this ticket
|
||||
if (user.role !== 'admin') {
|
||||
const userGroups = (user as any).userGroups;
|
||||
const userGroupIds = userGroups ? userGroups.map((ug: any) => ug.groupId) : [];
|
||||
if (!userGroupIds.includes(ticket.groupId)) {
|
||||
return res.status(403).json({ message: "Access denied to add comments to this ticket" });
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and validate history entry
|
||||
const historyData = insertSavTicketHistorySchema.parse({
|
||||
ticketId: ticketId,
|
||||
action: 'comment',
|
||||
description: req.body.description,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
const history = await storage.addSavTicketHistory(historyData);
|
||||
res.status(201).json(history);
|
||||
} catch (error) {
|
||||
console.error("Error adding SAV ticket history:", error);
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ message: "Validation error", errors: error.errors });
|
||||
}
|
||||
res.status(500).json({ message: "Failed to add comment" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/sav/stats', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.claims ? req.user.claims.sub : req.user.id;
|
||||
const user = await storage.getUserWithGroups(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
// Get user's group IDs for filtering
|
||||
let groupIds: number[] = [];
|
||||
if (user.role === 'admin') {
|
||||
// Admin can see all stats
|
||||
groupIds = [];
|
||||
} else {
|
||||
// Other roles see only their assigned groups
|
||||
const userGroups = (user as any).userGroups;
|
||||
groupIds = userGroups ? userGroups.map((ug: any) => ug.groupId) : [];
|
||||
}
|
||||
|
||||
const stats = await storage.getSavTicketStats(groupIds.length > 0 ? groupIds : undefined);
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
console.error("Error fetching SAV stats:", error);
|
||||
res.status(500).json({ message: "Failed to fetch stats" });
|
||||
}
|
||||
});
|
||||
|
||||
// Create server instance
|
||||
const httpServer = createServer(app);
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
tasks,
|
||||
nocodbConfig,
|
||||
invoiceVerificationCache,
|
||||
savTickets,
|
||||
savTicketHistory,
|
||||
type User,
|
||||
type UpsertUser,
|
||||
type Group,
|
||||
@@ -47,6 +49,12 @@ import {
|
||||
type InsertNocodbConfig,
|
||||
type InvoiceVerificationCache,
|
||||
type InsertInvoiceVerificationCache,
|
||||
type SavTicket,
|
||||
type InsertSavTicket,
|
||||
type SavTicketHistory,
|
||||
type InsertSavTicketHistory,
|
||||
type SavTicketWithRelations,
|
||||
type SavTicketHistoryWithCreator,
|
||||
} from "@shared/schema";
|
||||
import { db } from "./db";
|
||||
import { eq, and, inArray, desc, sql, gte, lte, lt, or, isNull, isNotNull, asc } from "drizzle-orm";
|
||||
@@ -156,6 +164,29 @@ export interface IStorage {
|
||||
updateTask(id: number, task: Partial<InsertTask>): Promise<Task>;
|
||||
deleteTask(id: number): Promise<void>;
|
||||
completeTask(id: number, completedBy?: string): Promise<void>;
|
||||
|
||||
// SAV operations
|
||||
getSavTickets(filters?: {
|
||||
groupIds?: number[];
|
||||
status?: string;
|
||||
supplierId?: number;
|
||||
priority?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<SavTicketWithRelations[]>;
|
||||
getSavTicket(id: number): Promise<SavTicketWithRelations | undefined>;
|
||||
createSavTicket(ticket: InsertSavTicket): Promise<SavTicket>;
|
||||
updateSavTicket(id: number, ticket: Partial<InsertSavTicket>): Promise<SavTicket>;
|
||||
deleteSavTicket(id: number): Promise<void>;
|
||||
getSavTicketHistory(ticketId: number): Promise<SavTicketHistoryWithCreator[]>;
|
||||
addSavTicketHistory(history: InsertSavTicketHistory): Promise<SavTicketHistory>;
|
||||
getSavTicketStats(groupIds?: number[]): Promise<{
|
||||
totalTickets: number;
|
||||
newTickets: number;
|
||||
inProgressTickets: number;
|
||||
resolvedTickets: number;
|
||||
criticalTickets: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class DatabaseStorage implements IStorage {
|
||||
@@ -1487,6 +1518,211 @@ export class DatabaseStorage implements IStorage {
|
||||
})
|
||||
.where(eq(tasks.id, id));
|
||||
}
|
||||
|
||||
// SAV operations
|
||||
async getSavTickets(filters?: {
|
||||
groupIds?: number[];
|
||||
status?: string;
|
||||
supplierId?: number;
|
||||
priority?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<SavTicketWithRelations[]> {
|
||||
let query = db
|
||||
.select({
|
||||
ticket: savTickets,
|
||||
supplier: suppliers,
|
||||
group: groups,
|
||||
creator: users,
|
||||
})
|
||||
.from(savTickets)
|
||||
.leftJoin(suppliers, eq(savTickets.supplierId, suppliers.id))
|
||||
.leftJoin(groups, eq(savTickets.groupId, groups.id))
|
||||
.leftJoin(users, eq(savTickets.createdBy, users.id));
|
||||
|
||||
const conditions = [];
|
||||
|
||||
if (filters?.groupIds?.length) {
|
||||
conditions.push(inArray(savTickets.groupId, filters.groupIds));
|
||||
}
|
||||
if (filters?.status) {
|
||||
conditions.push(eq(savTickets.status, filters.status));
|
||||
}
|
||||
if (filters?.supplierId) {
|
||||
conditions.push(eq(savTickets.supplierId, filters.supplierId));
|
||||
}
|
||||
if (filters?.priority) {
|
||||
conditions.push(eq(savTickets.priority, filters.priority));
|
||||
}
|
||||
if (filters?.startDate) {
|
||||
conditions.push(gte(savTickets.createdAt, new Date(filters.startDate)));
|
||||
}
|
||||
if (filters?.endDate) {
|
||||
conditions.push(lte(savTickets.createdAt, new Date(filters.endDate)));
|
||||
}
|
||||
|
||||
if (conditions.length) {
|
||||
query = query.where(and(...conditions));
|
||||
}
|
||||
|
||||
const results = await query.orderBy(desc(savTickets.createdAt));
|
||||
|
||||
// Get history for each ticket
|
||||
const ticketsWithHistory = await Promise.all(
|
||||
results.map(async (result) => {
|
||||
const history = await this.getSavTicketHistory(result.ticket.id);
|
||||
return {
|
||||
...result.ticket,
|
||||
supplier: result.supplier!,
|
||||
group: result.group!,
|
||||
creator: result.creator!,
|
||||
history,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return ticketsWithHistory;
|
||||
}
|
||||
|
||||
async getSavTicket(id: number): Promise<SavTicketWithRelations | undefined> {
|
||||
const [result] = await db
|
||||
.select({
|
||||
ticket: savTickets,
|
||||
supplier: suppliers,
|
||||
group: groups,
|
||||
creator: users,
|
||||
})
|
||||
.from(savTickets)
|
||||
.leftJoin(suppliers, eq(savTickets.supplierId, suppliers.id))
|
||||
.leftJoin(groups, eq(savTickets.groupId, groups.id))
|
||||
.leftJoin(users, eq(savTickets.createdBy, users.id))
|
||||
.where(eq(savTickets.id, id));
|
||||
|
||||
if (!result) return undefined;
|
||||
|
||||
const history = await this.getSavTicketHistory(id);
|
||||
|
||||
return {
|
||||
...result.ticket,
|
||||
supplier: result.supplier!,
|
||||
group: result.group!,
|
||||
creator: result.creator!,
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
async createSavTicket(ticketData: InsertSavTicket): Promise<SavTicket> {
|
||||
// Generate ticket number
|
||||
const currentYear = new Date().getFullYear();
|
||||
const count = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(savTickets)
|
||||
.where(sql`EXTRACT(year from created_at) = ${currentYear}`);
|
||||
|
||||
const ticketNumber = `SAV-${currentYear}-${String((count[0]?.count || 0) + 1).padStart(4, '0')}`;
|
||||
|
||||
const [ticket] = await db
|
||||
.insert(savTickets)
|
||||
.values({ ...ticketData, ticketNumber })
|
||||
.returning();
|
||||
|
||||
// Add initial history entry
|
||||
await this.addSavTicketHistory({
|
||||
ticketId: ticket.id,
|
||||
action: 'ticket_created',
|
||||
description: `Ticket créé avec le statut "${ticket.status}"`,
|
||||
createdBy: ticket.createdBy,
|
||||
});
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
async updateSavTicket(id: number, ticketData: Partial<InsertSavTicket>): Promise<SavTicket> {
|
||||
const [ticket] = await db
|
||||
.update(savTickets)
|
||||
.set({ ...ticketData, updatedAt: new Date() })
|
||||
.where(eq(savTickets.id, id))
|
||||
.returning();
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
async deleteSavTicket(id: number): Promise<void> {
|
||||
// Delete history first
|
||||
await db.delete(savTicketHistory).where(eq(savTicketHistory.ticketId, id));
|
||||
// Delete ticket
|
||||
await db.delete(savTickets).where(eq(savTickets.id, id));
|
||||
}
|
||||
|
||||
async getSavTicketHistory(ticketId: number): Promise<SavTicketHistoryWithCreator[]> {
|
||||
const results = await db
|
||||
.select({
|
||||
history: savTicketHistory,
|
||||
creator: users,
|
||||
})
|
||||
.from(savTicketHistory)
|
||||
.leftJoin(users, eq(savTicketHistory.createdBy, users.id))
|
||||
.where(eq(savTicketHistory.ticketId, ticketId))
|
||||
.orderBy(desc(savTicketHistory.createdAt));
|
||||
|
||||
return results.map(result => ({
|
||||
...result.history,
|
||||
creator: result.creator!,
|
||||
}));
|
||||
}
|
||||
|
||||
async addSavTicketHistory(historyData: InsertSavTicketHistory): Promise<SavTicketHistory> {
|
||||
const [history] = await db
|
||||
.insert(savTicketHistory)
|
||||
.values(historyData)
|
||||
.returning();
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
async getSavTicketStats(groupIds?: number[]): Promise<{
|
||||
totalTickets: number;
|
||||
newTickets: number;
|
||||
inProgressTickets: number;
|
||||
resolvedTickets: number;
|
||||
criticalTickets: number;
|
||||
}> {
|
||||
let query = db.select({ count: sql<number>`count(*)`, status: savTickets.status, priority: savTickets.priority })
|
||||
.from(savTickets);
|
||||
|
||||
if (groupIds?.length) {
|
||||
query = query.where(inArray(savTickets.groupId, groupIds));
|
||||
}
|
||||
|
||||
const results = await query.groupBy(savTickets.status, savTickets.priority);
|
||||
|
||||
const stats = {
|
||||
totalTickets: 0,
|
||||
newTickets: 0,
|
||||
inProgressTickets: 0,
|
||||
resolvedTickets: 0,
|
||||
criticalTickets: 0,
|
||||
};
|
||||
|
||||
results.forEach(result => {
|
||||
const count = result.count || 0;
|
||||
stats.totalTickets += count;
|
||||
|
||||
if (result.status === 'nouveau') {
|
||||
stats.newTickets += count;
|
||||
} else if (['en_cours', 'attente_pieces', 'attente_echange'].includes(result.status)) {
|
||||
stats.inProgressTickets += count;
|
||||
} else if (['resolu', 'ferme'].includes(result.status)) {
|
||||
stats.resolvedTickets += count;
|
||||
}
|
||||
|
||||
if (result.priority === 'critique') {
|
||||
stats.criticalTickets += count;
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
// MemStorage class for development
|
||||
@@ -1502,6 +1738,8 @@ export class MemStorage implements IStorage {
|
||||
private customerOrders = new Map<number, CustomerOrder>();
|
||||
private dlcProducts = new Map<number, DlcProduct>();
|
||||
private tasks = new Map<number, Task>();
|
||||
private savTickets = new Map<number, SavTicket>();
|
||||
private savTicketHistories = new Map<number, SavTicketHistory[]>();
|
||||
|
||||
private idCounters = {
|
||||
group: 1,
|
||||
@@ -1512,6 +1750,8 @@ export class MemStorage implements IStorage {
|
||||
customerOrder: 1,
|
||||
dlcProduct: 1,
|
||||
task: 1,
|
||||
savTicket: 1,
|
||||
savTicketHistory: 1,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@@ -2444,6 +2684,9 @@ export class MemStorage implements IStorage {
|
||||
this.tasks.set(1, testTask1);
|
||||
this.tasks.set(2, testTask2);
|
||||
this.idCounters.task = 3;
|
||||
|
||||
// Initialize test SAV data
|
||||
this.initializeSavTestData();
|
||||
}
|
||||
|
||||
async updateTask(id: number, taskData: Partial<InsertTask>): Promise<Task> {
|
||||
@@ -2472,6 +2715,367 @@ export class MemStorage implements IStorage {
|
||||
};
|
||||
this.tasks.set(id, updatedTask);
|
||||
}
|
||||
|
||||
// SAV operations
|
||||
async getSavTickets(filters?: {
|
||||
groupIds?: number[];
|
||||
status?: string;
|
||||
supplierId?: number;
|
||||
priority?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<SavTicketWithRelations[]> {
|
||||
const tickets = Array.from(this.savTickets.values());
|
||||
|
||||
let filteredTickets = tickets;
|
||||
|
||||
if (filters?.groupIds?.length) {
|
||||
filteredTickets = filteredTickets.filter(ticket => filters.groupIds!.includes(ticket.groupId));
|
||||
}
|
||||
if (filters?.status) {
|
||||
filteredTickets = filteredTickets.filter(ticket => ticket.status === filters.status);
|
||||
}
|
||||
if (filters?.supplierId) {
|
||||
filteredTickets = filteredTickets.filter(ticket => ticket.supplierId === filters.supplierId);
|
||||
}
|
||||
if (filters?.priority) {
|
||||
filteredTickets = filteredTickets.filter(ticket => ticket.priority === filters.priority);
|
||||
}
|
||||
if (filters?.startDate) {
|
||||
const startDate = new Date(filters.startDate);
|
||||
filteredTickets = filteredTickets.filter(ticket =>
|
||||
ticket.createdAt && ticket.createdAt >= startDate
|
||||
);
|
||||
}
|
||||
if (filters?.endDate) {
|
||||
const endDate = new Date(filters.endDate);
|
||||
filteredTickets = filteredTickets.filter(ticket =>
|
||||
ticket.createdAt && ticket.createdAt <= endDate
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
filteredTickets.sort((a, b) => {
|
||||
const dateA = a.createdAt || new Date(0);
|
||||
const dateB = b.createdAt || new Date(0);
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
});
|
||||
|
||||
// Add relations for each ticket
|
||||
const ticketsWithRelations = await Promise.all(
|
||||
filteredTickets.map(async (ticket) => {
|
||||
const supplier = this.suppliers.get(ticket.supplierId);
|
||||
const group = this.groups.get(ticket.groupId);
|
||||
const creator = this.users.get(ticket.createdBy);
|
||||
const history = await this.getSavTicketHistory(ticket.id);
|
||||
|
||||
return {
|
||||
...ticket,
|
||||
supplier: supplier!,
|
||||
group: group!,
|
||||
creator: creator!,
|
||||
history,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return ticketsWithRelations;
|
||||
}
|
||||
|
||||
async getSavTicket(id: number): Promise<SavTicketWithRelations | undefined> {
|
||||
const ticket = this.savTickets.get(id);
|
||||
if (!ticket) return undefined;
|
||||
|
||||
const supplier = this.suppliers.get(ticket.supplierId);
|
||||
const group = this.groups.get(ticket.groupId);
|
||||
const creator = this.users.get(ticket.createdBy);
|
||||
const history = await this.getSavTicketHistory(id);
|
||||
|
||||
return {
|
||||
...ticket,
|
||||
supplier: supplier!,
|
||||
group: group!,
|
||||
creator: creator!,
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
async createSavTicket(ticketData: InsertSavTicket): Promise<SavTicket> {
|
||||
const id = this.idCounters.savTicket++;
|
||||
|
||||
// Generate ticket number
|
||||
const currentYear = new Date().getFullYear();
|
||||
const ticketNumber = `SAV-${currentYear}-${String(id).padStart(4, '0')}`;
|
||||
|
||||
const ticket: SavTicket = {
|
||||
id,
|
||||
ticketNumber,
|
||||
...ticketData,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
resolvedAt: null,
|
||||
closedAt: null,
|
||||
};
|
||||
|
||||
this.savTickets.set(id, ticket);
|
||||
|
||||
// Add initial history entry
|
||||
await this.addSavTicketHistory({
|
||||
ticketId: id,
|
||||
action: 'ticket_created',
|
||||
description: `Ticket créé avec le statut "${ticket.status}"`,
|
||||
createdBy: ticket.createdBy,
|
||||
});
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
async updateSavTicket(id: number, ticketData: Partial<InsertSavTicket>): Promise<SavTicket> {
|
||||
const existingTicket = this.savTickets.get(id);
|
||||
if (!existingTicket) throw new Error('SAV ticket not found');
|
||||
|
||||
const updatedTicket = {
|
||||
...existingTicket,
|
||||
...ticketData,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
// Set resolved/closed dates based on status
|
||||
if (ticketData.status === 'resolu' && !updatedTicket.resolvedAt) {
|
||||
updatedTicket.resolvedAt = new Date();
|
||||
}
|
||||
if (ticketData.status === 'ferme' && !updatedTicket.closedAt) {
|
||||
updatedTicket.closedAt = new Date();
|
||||
}
|
||||
|
||||
this.savTickets.set(id, updatedTicket);
|
||||
return updatedTicket;
|
||||
}
|
||||
|
||||
async deleteSavTicket(id: number): Promise<void> {
|
||||
// Delete history first
|
||||
this.savTicketHistories.delete(id);
|
||||
// Delete ticket
|
||||
this.savTickets.delete(id);
|
||||
}
|
||||
|
||||
async getSavTicketHistory(ticketId: number): Promise<SavTicketHistoryWithCreator[]> {
|
||||
const histories = this.savTicketHistories.get(ticketId) || [];
|
||||
|
||||
const historiesWithCreators = histories.map(history => {
|
||||
const creator = this.users.get(history.createdBy);
|
||||
return {
|
||||
...history,
|
||||
creator: creator!,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
return historiesWithCreators.sort((a, b) => {
|
||||
const dateA = a.createdAt || new Date(0);
|
||||
const dateB = b.createdAt || new Date(0);
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
});
|
||||
}
|
||||
|
||||
async addSavTicketHistory(historyData: InsertSavTicketHistory): Promise<SavTicketHistory> {
|
||||
const id = this.idCounters.savTicketHistory++;
|
||||
|
||||
const history: SavTicketHistory = {
|
||||
id,
|
||||
...historyData,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const existingHistories = this.savTicketHistories.get(historyData.ticketId) || [];
|
||||
existingHistories.push(history);
|
||||
this.savTicketHistories.set(historyData.ticketId, existingHistories);
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
async getSavTicketStats(groupIds?: number[]): Promise<{
|
||||
totalTickets: number;
|
||||
newTickets: number;
|
||||
inProgressTickets: number;
|
||||
resolvedTickets: number;
|
||||
criticalTickets: number;
|
||||
}> {
|
||||
let tickets = Array.from(this.savTickets.values());
|
||||
|
||||
if (groupIds?.length) {
|
||||
tickets = tickets.filter(ticket => groupIds.includes(ticket.groupId));
|
||||
}
|
||||
|
||||
const stats = {
|
||||
totalTickets: tickets.length,
|
||||
newTickets: tickets.filter(t => t.status === 'nouveau').length,
|
||||
inProgressTickets: tickets.filter(t =>
|
||||
['en_cours', 'attente_pieces', 'attente_echange'].includes(t.status)
|
||||
).length,
|
||||
resolvedTickets: tickets.filter(t =>
|
||||
['resolu', 'ferme'].includes(t.status)
|
||||
).length,
|
||||
criticalTickets: tickets.filter(t => t.priority === 'critique').length,
|
||||
};
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
private initializeSavTestData() {
|
||||
// Test SAV tickets
|
||||
const testTicket1: SavTicket = {
|
||||
id: 1,
|
||||
ticketNumber: 'SAV-2025-0001',
|
||||
groupId: 1,
|
||||
supplierId: 1,
|
||||
clientName: 'Jean Dupont',
|
||||
clientPhone: '03 83 12 34 56',
|
||||
problemDescription: 'Produit défaillant après 2 jours d\'utilisation. Écran cassé et boutons non fonctionnels.',
|
||||
productGencode: '12345678901234',
|
||||
productReference: 'REF-ABC-123',
|
||||
productDesignation: 'Tablette Samsung Galaxy Tab A7',
|
||||
status: 'nouveau',
|
||||
priority: 'haute',
|
||||
createdBy: 'admin',
|
||||
createdAt: new Date('2025-08-10T08:30:00'),
|
||||
updatedAt: new Date('2025-08-10T08:30:00'),
|
||||
resolvedAt: null,
|
||||
closedAt: null,
|
||||
};
|
||||
|
||||
const testTicket2: SavTicket = {
|
||||
id: 2,
|
||||
ticketNumber: 'SAV-2025-0002',
|
||||
groupId: 1,
|
||||
supplierId: 2,
|
||||
clientName: 'Marie Martin',
|
||||
clientPhone: '06 12 34 56 78',
|
||||
problemDescription: 'Pièces manquantes dans le colis. Manque le chargeur et les écouteurs.',
|
||||
productGencode: '23456789012345',
|
||||
productReference: 'REF-XYZ-456',
|
||||
productDesignation: 'Smartphone iPhone 14',
|
||||
status: 'en_cours',
|
||||
priority: 'normale',
|
||||
createdBy: 'admin',
|
||||
createdAt: new Date('2025-08-09T14:15:00'),
|
||||
updatedAt: new Date('2025-08-12T10:20:00'),
|
||||
resolvedAt: null,
|
||||
closedAt: null,
|
||||
};
|
||||
|
||||
const testTicket3: SavTicket = {
|
||||
id: 3,
|
||||
ticketNumber: 'SAV-2025-0003',
|
||||
groupId: 1,
|
||||
supplierId: 1,
|
||||
clientName: 'Pierre Durand',
|
||||
clientPhone: '03 83 98 76 54',
|
||||
problemDescription: 'En attente de la pièce de rechange. Écran de remplacement commandé chez le fournisseur.',
|
||||
productGencode: '34567890123456',
|
||||
productReference: 'REF-DEF-789',
|
||||
productDesignation: 'Ordinateur portable Dell Inspiron',
|
||||
status: 'attente_pieces',
|
||||
priority: 'normale',
|
||||
createdBy: 'admin',
|
||||
createdAt: new Date('2025-08-08T09:45:00'),
|
||||
updatedAt: new Date('2025-08-13T16:30:00'),
|
||||
resolvedAt: null,
|
||||
closedAt: null,
|
||||
};
|
||||
|
||||
const testTicket4: SavTicket = {
|
||||
id: 4,
|
||||
ticketNumber: 'SAV-2025-0004',
|
||||
groupId: 1,
|
||||
supplierId: 3,
|
||||
clientName: 'Sophie Moreau',
|
||||
clientPhone: '06 98 76 54 32',
|
||||
problemDescription: 'Problème résolu. Produit réparé et testé. Prêt pour le retour client.',
|
||||
productGencode: '45678901234567',
|
||||
productReference: 'REF-GHI-012',
|
||||
productDesignation: 'Casque Sony WH-1000XM4',
|
||||
status: 'resolu',
|
||||
priority: 'faible',
|
||||
createdBy: 'admin',
|
||||
createdAt: new Date('2025-08-05T11:20:00'),
|
||||
updatedAt: new Date('2025-08-14T08:00:00'),
|
||||
resolvedAt: new Date('2025-08-14T08:00:00'),
|
||||
closedAt: null,
|
||||
};
|
||||
|
||||
const testTicket5: SavTicket = {
|
||||
id: 5,
|
||||
ticketNumber: 'SAV-2025-0005',
|
||||
groupId: 1,
|
||||
supplierId: 2,
|
||||
clientName: 'Luc Bernard',
|
||||
clientPhone: '03 83 45 67 89',
|
||||
problemDescription: 'URGENT - Produit en panne critique pour activité professionnelle. Besoin intervention rapide.',
|
||||
productGencode: '56789012345678',
|
||||
productReference: 'REF-JKL-345',
|
||||
productDesignation: 'Imprimante HP LaserJet Pro',
|
||||
status: 'en_cours',
|
||||
priority: 'critique',
|
||||
createdBy: 'admin',
|
||||
createdAt: new Date('2025-08-14T07:30:00'),
|
||||
updatedAt: new Date('2025-08-14T07:30:00'),
|
||||
resolvedAt: null,
|
||||
closedAt: null,
|
||||
};
|
||||
|
||||
// Store the tickets
|
||||
this.savTickets.set(1, testTicket1);
|
||||
this.savTickets.set(2, testTicket2);
|
||||
this.savTickets.set(3, testTicket3);
|
||||
this.savTickets.set(4, testTicket4);
|
||||
this.savTickets.set(5, testTicket5);
|
||||
|
||||
// Add history entries for each ticket
|
||||
const histories = [
|
||||
// Ticket 1 history
|
||||
{ ticketId: 1, action: 'ticket_created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-10T08:30:00') },
|
||||
|
||||
// Ticket 2 history
|
||||
{ ticketId: 2, action: 'ticket_created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-09T14:15:00') },
|
||||
{ ticketId: 2, action: 'status_change', description: 'Statut changé de "nouveau" vers "en_cours"', createdBy: 'admin', createdAt: new Date('2025-08-12T10:20:00') },
|
||||
{ ticketId: 2, action: 'comment', description: 'Contact avec le client pour confirmer les pièces manquantes. Commande des accessoires en cours.', createdBy: 'admin', createdAt: new Date('2025-08-12T10:25:00') },
|
||||
|
||||
// Ticket 3 history
|
||||
{ ticketId: 3, action: 'ticket_created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-08T09:45:00') },
|
||||
{ ticketId: 3, action: 'status_change', description: 'Statut changé de "nouveau" vers "en_cours"', createdBy: 'admin', createdAt: new Date('2025-08-10T14:00:00') },
|
||||
{ ticketId: 3, action: 'status_change', description: 'Statut changé de "en_cours" vers "attente_pieces"', createdBy: 'admin', createdAt: new Date('2025-08-13T16:30:00') },
|
||||
{ ticketId: 3, action: 'comment', description: 'Écran de remplacement commandé. Délai annoncé de 5-7 jours ouvrables.', createdBy: 'admin', createdAt: new Date('2025-08-13T16:35:00') },
|
||||
|
||||
// Ticket 4 history
|
||||
{ ticketId: 4, action: 'ticket_created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-05T11:20:00') },
|
||||
{ ticketId: 4, action: 'status_change', description: 'Statut changé de "nouveau" vers "en_cours"', createdBy: 'admin', createdAt: new Date('2025-08-06T09:00:00') },
|
||||
{ ticketId: 4, action: 'comment', description: 'Diagnostic effectué : problème de connectivité Bluetooth. Réparation en cours.', createdBy: 'admin', createdAt: new Date('2025-08-07T15:30:00') },
|
||||
{ ticketId: 4, action: 'status_change', description: 'Statut changé de "en_cours" vers "resolu"', createdBy: 'admin', createdAt: new Date('2025-08-14T08:00:00') },
|
||||
{ ticketId: 4, action: 'comment', description: 'Casque réparé avec succès. Module Bluetooth remplacé. Tests complets effectués.', createdBy: 'admin', createdAt: new Date('2025-08-14T08:05:00') },
|
||||
|
||||
// Ticket 5 history
|
||||
{ ticketId: 5, action: 'ticket_created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-14T07:30:00') },
|
||||
{ ticketId: 5, action: 'status_change', description: 'Statut changé de "nouveau" vers "en_cours"', createdBy: 'admin', createdAt: new Date('2025-08-14T07:30:00') },
|
||||
{ ticketId: 5, action: 'comment', description: 'Priorité critique - Intervention d\'urgence programmée pour ce matin.', createdBy: 'admin', createdAt: new Date('2025-08-14T07:35:00') },
|
||||
];
|
||||
|
||||
// Initialize history for each ticket
|
||||
histories.forEach((historyEntry) => {
|
||||
const id = this.idCounters.savTicketHistory++;
|
||||
const history: SavTicketHistory = {
|
||||
id,
|
||||
...historyEntry,
|
||||
};
|
||||
|
||||
const existingHistories = this.savTicketHistories.get(historyEntry.ticketId) || [];
|
||||
existingHistories.push(history);
|
||||
this.savTicketHistories.set(historyEntry.ticketId, existingHistories);
|
||||
});
|
||||
|
||||
this.idCounters.savTicket = 6;
|
||||
console.log('✅ SAV test data initialized with 5 tickets and comprehensive history');
|
||||
}
|
||||
}
|
||||
|
||||
// Use MemStorage in development, DatabaseStorage in production
|
||||
|
||||
@@ -604,3 +604,98 @@ export type TaskWithRelations = Task & {
|
||||
creator: User;
|
||||
group: Group;
|
||||
};
|
||||
|
||||
// SAV (Service Après-Vente) Tables
|
||||
export const savTickets = pgTable("sav_tickets", {
|
||||
id: serial("id").primaryKey(),
|
||||
ticketNumber: varchar("ticket_number", { length: 50 }).notNull(),
|
||||
supplierId: integer("supplier_id").notNull(),
|
||||
groupId: integer("group_id").notNull(),
|
||||
productGencode: varchar("product_gencode", { length: 255 }).notNull(),
|
||||
productReference: varchar("product_reference", { length: 255 }),
|
||||
productDesignation: varchar("product_designation", { length: 500 }).notNull(),
|
||||
problemType: varchar("problem_type", { length: 100 }).notNull(),
|
||||
problemDescription: text("problem_description").notNull(),
|
||||
resolutionDescription: text("resolution_description"),
|
||||
status: varchar("status", { length: 50 }).notNull().default("nouveau"),
|
||||
priority: varchar("priority", { length: 50 }).notNull().default("normal"), // normal, urgent, critique
|
||||
clientName: varchar("client_name", { length: 255 }),
|
||||
clientPhone: varchar("client_phone", { length: 255 }),
|
||||
createdBy: varchar("created_by", { length: 255 }).notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
resolvedAt: timestamp("resolved_at"),
|
||||
closedAt: timestamp("closed_at"),
|
||||
});
|
||||
|
||||
export const savTicketHistory = pgTable("sav_ticket_history", {
|
||||
id: serial("id").primaryKey(),
|
||||
ticketId: integer("ticket_id").notNull(),
|
||||
action: varchar("action", { length: 100 }).notNull(), // status_change, comment, resolution, etc.
|
||||
description: text("description"),
|
||||
createdBy: varchar("created_by", { length: 255 }).notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
});
|
||||
|
||||
// SAV Relations
|
||||
export const savTicketRelations = relations(savTickets, ({ one, many }) => ({
|
||||
supplier: one(suppliers, {
|
||||
fields: [savTickets.supplierId],
|
||||
references: [suppliers.id],
|
||||
}),
|
||||
group: one(groups, {
|
||||
fields: [savTickets.groupId],
|
||||
references: [groups.id],
|
||||
}),
|
||||
creator: one(users, {
|
||||
fields: [savTickets.createdBy],
|
||||
references: [users.id],
|
||||
}),
|
||||
history: many(savTicketHistory),
|
||||
}));
|
||||
|
||||
export const savTicketHistoryRelations = relations(savTicketHistory, ({ one }) => ({
|
||||
ticket: one(savTickets, {
|
||||
fields: [savTicketHistory.ticketId],
|
||||
references: [savTickets.id],
|
||||
}),
|
||||
creator: one(users, {
|
||||
fields: [savTicketHistory.createdBy],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// SAV Zod Schemas
|
||||
export const insertSavTicketSchema = createInsertSchema(savTickets).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
resolvedAt: true,
|
||||
closedAt: true,
|
||||
}).extend({
|
||||
priority: z.enum(["normal", "urgent", "critique"]).default("normal"),
|
||||
status: z.enum(["nouveau", "en_cours", "attente_pieces", "attente_echange", "resolu", "ferme"]).default("nouveau"),
|
||||
problemType: z.enum(["defectueux", "pieces_manquantes", "non_conforme", "autre"]).default("defectueux"),
|
||||
});
|
||||
|
||||
export const insertSavTicketHistorySchema = createInsertSchema(savTicketHistory).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
});
|
||||
|
||||
// SAV Types
|
||||
export type SavTicket = typeof savTickets.$inferSelect;
|
||||
export type InsertSavTicket = z.infer<typeof insertSavTicketSchema>;
|
||||
export type SavTicketHistory = typeof savTicketHistory.$inferSelect;
|
||||
export type InsertSavTicketHistory = z.infer<typeof insertSavTicketHistorySchema>;
|
||||
|
||||
export type SavTicketWithRelations = SavTicket & {
|
||||
supplier: Supplier;
|
||||
group: Group;
|
||||
creator: User;
|
||||
history: (SavTicketHistory & { creator: User })[];
|
||||
};
|
||||
|
||||
export type SavTicketHistoryWithCreator = SavTicketHistory & {
|
||||
creator: User;
|
||||
};
|
||||
Reference in new issue
Block a user