mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-21 14:08:15 +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
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
import { useState, useEffect, useMemo } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
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 { format } from "date-fns";
|
|
import { safeFormat } from "@/lib/dateUtils";
|
|
import type { Group, Supplier, OrderWithRelations } from "@shared/schema";
|
|
|
|
// Référence stable tant que les magasins ne sont pas chargés
|
|
const NO_GROUPS: Group[] = [];
|
|
|
|
interface CreateDeliveryModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
selectedDate: Date | null;
|
|
}
|
|
|
|
export default function CreateDeliveryModal({
|
|
isOpen,
|
|
onClose,
|
|
selectedDate,
|
|
}: CreateDeliveryModalProps) {
|
|
const { user } = useAuthUnified();
|
|
const { selectedStoreId } = useStore();
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [formData, setFormData] = useState({
|
|
orderId: "",
|
|
supplierId: "",
|
|
groupId: "",
|
|
scheduledDate: selectedDate ? format(selectedDate, 'yyyy-MM-dd') : "",
|
|
quantity: "",
|
|
unit: "palettes",
|
|
notes: "",
|
|
});
|
|
|
|
const { data: suppliers = [] } = useQuery<Supplier[]>({
|
|
queryKey: ['/api/suppliers'],
|
|
});
|
|
|
|
const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
|
|
queryKey: ['/api/groups'],
|
|
});
|
|
|
|
// Filtrer les groupes selon le magasin sélectionné pour les admins
|
|
// (mémorisé : la liste est une dépendance de l'effet ci-dessous, qui
|
|
// tournait sinon à chaque rendu, donc à chaque frappe dans le formulaire)
|
|
const groups = useMemo(() => Array.isArray(groupsData) ? (
|
|
user?.role === 'admin' && selectedStoreId
|
|
? groupsData.filter(g => g.id === selectedStoreId)
|
|
: groupsData
|
|
) : NO_GROUPS, [groupsData, user?.role, selectedStoreId]);
|
|
|
|
// Commandes du magasin du formulaire uniquement (paramètre storeId, dont
|
|
// l'accès est contrôlé par le serveur) au lieu de tout l'historique de tous
|
|
// les magasins ; filtrées ensuite par fournisseur
|
|
const { data: allOrders = [] } = useQuery<OrderWithRelations[]>({
|
|
queryKey: ['/api/orders', 'store', formData.groupId],
|
|
queryFn: async () => {
|
|
const data = await apiRequest(`/api/orders?storeId=${formData.groupId}`);
|
|
return Array.isArray(data) ? data : [];
|
|
},
|
|
enabled: !!formData.groupId,
|
|
});
|
|
|
|
// Filtrer les commandes par fournisseur ET magasin sélectionnés - montrer toutes les commandes non livrées
|
|
const availableOrders = allOrders.filter(order => {
|
|
if (!formData.supplierId || !formData.groupId) return false;
|
|
|
|
return (
|
|
order.supplierId === parseInt(formData.supplierId) &&
|
|
order.groupId === parseInt(formData.groupId) &&
|
|
order.status !== 'delivered'
|
|
);
|
|
});
|
|
|
|
// Auto-sélectionner le magasin selon les règles
|
|
useEffect(() => {
|
|
// Reset le formulaire si le magasin sélectionné change
|
|
if (user?.role === 'admin' && selectedStoreId && formData.groupId && formData.groupId !== selectedStoreId.toString()) {
|
|
setFormData(prev => ({ ...prev, groupId: "", orderId: "" }));
|
|
return;
|
|
}
|
|
|
|
if (groups.length > 0 && !formData.groupId) {
|
|
let defaultGroupId = "";
|
|
|
|
if (user?.role === 'admin') {
|
|
// Pour l'admin : utiliser le magasin sélectionné dans le header (filtré), sinon le premier de la liste
|
|
if (selectedStoreId && groups.find(g => g.id === selectedStoreId)) {
|
|
defaultGroupId = selectedStoreId.toString();
|
|
} else {
|
|
defaultGroupId = groups[0].id.toString();
|
|
}
|
|
} else {
|
|
// Pour les autres rôles : prendre le premier magasin attribué
|
|
// (La logique existante filtre déjà les groupes selon les permissions)
|
|
defaultGroupId = groups[0].id.toString();
|
|
}
|
|
|
|
if (defaultGroupId) {
|
|
setFormData(prev => ({ ...prev, groupId: defaultGroupId }));
|
|
}
|
|
}
|
|
}, [groups, selectedStoreId, user?.role, formData.groupId]);
|
|
|
|
// Réinitialiser la commande sélectionnée quand on change de fournisseur
|
|
useEffect(() => {
|
|
if (formData.supplierId) {
|
|
setFormData(prev => ({ ...prev, orderId: "" }));
|
|
}
|
|
}, [formData.supplierId]);
|
|
|
|
// Réinitialiser la commande sélectionnée quand on change de magasin
|
|
useEffect(() => {
|
|
if (formData.groupId) {
|
|
setFormData(prev => ({ ...prev, orderId: "" }));
|
|
}
|
|
}, [formData.groupId]);
|
|
|
|
const createDeliveryMutation = useMutation({
|
|
mutationFn: async (data: any) => {
|
|
await apiRequest("/api/deliveries", "POST", data);
|
|
},
|
|
onSuccess: () => {
|
|
toast({
|
|
title: "Succès",
|
|
description: "Livraison créée avec succès",
|
|
});
|
|
|
|
// Invalider toutes les variantes de queryKey pour assurer cohérence
|
|
queryClient.invalidateQueries({ predicate: (query) => {
|
|
const key = query.queryKey;
|
|
const firstKey = key[0]?.toString() || '';
|
|
return firstKey.includes('/api/orders') ||
|
|
firstKey.includes('/api/deliveries') ||
|
|
firstKey.includes('/api/stats/monthly');
|
|
}});
|
|
|
|
onClose();
|
|
},
|
|
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 créer la livraison",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!formData.supplierId || !formData.groupId || !formData.scheduledDate || !formData.quantity) {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Veuillez remplir tous les champs obligatoires",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
createDeliveryMutation.mutate({
|
|
orderId: formData.orderId && formData.orderId !== "none" ? parseInt(formData.orderId) : undefined,
|
|
supplierId: parseInt(formData.supplierId),
|
|
groupId: parseInt(formData.groupId),
|
|
scheduledDate: formData.scheduledDate,
|
|
quantity: parseInt(formData.quantity),
|
|
unit: formData.unit,
|
|
status: "planned",
|
|
notes: formData.notes || undefined,
|
|
createdBy: user?.id,
|
|
});
|
|
};
|
|
|
|
const handleChange = (field: string, value: string) => {
|
|
setFormData(prev => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
// Afficher toutes les commandes du fournisseur sélectionné (pas de filtrage supplémentaire)
|
|
const finalFilteredOrders = availableOrders;
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className="sm:max-w-md" aria-describedby="delivery-modal-description">
|
|
<DialogHeader>
|
|
<DialogTitle>Nouvelle Livraison</DialogTitle>
|
|
<p id="delivery-modal-description" className="text-sm text-gray-600 mt-1">
|
|
Créer une nouvelle livraison pour un fournisseur
|
|
</p>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="supplier">Fournisseur *</Label>
|
|
<Select value={formData.supplierId} onValueChange={(value) => handleChange('supplierId', value)}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Sélectionnez un fournisseur" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Array.isArray(suppliers) && suppliers.map((supplier) => (
|
|
<SelectItem key={supplier.id} value={supplier.id.toString()}>
|
|
{supplier.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{formData.supplierId && (
|
|
<div>
|
|
<Label htmlFor="order">Commande liée (optionnel)</Label>
|
|
<Select value={formData.orderId} onValueChange={(value) => handleChange('orderId', value)}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Sélectionnez une commande de ce fournisseur" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">Aucune commande liée</SelectItem>
|
|
{Array.isArray(finalFilteredOrders) && finalFilteredOrders.map((order) => (
|
|
<SelectItem key={order.id} value={order.id.toString()}>
|
|
#{order.id} - {order.supplier.name} - {safeFormat(order.plannedDate, 'dd/MM/yyyy')}
|
|
</SelectItem>
|
|
))}
|
|
{(!Array.isArray(finalFilteredOrders) || finalFilteredOrders.length === 0) && (
|
|
<SelectItem value="disabled" disabled>
|
|
Aucune commande disponible pour ce fournisseur
|
|
</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Affichage du magasin sélectionné */}
|
|
{formData.groupId && (
|
|
<div>
|
|
<Label>Magasin/Groupe</Label>
|
|
<div className="flex items-center space-x-2 p-3 bg-gray-50 rounded-md border">
|
|
{(() => {
|
|
const selectedGroup = groups.find(g => g.id.toString() === formData.groupId);
|
|
return selectedGroup ? (
|
|
<>
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: selectedGroup.color || undefined }}
|
|
/>
|
|
<span className="font-medium">{selectedGroup.name}</span>
|
|
</>
|
|
) : null;
|
|
})()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<Label htmlFor="scheduledDate">Date prévue *</Label>
|
|
<Input
|
|
id="scheduledDate"
|
|
type="date"
|
|
value={formData.scheduledDate}
|
|
onChange={(e) => handleChange('scheduledDate', e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="quantity">Quantité *</Label>
|
|
<Input
|
|
id="quantity"
|
|
type="number"
|
|
min="1"
|
|
value={formData.quantity}
|
|
onChange={(e) => handleChange('quantity', e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="unit">Unité</Label>
|
|
<Select value={formData.unit} onValueChange={(value) => handleChange('unit', value)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="palettes">Palettes</SelectItem>
|
|
<SelectItem value="colis">Colis</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="notes">Commentaires</Label>
|
|
<Textarea
|
|
id="notes"
|
|
value={formData.notes}
|
|
onChange={(e) => handleChange('notes', e.target.value)}
|
|
placeholder="Commentaires additionnels..."
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-3 pt-4">
|
|
<Button type="button" variant="outline" onClick={onClose}>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={createDeliveryMutation.isPending}
|
|
className="bg-secondary hover:bg-green-700"
|
|
>
|
|
{createDeliveryMutation.isPending ? "Création..." : "Créer la livraison"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|