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é
+
+ ) : (
+
+
+
+
+ |
+ Ticket
+ |
+
+ Client
+ |
+
+ Produit
+ |
+
+ Problème
+ |
+
+ Statut
+ |
+
+ Priorité
+ |
+
+ Fournisseur
+ |
+
+ Date
+ |
+
+ Actions
+ |
+
+
+
+ {filteredTickets.map((ticket) => {
+ const StatusIcon = statusConfig[ticket.status as keyof typeof statusConfig]?.icon || Clock;
+ return (
+
+ |
+
+
+
+ {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;
+};