mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
Add ability to mark products as out of stock and restore them
Introduce new API endpoints and storage logic for managing product stock status, including 'stock épuisé' and 'restore stock' functionalities with role-based access control. Replit-Commit-Author: Agent Replit-Commit-Session-Id: f9197b9b-a5b3-4469-a27d-930a9e9ee736 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1957c339-2757-4d1f-8e92-e9f71a1ce58e/f9197b9b-a5b3-4469-a27d-930a9e9ee736/JKfj3tT
This commit is contained in:
1 parent
08698d6fe6
commit
4881833925
4 files changed
+239
-7
No files matched your search
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AlertTriangle, Plus, Eye, Edit, Trash2, CheckCircle, Package, Clock, AlertCircle, Filter, Download, FileText } from "lucide-react";
|
||||
import { AlertTriangle, Plus, Eye, Edit, Trash2, CheckCircle, Package, Clock, AlertCircle, Filter, Download, FileText, PackageX, RotateCcw } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
@@ -190,6 +190,40 @@ export default function DlcPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Stock épuisé mutation - accessible à tous
|
||||
const markStockEpuiseMutation = useMutation({
|
||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/stock-epuise`, "PUT"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
|
||||
toast({ title: "Produit marqué comme stock épuisé" });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur lors du marquage",
|
||||
description: error.message || "Une erreur est survenue",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Restaurer stock mutation - réservé aux admins, directeurs et managers
|
||||
const restoreStockMutation = useMutation({
|
||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/restore-stock`, "PUT"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
|
||||
toast({ title: "Stock restauré avec succès" });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur lors de la restauration",
|
||||
description: error.message || "Une erreur est survenue",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: DlcFormData) => {
|
||||
// Calculer la date d'expiration et le seuil d'alerte (15 jours avant)
|
||||
const dlcDate = new Date(data.dlcDate);
|
||||
@@ -275,6 +309,14 @@ export default function DlcPage() {
|
||||
validateMutation.mutate(id);
|
||||
};
|
||||
|
||||
const handleMarkStockEpuise = (id: number) => {
|
||||
markStockEpuiseMutation.mutate(id);
|
||||
};
|
||||
|
||||
const handleRestoreStock = (id: number) => {
|
||||
restoreStockMutation.mutate(id);
|
||||
};
|
||||
|
||||
// Fonction pour déterminer si un produit doit afficher le bouton de validation
|
||||
const shouldShowValidateButton = (product: DlcProductWithRelations) => {
|
||||
const today = new Date();
|
||||
@@ -286,12 +328,17 @@ export default function DlcPage() {
|
||||
return (daysUntilExpiry <= 15 && product.status !== "valides");
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string, dlcDate: string | null) => {
|
||||
const getStatusBadge = (status: string, dlcDate: string | null, stockEpuise?: boolean) => {
|
||||
if (!dlcDate) return <Badge variant="outline">Non défini</Badge>;
|
||||
|
||||
// LOGIQUE SIMPLIFIÉE ET COHÉRENTE :
|
||||
// 1. Si le statut en base est "valides", afficher "Validé" (peu importe la date)
|
||||
// 2. Sinon, calculer selon la date d'expiration
|
||||
// LOGIQUE MISE À JOUR :
|
||||
// 1. Si stock épuisé, afficher "Stock épuisé" avec style spécifique
|
||||
// 2. Si le statut en base est "valides", afficher "Validé" (peu importe la date)
|
||||
// 3. Sinon, calculer selon la date d'expiration
|
||||
|
||||
if (stockEpuise) {
|
||||
return <Badge variant="secondary" className="bg-yellow-100 text-yellow-800 border-yellow-300">Stock épuisé</Badge>;
|
||||
}
|
||||
|
||||
if (status === "valides") {
|
||||
return <Badge variant="outline" className="bg-gray-100 text-gray-800">Validé</Badge>;
|
||||
@@ -767,7 +814,10 @@ export default function DlcPage() {
|
||||
{paginatedProducts.map((product) => (
|
||||
<TableRow
|
||||
key={product.id}
|
||||
className={product.status === "valide" ? "opacity-60 bg-gray-50 dark:bg-gray-800" : ""}
|
||||
className={
|
||||
product.status === "valides" ? "opacity-60 bg-gray-50 dark:bg-gray-800" :
|
||||
product.stockEpuise ? "opacity-50 bg-yellow-50 dark:bg-yellow-900/20" : ""
|
||||
}
|
||||
>
|
||||
<TableCell className="font-medium">{product.productName}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
@@ -782,7 +832,7 @@ export default function DlcPage() {
|
||||
{product.expiryDate ? format(new Date(product.expiryDate), "dd/MM/yyyy", { locale: fr }) : 'Date non définie'}
|
||||
</TableCell>
|
||||
<TableCell>{product.supplier?.name || 'Non défini'}</TableCell>
|
||||
<TableCell>{getStatusBadge(product.status, product.expiryDate)}</TableCell>
|
||||
<TableCell>{getStatusBadge(product.status, product.expiryDate, product.stockEpuise)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
@@ -803,6 +853,32 @@ export default function DlcPage() {
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Bouton Stock épuisé - accessible à tous */}
|
||||
{!product.stockEpuise && product.status !== "valides" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleMarkStockEpuise(product.id)}
|
||||
disabled={markStockEpuiseMutation.isPending}
|
||||
className="border-yellow-300 text-yellow-700 hover:bg-yellow-50"
|
||||
>
|
||||
<PackageX className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Bouton Restaurer stock - réservé aux admins, directeurs et managers */}
|
||||
{product.stockEpuise && (user?.role === 'admin' || user?.role === 'manager' || user?.role === 'directeur') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRestoreStock(product.id)}
|
||||
disabled={restoreStockMutation.isPending}
|
||||
className="border-blue-300 text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
|
||||
@@ -1235,6 +1235,91 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Route pour marquer un produit DLC comme stock épuisé - accessible à tous
|
||||
app.put('/api/dlc-products/:id/stock-epuise', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
const id = parseInt(req.params.id);
|
||||
const dlcProduct = await storage.getDlcProduct(id);
|
||||
|
||||
if (!dlcProduct) {
|
||||
return res.status(404).json({ message: "DLC Product not found" });
|
||||
}
|
||||
|
||||
// For non-admin users, check if they have access to the product's group
|
||||
if (user.role !== 'admin') {
|
||||
const userGroupIds = user.userGroups.map(ug => ug.groupId);
|
||||
if (!userGroupIds.includes(dlcProduct.groupId)) {
|
||||
return res.status(403).json({ message: "Access denied to this group's DLC products" });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔍 DLC Stock épuisé attempt:', {
|
||||
userId: user.id,
|
||||
userRole: user.role,
|
||||
dlcProductId: id,
|
||||
dlcGroupId: dlcProduct.groupId
|
||||
});
|
||||
|
||||
const markedProduct = await storage.markDlcProductStockEpuise(id, user.id);
|
||||
console.log('✅ DLC Product marked as stock épuisé by:', user.role, user.id);
|
||||
|
||||
res.json(markedProduct);
|
||||
} catch (error) {
|
||||
console.error("Error marking DLC product as stock épuisé:", error);
|
||||
res.status(500).json({ message: "Failed to mark DLC product as stock épuisé" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route pour restaurer le stock d'un produit DLC - réservé aux admins, directeurs et managers
|
||||
app.put('/api/dlc-products/:id/restore-stock', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
// Check if user has permission to restore stock (admin, directeur, manager)
|
||||
if (!['admin', 'directeur', 'manager'].includes(user.role)) {
|
||||
return res.status(403).json({ message: "Insufficient permissions to restore DLC product stock" });
|
||||
}
|
||||
|
||||
const id = parseInt(req.params.id);
|
||||
const dlcProduct = await storage.getDlcProduct(id);
|
||||
|
||||
if (!dlcProduct) {
|
||||
return res.status(404).json({ message: "DLC Product not found" });
|
||||
}
|
||||
|
||||
// For non-admin users, check if they have access to the product's group
|
||||
if (user.role !== 'admin') {
|
||||
const userGroupIds = user.userGroups.map(ug => ug.groupId);
|
||||
if (!userGroupIds.includes(dlcProduct.groupId)) {
|
||||
return res.status(403).json({ message: "Access denied to this group's DLC products" });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔍 DLC Stock restore attempt:', {
|
||||
userId: user.id,
|
||||
userRole: user.role,
|
||||
dlcProductId: id,
|
||||
dlcGroupId: dlcProduct.groupId
|
||||
});
|
||||
|
||||
const restoredProduct = await storage.restoreDlcProductStock(id);
|
||||
console.log('✅ DLC Product stock restored by:', user.role, user.id);
|
||||
|
||||
res.json(restoredProduct);
|
||||
} catch (error) {
|
||||
console.error("Error restoring DLC product stock:", error);
|
||||
res.status(500).json({ message: "Failed to restore DLC product stock" });
|
||||
}
|
||||
});
|
||||
|
||||
// Tasks routes
|
||||
app.get('/api/tasks', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
|
||||
@@ -168,6 +168,8 @@ export interface IStorage {
|
||||
updateDlcProduct(id: number, dlcProduct: Partial<InsertDlcProduct>): Promise<DlcProductFrontend>;
|
||||
deleteDlcProduct(id: number): Promise<void>;
|
||||
validateDlcProduct(id: number, validatedBy: string): Promise<DlcProductFrontend>;
|
||||
markDlcProductStockEpuise(id: number, markedBy: string): Promise<DlcProductFrontend>;
|
||||
restoreDlcProductStock(id: number): Promise<DlcProductFrontend>;
|
||||
getDlcStats(groupIds?: number[]): Promise<{ active: number; expiringSoon: number; expired: number; }>;
|
||||
|
||||
// Task operations
|
||||
@@ -1520,6 +1522,35 @@ export class DatabaseStorage implements IStorage {
|
||||
.set({
|
||||
validatedBy,
|
||||
validatedAt: new Date(),
|
||||
status: "valides",
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(dlcProducts.id, id))
|
||||
.returning();
|
||||
return dlcProduct as DlcProductFrontend;
|
||||
}
|
||||
|
||||
async markDlcProductStockEpuise(id: number, markedBy: string): Promise<DlcProductFrontend> {
|
||||
const [dlcProduct] = await db
|
||||
.update(dlcProducts)
|
||||
.set({
|
||||
stockEpuise: true,
|
||||
stockEpuiseBy: markedBy,
|
||||
stockEpuiseAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(dlcProducts.id, id))
|
||||
.returning();
|
||||
return dlcProduct as DlcProductFrontend;
|
||||
}
|
||||
|
||||
async restoreDlcProductStock(id: number): Promise<DlcProductFrontend> {
|
||||
const [dlcProduct] = await db
|
||||
.update(dlcProducts)
|
||||
.set({
|
||||
stockEpuise: false,
|
||||
stockEpuiseBy: null,
|
||||
stockEpuiseAt: null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(dlcProducts.id, id))
|
||||
@@ -2716,6 +2747,37 @@ export class MemStorage implements IStorage {
|
||||
...existingProduct,
|
||||
validatedBy,
|
||||
validatedAt: new Date(),
|
||||
status: "valides",
|
||||
updatedAt: new Date()
|
||||
};
|
||||
this.dlcProducts.set(id, updatedProduct);
|
||||
return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend;
|
||||
}
|
||||
|
||||
async markDlcProductStockEpuise(id: number, markedBy: string): Promise<DlcProductFrontend> {
|
||||
const existingProduct = this.dlcProducts.get(id);
|
||||
if (!existingProduct) throw new Error('DLC Product not found');
|
||||
|
||||
const updatedProduct = {
|
||||
...existingProduct,
|
||||
stockEpuise: true,
|
||||
stockEpuiseBy: markedBy,
|
||||
stockEpuiseAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
this.dlcProducts.set(id, updatedProduct);
|
||||
return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend;
|
||||
}
|
||||
|
||||
async restoreDlcProductStock(id: number): Promise<DlcProductFrontend> {
|
||||
const existingProduct = this.dlcProducts.get(id);
|
||||
if (!existingProduct) throw new Error('DLC Product not found');
|
||||
|
||||
const updatedProduct = {
|
||||
...existingProduct,
|
||||
stockEpuise: false,
|
||||
stockEpuiseBy: null,
|
||||
stockEpuiseAt: null,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
this.dlcProducts.set(id, updatedProduct);
|
||||
@@ -2853,6 +2915,9 @@ export class MemStorage implements IStorage {
|
||||
alertThreshold: 5,
|
||||
validatedBy: null as string | null,
|
||||
validatedAt: null,
|
||||
stockEpuise: false,
|
||||
stockEpuiseBy: null,
|
||||
stockEpuiseAt: null,
|
||||
createdAt: new Date('2025-08-10'),
|
||||
updatedAt: new Date('2025-08-10'),
|
||||
};
|
||||
@@ -2874,6 +2939,9 @@ export class MemStorage implements IStorage {
|
||||
alertThreshold: 3,
|
||||
validatedBy: null as string | null,
|
||||
validatedAt: null,
|
||||
stockEpuise: false,
|
||||
stockEpuiseBy: null,
|
||||
stockEpuiseAt: null,
|
||||
createdAt: new Date('2025-08-08'),
|
||||
updatedAt: new Date('2025-08-13'),
|
||||
};
|
||||
|
||||
@@ -232,6 +232,9 @@ export const dlcProducts = pgTable("dlc_products", {
|
||||
location: varchar("location").notNull().default("Magasin"), // Emplacement (fixe par défaut)
|
||||
alertThreshold: integer("alert_threshold").notNull().default(15), // Seuil d'alerte fixe à 15 jours
|
||||
status: varchar("status").notNull().default("en_cours"), // Statut: en_cours, expires_soon, expires, valides
|
||||
stockEpuise: boolean("stock_epuise").default(false), // Produit épuisé en stock (pas périmé)
|
||||
stockEpuiseBy: varchar("stock_epuise_by"), // Marqué comme épuisé par (optionnel)
|
||||
stockEpuiseAt: timestamp("stock_epuise_at"), // Date de marquage épuisé (optionnel)
|
||||
notes: text("notes"), // Notes (optionnel)
|
||||
createdBy: varchar("created_by").notNull(), // Créateur
|
||||
validatedBy: varchar("validated_by"), // Validé par (optionnel)
|
||||
|
||||
Reference in new issue
Block a user