Files
LogiFlow/client/src/pages/Deliveries.tsx
T
Claude 5d3e5357a0 perf(client): pages chargées à la demande et un seul appel /api/user
- 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
2026-10-03 04:29:51 +00:00

573 lines
23 KiB
TypeScript

import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Pagination, usePagination } from "@/components/ui/pagination";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { isUnauthorizedError } from "@/lib/authUtils";
import { usePermissions } from "@shared/permissions";
import {
Plus,
Search,
Filter,
Truck,
Calendar,
Eye,
Edit,
Trash2,
Building,
User,
Check,
Package,
AlertTriangle,
CheckCircle
} from "lucide-react";
import { safeFormat } from "@/lib/dateUtils";
import CreateDeliveryModal from "@/components/modals/CreateDeliveryModal";
import EditDeliveryModal from "@/components/modals/EditDeliveryModal";
import OrderDetailModal from "@/components/modals/OrderDetailModal";
import ValidateDeliveryModal from "@/components/modals/ValidateDeliveryModal";
import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal";
import type { DeliveryWithRelations } from "@shared/schema";
// Référence stable pendant le chargement (pour la mémoïsation du filtrage)
const NO_DELIVERIES: DeliveryWithRelations[] = [];
export default function Deliveries() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
const { toast } = useToast();
const queryClient = useQueryClient();
const permissions = usePermissions(user?.role);
// Redirection pour les employés
if (user?.role === 'employee') {
return (
<div className="p-4 sm:p-6">
<div className="bg-orange-50 border-l-4 border-orange-400 p-4 rounded-r-lg">
<div className="flex">
<div className="flex-shrink-0">
<Truck className="h-5 w-5 text-orange-400" />
</div>
<div className="ml-3">
<p className="text-sm text-orange-700">
<strong>Accès restreint</strong><br />
Seuls les managers et administrateurs peuvent accéder à la page des livraisons.
</p>
</div>
</div>
</div>
</div>
);
}
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showValidateModal, setShowValidateModal] = useState(false);
const [selectedDelivery, setSelectedDelivery] = useState<DeliveryWithRelations | null>(null);
const [deliveryToDelete, setDeliveryToDelete] = useState<DeliveryWithRelations | null>(null);
const [deliveryToValidate, setDeliveryToValidate] = useState<DeliveryWithRelations | null>(null);
// Construire l'URL pour l'historique complet sans filtrage par date
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
const deliveriesUrl = `/api/deliveries${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
const { data: deliveriesData = NO_DELIVERIES, isLoading } = useQuery<DeliveryWithRelations[]>({
queryKey: ['/api/deliveries', selectedStoreId, user?.role],
queryFn: async () => {
const response = await fetch(deliveriesUrl, { credentials: 'include' });
if (!response.ok) {
throw new Error('Failed to fetch deliveries');
}
const data = await response.json();
return Array.isArray(data) ? data : [];
},
});
// Production Bug Fix: Ensure array safety for all data operations
const deliveries = Array.isArray(deliveriesData) ? deliveriesData : NO_DELIVERIES;
// Clé construite sur l'URL (tableau de bord admin avec magasin sélectionné).
// Sans magasin, elle vaut ['/api/deliveries'], déjà invalidée : la réinvalider
// relancerait une seconde fois le chargement complet des livraisons.
const invalidateDeliveriesUrlKey = () => {
if (selectedStoreId) {
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
}
};
const markControlValidatedMutation = useMutation({
mutationFn: async (id: number) => {
await apiRequest(`/api/deliveries/${id}/control`, "PUT");
},
onSuccess: () => {
toast({
title: "Succès",
description: "Contrôle validé avec succès",
});
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
invalidateDeliveriesUrlKey();
},
onError: (error: any) => {
if (isUnauthorizedError(error)) {
return;
}
toast({
title: "Erreur",
description: "Impossible de valider le contrôle",
variant: "destructive",
});
},
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
await apiRequest(`/api/deliveries/${id}`, "DELETE");
},
onSuccess: () => {
toast({
title: "Succès",
description: "Livraison supprimée avec succès",
});
// Invalider tous les caches liés aux livraisons
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
invalidateDeliveriesUrlKey();
queryClient.invalidateQueries({ queryKey: ['/api/stats/monthly'] });
// L'échéancier (cache de 5 min) liste les livraisons facturées
queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] });
},
onError: (error) => {
if (isUnauthorizedError(error)) {
toast({
title: "Non autorisé",
description: "Vous êtes déconnecté. Reconnexion...",
variant: "destructive",
});
setTimeout(() => {
window.location.href = "/api/login";
}, 500);
return;
}
toast({
title: "Erreur",
description: "Impossible de supprimer la livraison",
variant: "destructive",
});
},
});
// Filtrage recalculé seulement quand la liste, la recherche ou le statut changent
const filteredDeliveries = useMemo(() => {
const search = searchTerm.toLowerCase();
return deliveries.filter(delivery => {
const matchesSearch = delivery.supplier?.name.toLowerCase().includes(search) ||
delivery.group?.name.toLowerCase().includes(search) ||
delivery.notes?.toLowerCase().includes(search);
const matchesStatus = statusFilter === "all" || delivery.status === statusFilter;
return matchesSearch && matchesStatus;
});
}, [deliveries, searchTerm, statusFilter]);
// Pagination
const {
currentPage,
setCurrentPage,
itemsPerPage,
setItemsPerPage,
totalPages,
paginatedData: paginatedDeliveries,
totalItems
} = usePagination(filteredDeliveries, 20);
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
return <Badge variant="secondary">En attente</Badge>;
case 'planned':
return <Badge className="bg-blue-100 text-blue-800">Planifié</Badge>;
case 'delivered':
return <Badge className="bg-green-100 text-green-800">Livré</Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
const getControlBadge = (delivery: DeliveryWithRelations) => {
if (!delivery.supplier?.requiresControl) {
return null;
}
if (delivery.controlValidated) {
return (
<Badge className="bg-green-50 text-green-700 border-green-200 flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Contrôle validé
</Badge>
);
}
return (
<Badge className="bg-orange-50 text-orange-700 border-orange-200 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Contrôle à faire
</Badge>
);
};
const formatQuantity = (quantity: number, unit: string) => {
return `${quantity} ${unit === 'palettes' ? 'P' : 'C'}`;
};
const handleViewDelivery = (delivery: DeliveryWithRelations) => {
setSelectedDelivery({ ...delivery, type: 'delivery' } as any);
setShowDetailModal(true);
};
const handleEditDelivery = (delivery: DeliveryWithRelations) => {
setSelectedDelivery(delivery);
setShowEditModal(true);
};
const handleValidateDelivery = (delivery: DeliveryWithRelations) => {
setDeliveryToValidate(delivery);
setShowValidateModal(true);
};
const handleValidateModalClose = () => {
setShowValidateModal(false);
setDeliveryToValidate(null);
};
const handleDeleteDelivery = (delivery: DeliveryWithRelations) => {
setDeliveryToDelete(delivery);
setShowDeleteModal(true);
};
const handleMarkControlValidated = (deliveryId: number) => {
markControlValidatedMutation.mutate(deliveryId);
};
const confirmDeleteDelivery = () => {
if (deliveryToDelete) {
deleteMutation.mutate(deliveryToDelete.id);
setShowDeleteModal(false);
setDeliveryToDelete(null);
}
};
const canCreate = permissions.canCreate('deliveries');
const canEdit = permissions.canEdit('deliveries');
const canDelete = permissions.canDelete('deliveries');
const canValidate = permissions.canValidate('deliveries');
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">
<Truck className="w-6 h-6 mr-3 text-green-600" />
Gestion des Livraisons
</h2>
<p className="text-gray-600 mt-1">
{filteredDeliveries.length} livraison{filteredDeliveries.length !== 1 ? 's' : ''}
</p>
</div>
{canCreate && (
<Button
onClick={() => setShowCreateModal(true)}
className="bg-green-600 hover:bg-green-700 text-white shadow-md"
>
<Plus className="w-4 h-4 mr-2" />
Nouvelle Livraison
</Button>
)}
</div>
</div>
{/* Filters */}
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
<div className="flex flex-wrap items-center gap-4">
<div className="flex-1 min-w-64">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Rechercher par fournisseur, groupe ou commentaire..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 border border-gray-300 shadow-sm"
/>
</div>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-48 border border-gray-300 shadow-sm">
<Filter className="w-4 h-4 mr-2" />
<SelectValue placeholder="Filtrer par statut" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les statuts</SelectItem>
<SelectItem value="pending">En attente</SelectItem>
<SelectItem value="planned">Planifié</SelectItem>
<SelectItem value="delivered">Livré</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Deliveries List */}
<div className="flex-1 overflow-hidden flex flex-col">
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary"></div>
</div>
) : filteredDeliveries.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
<Truck className="w-16 h-16 mb-4 text-gray-300" />
<h3 className="text-lg font-medium mb-2">Aucune livraison trouvée</h3>
<p className="text-center max-w-md">
{searchTerm || statusFilter !== "all"
? "Aucune livraison ne correspond à vos critères de recherche."
: "Vous n'avez pas encore de livraisons. Créez votre première livraison pour commencer."}
</p>
{canCreate && !searchTerm && statusFilter === "all" && (
<Button
onClick={() => setShowCreateModal(true)}
className="mt-4 bg-secondary hover:bg-green-700 text-white"
>
<Plus className="w-4 h-4 mr-2" />
Créer une livraison
</Button>
)}
</div>
) : (
<div className="flex-1 overflow-y-auto p-6">
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden">
<div>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Fournisseur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Groupe
</th>
<th className="px-6 py-4 text-left text-sm font-black text-gray-800 uppercase tracking-wider">
Date prévue
</th>
<th className="px-6 py-4 text-left text-sm font-black text-gray-800 uppercase tracking-wider">
Quantité
</th>
<th className="px-6 py-4 text-left text-sm font-black text-gray-800 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Commande liée
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Créé par
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedDeliveries.map((delivery) => (
<tr key={delivery.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Building className="w-5 h-5 text-gray-400 mr-2" />
<div>
<div className="text-sm font-medium text-gray-900">
{delivery.supplier?.name}
</div>
<div className="text-sm text-gray-500">
#{delivery.id}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: delivery.group?.color }}
/>
<span className="text-sm text-gray-900">{delivery.group?.name}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div className="flex items-center">
<Calendar className="w-4 h-4 text-gray-400 mr-2" />
{safeFormat(delivery.scheduledDate, 'dd MMM yyyy')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<Badge variant="outline" className="text-sm">
{formatQuantity(delivery.quantity, delivery.unit)}
</Badge>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-col gap-1">
{getStatusBadge(delivery.status)}
{getControlBadge(delivery)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{delivery.order ? (
<div className="flex items-center">
<Package className="w-4 h-4 text-primary mr-2" />
<span>#{delivery.order.id}</span>
</div>
) : (
<span className="text-gray-400">Aucune</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div className="flex items-center">
<User className="w-4 h-4 text-gray-400 mr-2" />
{delivery.creator?.firstName} {delivery.creator?.lastName}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewDelivery(delivery)}
>
<Eye className="w-4 h-4" />
</Button>
{canEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => handleEditDelivery(delivery)}
>
<Edit className="w-4 h-4" />
</Button>
)}
{canValidate && delivery.status !== 'delivered' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleValidateDelivery(delivery)}
className="text-green-600 hover:text-green-700"
disabled={false}
>
<Check className="w-4 h-4 border border-gray-300 rounded p-0.5" />
</Button>
)}
{delivery.supplier?.requiresControl && !delivery.controlValidated && delivery.status === 'delivered' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleMarkControlValidated(delivery.id)}
className="text-orange-600 hover:text-orange-700"
data-testid={`button-validate-control-${delivery.id}`}
title="Marquer le contrôle comme effectué"
>
<CheckCircle className="w-4 h-4" />
</Button>
)}
{canDelete && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteDelivery(delivery)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
onPageChange={setCurrentPage}
onItemsPerPageChange={setItemsPerPage}
className="mt-4 p-4 border-t border-gray-200"
/>
</div>
</div>
)}
</div>
{/* Modals */}
{showCreateModal && (
<CreateDeliveryModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
selectedDate={null}
/>
)}
{showEditModal && selectedDelivery && (
<EditDeliveryModal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
delivery={selectedDelivery}
/>
)}
{showDetailModal && selectedDelivery && (
<OrderDetailModal
isOpen={showDetailModal}
onClose={() => setShowDetailModal(false)}
item={selectedDelivery}
/>
)}
{showValidateModal && deliveryToValidate && (
<ValidateDeliveryModal
isOpen={showValidateModal}
onClose={handleValidateModalClose}
delivery={deliveryToValidate}
/>
)}
{/* Modal de confirmation de suppression */}
<ConfirmDeleteModal
isOpen={showDeleteModal}
onClose={() => {
setShowDeleteModal(false);
setDeliveryToDelete(null);
}}
onConfirm={confirmDeleteDelivery}
title="Supprimer la livraison"
description="Êtes-vous sûr de vouloir supprimer cette livraison ?"
itemName={deliveryToDelete ? `${deliveryToDelete.supplier?.name} - ${safeFormat(deliveryToDelete.scheduledDate, 'dd/MM/yyyy')}` : undefined}
isLoading={deleteMutation.isPending}
/>
</div>
);
}