mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-17 20:27:10 +02:00
Add new service for managing customer support tickets
Introduce the Service Après-Vente (SAV) module with API endpoints for ticket creation, retrieval, and filtering, integrated with the sidebar navigation and utilizing React Query for frontend state management. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 31f9bd88-0285-4787-81b3-31ceeea5a08b Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1957c339-2757-4d1f-8e92-e9f71a1ce58e/31f9bd88-0285-4787-81b3-31ceeea5a08b/Dkpn0ry
This commit is contained in:
1 parent
da4a46156e
commit
d678b3fe7a
6 files changed
+1402
-2
No files matched your search
+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
|
||||
|
||||
Reference in new issue
Block a user