mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-21 22:18:17 +02:00
- Routes en React.lazy (sauf connexion et accueil) avec un Suspense dans la zone de contenu ; recharts n'est plus préchargé. JS initial : 1,72 Mo -> 625 Ko (183 Ko gzip). Rechargement automatique unique si un ancien fichier JS a disparu après un déploiement, et ErrorBoundary par page pour garder le menu affiché en cas d'erreur. - Authentification unifiée sur le cache React Query ['/api/user'] : un seul GET /api/user pour toute l'application (au lieu de 4 à 5 par page), plus de rechargement complet après la connexion. - Pas de nouvel essai sur les erreurs 4xx ; fournisseurs et magasins en cache 5 minutes ; valeur du StoreContext mémoïsée et redimensionnement qui ne re-rend que lorsque le palier d'écran change. - Pages : requêtes inutilisées ou en double supprimées, appels lancés seulement une fois l'utilisateur connu, invalidations ciblées au lieu de rechargements complets, filtres et tris mémoïsés, anciennes données gardées (et estompées) pendant un changement de filtre ou de mois, recherche DLC mobile temporisée, vérifications de factures à concurrence bornée, navigation interne sans rechargement de la page. - index.html : script de bannière Replit retiré, lang="fr". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
1195 lines
50 KiB
TypeScript
1195 lines
50 KiB
TypeScript
import { useState, useEffect, useMemo, useRef } from "react";
|
|
import { useQuery, useMutation, useMutationState, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Pagination, usePagination } from "@/components/ui/pagination";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/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 { Textarea } from "@/components/ui/textarea";
|
|
import { AlertTriangle, Plus, Eye, Edit, Trash2, CheckCircle, CheckCircle2, Package, Clock, AlertCircle, Filter, Download, FileText, PackageX, RotateCcw, X, Loader2 } from "lucide-react";
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { useForm } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { z } from "zod";
|
|
import { format } from "date-fns";
|
|
import { fr } from "date-fns/locale";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
|
import { useStore } from "@/contexts/StoreContext";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
import type { DlcProductWithRelations, InsertDlcProduct } from "@shared/schema";
|
|
|
|
const dlcFormSchema = z.object({
|
|
productName: z.string().min(1, "Le nom du produit est obligatoire"),
|
|
gencode: z.string().optional(),
|
|
dlcDate: z.string().min(1, "La date d'expiration est obligatoire"),
|
|
dateType: z.enum(["dlc", "ddm", "dluo"], { required_error: "Le type de date est obligatoire" }),
|
|
supplierId: z.number().min(1, "Le fournisseur est obligatoire"),
|
|
status: z.enum(["en_cours", "expires_soon", "expires", "valides"]).default("en_cours"),
|
|
notes: z.string().optional(),
|
|
});
|
|
|
|
type DlcFormData = z.infer<typeof dlcFormSchema>;
|
|
|
|
// Référence stable tant que la liste n'est pas chargée (évite de recalculer
|
|
// les listes d'impression à chaque rendu)
|
|
const NO_PRODUCTS: DlcProductWithRelations[] = [];
|
|
|
|
// Préfixe des actions faites depuis une ligne du tableau : sert à savoir
|
|
// quelles lignes ont une action en cours
|
|
const DLC_ROW_ACTION_KEY = "dlc-row-action";
|
|
|
|
export default function DlcPage() {
|
|
const { user, isLoading: authLoading } = useAuthUnified();
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const { selectedStoreId } = useStore();
|
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
const [supplierFilter, setSupplierFilter] = useState<string>("all");
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
const [editingProduct, setEditingProduct] = useState<DlcProductWithRelations | null>(null);
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [productToDelete, setProductToDelete] = useState<number | null>(null);
|
|
|
|
// Debounce search term to reduce API calls
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
setDebouncedSearchTerm(searchTerm);
|
|
}, 500);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [searchTerm]);
|
|
|
|
// Fetch stores/groups - optimized cache
|
|
const { data: stores = [] } = useQuery<any[]>({
|
|
queryKey: ["/api/groups"],
|
|
enabled: !authLoading,
|
|
staleTime: 5 * 60 * 1000, // 5 minutes cache
|
|
});
|
|
|
|
// Fetch suppliers with DLC enabled only - optimized cache
|
|
const { data: suppliers = [] } = useQuery({
|
|
queryKey: ["/api/suppliers", "dlc"],
|
|
queryFn: () => apiRequest("/api/suppliers?dlc=true"),
|
|
enabled: !authLoading,
|
|
staleTime: 10 * 60 * 1000, // 10 minutes cache
|
|
});
|
|
|
|
// Fetch DLC products - optimized with server-side search
|
|
const { data: dlcProducts = NO_PRODUCTS, isLoading: productsLoading, isPlaceholderData } = useQuery({
|
|
queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, supplierFilter, debouncedSearchTerm],
|
|
queryFn: () => {
|
|
const params = new URLSearchParams();
|
|
if (selectedStoreId && user?.role === 'admin') params.append("storeId", selectedStoreId.toString());
|
|
if (statusFilter && statusFilter !== "all") params.append("status", statusFilter);
|
|
if (supplierFilter && supplierFilter !== "all") params.append("supplierId", supplierFilter);
|
|
if (debouncedSearchTerm) params.append("search", debouncedSearchTerm);
|
|
|
|
return apiRequest(`/api/dlc-products?${params.toString()}`);
|
|
},
|
|
enabled: !authLoading,
|
|
staleTime: 2 * 60 * 1000, // 2 minutes cache for DLC data
|
|
// Changement de filtre ou de recherche : le tableau précédent reste affiché
|
|
// (estompé, avec un indicateur) au lieu de disparaître. Jamais au
|
|
// changement de magasin (produits d'un autre magasin).
|
|
placeholderData: (previousData, previousQuery) =>
|
|
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
|
|
});
|
|
|
|
// Fetch DLC stats - optimized cache
|
|
const { data: stats = { active: 0, expiringSoon: 0, expired: 0 } } = useQuery({
|
|
queryKey: ["/api/dlc-products/stats", selectedStoreId],
|
|
queryFn: () => {
|
|
const params = new URLSearchParams();
|
|
if (selectedStoreId && user?.role === 'admin') params.append("storeId", selectedStoreId.toString());
|
|
return apiRequest(`/api/dlc-products/stats?${params.toString()}`);
|
|
},
|
|
enabled: !authLoading,
|
|
staleTime: 5 * 60 * 1000, // 5 minutes cache for stats
|
|
});
|
|
|
|
// Form setup
|
|
const form = useForm<DlcFormData>({
|
|
resolver: zodResolver(dlcFormSchema),
|
|
defaultValues: {
|
|
productName: "",
|
|
gencode: "",
|
|
dateType: "dlc",
|
|
status: "en_cours",
|
|
notes: "",
|
|
},
|
|
});
|
|
|
|
// EAN lookup from external API
|
|
const [eanLookupLoading, setEanLookupLoading] = useState(false);
|
|
const eanDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const gencodeValue = form.watch("gencode");
|
|
|
|
useEffect(() => {
|
|
if (!gencodeValue || gencodeValue.length < 8) return;
|
|
|
|
if (eanDebounceRef.current) clearTimeout(eanDebounceRef.current);
|
|
|
|
eanDebounceRef.current = setTimeout(async () => {
|
|
setEanLookupLoading(true);
|
|
try {
|
|
// Étape 1 : récupérer l'article par EAN
|
|
const res = await fetch(`/api/ffnancy/articles?ean=${encodeURIComponent(gencodeValue)}&limit=1`, { credentials: 'include' });
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
const article = data.articles?.[0];
|
|
if (!article) return;
|
|
|
|
form.setValue("productName", article.libelle1, { shouldValidate: true });
|
|
|
|
// Étape 2 : chercher le fournisseur de la dernière entrée en stock
|
|
let codefouToMatch = article.codefou_principal;
|
|
try {
|
|
const mvtRes = await fetch(
|
|
`/api/ffnancy/mouvements/entrees?artNoId=${article.no_id}&limit=1&dateDebut=2000-01-01`,
|
|
{ credentials: 'include' }
|
|
);
|
|
if (mvtRes.ok) {
|
|
const mvtData = await mvtRes.json();
|
|
const lastEntree = mvtData.entrees?.[0];
|
|
if (lastEntree?.codefou) codefouToMatch = lastEntree.codefou;
|
|
}
|
|
} catch { /* fallback sur codefou_principal */ }
|
|
|
|
// Matching : codefou exact en priorité, fallback par nom
|
|
if (codefouToMatch) {
|
|
const matched = (suppliers as any[]).find((s: any) =>
|
|
s.codefou && s.codefou.trim().toLowerCase() === codefouToMatch.trim().toLowerCase()
|
|
) || (suppliers as any[]).find((s: any) =>
|
|
s.name.toLowerCase().trim().includes(article.nom_fou_principal?.toLowerCase().trim() || '') ||
|
|
(article.nom_fou_principal?.toLowerCase().trim() || '').includes(s.name.toLowerCase().trim())
|
|
);
|
|
if (matched) {
|
|
form.setValue("supplierId", matched.id, { shouldValidate: true });
|
|
}
|
|
}
|
|
} catch {
|
|
// Lookup best-effort, on ignore les erreurs
|
|
} finally {
|
|
setEanLookupLoading(false);
|
|
}
|
|
}, 600);
|
|
|
|
return () => {
|
|
if (eanDebounceRef.current) clearTimeout(eanDebounceRef.current);
|
|
};
|
|
}, [gencodeValue, suppliers]);
|
|
|
|
// Après une modification, la liste et les compteurs changent : seules les
|
|
// requêtes affichées sont rechargées, les autres (autres filtres, tableau de
|
|
// bord) sont seulement marquées périmées
|
|
const invalidateDlcData = () =>
|
|
Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], refetchType: "active" }),
|
|
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], refetchType: "active" }),
|
|
]);
|
|
|
|
// Actions en cours, par produit : seuls les boutons de la ligne concernée sont
|
|
// désactivés (et non ceux de toutes les lignes), y compris si plusieurs
|
|
// produits sont traités à la suite
|
|
const pendingRowActions = useMutationState({
|
|
filters: { mutationKey: [DLC_ROW_ACTION_KEY], status: "pending" },
|
|
select: (mutation) => `${String(mutation.options.mutationKey?.[1])}:${String(mutation.state.variables)}`,
|
|
});
|
|
const isRowActionPending = (action: string, productId: number) =>
|
|
pendingRowActions.includes(`${action}:${productId}`);
|
|
|
|
// Create mutation - optimized cache invalidation
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: InsertDlcProduct) => apiRequest("/api/dlc-products", "POST", data),
|
|
onSuccess: () => {
|
|
invalidateDlcData();
|
|
toast({ title: "Produit DLC créé avec succès" });
|
|
setIsDialogOpen(false);
|
|
form.reset();
|
|
setEditingProduct(null);
|
|
},
|
|
onError: (error: any) => {
|
|
toast({
|
|
title: "Erreur lors de la création",
|
|
description: error.message || "Une erreur est survenue",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
// Update mutation - optimized cache invalidation
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: number; data: Partial<InsertDlcProduct> }) =>
|
|
apiRequest(`/api/dlc-products/${id}`, "PUT", data),
|
|
onSuccess: () => {
|
|
invalidateDlcData();
|
|
toast({ title: "Produit DLC mis à jour avec succès" });
|
|
setIsDialogOpen(false);
|
|
form.reset();
|
|
setEditingProduct(null);
|
|
},
|
|
onError: (error: any) => {
|
|
toast({
|
|
title: "Erreur lors de la mise à jour",
|
|
description: error.message || "Une erreur est survenue",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
// Validate mutation - optimized cache invalidation
|
|
const validateMutation = useMutation({
|
|
mutationKey: [DLC_ROW_ACTION_KEY, "validate"],
|
|
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/validate`, "POST"),
|
|
onSuccess: () => {
|
|
toast({ title: "Produit validé avec succès" });
|
|
// La ligne reste désactivée jusqu'au rechargement de la liste
|
|
return invalidateDlcData();
|
|
},
|
|
onError: (error: any) => {
|
|
toast({
|
|
title: "Erreur lors de la validation",
|
|
description: error.message || "Une erreur est survenue",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
// Delete mutation - optimized cache invalidation
|
|
const deleteMutation = useMutation({
|
|
mutationKey: [DLC_ROW_ACTION_KEY, "delete"],
|
|
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}`, "DELETE"),
|
|
onSuccess: () => {
|
|
toast({ title: "Produit supprimé avec succès" });
|
|
// La ligne reste désactivée jusqu'au rechargement de la liste
|
|
return invalidateDlcData();
|
|
},
|
|
onError: (error: any) => {
|
|
toast({
|
|
title: "Erreur lors de la suppression",
|
|
description: error.message || "Une erreur est survenue",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
// Stock épuisé mutation - accessible à tous
|
|
const markStockEpuiseMutation = useMutation({
|
|
mutationKey: [DLC_ROW_ACTION_KEY, "stock-epuise"],
|
|
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/stock-epuise`, "PUT"),
|
|
onSuccess: () => {
|
|
toast({ title: "Produit marqué comme stock épuisé" });
|
|
// La ligne reste désactivée jusqu'au rechargement de la liste
|
|
return invalidateDlcData();
|
|
},
|
|
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({
|
|
mutationKey: [DLC_ROW_ACTION_KEY, "restore-stock"],
|
|
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/restore-stock`, "PUT"),
|
|
onSuccess: () => {
|
|
toast({ title: "Stock restauré avec succès" });
|
|
// La ligne reste désactivée jusqu'au rechargement de la liste
|
|
return invalidateDlcData();
|
|
},
|
|
onError: (error: any) => {
|
|
toast({
|
|
title: "Erreur lors de la restauration",
|
|
description: error.message || "Une erreur est survenue",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
// Marquer comme traité mutation - accessible à tous
|
|
const markProcessedMutation = useMutation({
|
|
mutationKey: [DLC_ROW_ACTION_KEY, "mark-processed"],
|
|
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/mark-processed`, "PUT"),
|
|
onSuccess: () => {
|
|
toast({ title: "Produit marqué comme traité" });
|
|
// La ligne reste désactivée jusqu'au rechargement de la liste
|
|
return invalidateDlcData();
|
|
},
|
|
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({
|
|
mutationKey: [DLC_ROW_ACTION_KEY, "unmark-processed"],
|
|
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/unmark-processed`, "PUT"),
|
|
onSuccess: () => {
|
|
toast({ title: "Traitement annulé avec succès" });
|
|
// La ligne reste désactivée jusqu'au rechargement de la liste
|
|
return invalidateDlcData();
|
|
},
|
|
onError: (error: any) => {
|
|
toast({
|
|
title: "Erreur lors de l'annulation",
|
|
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);
|
|
|
|
// Déterminer le groupId correctement selon le rôle utilisateur
|
|
let groupId;
|
|
|
|
// FORCE L'UTILISATION DU GROUPE ASSIGNÉ EN PREMIER
|
|
if (user?.userGroups?.[0]?.groupId) {
|
|
// Utilisateur avec groupe assigné : TOUJOURS utiliser ce groupe
|
|
groupId = user.userGroups[0].groupId;
|
|
} else if (user?.role === 'admin' && selectedStoreId) {
|
|
// Admin avec magasin sélectionné ET pas de groupe assigné
|
|
groupId = selectedStoreId;
|
|
} else if (user?.role === 'admin' && stores?.[0]?.id) {
|
|
// Admin sans sélection : premier magasin disponible
|
|
groupId = stores[0].id;
|
|
} else {
|
|
// Fallback d'urgence
|
|
groupId = 1;
|
|
}
|
|
|
|
const dlcData: any = {
|
|
...data,
|
|
name: data.productName, // Copier productName vers name (requis dans la DB)
|
|
expiryDate: dlcDate as any,
|
|
quantity: 1, // Valeur par défaut
|
|
unit: "unité", // Valeur par défaut
|
|
location: "Magasin", // Valeur par défaut
|
|
alertThreshold: 15, // Toujours 15 jours
|
|
groupId,
|
|
};
|
|
|
|
if (editingProduct) {
|
|
updateMutation.mutate({ id: editingProduct.id, data: dlcData });
|
|
} else {
|
|
createMutation.mutate(dlcData);
|
|
}
|
|
};
|
|
|
|
const handleEdit = (product: DlcProductWithRelations) => {
|
|
setEditingProduct(product);
|
|
form.reset({
|
|
productName: product.productName as string,
|
|
gencode: product.gencode || "",
|
|
dlcDate: product.expiryDate ? format(new Date(product.expiryDate), "yyyy-MM-dd") : "",
|
|
dateType: product.dateType as "dlc" | "ddm" | "dluo",
|
|
supplierId: product.supplierId,
|
|
status: product.status as "en_cours" | "expires_soon" | "expires" | "valides",
|
|
notes: product.notes || "",
|
|
});
|
|
setIsDialogOpen(true);
|
|
};
|
|
|
|
const handleDelete = (id: number) => {
|
|
setProductToDelete(id);
|
|
setDeleteDialogOpen(true);
|
|
};
|
|
|
|
const confirmDelete = () => {
|
|
if (productToDelete) {
|
|
deleteMutation.mutate(productToDelete);
|
|
setDeleteDialogOpen(false);
|
|
setProductToDelete(null);
|
|
}
|
|
};
|
|
|
|
const handleValidate = (id: number) => {
|
|
validateMutation.mutate(id);
|
|
};
|
|
|
|
const handleMarkStockEpuise = (id: number) => {
|
|
markStockEpuiseMutation.mutate(id);
|
|
};
|
|
|
|
const handleRestoreStock = (id: number) => {
|
|
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
|
|
const shouldShowValidateButton = (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 SEULEMENT pour les produits EXPIRÉS (pas ceux qui expirent bientôt)
|
|
// ET si le produit n'est pas déjà validé
|
|
return (daysUntilExpiry < 0 && product.status !== "valides");
|
|
};
|
|
|
|
// 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>;
|
|
|
|
// 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. Si traité temporairement ET pas encore expiré, afficher "Traité - Expire dans X jours"
|
|
// 4. 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>;
|
|
}
|
|
|
|
// Pour tous les autres statuts, calculer selon la date
|
|
const today = new Date();
|
|
const expiry = new Date(dlcDate);
|
|
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) {
|
|
return <Badge variant="destructive">Expiré</Badge>;
|
|
} else if (daysUntilExpiry <= 15) {
|
|
return <Badge variant="secondary" className="bg-orange-100 text-orange-800">Expire bientôt</Badge>;
|
|
} else {
|
|
return <Badge variant="default" className="bg-green-100 text-green-800">Actif</Badge>;
|
|
}
|
|
};
|
|
|
|
const printExpiringSoon = () => {
|
|
const expiringSoon = expiringSoonProducts;
|
|
|
|
const printContent = `
|
|
<html>
|
|
<head>
|
|
<title>Produits expirant bientôt</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
|
th { background-color: #f2f2f2; }
|
|
h1 { color: #ff6b35; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Produits expirant dans les 15 prochains jours</h1>
|
|
<p>Date d'impression: ${format(new Date(), "dd/MM/yyyy HH:mm")}</p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Produit</th>
|
|
<th>Code EAN13</th>
|
|
<th>Date d'expiration</th>
|
|
<th>Type</th>
|
|
<th>Fournisseur</th>
|
|
<th>Jours restants</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${expiringSoon.map((product: any) => {
|
|
const today = new Date();
|
|
const expiry = new Date(product.dlcDate || new Date());
|
|
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
|
return `
|
|
<tr>
|
|
<td>${product.productName}</td>
|
|
<td>${product.gencode || '-'}</td>
|
|
<td>${product.dlcDate ? format(new Date(product.dlcDate), "dd/MM/yyyy") : 'N/A'}</td>
|
|
<td>${product.dateType.toUpperCase()}</td>
|
|
<td>${product.supplier?.name || 'N/A'}</td>
|
|
<td>${diffDays}</td>
|
|
</tr>
|
|
`;
|
|
}).join('')}
|
|
</tbody>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const printWindow = window.open('', '_blank');
|
|
if (printWindow) {
|
|
printWindow.document.write(printContent);
|
|
printWindow.document.close();
|
|
printWindow.print();
|
|
}
|
|
};
|
|
|
|
const printExpired = () => {
|
|
const expired = expiredProducts;
|
|
|
|
const printContent = `
|
|
<html>
|
|
<head>
|
|
<title>Produits expirés</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
|
th { background-color: #f2f2f2; }
|
|
h1 { color: #dc2626; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Produits expirés</h1>
|
|
<p>Date d'impression: ${format(new Date(), "dd/MM/yyyy HH:mm")}</p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Produit</th>
|
|
<th>Code EAN13</th>
|
|
<th>Date d'expiration</th>
|
|
<th>Type</th>
|
|
<th>Fournisseur</th>
|
|
<th>Jours dépassés</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${expired.map((product: any) => {
|
|
const today = new Date();
|
|
const expiry = new Date(product.dlcDate || new Date());
|
|
const diffDays = Math.ceil((today.getTime() - expiry.getTime()) / (1000 * 60 * 60 * 24));
|
|
return `
|
|
<tr>
|
|
<td>${product.productName}</td>
|
|
<td>${product.gencode || '-'}</td>
|
|
<td>${product.dlcDate ? format(new Date(product.dlcDate), "dd/MM/yyyy") : 'N/A'}</td>
|
|
<td>${product.dateType.toUpperCase()}</td>
|
|
<td>${product.supplier?.name || 'N/A'}</td>
|
|
<td>${diffDays}</td>
|
|
</tr>
|
|
`;
|
|
}).join('')}
|
|
</tbody>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const printWindow = window.open('', '_blank');
|
|
if (printWindow) {
|
|
printWindow.document.write(printContent);
|
|
printWindow.document.close();
|
|
printWindow.print();
|
|
}
|
|
};
|
|
|
|
// Products are already filtered server-side, no client filtering needed
|
|
const filteredProducts = dlcProducts || NO_PRODUCTS;
|
|
|
|
// Memoized calculations for print functions
|
|
const { expiringSoonProducts, expiredProducts } = useMemo(() => {
|
|
const today = new Date();
|
|
const expiringSoon = filteredProducts.filter((product: any) => {
|
|
if (product.status === 'valides') return false;
|
|
const expiryDate = new Date(product.dlcDate || new Date());
|
|
const diffDays = Math.ceil((expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
|
return diffDays <= 15 && diffDays > 0;
|
|
});
|
|
|
|
const expired = filteredProducts.filter((product: any) => {
|
|
if (product.status === 'valides') return false;
|
|
const expiryDate = new Date(product.dlcDate || new Date());
|
|
return expiryDate <= today;
|
|
});
|
|
|
|
return { expiringSoonProducts: expiringSoon, expiredProducts: expired };
|
|
}, [filteredProducts]);
|
|
|
|
// Pagination
|
|
const {
|
|
currentPage,
|
|
setCurrentPage,
|
|
itemsPerPage,
|
|
setItemsPerPage,
|
|
totalPages,
|
|
paginatedData: paginatedProducts,
|
|
totalItems
|
|
} = usePagination(filteredProducts, 10);
|
|
|
|
// Retour à la première page quand le magasin, un filtre ou la recherche change
|
|
// (le tableau précédent reste affiché pendant le chargement : sa longueur ne
|
|
// change pas forcément)
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [selectedStoreId, statusFilter, supplierFilter, debouncedSearchTerm, setCurrentPage]);
|
|
|
|
if (authLoading) {
|
|
return <div className="flex justify-center items-center h-64">Chargement...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
{/* Header */}
|
|
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
|
<Clock className="w-6 h-6 mr-3 text-orange-600" />
|
|
Gestion DLC
|
|
</h2>
|
|
<p className="text-gray-600 mt-1">
|
|
Gestion des dates limites de consommation
|
|
</p>
|
|
</div>
|
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button className="bg-blue-600 hover:bg-blue-700 text-white shadow-md">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Nouveau produit DLC
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editingProduct ? "Modifier le produit DLC" : "Nouveau produit DLC"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="productName"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Nom du produit</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} placeholder="Nom du produit" />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="supplierId"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Fournisseur</FormLabel>
|
|
<Select
|
|
onValueChange={(value) => field.onChange(parseInt(value))}
|
|
value={field.value?.toString()}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Sélectionner un fournisseur" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{suppliers.map((supplier: any) => (
|
|
<SelectItem key={supplier.id} value={supplier.id.toString()}>
|
|
{supplier.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="gencode"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Code EAN13 (optionnel)</FormLabel>
|
|
<FormControl>
|
|
<div className="relative">
|
|
<Input
|
|
{...field}
|
|
value={field.value || ""}
|
|
placeholder="1234567890123"
|
|
className={eanLookupLoading ? "pr-8" : ""}
|
|
/>
|
|
{eanLookupLoading && (
|
|
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-blue-500" />
|
|
)}
|
|
</div>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="dlcDate"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Date d'expiration</FormLabel>
|
|
<FormControl>
|
|
<Input type="date" {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="dateType"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Type de date</FormLabel>
|
|
<Select onValueChange={field.onChange} value={field.value}>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Sélectionner le type" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="dlc">DLC (Date Limite de Consommation)</SelectItem>
|
|
<SelectItem value="ddm">DDM (Date de Durabilité Minimale)</SelectItem>
|
|
<SelectItem value="dluo">DLUO (Date Limite d'Utilisation Optimale)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="notes"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Notes (optionnel)</FormLabel>
|
|
<FormControl>
|
|
<Textarea {...field} placeholder="Notes ou observations..." />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<div className="flex justify-end space-x-2">
|
|
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>
|
|
Annuler
|
|
</Button>
|
|
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
|
{editingProduct ? "Mettre à jour" : "Créer"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Filter className="w-5 h-5" />
|
|
Filtres
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="text-sm font-medium">Statut</label>
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Tous les statuts</SelectItem>
|
|
<SelectItem value="en_cours">Actifs</SelectItem>
|
|
<SelectItem value="expires_soon">Expire bientôt</SelectItem>
|
|
<SelectItem value="expires">Expirés</SelectItem>
|
|
<SelectItem value="valides">Validés</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">Fournisseur</label>
|
|
<Select value={supplierFilter} onValueChange={setSupplierFilter}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Tous les fournisseurs" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Tous les fournisseurs</SelectItem>
|
|
{suppliers.map((supplier: any) => (
|
|
<SelectItem key={supplier.id} value={supplier.id.toString()}>
|
|
{supplier.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">Recherche</label>
|
|
<Input
|
|
placeholder="Rechercher un produit..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Statistics */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Produits Actifs</CardTitle>
|
|
<Package className="h-4 w-4 text-green-600" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
|
|
<p className="text-xs text-muted-foreground">Produits en cours</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Expire Bientôt</CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
<Clock className="h-4 w-4 text-orange-600" />
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={printExpiringSoon}
|
|
className="h-6 w-6 p-0 hover:bg-orange-100"
|
|
>
|
|
<FileText className="h-3 w-3 text-orange-600" />
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-orange-600">{stats.expiringSoon}</div>
|
|
<p className="text-xs text-muted-foreground">Dans les 15 prochains jours</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Expirés</CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
<AlertCircle className="h-4 w-4 text-red-600" />
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={printExpired}
|
|
className="h-6 w-6 p-0 hover:bg-red-100"
|
|
>
|
|
<FileText className="h-3 w-3 text-red-600" />
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-red-600">{stats.expired}</div>
|
|
<p className="text-xs text-muted-foreground">Nécessitent une action</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Products Table Section */}
|
|
<div>
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex justify-between items-center">
|
|
<CardTitle>
|
|
Produits DLC ({totalItems})
|
|
{isPlaceholderData && (
|
|
<span className="inline-flex items-center gap-1 ml-2 text-xs font-normal text-gray-500" role="status">
|
|
<span className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary" />
|
|
Mise à jour…
|
|
</span>
|
|
)}
|
|
</CardTitle>
|
|
<Button variant="outline" size="sm">
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Exporter PDF
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{productsLoading || (isPlaceholderData && totalItems === 0) ? (
|
|
<div className="flex justify-center items-center h-32">Chargement des produits...</div>
|
|
) : totalItems === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
Aucun produit DLC trouvé
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={isPlaceholderData ? "opacity-60 pointer-events-none transition-opacity" : ""}
|
|
aria-busy={isPlaceholderData}
|
|
>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Produit</TableHead>
|
|
<TableHead>Code EAN13</TableHead>
|
|
<TableHead>Type</TableHead>
|
|
<TableHead>Date d'expiration</TableHead>
|
|
<TableHead>Fournisseur</TableHead>
|
|
<TableHead>Statut</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{paginatedProducts.map((product) => (
|
|
<TableRow
|
|
key={product.id}
|
|
className={
|
|
product.status === "valides" ? "opacity-60 bg-gray-50 dark:bg-gray-800" :
|
|
product.processedUntilExpiry ? "opacity-60 bg-blue-50 dark:bg-blue-900/20" :
|
|
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">
|
|
{product.gencode || '-'}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className="uppercase">
|
|
{product.dateType}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
{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, product.stockEpuise, product.processedAt, product.processedUntilExpiry)}</TableCell>
|
|
<TableCell>
|
|
<div className="flex space-x-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleEdit(product)}
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
{shouldShowValidateButton(product) && (user?.role === 'admin' || user?.role === 'manager' || user?.role === 'directeur') && (
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => handleValidate(product.id)}
|
|
disabled={isRowActionPending("validate", product.id)}
|
|
className="bg-green-600 hover:bg-green-700"
|
|
data-testid={`button-validate-${product.id}`}
|
|
title="Valider définitivement ce produit"
|
|
>
|
|
<CheckCircle className="w-4 h-4" />
|
|
</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={isRowActionPending("mark-processed", product.id)}
|
|
className="border-blue-300 text-blue-700 hover:bg-blue-50"
|
|
data-testid={`button-mark-processed-${product.id}`}
|
|
>
|
|
<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={isRowActionPending("unmark-processed", product.id)}
|
|
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, pour tous les produits non validés et pas déjà en stock épuisé */}
|
|
{!product.stockEpuise &&
|
|
product.status !== "valides" && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleMarkStockEpuise(product.id)}
|
|
disabled={isRowActionPending("stock-epuise", product.id)}
|
|
className="border-yellow-300 text-yellow-700 hover:bg-yellow-50"
|
|
>
|
|
<PackageX className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Marquer comme stock épuisé</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Indique que ce produit n'est plus en stock (différent de périmé)
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
|
|
{/* Bouton Restaurer stock - réservé aux admins, directeurs et managers */}
|
|
{product.stockEpuise && (user?.role === 'admin' || user?.role === 'manager' || user?.role === 'directeur') && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleRestoreStock(product.id)}
|
|
disabled={isRowActionPending("restore-stock", product.id)}
|
|
className="border-blue-300 text-blue-700 hover:bg-blue-50"
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Restaurer le stock</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Marquer ce produit comme à nouveau disponible en stock
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => handleDelete(product.id)}
|
|
disabled={isRowActionPending("delete", product.id)}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{totalItems > 0 && (
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
totalItems={totalItems}
|
|
itemsPerPage={itemsPerPage}
|
|
onPageChange={setCurrentPage}
|
|
onItemsPerPageChange={setItemsPerPage}
|
|
className="mt-4 border-t border-gray-200 pt-4"
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Modal de confirmation de suppression */}
|
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Confirmer la suppression</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Êtes-vous sûr de vouloir supprimer ce produit DLC ? Cette action est irréversible.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
|
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
|
Supprimer
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
} |