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; // 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("all"); const [supplierFilter, setSupplierFilter] = useState("all"); const [searchTerm, setSearchTerm] = useState(""); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingProduct, setEditingProduct] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [productToDelete, setProductToDelete] = useState(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({ 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({ 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 | 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 }) => 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 Non défini; // 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 Stock épuisé; } if (status === "valides") { return Validé; } // 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 Traité - Expire dans {daysUntilExpiry} jour{daysUntilExpiry !== 1 ? 's' : ''} ; } if (daysUntilExpiry < 0) { return Expiré; } else if (daysUntilExpiry <= 15) { return Expire bientôt; } else { return Actif; } }; const printExpiringSoon = () => { const expiringSoon = expiringSoonProducts; const printContent = ` Produits expirant bientôt

Produits expirant dans les 15 prochains jours

Date d'impression: ${format(new Date(), "dd/MM/yyyy HH:mm")}

${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 ` `; }).join('')}
Produit Code EAN13 Date d'expiration Type Fournisseur Jours restants
${product.productName} ${product.gencode || '-'} ${product.dlcDate ? format(new Date(product.dlcDate), "dd/MM/yyyy") : 'N/A'} ${product.dateType.toUpperCase()} ${product.supplier?.name || 'N/A'} ${diffDays}
`; const printWindow = window.open('', '_blank'); if (printWindow) { printWindow.document.write(printContent); printWindow.document.close(); printWindow.print(); } }; const printExpired = () => { const expired = expiredProducts; const printContent = ` Produits expirés

Produits expirés

Date d'impression: ${format(new Date(), "dd/MM/yyyy HH:mm")}

${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 ` `; }).join('')}
Produit Code EAN13 Date d'expiration Type Fournisseur Jours dépassés
${product.productName} ${product.gencode || '-'} ${product.dlcDate ? format(new Date(product.dlcDate), "dd/MM/yyyy") : 'N/A'} ${product.dateType.toUpperCase()} ${product.supplier?.name || 'N/A'} ${diffDays}
`; 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
Chargement...
; } return (
{/* Header */}

Gestion DLC

Gestion des dates limites de consommation

{editingProduct ? "Modifier le produit DLC" : "Nouveau produit DLC"}
( Nom du produit )} /> ( Fournisseur )} />
( Code EAN13 (optionnel)
{eanLookupLoading && ( )}
)} />
( Date d'expiration )} /> ( Type de date )} />
( Notes (optionnel)