diff --git a/client/src/components/RouterProduction.tsx b/client/src/components/RouterProduction.tsx index 6a7506f..d6484b7 100644 --- a/client/src/components/RouterProduction.tsx +++ b/client/src/components/RouterProduction.tsx @@ -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() { + {/* Routes de compatibilité - redirection vers utilities */} diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 99069b6..776ba37 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -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 = [ diff --git a/client/src/pages/SavTickets.tsx b/client/src/pages/SavTickets.tsx new file mode 100644 index 0000000..dac8bac --- /dev/null +++ b/client/src/pages/SavTickets.tsx @@ -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 ( +
+
+
+
+ +
+
+

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

+
+
+
+
+ ); + } + + // 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({ + queryKey: [ticketsUrl], + staleTime: 1000 * 60 * 5, // 5 minutes + }); + + const { data: suppliersData = [] } = useQuery({ + 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 ( +
+ {/* Header */} +
+
+

Service Après-Vente

+

Gestion des tickets SAV et suivi des réparations

+
+ {canModify && ( + + )} +
+ + {/* Stats Cards */} + {statsData && ( +
+ + + Total + + + +
{statsData.totalTickets}
+
+
+ + + Nouveaux + + + +
{statsData.newTickets}
+
+
+ + + En cours + + + +
{statsData.inProgressTickets}
+
+
+ + + Résolus + + + +
{statsData.resolvedTickets}
+
+
+ + + Critiques + + + +
{statsData.criticalTickets}
+
+
+
+ )} + + {/* Filters */} + + +
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + + + +
+
+
+ + {/* Tickets List */} + + + Tickets SAV ({filteredTickets.length}) + + Liste des tickets de service après-vente + + + + {isLoading ? ( +
+
+

Chargement des tickets...

+
+ ) : filteredTickets.length === 0 ? ( +
+ +

Aucun ticket trouvé

+
+ ) : ( +
+ + + + + + + + + + + + + + + + {filteredTickets.map((ticket) => { + const StatusIcon = statusConfig[ticket.status as keyof typeof statusConfig]?.icon || Clock; + return ( + + + + + + + + + + + + ); + })} + +
+ Ticket + + Client + + Produit + + Problème + + Statut + + Priorité + + Fournisseur + + Date + + Actions +
+
+ + + {ticket.ticketNumber} + +
+
+
+
+ {ticket.clientName || 'N/A'} +
+ {ticket.clientPhone && ( +
+ {ticket.clientPhone} +
+ )} +
+
+
+ {ticket.productDesignation && ( +
+ {ticket.productDesignation} +
+ )} + {ticket.productGencode && ( +
+ {ticket.productGencode} +
+ )} +
+
+
+ {ticket.problemDescription} +
+
+
+ + + {statusConfig[ticket.status as keyof typeof statusConfig]?.label} + +
+
+ + {priorityConfig[ticket.priority as keyof typeof priorityConfig]?.label} + + +
+ + + {ticket.supplier?.name} + +
+
+
+ + {safeFormat(ticket.createdAt, 'dd/MM/yyyy')} +
+
+
+ + {canModify && ( + <> + + + + )} + {canDelete && ( + + )} +
+
+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index 5e046d0..d226054 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -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); diff --git a/server/storage.ts b/server/storage.ts index 5a2cc71..d4d8a6f 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -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): Promise; deleteTask(id: number): Promise; completeTask(id: number, completedBy?: string): Promise; + + // SAV operations + getSavTickets(filters?: { + groupIds?: number[]; + status?: string; + supplierId?: number; + priority?: string; + startDate?: string; + endDate?: string; + }): Promise; + getSavTicket(id: number): Promise; + createSavTicket(ticket: InsertSavTicket): Promise; + updateSavTicket(id: number, ticket: Partial): Promise; + deleteSavTicket(id: number): Promise; + getSavTicketHistory(ticketId: number): Promise; + addSavTicketHistory(history: InsertSavTicketHistory): Promise; + 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 { + 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 { + 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 { + // Generate ticket number + const currentYear = new Date().getFullYear(); + const count = await db + .select({ count: sql`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): Promise { + const [ticket] = await db + .update(savTickets) + .set({ ...ticketData, updatedAt: new Date() }) + .where(eq(savTickets.id, id)) + .returning(); + + return ticket; + } + + async deleteSavTicket(id: number): Promise { + // 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 { + 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 { + 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`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(); private dlcProducts = new Map(); private tasks = new Map(); + private savTickets = new Map(); + private savTicketHistories = new Map(); 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): Promise { @@ -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 { + 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 { + 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 { + 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): Promise { + 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 { + // Delete history first + this.savTicketHistories.delete(id); + // Delete ticket + this.savTickets.delete(id); + } + + async getSavTicketHistory(ticketId: number): Promise { + 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 { + 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 diff --git a/shared/schema.ts b/shared/schema.ts index 2ca421a..8013f7a 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -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; +export type SavTicketHistory = typeof savTicketHistory.$inferSelect; +export type InsertSavTicketHistory = z.infer; + +export type SavTicketWithRelations = SavTicket & { + supplier: Supplier; + group: Group; + creator: User; + history: (SavTicketHistory & { creator: User })[]; +}; + +export type SavTicketHistoryWithCreator = SavTicketHistory & { + creator: User; +};