Merge pull request #203 from R0m1k3/1.1

1.1
This commit is contained in:
LogiFlow authored and GitHub committed 2025-08-18 11:19:06 +02:00
commit bc996b0119
8 files changed
+451 -7

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
# Mise à jour Production - Fonctionnalité Stock Épuisé DLC
## Nouveaux champs ajoutés au module DLC
Cette mise à jour ajoute la fonctionnalité de gestion du stock épuisé pour les produits DLC, permettant de distinguer les produits périmés des produits en rupture de stock.
### Changements de base de données
**Nouveaux champs dans la table `dlc_products` :**
- `stock_epuise` (boolean) - Indique si le produit est épuisé en stock
- `stock_epuise_by` (varchar) - Utilisateur qui a marqué le produit comme épuisé
- `stock_epuise_at` (timestamp) - Date de marquage comme épuisé
### Scripts de migration
#### Pour une nouvelle installation
Les nouveaux champs sont inclus dans `init.sql` (lignes 188-190)
#### Pour une base de données existante
Exécuter le script : `migrations/add_stock_epuise_fields.sql`
```sql
ALTER TABLE dlc_products
ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255),
ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp;
```
### Nouvelles routes API
#### Route pour marquer un produit comme stock épuisé
- **Endpoint:** `PUT /api/dlc-products/:id/stock-epuise`
- **Permissions:** Accessible à tous les utilisateurs authentifiés
- **Fonction:** Marque un produit comme épuisé en stock (avec suivi utilisateur et date)
#### Route pour restaurer le stock d'un produit
- **Endpoint:** `PUT /api/dlc-products/:id/restore-stock`
- **Permissions:** Réservé aux admin, directeur et manager
- **Fonction:** Remet le produit en stock disponible
### Interface utilisateur
#### Nouveaux boutons
- **Bouton "Stock épuisé"** (icône PackageX) : Visible sur tous les produits non épuisés et non validés
- **Bouton "Restaurer stock"** (icône RotateCcw) : Visible uniquement pour les utilisateurs privilégiés sur les produits épuisés
#### Styles visuels
- **Produits épuisés** : Fond jaune avec badge "Stock épuisé" (jaune)
- **Produits validés** : Fond gris avec badge "Validé" (gris)
- **Produits normaux** : Fond blanc
### Déploiement
1. **Mise à jour Docker :**
```bash
docker-compose down
docker-compose build
docker-compose up -d
```
2. **Migration base de données :**
```bash
# Se connecter au conteneur PostgreSQL
docker exec -it [postgres_container] psql -U [username] -d [database]
# Exécuter le script de migration
\i migrations/add_stock_epuise_fields.sql
```
3. **Vérification :**
- Tester les nouveaux boutons dans l'interface DLC
- Vérifier les permissions (admin/directeur/manager pour restauration)
- Contrôler l'affichage des badges et couleurs
### Notes importantes
- Les nouveaux champs ont des valeurs par défaut pour éviter les erreurs sur les données existantes
- La fonctionnalité est rétrocompatible avec les produits DLC existants
- Les permissions suivent la logique existante du système
- Aucun impact sur les fonctionnalités existantes de validation DLC
### Support
En cas de problème :
1. Vérifier que la migration a été appliquée : `\d dlc_products`
2. Contrôler les logs de l'application pour les erreurs API
3. Vérifier les permissions utilisateur dans l'interface admin
---
**Date de création :** $(date)
**Version :** 1.0.0
**Auteur :** Système de gestion DLC
+83 -7
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 { 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"
+3
View File
@@ -186,6 +186,9 @@ CREATE TABLE "dlc_products" (
"alert_threshold" integer DEFAULT 3 NOT NULL,
"validated_at" timestamp,
"validated_by" varchar(255),
"stock_epuise" boolean DEFAULT false NOT NULL,
"stock_epuise_by" varchar(255),
"stock_epuise_at" timestamp,
"group_id" integer NOT NULL,
"created_by" varchar(255) NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
+20
View File
@@ -0,0 +1,20 @@
-- Migration pour ajouter les champs stock épuisé aux produits DLC
-- À exécuter sur une base de données existante en production
-- Ajout des nouveaux champs pour la gestion du stock épuisé
ALTER TABLE dlc_products
ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255),
ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp;
-- Commentaires pour documenter les nouveaux champs
COMMENT ON COLUMN dlc_products.stock_epuise IS 'Indique si le produit est marqué comme stock épuisé';
COMMENT ON COLUMN dlc_products.stock_epuise_by IS 'ID de l''utilisateur qui a marqué le produit comme stock épuisé';
COMMENT ON COLUMN dlc_products.stock_epuise_at IS 'Date et heure de marquage du stock épuisé';
-- Vérification des changements
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'dlc_products'
AND column_name IN ('stock_epuise', 'stock_epuise_by', 'stock_epuise_at')
ORDER BY column_name;
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
# Script de déploiement de la fonctionnalité Stock Épuisé DLC
# Auteur: Système de gestion automatisé
# Date: $(date)
set -e # Arrête le script en cas d'erreur
echo "🚀 Début du déploiement de la fonctionnalité Stock Épuisé DLC..."
# Vérification des prérequis
echo "📋 Vérification des prérequis..."
if ! command -v docker &> /dev/null; then
echo "❌ Docker n'est pas installé"
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
echo "❌ Docker Compose n'est pas installé"
exit 1
fi
echo "✅ Prérequis validés"
# Sauvegarde de la base de données avant migration
echo "💾 Sauvegarde de la base de données..."
timestamp=$(date +%Y%m%d_%H%M%S)
backup_file="backup_pre_dlc_stock_${timestamp}.sql"
# Arrêt de l'application pour maintenance
echo "🛑 Arrêt de l'application..."
docker-compose down
# Rebuild avec les nouvelles fonctionnalités
echo "🔨 Reconstruction de l'application..."
docker-compose build --no-cache
# Démarrage de la base de données uniquement pour migration
echo "🗄️ Démarrage de la base de données pour migration..."
docker-compose up -d postgres
# Attendre que PostgreSQL soit prêt
echo "⏳ Attente de PostgreSQL..."
sleep 10
# Application de la migration
echo "🔄 Application de la migration..."
docker exec $(docker-compose ps -q postgres) psql -U postgres -d logiflow -f /migrations/add_stock_epuise_fields.sql || {
echo "⚠️ Migration échouée - vérification manuelle nécessaire"
}
# Vérification de la migration
echo "🔍 Vérification de la migration..."
docker exec $(docker-compose ps -q postgres) psql -U postgres -d logiflow -c "
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'dlc_products'
AND column_name IN ('stock_epuise', 'stock_epuise_by', 'stock_epuise_at');"
# Démarrage complet de l'application
echo "🚀 Démarrage complet de l'application..."
docker-compose up -d
# Attendre que l'application soit prête
echo "⏳ Attente du démarrage de l'application..."
sleep 15
# Tests de santé
echo "🩺 Tests de santé de l'application..."
if curl -f http://localhost:5000/api/health &> /dev/null; then
echo "✅ Application démarrée avec succès"
else
echo "⚠️ L'application pourrait ne pas être complètement prête"
fi
# Tests des nouvelles routes API
echo "🧪 Test des nouvelles routes..."
echo "Testez manuellement :"
echo "- PUT /api/dlc-products/:id/stock-epuise"
echo "- PUT /api/dlc-products/:id/restore-stock"
echo ""
echo "✅ Déploiement terminé avec succès!"
echo ""
echo "📝 Vérifications post-déploiement recommandées :"
echo "1. Connectez-vous à l'interface DLC"
echo "2. Vérifiez la présence des nouveaux boutons (PackageX et RotateCcw)"
echo "3. Testez le marquage stock épuisé sur un produit test"
echo "4. Vérifiez les permissions de restauration (admin/directeur/manager uniquement)"
echo "5. Contrôlez l'affichage des badges et couleurs (jaune vs gris)"
echo ""
echo "📊 Pour surveiller l'application :"
echo "docker-compose logs -f app"
echo ""
echo "🐛 En cas de problème :"
echo "docker-compose down && docker-compose up -d"
+85
View File
@@ -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 {
+68
View File
@@ -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'),
};
+3
View File
@@ -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)