Merge pull request #113 from R0m1k3/1.1

Add new service for managing customer support tickets
This commit is contained in:
LogiFlow authored and GitHub committed 2025-08-14 10:56:10 +02:00
commit e145f1b0ff
6 files changed
+1402 -2

No files matched your search

@@ -21,6 +21,7 @@ import BackupManager from "@/pages/BackupManager";
import Utilities from "@/pages/Utilities";
import Tasks from "@/pages/Tasks";
import TasksSimplified from "@/pages/TasksSimplified";
import SavTickets from "@/pages/SavTickets";
import Layout from "@/components/Layout";
function RouterProduction() {
@@ -94,6 +95,7 @@ function RouterProduction() {
<Route path="/dlc" component={DlcPage} />
<Route path="/utilities" component={Utilities} />
<Route path="/tasks" component={Tasks} />
<Route path="/sav" component={SavTickets} />
{/* Routes de compatibilité - redirection vers utilities */}
<Route path="/backup" component={Utilities} />
+8 -1
View File
@@ -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 = [
+415
View File
@@ -0,0 +1,415 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { usePermissions } from "@shared/permissions";
import {
Plus,
Search,
Filter,
Wrench,
Calendar,
Eye,
Edit,
Trash2,
Building,
User,
MessageCircle,
AlertTriangle,
CheckCircle,
Clock,
Ban
} from "lucide-react";
import { safeFormat } from "@/lib/dateUtils";
import type { SavTicketWithRelations, Supplier } from "@shared/schema";
const statusConfig = {
nouveau: { label: "Nouveau", color: "bg-blue-100 text-blue-800", icon: AlertTriangle },
en_cours: { label: "En cours", color: "bg-yellow-100 text-yellow-800", icon: Clock },
attente_pieces: { label: "Attente pièces", color: "bg-orange-100 text-orange-800", icon: Clock },
attente_echange: { label: "Attente échange", color: "bg-purple-100 text-purple-800", icon: Clock },
resolu: { label: "Résolu", color: "bg-green-100 text-green-800", icon: CheckCircle },
ferme: { label: "Fermé", color: "bg-gray-100 text-gray-800", icon: Ban }
};
const priorityConfig = {
faible: { label: "Faible", color: "bg-gray-100 text-gray-800" },
normale: { label: "Normale", color: "bg-blue-100 text-blue-800" },
haute: { label: "Haute", color: "bg-orange-100 text-orange-800" },
critique: { label: "Critique", color: "bg-red-100 text-red-800" }
};
export default function SavTickets() {
const { user } = useAuthUnified();
const { toast } = useToast();
const queryClient = useQueryClient();
const permissions = usePermissions(user?.role);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [priorityFilter, setPriorityFilter] = useState("all");
const [supplierFilter, setSupplierFilter] = useState("all");
// Redirection pour les employés sans permission
const canView = ['admin', 'directeur', 'manager', 'employee'].includes(user?.role || '');
const canModify = ['admin', 'directeur', 'manager'].includes(user?.role || '');
const canDelete = ['admin', 'directeur'].includes(user?.role || '');
if (!canView) {
return (
<div className="p-4 sm:p-6">
<div className="bg-orange-50 border-l-4 border-orange-400 p-4 rounded-r-lg">
<div className="flex">
<div className="flex-shrink-0">
<Wrench className="h-5 w-5 text-orange-400" />
</div>
<div className="ml-3">
<p className="text-sm text-orange-700">
<strong>Accès restreint</strong><br />
Vous n'avez pas les permissions nécessaires pour accéder au module SAV.
</p>
</div>
</div>
</div>
</div>
);
}
// Build query parameters
const queryParams = new URLSearchParams();
if (statusFilter !== 'all') queryParams.append('status', statusFilter);
if (priorityFilter !== 'all') queryParams.append('priority', priorityFilter);
if (supplierFilter !== 'all') queryParams.append('supplierId', supplierFilter);
const ticketsUrl = `/api/sav/tickets${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
const { data: ticketsData = [], isLoading } = useQuery<SavTicketWithRelations[]>({
queryKey: [ticketsUrl],
staleTime: 1000 * 60 * 5, // 5 minutes
});
const { data: suppliersData = [] } = useQuery<Supplier[]>({
queryKey: ['/api/suppliers'],
staleTime: 1000 * 60 * 10, // 10 minutes
});
const { data: statsData } = useQuery<{
totalTickets: number;
newTickets: number;
inProgressTickets: number;
resolvedTickets: number;
criticalTickets: number;
}>({
queryKey: ['/api/sav/stats'],
staleTime: 1000 * 60 * 2, // 2 minutes
});
// Filter tickets by search term
const filteredTickets = ticketsData.filter(ticket => {
if (!searchTerm) return true;
const searchLower = searchTerm.toLowerCase();
return (
ticket.ticketNumber.toLowerCase().includes(searchLower) ||
ticket.clientName?.toLowerCase().includes(searchLower) ||
ticket.problemDescription?.toLowerCase().includes(searchLower) ||
ticket.productDesignation?.toLowerCase().includes(searchLower) ||
ticket.supplier?.name.toLowerCase().includes(searchLower)
);
});
return (
<div className="p-4 sm:p-6 space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Service Après-Vente</h1>
<p className="text-gray-600 mt-1">Gestion des tickets SAV et suivi des réparations</p>
</div>
{canModify && (
<Button className="w-full sm:w-auto">
<Plus className="h-4 w-4 mr-2" />
Nouveau Ticket
</Button>
)}
</div>
{/* Stats Cards */}
{statsData && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total</CardTitle>
<Wrench className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{statsData.totalTickets}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Nouveaux</CardTitle>
<AlertTriangle className="h-4 w-4 text-blue-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{statsData.newTickets}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">En cours</CardTitle>
<Clock className="h-4 w-4 text-yellow-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">{statsData.inProgressTickets}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Résolus</CardTitle>
<CheckCircle className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{statsData.resolvedTickets}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Critiques</CardTitle>
<AlertTriangle className="h-4 w-4 text-red-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{statsData.criticalTickets}</div>
</CardContent>
</Card>
</div>
)}
{/* Filters */}
<Card>
<CardContent className="p-4 sm:p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
type="text"
placeholder="Rechercher..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="Statut" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les statuts</SelectItem>
{Object.entries(statusConfig).map(([key, config]) => (
<SelectItem key={key} value={key}>{config.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger>
<SelectValue placeholder="Priorité" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes priorités</SelectItem>
{Object.entries(priorityConfig).map(([key, config]) => (
<SelectItem key={key} value={key}>{config.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={supplierFilter} onValueChange={setSupplierFilter}>
<SelectTrigger>
<SelectValue placeholder="Fournisseur" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous fournisseurs</SelectItem>
{suppliersData.map((supplier) => (
<SelectItem key={supplier.id} value={supplier.id.toString()}>
{supplier.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => {
setSearchTerm("");
setStatusFilter("all");
setPriorityFilter("all");
setSupplierFilter("all");
}}
>
<Filter className="h-4 w-4 mr-2" />
Réinitialiser
</Button>
</div>
</CardContent>
</Card>
{/* Tickets List */}
<Card>
<CardHeader>
<CardTitle>Tickets SAV ({filteredTickets.length})</CardTitle>
<CardDescription>
Liste des tickets de service après-vente
</CardDescription>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-gray-500">Chargement des tickets...</p>
</div>
) : filteredTickets.length === 0 ? (
<div className="p-8 text-center">
<Wrench className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">Aucun ticket trouvé</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Ticket
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Client
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Produit
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Problème
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Priorité
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Fournisseur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredTickets.map((ticket) => {
const StatusIcon = statusConfig[ticket.status as keyof typeof statusConfig]?.icon || Clock;
return (
<tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Wrench className="h-4 w-4 text-gray-400 mr-2" />
<span className="text-sm font-medium text-gray-900">
{ticket.ticketNumber}
</span>
</div>
</td>
<td className="px-6 py-4">
<div>
<div className="text-sm font-medium text-gray-900">
{ticket.clientName || 'N/A'}
</div>
{ticket.clientPhone && (
<div className="text-sm text-gray-500">
{ticket.clientPhone}
</div>
)}
</div>
</td>
<td className="px-6 py-4">
<div>
{ticket.productDesignation && (
<div className="text-sm font-medium text-gray-900">
{ticket.productDesignation}
</div>
)}
{ticket.productGencode && (
<div className="text-sm text-gray-500">
{ticket.productGencode}
</div>
)}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900 max-w-xs truncate">
{ticket.problemDescription}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<StatusIcon className="h-4 w-4 mr-2" />
<Badge className={statusConfig[ticket.status as keyof typeof statusConfig]?.color}>
{statusConfig[ticket.status as keyof typeof statusConfig]?.label}
</Badge>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<Badge className={priorityConfig[ticket.priority as keyof typeof priorityConfig]?.color}>
{priorityConfig[ticket.priority as keyof typeof priorityConfig]?.label}
</Badge>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Building className="h-4 w-4 text-gray-400 mr-2" />
<span className="text-sm text-gray-900">
{ticket.supplier?.name}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center">
<Calendar className="h-4 w-4 mr-1" />
{safeFormat(ticket.createdAt, 'dd/MM/yyyy')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
{canModify && (
<>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm">
<MessageCircle className="h-4 w-4" />
</Button>
</>
)}
{canDelete && (
<Button variant="ghost" size="sm" className="text-red-600 hover:text-red-900">
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
+278 -1
View File
@@ -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);
+604
View File
@@ -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
+95
View File
@@ -604,3 +604,98 @@ export type TaskWithRelations = Task & {
creator: User;
group: Group;
};
// SAV (Service Après-Vente) Tables
export const savTickets = pgTable("sav_tickets", {
id: serial("id").primaryKey(),
ticketNumber: varchar("ticket_number", { length: 50 }).notNull(),
supplierId: integer("supplier_id").notNull(),
groupId: integer("group_id").notNull(),
productGencode: varchar("product_gencode", { length: 255 }).notNull(),
productReference: varchar("product_reference", { length: 255 }),
productDesignation: varchar("product_designation", { length: 500 }).notNull(),
problemType: varchar("problem_type", { length: 100 }).notNull(),
problemDescription: text("problem_description").notNull(),
resolutionDescription: text("resolution_description"),
status: varchar("status", { length: 50 }).notNull().default("nouveau"),
priority: varchar("priority", { length: 50 }).notNull().default("normal"), // normal, urgent, critique
clientName: varchar("client_name", { length: 255 }),
clientPhone: varchar("client_phone", { length: 255 }),
createdBy: varchar("created_by", { length: 255 }).notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
resolvedAt: timestamp("resolved_at"),
closedAt: timestamp("closed_at"),
});
export const savTicketHistory = pgTable("sav_ticket_history", {
id: serial("id").primaryKey(),
ticketId: integer("ticket_id").notNull(),
action: varchar("action", { length: 100 }).notNull(), // status_change, comment, resolution, etc.
description: text("description"),
createdBy: varchar("created_by", { length: 255 }).notNull(),
createdAt: timestamp("created_at").defaultNow(),
});
// SAV Relations
export const savTicketRelations = relations(savTickets, ({ one, many }) => ({
supplier: one(suppliers, {
fields: [savTickets.supplierId],
references: [suppliers.id],
}),
group: one(groups, {
fields: [savTickets.groupId],
references: [groups.id],
}),
creator: one(users, {
fields: [savTickets.createdBy],
references: [users.id],
}),
history: many(savTicketHistory),
}));
export const savTicketHistoryRelations = relations(savTicketHistory, ({ one }) => ({
ticket: one(savTickets, {
fields: [savTicketHistory.ticketId],
references: [savTickets.id],
}),
creator: one(users, {
fields: [savTicketHistory.createdBy],
references: [users.id],
}),
}));
// SAV Zod Schemas
export const insertSavTicketSchema = createInsertSchema(savTickets).omit({
id: true,
createdAt: true,
updatedAt: true,
resolvedAt: true,
closedAt: true,
}).extend({
priority: z.enum(["normal", "urgent", "critique"]).default("normal"),
status: z.enum(["nouveau", "en_cours", "attente_pieces", "attente_echange", "resolu", "ferme"]).default("nouveau"),
problemType: z.enum(["defectueux", "pieces_manquantes", "non_conforme", "autre"]).default("defectueux"),
});
export const insertSavTicketHistorySchema = createInsertSchema(savTicketHistory).omit({
id: true,
createdAt: true,
});
// SAV Types
export type SavTicket = typeof savTickets.$inferSelect;
export type InsertSavTicket = z.infer<typeof insertSavTicketSchema>;
export type SavTicketHistory = typeof savTicketHistory.$inferSelect;
export type InsertSavTicketHistory = z.infer<typeof insertSavTicketHistorySchema>;
export type SavTicketWithRelations = SavTicket & {
supplier: Supplier;
group: Group;
creator: User;
history: (SavTicketHistory & { creator: User })[];
};
export type SavTicketHistoryWithCreator = SavTicketHistory & {
creator: User;
};