Add functionality to mark and unmark products as processed

This commit introduces new mutations and UI logic to the DLC page for marking products as processed or unmarking them. It also includes updates to icons and database schema for announcements and avoirs.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1e4433b5-47ab-464c-b663-fea2e53367dd
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1957c339-2757-4d1f-8e92-e9f71a1ce58e/1e4433b5-47ab-464c-b663-fea2e53367dd/px42AkO
This commit is contained in:
michaelschal committed 2025-09-25 06:42:41 +00:00
1 parent 6a715955c0
commit f1f2cf5fc4
6 files changed
+624 -262

No files matched your search

+4 -12
View File
@@ -19,6 +19,10 @@ externalPort = 80
localPort = 33873 localPort = 33873
externalPort = 3003 externalPort = 3003
[[ports]]
localPort = 37973
externalPort = 4200
[[ports]] [[ports]]
localPort = 38343 localPort = 38343
externalPort = 3000 externalPort = 3000
@@ -27,22 +31,10 @@ externalPort = 3000
localPort = 38451 localPort = 38451
externalPort = 3002 externalPort = 3002
[[ports]]
localPort = 39283
externalPort = 6000
[[ports]] [[ports]]
localPort = 41343 localPort = 41343
externalPort = 3001 externalPort = 3001
[[ports]]
localPort = 41735
externalPort = 4200
[[ports]]
localPort = 46559
externalPort = 5173
[workflows] [workflows]
runButton = "Project" runButton = "Project"
+130 -4
View File
@@ -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 { 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 { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { AlertTriangle, Plus, Eye, Edit, Trash2, CheckCircle, Package, Clock, AlertCircle, Filter, Download, FileText, PackageX, RotateCcw } from "lucide-react"; import { AlertTriangle, Plus, Eye, Edit, Trash2, CheckCircle, CheckCircle2, Package, Clock, AlertCircle, Filter, Download, FileText, PackageX, RotateCcw, X } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -225,6 +225,40 @@ export default function DlcPage() {
}, },
}); });
// Marquer comme traité mutation - accessible à tous
const markProcessedMutation = useMutation({
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/mark-processed`, "PUT"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Produit marqué comme traité" });
},
onError: (error: any) => {
toast({
title: "Erreur lors du marquage",
description: error.message || "Une erreur est survenue",
variant: "destructive",
});
},
});
// Annuler traitement mutation - réservé aux admins, directeurs et managers
const unmarkProcessedMutation = useMutation({
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/unmark-processed`, "PUT"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Traitement annulé avec succès" });
},
onError: (error: any) => {
toast({
title: "Erreur lors de l'annulation",
description: error.message || "Une erreur est survenue",
variant: "destructive",
});
},
});
const onSubmit = (data: DlcFormData) => { const onSubmit = (data: DlcFormData) => {
// Calculer la date d'expiration et le seuil d'alerte (15 jours avant) // Calculer la date d'expiration et le seuil d'alerte (15 jours avant)
const dlcDate = new Date(data.dlcDate); const dlcDate = new Date(data.dlcDate);
@@ -319,6 +353,14 @@ export default function DlcPage() {
restoreStockMutation.mutate(id); restoreStockMutation.mutate(id);
}; };
const handleMarkProcessed = (id: number) => {
markProcessedMutation.mutate(id);
};
const handleUnmarkProcessed = (id: number) => {
unmarkProcessedMutation.mutate(id);
};
// Fonction pour déterminer si un produit doit afficher le bouton de validation // Fonction pour déterminer si un produit doit afficher le bouton de validation
const shouldShowValidateButton = (product: DlcProductWithRelations) => { const shouldShowValidateButton = (product: DlcProductWithRelations) => {
const today = new Date(); const today = new Date();
@@ -330,13 +372,40 @@ export default function DlcPage() {
return (daysUntilExpiry <= 15 && product.status !== "valides"); return (daysUntilExpiry <= 15 && product.status !== "valides");
}; };
const getStatusBadge = (status: string, dlcDate: string | null, stockEpuise?: boolean) => { // Fonction pour déterminer si un produit doit afficher le bouton "Marquer comme traité"
const shouldShowMarkProcessedButton = (product: DlcProductWithRelations) => {
const today = new Date();
const expiry = new Date(product.expiryDate || new Date());
const daysUntilExpiry = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
// Afficher le bouton si :
// - Le produit expire bientôt (dans 15 jours ou moins) ET pas encore expiré
// - Il n'est pas déjà traité
// - Il n'est pas validé et pas en stock épuisé
return (daysUntilExpiry <= 15 && daysUntilExpiry > 0 &&
!product.processedUntilExpiry &&
product.status !== "valides" &&
!product.stockEpuise);
};
// Fonction pour déterminer si un produit doit afficher le bouton "Annuler traitement"
const shouldShowUnmarkProcessedButton = (product: DlcProductWithRelations) => {
const today = new Date();
const expiry = new Date(product.expiryDate || new Date());
const daysUntilExpiry = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
// Afficher le bouton si le produit est traité ET pas encore expiré
return (product.processedUntilExpiry && daysUntilExpiry > 0);
};
const getStatusBadge = (status: string, dlcDate: string | null, stockEpuise?: boolean, processedAt?: string | null, processedUntilExpiry?: boolean) => {
if (!dlcDate) return <Badge variant="outline">Non défini</Badge>; if (!dlcDate) return <Badge variant="outline">Non défini</Badge>;
// LOGIQUE MISE À JOUR : // LOGIQUE MISE À JOUR :
// 1. Si stock épuisé, afficher "Stock épuisé" avec style spécifique // 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) // 2. Si le statut en base est "valides", afficher "Validé" (peu importe la date)
// 3. Sinon, calculer selon la date d'expiration // 3. Si traité temporairement ET pas encore expiré, afficher "Traité - Expire dans X jours"
// 4. Sinon, calculer selon la date d'expiration
if (stockEpuise) { if (stockEpuise) {
return <Badge variant="secondary" className="bg-yellow-100 text-yellow-800 border-yellow-300">Stock épuisé</Badge>; return <Badge variant="secondary" className="bg-yellow-100 text-yellow-800 border-yellow-300">Stock épuisé</Badge>;
@@ -351,6 +420,13 @@ export default function DlcPage() {
const expiry = new Date(dlcDate); const expiry = new Date(dlcDate);
const daysUntilExpiry = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); const daysUntilExpiry = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
// Si traité temporairement et pas encore expiré
if (processedAt && processedUntilExpiry && daysUntilExpiry > 0) {
return <Badge variant="outline" className="bg-blue-100 text-blue-800 border-blue-300">
Traité - Expire dans {daysUntilExpiry} jour{daysUntilExpiry !== 1 ? 's' : ''}
</Badge>;
}
if (daysUntilExpiry < 0) { if (daysUntilExpiry < 0) {
return <Badge variant="destructive">Expiré</Badge>; return <Badge variant="destructive">Expiré</Badge>;
} else if (daysUntilExpiry <= 15) { } else if (daysUntilExpiry <= 15) {
@@ -834,7 +910,7 @@ export default function DlcPage() {
{product.expiryDate ? format(new Date(product.expiryDate), "dd/MM/yyyy", { locale: fr }) : 'Date non définie'} {product.expiryDate ? format(new Date(product.expiryDate), "dd/MM/yyyy", { locale: fr }) : 'Date non définie'}
</TableCell> </TableCell>
<TableCell>{product.supplier?.name || 'Non défini'}</TableCell> <TableCell>{product.supplier?.name || 'Non défini'}</TableCell>
<TableCell>{getStatusBadge(product.status, product.expiryDate, product.stockEpuise)}</TableCell> <TableCell>{getStatusBadge(product.status, product.expiryDate, product.stockEpuise, product.processedAt, product.processedUntilExpiry)}</TableCell>
<TableCell> <TableCell>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button <Button
@@ -856,6 +932,56 @@ export default function DlcPage() {
</Button> </Button>
)} )}
{/* Bouton Marquer comme traité - accessible à tous, pour produits expirant bientôt non traités */}
{shouldShowMarkProcessedButton(product) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => handleMarkProcessed(product.id)}
disabled={markProcessedMutation.isPending}
className="border-blue-300 text-blue-700 hover:bg-blue-50"
>
<CheckCircle2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Marquer comme traité</p>
<p className="text-xs text-muted-foreground">
Indique que vous vous êtes occupé de ce produit (réapparaîtra à expiration)
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Bouton Annuler traitement - réservé aux admins, directeurs et managers */}
{shouldShowUnmarkProcessedButton(product) && (user?.role === 'admin' || user?.role === 'manager' || user?.role === 'directeur') && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => handleUnmarkProcessed(product.id)}
disabled={unmarkProcessedMutation.isPending}
className="border-orange-300 text-orange-700 hover:bg-orange-50"
>
<X className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Annuler le traitement</p>
<p className="text-xs text-muted-foreground">
Retire le marquage "traité" de ce produit
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Bouton Stock épuisé - accessible à tous, SEULEMENT pour produits en cours qui ne sont ni expirent bientôt ni expirés */} {/* Bouton Stock épuisé - accessible à tous, SEULEMENT pour produits en cours qui ne sont ni expirent bientôt ni expirés */}
{!product.stockEpuise && {!product.stockEpuise &&
product.status === "en_cours" && product.status === "en_cours" &&
+85
View File
@@ -2051,6 +2051,91 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
}); });
// Route pour marquer un produit DLC comme traité temporairement (expire bientôt) - accessible à tous
app.put('/api/dlc-products/:id/mark-processed', 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 Product mark as processed attempt:', {
userId: user.id,
userRole: user.role,
dlcProductId: id,
dlcGroupId: dlcProduct.groupId
});
const processedProduct = await storage.markDlcProductAsProcessed(id, user.id);
console.log('✅ DLC Product marked as processed by:', user.role, user.id);
res.json(processedProduct);
} catch (error) {
console.error("Error marking DLC product as processed:", error);
res.status(500).json({ message: "Failed to mark DLC product as processed" });
}
});
// Route pour annuler le traitement temporaire d'un produit DLC - réservé aux admins, directeurs et managers
app.put('/api/dlc-products/:id/unmark-processed', 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 unmark processed (admin, directeur, manager)
if (!['admin', 'directeur', 'manager'].includes(user.role)) {
return res.status(403).json({ message: "Insufficient permissions to unmark DLC product as processed" });
}
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 Product unmark processed attempt:', {
userId: user.id,
userRole: user.role,
dlcProductId: id,
dlcGroupId: dlcProduct.groupId
});
const unprocessedProduct = await storage.unmarkDlcProductAsProcessed(id);
console.log('✅ DLC Product unmarked as processed by:', user.role, user.id);
res.json(unprocessedProduct);
} catch (error) {
console.error("Error unmarking DLC product as processed:", error);
res.status(500).json({ message: "Failed to unmark DLC product as processed" });
}
});
// Tasks routes // Tasks routes
app.get('/api/tasks', isAuthenticated, async (req: any, res) => { app.get('/api/tasks', isAuthenticated, async (req: any, res) => {
try { try {
+77 -4
View File
@@ -193,6 +193,8 @@ export interface IStorage {
validateDlcProduct(id: number, validatedBy: string): Promise<DlcProductFrontend>; validateDlcProduct(id: number, validatedBy: string): Promise<DlcProductFrontend>;
markDlcProductStockEpuise(id: number, markedBy: string): Promise<DlcProductFrontend>; markDlcProductStockEpuise(id: number, markedBy: string): Promise<DlcProductFrontend>;
restoreDlcProductStock(id: number): Promise<DlcProductFrontend>; restoreDlcProductStock(id: number): Promise<DlcProductFrontend>;
markDlcProductAsProcessed(id: number, processedBy: string): Promise<DlcProductFrontend>;
unmarkDlcProductAsProcessed(id: number): Promise<DlcProductFrontend>;
getDlcStats(groupIds?: number[]): Promise<{ active: number; expiringSoon: number; expired: number; }>; getDlcStats(groupIds?: number[]): Promise<{ active: number; expiringSoon: number; expired: number; }>;
// Task operations // Task operations
@@ -1990,6 +1992,34 @@ export class DatabaseStorage implements IStorage {
return dlcProduct as DlcProductFrontend; return dlcProduct as DlcProductFrontend;
} }
async markDlcProductAsProcessed(id: number, processedBy: string): Promise<DlcProductFrontend> {
const [dlcProduct] = await db
.update(dlcProducts)
.set({
processedAt: new Date(),
processedBy: processedBy,
processedUntilExpiry: true,
updatedAt: new Date()
})
.where(eq(dlcProducts.id, id))
.returning();
return dlcProduct as DlcProductFrontend;
}
async unmarkDlcProductAsProcessed(id: number): Promise<DlcProductFrontend> {
const [dlcProduct] = await db
.update(dlcProducts)
.set({
processedAt: null,
processedBy: null,
processedUntilExpiry: false,
updatedAt: new Date()
})
.where(eq(dlcProducts.id, id))
.returning();
return dlcProduct as DlcProductFrontend;
}
async getDlcStats(groupIds?: number[]): Promise<{ active: number; expiringSoon: number; expired: number; }> { async getDlcStats(groupIds?: number[]): Promise<{ active: number; expiringSoon: number; expired: number; }> {
const today = new Date(); const today = new Date();
const alertDate = new Date(); const alertDate = new Date();
@@ -2003,8 +2033,14 @@ export class DatabaseStorage implements IStorage {
const [stats] = await db const [stats] = await db
.select({ .select({
active: sql<number>`COUNT(CASE WHEN ${dlcProducts.expiryDate} > ${today.toISOString().split('T')[0]} THEN 1 END)`, active: sql<number>`COUNT(CASE WHEN ${dlcProducts.expiryDate} > ${today.toISOString().split('T')[0]} THEN 1 END)`,
expiringSoon: sql<number>`COUNT(CASE WHEN ${dlcProducts.expiryDate} BETWEEN ${today.toISOString().split('T')[0]} AND ${alertDate.toISOString().split('T')[0]} THEN 1 END)`, expiringSoon: sql<number>`COUNT(CASE
expired: sql<number>`COUNT(CASE WHEN ${dlcProducts.expiryDate} <= ${today.toISOString().split('T')[0]} THEN 1 END)` WHEN ${dlcProducts.expiryDate} BETWEEN ${today.toISOString().split('T')[0]} AND ${alertDate.toISOString().split('T')[0]}
AND (${dlcProducts.processedUntilExpiry} IS NULL OR ${dlcProducts.processedUntilExpiry} = false)
THEN 1 END)`,
expired: sql<number>`COUNT(CASE
WHEN ${dlcProducts.expiryDate} <= ${today.toISOString().split('T')[0]}
AND (${dlcProducts.processedUntilExpiry} IS NULL OR ${dlcProducts.processedUntilExpiry} = false OR ${dlcProducts.expiryDate} < ${today.toISOString().split('T')[0]})
THEN 1 END)`
}) })
.from(dlcProducts) .from(dlcProducts)
.where(whereCondition); .where(whereCondition);
@@ -4096,6 +4132,36 @@ export class MemStorage implements IStorage {
return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend; return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend;
} }
async markDlcProductAsProcessed(id: number, processedBy: string): Promise<DlcProductFrontend> {
const existingProduct = this.dlcProducts.get(id);
if (!existingProduct) throw new Error('DLC Product not found');
const updatedProduct = {
...existingProduct,
processedAt: new Date(),
processedBy: processedBy,
processedUntilExpiry: true,
updatedAt: new Date()
};
this.dlcProducts.set(id, updatedProduct);
return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend;
}
async unmarkDlcProductAsProcessed(id: number): Promise<DlcProductFrontend> {
const existingProduct = this.dlcProducts.get(id);
if (!existingProduct) throw new Error('DLC Product not found');
const updatedProduct = {
...existingProduct,
processedAt: null,
processedBy: null,
processedUntilExpiry: false,
updatedAt: new Date()
};
this.dlcProducts.set(id, updatedProduct);
return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend;
}
async getDlcStats(groupIds?: number[]): Promise<{ active: number; expiringSoon: number; expired: number; }> { async getDlcStats(groupIds?: number[]): Promise<{ active: number; expiringSoon: number; expired: number; }> {
const today = new Date(); const today = new Date();
const alertDate = new Date(); const alertDate = new Date();
@@ -4111,9 +4177,16 @@ export class MemStorage implements IStorage {
active: products.filter(p => new Date(p.expiryDate) > today).length, active: products.filter(p => new Date(p.expiryDate) > today).length,
expiringSoon: products.filter(p => { expiringSoon: products.filter(p => {
const expiry = new Date(p.expiryDate); const expiry = new Date(p.expiryDate);
return expiry >= today && expiry <= alertDate; const isExpiringSoon = expiry >= today && expiry <= alertDate;
const isProcessed = p.processedUntilExpiry && expiry > today;
return isExpiringSoon && !isProcessed;
}).length, }).length,
expired: products.filter(p => new Date(p.expiryDate) <= today).length expired: products.filter(p => {
const expiry = new Date(p.expiryDate);
const isExpired = expiry <= today;
const isProcessed = p.processedUntilExpiry && expiry > today;
return isExpired && !isProcessed;
}).length
}; };
} }
+3
View File
@@ -248,6 +248,9 @@ export const dlcProducts = pgTable("dlc_products", {
stockEpuise: boolean("stock_epuise").default(false), // Produit épuisé en stock (pas périmé) stockEpuise: boolean("stock_epuise").default(false), // Produit épuisé en stock (pas périmé)
stockEpuiseBy: varchar("stock_epuise_by"), // Marqué comme épuisé par (optionnel) stockEpuiseBy: varchar("stock_epuise_by"), // Marqué comme épuisé par (optionnel)
stockEpuiseAt: timestamp("stock_epuise_at"), // Date de marquage épuisé (optionnel) stockEpuiseAt: timestamp("stock_epuise_at"), // Date de marquage épuisé (optionnel)
processedAt: timestamp("processed_at"), // Date de traitement "expire bientôt" (optionnel)
processedBy: varchar("processed_by"), // Qui a marqué comme traité (optionnel)
processedUntilExpiry: boolean("processed_until_expiry").default(false), // Traité jusqu'à expiration
notes: text("notes"), // Notes (optionnel) notes: text("notes"), // Notes (optionnel)
createdBy: varchar("created_by").notNull(), // Créateur createdBy: varchar("created_by").notNull(), // Créateur
validatedBy: varchar("validated_by"), // Validé par (optionnel) validatedBy: varchar("validated_by"), // Validé par (optionnel)