);
}
+
+// Mémorisé : l'ouverture d'une modale dans la page Calendrier ne recalcule pas la grille
+export default memo(CalendarGrid);
diff --git a/client/src/components/DlcAlertModal.tsx b/client/src/components/DlcAlertModal.tsx
index 5a604ec..6727ce6 100644
--- a/client/src/components/DlcAlertModal.tsx
+++ b/client/src/components/DlcAlertModal.tsx
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useLocation } from "wouter";
import {
Dialog,
DialogContent,
@@ -39,13 +40,9 @@ interface DlcAlertModalProps {
export function DlcAlertModal({ isOpen, onClose, dlcStats, selectedStoreId }: DlcAlertModalProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
+ const [, setLocation] = useLocation();
const [snoozeUntil, setSnoozeUntil] = useState(null);
- // Invalider les queries DLC quand le magasin change
- useEffect(() => {
- queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
- }, [selectedStoreId, queryClient]);
-
// Fetch detailed DLC products for the modal
const { data: expiredProducts = [] } = useQuery({
queryKey: ["/api/dlc-products", "expires", selectedStoreId],
@@ -138,8 +135,10 @@ export function DlcAlertModal({ isOpen, onClose, dlcStats, selectedStoreId }: Dl
markStockEpuiseMutation.mutate(productId);
};
+ // Navigation interne : pas de rechargement complet de l'application
const handleViewDlcModule = () => {
- window.location.href = '/dlc';
+ setLocation('/dlc');
+ onClose();
};
return (
diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx
index 3c22319..06b4553 100644
--- a/client/src/components/Layout.tsx
+++ b/client/src/components/Layout.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, useState, useEffect } from "react";
+import { ReactNode, Suspense, useState, useEffect, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocation } from "wouter";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -9,6 +9,7 @@ import { useIsMobile } from "@/hooks/use-mobile";
import { useScreenSize } from "@/hooks/use-screen-size";
import { StoreProvider } from "@/contexts/StoreContext";
import Sidebar from "./Sidebar";
+import ErrorBoundary from "./ErrorBoundary";
import WeatherWidget from "./WeatherWidget";
import DateWidget from "./DateWidget";
import type { Group } from "@shared/schema";
@@ -18,6 +19,23 @@ interface LayoutProps {
children: ReactNode;
}
+// Tableau vide stable : évite de recréer la valeur du contexte magasin à chaque
+// rendu tant que /api/groups n'a pas répondu
+const EMPTY_STORES: Group[] = [];
+
+// Affiché dans la zone de contenu pendant le chargement du code d'une page
+// (le menu et l'en-tête restent visibles)
+function PageFallback() {
+ return (
+
+
+
+
Chargement...
+
+
+ );
+}
+
export default function Layout({ children }: LayoutProps) {
const { user } = useAuthUnified();
const [location] = useLocation();
@@ -45,11 +63,35 @@ export default function Layout({ children }: LayoutProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
- const { data: stores = [] } = useQuery({
+ const { data: stores = EMPTY_STORES } = useQuery({
queryKey: ['/api/groups'],
enabled: !!user,
});
+ // Changement de magasin demandé dans le sélecteur : les données dépendantes du
+ // magasin sont invalidées après le rendu (effet ci-dessous) et non dans le
+ // gestionnaire. À ce moment les pages ont déjà basculé sur les clés du nouveau
+ // magasin : les requêtes de l'ancien magasin ne sont plus actives et ne sont
+ // donc plus rechargées pour rien, et cancelRefetch: false réutilise les
+ // requêtes du nouveau magasin déjà en cours. L'invalidation est conservée pour
+ // les requêtes dont la clé ne contient pas le magasin (ex. ['/api/orders'] des
+ // modales de livraison).
+ const storeChangePendingRef = useRef(false);
+
+ useEffect(() => {
+ if (!storeChangePendingRef.current) return;
+ storeChangePendingRef.current = false;
+ queryClient.invalidateQueries({
+ predicate: (query) => {
+ const key = query.queryKey;
+ return Boolean(key[0]?.toString().includes('/api/orders') ||
+ key[0]?.toString().includes('/api/deliveries') ||
+ key[0]?.toString().includes('/api/stats/monthly') ||
+ key[0]?.toString().includes('/api/tasks'));
+ }
+ }, { cancelRefetch: false });
+ }, [selectedStoreId, queryClient]);
+
// Effet pour marquer l'initialisation comme terminée
useEffect(() => {
if (user && stores.length > 0) {
@@ -82,8 +124,21 @@ export default function Layout({ children }: LayoutProps) {
window.location.href = "/api/logout";
};
+ // Valeur du contexte mémorisée : les consommateurs de useStore() ne sont
+ // re-rendus que lorsqu'une de ces valeurs change réellement
+ const storeContextValue = useMemo(() => ({
+ selectedStoreId,
+ setSelectedStoreId,
+ stores,
+ sidebarCollapsed,
+ setSidebarCollapsed,
+ mobileMenuOpen,
+ setMobileMenuOpen,
+ storeInitialized
+ }), [selectedStoreId, stores, sidebarCollapsed, mobileMenuOpen, storeInitialized]);
+
return (
-
+
{/* Mobile overlay for normal mode */}
{isMobile && mobileMenuOpen && (
@@ -135,15 +190,10 @@ export default function Layout({ children }: LayoutProps) {
value={selectedStoreId?.toString() || (user.role === 'admin' ? "all" : "")}
onValueChange={(value) => {
const newStoreId = value === "all" ? null : parseInt(value);
- queryClient.invalidateQueries({
- predicate: (query) => {
- const key = query.queryKey;
- return Boolean(key[0]?.toString().includes('/api/orders') ||
- key[0]?.toString().includes('/api/deliveries') ||
- key[0]?.toString().includes('/api/stats/monthly') ||
- key[0]?.toString().includes('/api/tasks'));
- }
- });
+ // Invalidation des données du magasin après le rendu (voir plus haut)
+ if (newStoreId !== selectedStoreId) {
+ storeChangePendingRef.current = true;
+ }
// Sauvegarder dans localStorage et mettre à jour l'état
if (newStoreId) {
@@ -210,7 +260,13 @@ export default function Layout({ children }: LayoutProps) {
- {children}
+ {/* Une erreur dans une page (ou l'échec du chargement de son fichier JS)
+ n'efface plus le menu ; réinitialisée à chaque changement d'URL */}
+
+ }>
+ {children}
+
+
diff --git a/client/src/components/ReconciliationComments.tsx b/client/src/components/ReconciliationComments.tsx
index b630d62..344e668 100644
--- a/client/src/components/ReconciliationComments.tsx
+++ b/client/src/components/ReconciliationComments.tsx
@@ -40,13 +40,36 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
const [newComment, setNewComment] = useState("");
const [editComment, setEditComment] = useState("");
+ const commentsQueryKey = [`/api/deliveries/${deliveryId}/reconciliation-comments`];
+
// Récupérer les commentaires
const { data: comments = [], isLoading } = useQuery({
- queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`],
+ queryKey: commentsQueryKey,
queryFn: () => apiRequest(`/api/deliveries/${deliveryId}/reconciliation-comments`),
enabled: !!deliveryId,
});
+ // Compteur de commentaires affiché dans le rapprochement : mis à jour
+ // localement dans la liste en cache (même clé que BLReconciliation) plutôt
+ // que de retélécharger toute la liste des livraisons pour un compteur.
+ // Les listes qui ne contiennent pas la livraison ne sont pas touchées
+ // (undefined : aucune écriture, leur état « périmé » éventuel est conservé).
+ const adjustCommentsCount = (delta: number) => {
+ queryClient.setQueriesData({ queryKey: ['/api/deliveries', 'reconciliation'] }, (old: unknown) => {
+ if (!Array.isArray(old) || !old.some((delivery: any) => delivery?.id === deliveryId)) {
+ return undefined;
+ }
+ return old.map((delivery: any) =>
+ delivery?.id === deliveryId
+ ? {
+ ...delivery,
+ reconciliationCommentsCount: Math.max(0, (Number(delivery.reconciliationCommentsCount) || 0) + delta),
+ }
+ : delivery
+ );
+ });
+ };
+
// Créer un commentaire
const createCommentMutation = useMutation({
mutationFn: async (content: string) => {
@@ -57,10 +80,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
title: "Succès",
description: "Commentaire ajouté avec succès",
});
- queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
- // Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires
- queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
- queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
+ queryClient.invalidateQueries({ queryKey: commentsQueryKey });
+ adjustCommentsCount(1);
setIsAdding(false);
setNewComment("");
},
@@ -83,10 +104,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
title: "Succès",
description: "Commentaire modifié avec succès",
});
- queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
- // Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires
- queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
- queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
+ // Le nombre de commentaires ne change pas : la liste des livraisons reste valable
+ queryClient.invalidateQueries({ queryKey: commentsQueryKey });
setEditingId(null);
},
onError: (error: any) => {
@@ -108,10 +127,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
title: "Succès",
description: "Commentaire supprimé avec succès",
});
- queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
- // Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires
- queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
- queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
+ queryClient.invalidateQueries({ queryKey: commentsQueryKey });
+ adjustCommentsCount(-1);
},
onError: (error: any) => {
toast({
diff --git a/client/src/components/RouterProduction.tsx b/client/src/components/RouterProduction.tsx
index 9fe05dd..de57377 100644
--- a/client/src/components/RouterProduction.tsx
+++ b/client/src/components/RouterProduction.tsx
@@ -1,47 +1,48 @@
-import { useEffect } from "react";
-import { Switch, Route, useLocation } from "wouter";
+import { lazy, useEffect } from "react";
+import { Switch, Route, Redirect, useLocation } from "wouter";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useScreenSize } from "@/hooks/use-screen-size";
import NotFound from "@/pages/not-found";
import AuthPage from "@/pages/AuthPage";
import Dashboard from "@/pages/Dashboard";
-import Calendar from "@/pages/Calendar";
-import Orders from "@/pages/Orders";
-import Deliveries from "@/pages/Deliveries";
-import Suppliers from "@/pages/Suppliers";
-import Groups from "@/pages/Groups";
-import Users from "@/pages/Users";
-import BLReconciliation from "@/pages/BLReconciliation";
-import Publicities from "@/pages/Publicities";
-
-import NocoDBConfig from "@/pages/NocoDBConfig";
-import DatabaseDebug from "@/pages/DatabaseDebug";
-import CustomerOrders from "@/pages/CustomerOrders";
-
-import DlcPage from "@/pages/DlcPage";
-import BackupManager from "@/pages/BackupManager";
-import Utilities from "@/pages/Utilities";
-import Tasks from "@/pages/Tasks";
-import SavTickets from "@/pages/SavTickets";
-import Avoirs from "@/pages/Avoirs";
-import WeatherSettings from "@/pages/WeatherSettings";
-import Analytics from "@/pages/Analytics";
-import PaymentSchedulePage from "@/pages/PaymentSchedulePage";
-import Contacts from "@/pages/Contacts";
import Layout from "@/components/Layout";
-// Mobile pages
+// Mobile : coque et page d'accueil chargées immédiatement
import MobileApp from "@/pages/mobile/MobileApp";
import MobileDashboardPage from "@/pages/mobile/DashboardPage";
-import MobileOrdersPage from "@/pages/mobile/OrdersPage";
-import MobileDeliveriesPage from "@/pages/mobile/DeliveriesPage";
-import MobileCalendarPage from "@/pages/mobile/CalendarPage";
-import MobileTasksPage from "@/pages/mobile/TasksPage";
-import MobilePublicitiesPage from "@/pages/mobile/PublicitiesPage";
-import MobileCustomerOrdersPage from "@/pages/mobile/CustomerOrdersPage";
-import MobileDlcPage from "@/pages/mobile/DlcPage";
-import MobileSavPage from "@/pages/mobile/SavPage";
-import MobileAvoirsPage from "@/pages/mobile/AvoirsPage";
+
+// Les autres pages sont chargées à la demande (un fichier JS par page) : l'écran
+// de connexion et le tableau de bord n'attendent plus le code de toutes les pages
+// ni la bibliothèque de graphiques. Le est placé dans la zone de
+// contenu de Layout et de MobileApp, le menu et l'en-tête restent affichés.
+const Calendar = lazy(() => import("@/pages/Calendar"));
+const Orders = lazy(() => import("@/pages/Orders"));
+const Deliveries = lazy(() => import("@/pages/Deliveries"));
+const Suppliers = lazy(() => import("@/pages/Suppliers"));
+const Groups = lazy(() => import("@/pages/Groups"));
+const Users = lazy(() => import("@/pages/Users"));
+const BLReconciliation = lazy(() => import("@/pages/BLReconciliation"));
+const Publicities = lazy(() => import("@/pages/Publicities"));
+const CustomerOrders = lazy(() => import("@/pages/CustomerOrders"));
+const DlcPage = lazy(() => import("@/pages/DlcPage"));
+const Utilities = lazy(() => import("@/pages/Utilities"));
+const Tasks = lazy(() => import("@/pages/Tasks"));
+const SavTickets = lazy(() => import("@/pages/SavTickets"));
+const Avoirs = lazy(() => import("@/pages/Avoirs"));
+const Analytics = lazy(() => import("@/pages/Analytics"));
+const PaymentSchedulePage = lazy(() => import("@/pages/PaymentSchedulePage"));
+const Contacts = lazy(() => import("@/pages/Contacts"));
+
+// Pages mobiles chargées à la demande
+const MobileOrdersPage = lazy(() => import("@/pages/mobile/OrdersPage"));
+const MobileDeliveriesPage = lazy(() => import("@/pages/mobile/DeliveriesPage"));
+const MobileCalendarPage = lazy(() => import("@/pages/mobile/CalendarPage"));
+const MobileTasksPage = lazy(() => import("@/pages/mobile/TasksPage"));
+const MobilePublicitiesPage = lazy(() => import("@/pages/mobile/PublicitiesPage"));
+const MobileCustomerOrdersPage = lazy(() => import("@/pages/mobile/CustomerOrdersPage"));
+const MobileDlcPage = lazy(() => import("@/pages/mobile/DlcPage"));
+const MobileSavPage = lazy(() => import("@/pages/mobile/SavPage"));
+const MobileAvoirsPage = lazy(() => import("@/pages/mobile/AvoirsPage"));
// Helper component to handle redirection
const RedirectToAuth = () => {
@@ -53,28 +54,9 @@ const RedirectToAuth = () => {
};
function RouterProduction() {
- const { isAuthenticated, isLoading, user, environment, error } = useAuthUnified();
+ const { isAuthenticated, isLoading, user } = useAuthUnified();
const { isMobile } = useScreenSize();
- // Debug uniquement en développement
- if (import.meta.env.DEV) {
- console.log('🔍 RouterProduction Debug:', {
- environment,
- isAuthenticated,
- isLoading,
- hasUser: !!user,
- userId: user?.id,
- username: user?.username,
- isMobile,
- error: error?.message
- });
- }
-
- // Debug minimal basé sur l'environnement
- if (environment === 'production' && error && import.meta.env.DEV) {
- console.error('🚨 Production Auth Error:', error);
- }
-
// Loading state
if (isLoading) {
return (
@@ -89,9 +71,6 @@ function RouterProduction() {
// Not authenticated
if (!isAuthenticated || !user) {
- if (import.meta.env.DEV) {
- console.log('🔐 Not authenticated, showing auth routes');
- }
return (
@@ -128,14 +107,9 @@ function RouterProduction() {
- {/* Redirection /auth vers dashboard */}
+ {/* Redirection /auth vers dashboard (navigation interne, sans rechargement) */}
- {() => {
- if (typeof window !== 'undefined') {
- window.location.href = '/dashboard';
- }
- return ;
- }}
+
@@ -175,17 +149,10 @@ function RouterProduction() {
- {/* Redirection depuis /auth vers dashboard après authentification */}
+ {/* Redirection depuis /auth vers dashboard après authentification
+ (navigation interne, sans rechargement complet de l'application) */}
- {() => {
- if (typeof window !== 'undefined') {
- if (import.meta.env.DEV) {
- console.log('🔄 Authenticated user on /auth, redirecting to dashboard');
- }
- window.location.href = '/';
- }
- return ;
- }}
+
diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx
index 4b99bd3..66f5e0e 100644
--- a/client/src/components/Sidebar.tsx
+++ b/client/src/components/Sidebar.tsx
@@ -1,5 +1,5 @@
import { Link, useLocation } from "wouter";
-import { useAuthSimple } from "@/hooks/useAuthSimple";
+import { useAuthUnified } from "@/hooks/useAuthUnified";
import { Button } from "@/components/ui/button";
import { useStore } from "@/contexts/StoreContext";
import { useIsMobile } from "@/hooks/use-mobile";
@@ -41,7 +41,7 @@ import {
} from "lucide-react";
export default function Sidebar() {
- const { user, isLoading, error } = useAuthSimple();
+ const { user, isLoading, error } = useAuthUnified();
const [location] = useLocation();
const { sidebarCollapsed, setSidebarCollapsed, mobileMenuOpen, setMobileMenuOpen } = useStore();
const isMobile = useIsMobile();
@@ -218,6 +218,8 @@ export default function Sidebar() {
const isActive = (path: string) => {
if (path === "/" && location === "/") return true;
+ // L'accueil « / » affiche le tableau de bord (même logique que le menu mobile)
+ if (path === "/dashboard" && (location === "/" || location === "/dashboard")) return true;
if (path !== "/" && location.startsWith(path)) return true;
return false;
};
diff --git a/client/src/components/StatsPanel.tsx b/client/src/components/StatsPanel.tsx
index 39caf53..40dd226 100644
--- a/client/src/components/StatsPanel.tsx
+++ b/client/src/components/StatsPanel.tsx
@@ -1,8 +1,7 @@
-import { useQuery } from "@tanstack/react-query";
+import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, Package, Truck, Clock } from "lucide-react";
import { useStore } from "@/contexts/StoreContext";
-import { useAuthUnified } from "@/hooks/useAuthUnified";
interface StatsPanelProps {
currentDate?: Date;
@@ -10,14 +9,13 @@ interface StatsPanelProps {
export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps) {
const { selectedStoreId } = useStore();
- const { user } = useAuthUnified();
const year = currentDate.getFullYear();
const month = currentDate.getMonth() + 1;
// Construire l'URL avec les paramètres
const statsUrl = `/api/stats/monthly?year=${year}&month=${month}${selectedStoreId ? `&storeId=${selectedStoreId}` : ''}`;
- const { data: stats, isLoading } = useQuery({
+ const { data: stats, isLoading, isPlaceholderData } = useQuery({
queryKey: [statsUrl, selectedStoreId], // Include selectedStoreId in key to force refetch
queryFn: async () => {
const response = await fetch(statsUrl, {
@@ -30,6 +28,10 @@ export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps
return response.json();
},
+ // Changement de mois : garder les chiffres affichés pendant le chargement au lieu
+ // du squelette. Jamais au changement de magasin (chiffres d'un autre magasin).
+ placeholderData: (previousData, previousQuery) =>
+ previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
});
if (isLoading) {
@@ -58,7 +60,8 @@ export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps
Statistiques du mois
-
+ {/* Chiffres du mois précédent estompés pendant le chargement du nouveau mois */}
+
diff --git a/client/src/components/WeatherWidget.tsx b/client/src/components/WeatherWidget.tsx
index b3e6967..7b4cf61 100644
--- a/client/src/components/WeatherWidget.tsx
+++ b/client/src/components/WeatherWidget.tsx
@@ -121,7 +121,11 @@ export default function WeatherWidget() {
const { data: weather, isLoading, error } = useQuery({
queryKey: ['/api/weather/current'],
refetchInterval: 30 * 60 * 1000, // Refetch every 30 minutes
- retry: 1,
+ // Données valables jusqu'au prochain rafraîchissement : pas de rechargement au remontage
+ staleTime: 30 * 60 * 1000,
+ // Pas de nouvel essai : un 404 (météo non configurée) ne changera pas, et le
+ // serveur peut appeler l'API météo externe à chaque requête
+ retry: false,
});
diff --git a/client/src/components/modals/CreateDeliveryModal.tsx b/client/src/components/modals/CreateDeliveryModal.tsx
index c87e030..130037b 100644
--- a/client/src/components/modals/CreateDeliveryModal.tsx
+++ b/client/src/components/modals/CreateDeliveryModal.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from "react";
+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";
@@ -15,6 +15,9 @@ 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;
@@ -45,20 +48,29 @@ export default function CreateDeliveryModal({
queryKey: ['/api/suppliers'],
});
- const { data: groupsData = [] } = useQuery({
+ const { data: groupsData = NO_GROUPS } = useQuery({
queryKey: ['/api/groups'],
});
// Filtrer les groupes selon le magasin sélectionné pour les admins
- const groups = Array.isArray(groupsData) ? (
+ // (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]);
- // Filtrer les commandes par fournisseur sélectionné
+ // 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({
- queryKey: ['/api/orders'],
+ 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
@@ -74,21 +86,8 @@ export default function CreateDeliveryModal({
// Auto-sélectionner le magasin selon les règles
useEffect(() => {
- console.log('🏪 CreateDeliveryModal - Store selection effect:', {
- groupsLength: groups.length,
- currentFormGroupId: formData.groupId,
- selectedStoreId,
- userRole: user?.role,
- allGroups: groups.map(g => ({ id: g.id, name: g.name })),
- filteredGroups: groups.length
- });
-
// Reset le formulaire si le magasin sélectionné change
if (user?.role === 'admin' && selectedStoreId && formData.groupId && formData.groupId !== selectedStoreId.toString()) {
- console.log('🔄 Resetting delivery form because store changed:', {
- currentGroupId: formData.groupId,
- newStoreId: selectedStoreId.toString()
- });
setFormData(prev => ({ ...prev, groupId: "", orderId: "" }));
return;
}
@@ -103,21 +102,13 @@ export default function CreateDeliveryModal({
} else {
defaultGroupId = groups[0].id.toString();
}
- console.log('🏪 Admin delivery store selection:', {
- selectedStoreId,
- defaultGroupId,
- firstGroupId: groups[0].id,
- groupsAvailable: groups.map(g => g.name)
- });
} 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();
- console.log('🏪 Non-admin delivery store selection:', { defaultGroupId, firstGroupId: groups[0].id });
}
if (defaultGroupId) {
- console.log('🏪 Setting default group ID for delivery:', defaultGroupId, 'for group:', groups.find(g => g.id.toString() === defaultGroupId)?.name);
setFormData(prev => ({ ...prev, groupId: defaultGroupId }));
}
}
@@ -148,7 +139,6 @@ export default function CreateDeliveryModal({
});
// Invalider toutes les variantes de queryKey pour assurer cohérence
- console.log('🚚 Delivery created, clearing cache for consistency');
queryClient.invalidateQueries({ predicate: (query) => {
const key = query.queryKey;
const firstKey = key[0]?.toString() || '';
diff --git a/client/src/components/modals/CreateOrderModal.tsx b/client/src/components/modals/CreateOrderModal.tsx
index 7dda54d..b255902 100644
--- a/client/src/components/modals/CreateOrderModal.tsx
+++ b/client/src/components/modals/CreateOrderModal.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from "react";
+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";
@@ -14,6 +14,9 @@ import { isUnauthorizedError } from "@/lib/authUtils";
import { format } from "date-fns";
import type { Group, Supplier } from "@shared/schema";
+// Référence stable tant que les magasins ne sont pas chargés
+const NO_GROUPS: Group[] = [];
+
interface CreateOrderModalProps {
isOpen: boolean;
onClose: () => void;
@@ -41,34 +44,23 @@ export default function CreateOrderModal({
queryKey: ['/api/suppliers'],
});
- const { data: groupsData = [] } = useQuery({
+ const { data: groupsData = NO_GROUPS } = useQuery({
queryKey: ['/api/groups'],
});
// Filtrer les groupes selon le magasin sélectionné pour les admins
- const groups = Array.isArray(groupsData) ? (
+ // (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]);
// Auto-sélectionner le magasin selon les règles
useEffect(() => {
- console.log('🏪 CreateOrderModal - Store selection effect:', {
- groupsLength: groups.length,
- currentFormGroupId: formData.groupId,
- selectedStoreId,
- userRole: user?.role,
- allGroups: groups.map(g => ({ id: g.id, name: g.name })),
- filteredGroups: groups.length
- });
-
// Reset le formulaire si le magasin sélectionné change
if (user?.role === 'admin' && selectedStoreId && formData.groupId && formData.groupId !== selectedStoreId.toString()) {
- console.log('🔄 Resetting form because store changed:', {
- currentGroupId: formData.groupId,
- newStoreId: selectedStoreId.toString()
- });
setFormData(prev => ({ ...prev, groupId: "" }));
return;
}
@@ -83,20 +75,12 @@ export default function CreateOrderModal({
} else {
defaultGroupId = groups[0].id.toString();
}
- console.log('🏪 Admin store selection:', {
- selectedStoreId,
- defaultGroupId,
- firstGroupId: groups[0].id,
- groupsAvailable: groups.map(g => g.name)
- });
} else {
// Pour les autres rôles : prendre le premier magasin attribué
defaultGroupId = groups[0].id.toString();
- console.log('🏪 Non-admin store selection:', { defaultGroupId, firstGroupId: groups[0].id });
}
if (defaultGroupId) {
- console.log('🏪 Setting default group ID:', defaultGroupId, 'for group:', groups.find(g => g.id.toString() === defaultGroupId)?.name);
setFormData(prev => ({ ...prev, groupId: defaultGroupId }));
}
}
@@ -104,9 +88,7 @@ export default function CreateOrderModal({
const createOrderMutation = useMutation({
mutationFn: async (data: any) => {
- console.log('🚀 Creating order with data:', data);
const response = await apiRequest("/api/orders", "POST", data);
- console.log('✅ Order created successfully:', response);
return response;
},
onSuccess: () => {
@@ -114,8 +96,6 @@ export default function CreateOrderModal({
title: "Succès",
description: "Commande créée avec succès",
});
- // Force un nettoyage complet du cache pour éviter incohérences
- console.log('🆕 Order created, clearing cache for consistency');
// Invalider toutes les variantes de queryKey pour assurer cohérence
queryClient.invalidateQueries({ predicate: (query) => {
diff --git a/client/src/components/modals/EditDeliveryModal.tsx b/client/src/components/modals/EditDeliveryModal.tsx
index b44fb55..9d3e85f 100644
--- a/client/src/components/modals/EditDeliveryModal.tsx
+++ b/client/src/components/modals/EditDeliveryModal.tsx
@@ -47,8 +47,16 @@ export default function EditDeliveryModal({
queryKey: ['/api/groups'],
});
+ // 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 ; même clé que la modale de création
const { data: allOrders = [] } = useQuery({
- queryKey: ['/api/orders'],
+ 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 sélectionné et groupe - montrer toutes les commandes non livrées du même groupe
diff --git a/client/src/components/modals/ValidateDeliveryModal.tsx b/client/src/components/modals/ValidateDeliveryModal.tsx
index e2906cf..ebd2027 100644
--- a/client/src/components/modals/ValidateDeliveryModal.tsx
+++ b/client/src/components/modals/ValidateDeliveryModal.tsx
@@ -54,10 +54,10 @@ export default function ValidateDeliveryModal({
title: "Succès",
description: "Livraison validée avec succès",
});
- // Invalider tous les caches liés aux livraisons
- queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
- // Invalider tous les caches BL/Rapprochement avec toutes les variations de clés
- queryClient.invalidateQueries({
+ // Invalider tous les caches liés aux livraisons et les caches BL/Rapprochement
+ // avec toutes les variations de clés, en un seul appel (un second appel sur
+ // ['/api/deliveries'] relançait le chargement des listes affichées)
+ queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === '/api/deliveries/bl' ||
query.queryKey[0] === '/api/deliveries'
diff --git a/client/src/hooks/use-mobile.tsx b/client/src/hooks/use-mobile.tsx
index 938433a..99d597a 100644
--- a/client/src/hooks/use-mobile.tsx
+++ b/client/src/hooks/use-mobile.tsx
@@ -3,7 +3,10 @@ import { useState, useEffect } from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
- const [isMobile, setIsMobile] = useState(undefined)
+ // Valeur calculée dès le premier rendu (évite un second rendu au montage)
+ const [isMobile, setIsMobile] = useState(() =>
+ typeof window !== "undefined" ? window.innerWidth < MOBILE_BREAKPOINT : undefined
+ )
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
diff --git a/client/src/hooks/use-screen-size.ts b/client/src/hooks/use-screen-size.ts
index 34fb515..651e6a2 100644
--- a/client/src/hooks/use-screen-size.ts
+++ b/client/src/hooks/use-screen-size.ts
@@ -10,30 +10,28 @@ export interface ScreenBreakpoints {
const defaultBreakpoints: ScreenBreakpoints = {
mobile: 768,
- tablet: 1024,
+ tablet: 1024,
desktop: 1280
}
+const getScreenSize = (width: number, breakpoints: ScreenBreakpoints): ScreenSize => {
+ if (width < breakpoints.mobile) return 'mobile'
+ if (width <= breakpoints.tablet) return 'tablet' // Inclut 1024px comme tablette
+ return 'desktop'
+}
+
export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoints) {
- const [screenSize, setScreenSize] = useState('desktop')
- const [dimensions, setDimensions] = useState({
- width: typeof window !== 'undefined' ? window.innerWidth : 1200,
- height: typeof window !== 'undefined' ? window.innerHeight : 800
- })
+ // Palier calculé dès le premier rendu (pas de passage transitoire par 'desktop')
+ const [screenSize, setScreenSize] = useState(() =>
+ typeof window !== 'undefined' ? getScreenSize(window.innerWidth, breakpoints) : 'desktop'
+ )
useEffect(() => {
- const getScreenSize = (width: number): ScreenSize => {
- if (width < breakpoints.mobile) return 'mobile'
- if (width <= breakpoints.tablet) return 'tablet' // Inclut 1024px comme tablette
- return 'desktop'
- }
-
+ // Seul le palier est stocké : setScreenSize avec la même valeur ne provoque
+ // pas de re-rendu, donc l'arbre n'est re-rendu que lorsque le palier change
+ // et non à chaque pixel de redimensionnement
const updateScreenSize = () => {
- const width = window.innerWidth
- const height = window.innerHeight
-
- setDimensions({ width, height })
- setScreenSize(getScreenSize(width))
+ setScreenSize(getScreenSize(window.innerWidth, breakpoints))
}
// Set initial values
@@ -41,18 +39,25 @@ export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoint
// Add event listener
window.addEventListener('resize', updateScreenSize)
-
+
// Cleanup
return () => window.removeEventListener('resize', updateScreenSize)
}, [breakpoints])
+ // Dimensions lues au moment du rendu (non stockées dans l'état pour ne pas
+ // re-rendre à chaque redimensionnement)
+ const dimensions = {
+ width: typeof window !== 'undefined' ? window.innerWidth : 1200,
+ height: typeof window !== 'undefined' ? window.innerHeight : 800
+ }
+
return {
screenSize,
dimensions,
isMobile: screenSize === 'mobile',
- isTablet: screenSize === 'tablet',
+ isTablet: screenSize === 'tablet',
isDesktop: screenSize === 'desktop',
isMobileOrTablet: screenSize === 'mobile' || screenSize === 'tablet',
isTabletOrDesktop: screenSize === 'tablet' || screenSize === 'desktop'
}
-}
\ No newline at end of file
+}
diff --git a/client/src/hooks/useAuth.ts b/client/src/hooks/useAuth.ts
index b524009..5a709c0 100644
--- a/client/src/hooks/useAuth.ts
+++ b/client/src/hooks/useAuth.ts
@@ -1,28 +1,3 @@
-import { useQuery } from "@tanstack/react-query";
-
-export function useAuth() {
- const { data: user, isLoading, error } = useQuery({
- queryKey: ["/api/user"],
- retry: (failureCount, error: any) => {
- // Ne pas retry si c'est une erreur d'authentification
- if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
- return false;
- }
- return failureCount < 2;
- },
- refetchInterval: false,
- refetchOnWindowFocus: false,
- refetchOnMount: true, // Permettre le rechargement au montage
- refetchOnReconnect: false,
- staleTime: 5 * 60 * 1000, // 5 minutes
- gcTime: 10 * 60 * 1000, // 10 minutes
- enabled: true,
- });
-
- return {
- user: user || null,
- isLoading,
- isAuthenticated: !!user,
- error,
- };
-}
+// Conservé pour compatibilité : délègue au hook unifié (même cache React Query
+// partagé, un seul GET /api/user pour toute l'application)
+export { useAuthUnified as useAuth } from './useAuthUnified';
diff --git a/client/src/hooks/useAuthProduction.ts b/client/src/hooks/useAuthProduction.ts
index e98a98f..bcb6ef4 100644
--- a/client/src/hooks/useAuthProduction.ts
+++ b/client/src/hooks/useAuthProduction.ts
@@ -1,70 +1,3 @@
-import { useState, useEffect } from 'react';
-
-// Hook d'authentification spécifique pour la production
-// Utilise fetch direct sans React Query pour éviter les problèmes de cache
-export function useAuthProduction() {
- const [user, setUser] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
- const [refreshTrigger, setRefreshTrigger] = useState(0);
-
- const checkAuth = async () => {
- try {
- setIsLoading(true);
- setError(null);
-
- console.log('🔄 Production auth check starting...');
-
- const response = await fetch('/api/user', {
- credentials: 'include',
- cache: 'no-cache',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json'
- }
- });
-
- console.log('🔄 Production auth response:', response.status);
-
- if (response.ok) {
- const userData = await response.json();
- console.log('✅ Production auth success:', { username: userData?.username, id: userData?.id });
- setUser(userData);
- setError(null);
- } else if (response.status === 401) {
- console.log('❌ Production auth 401 - user not authenticated');
- setUser(null);
- setError(null);
- } else {
- throw new Error(`Auth failed: ${response.status}`);
- }
- } catch (err) {
- console.error('❌ Production auth error:', err);
- setError(err);
- setUser(null);
- } finally {
- setIsLoading(false);
- console.log('🔄 Production auth check completed');
- }
- };
-
- // Authentification au montage et lors des triggers
- useEffect(() => {
- checkAuth();
- }, [refreshTrigger]);
-
- // Fonction pour rafraîchir l'authentification
- const refreshAuth = () => {
- console.log('🔄 RefreshAuth called for production');
- setRefreshTrigger(prev => prev + 1);
- };
-
- return {
- user,
- isLoading,
- isAuthenticated: !!user,
- error,
- refreshAuth,
- environment: 'production'
- };
-}
\ No newline at end of file
+// Conservé pour compatibilité : délègue au hook unifié (même cache React Query
+// partagé, un seul GET /api/user pour toute l'application)
+export { useAuthUnified as useAuthProduction } from './useAuthUnified';
diff --git a/client/src/hooks/useAuthSimple.ts b/client/src/hooks/useAuthSimple.ts
index bda76c7..9322a41 100644
--- a/client/src/hooks/useAuthSimple.ts
+++ b/client/src/hooks/useAuthSimple.ts
@@ -1,63 +1,3 @@
-import { useState, useEffect } from 'react';
-
-// Hook d'authentification simplifié qui fonctionne dans tous les environnements
-export function useAuthSimple() {
- const [user, setUser] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
- const [refreshCounter, setRefreshCounter] = useState(0);
-
- // Fonction pour vérifier l'authentification
- const checkAuth = async () => {
- try {
- setIsLoading(true);
-
- const response = await fetch('/api/user', {
- credentials: 'include',
- cache: 'no-cache',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json'
- }
- });
-
- if (response.ok) {
- const userData = await response.json();
- console.log('✅ Auth check successful:', { username: userData?.username, id: userData?.id });
- setUser(userData);
- setError(null);
- } else if (response.status === 401) {
- console.log('❌ Auth check 401 - user not authenticated');
- setUser(null);
- setError(null);
- } else {
- throw new Error(`Auth failed: ${response.status}`);
- }
- } catch (err) {
- console.error('Auth check error:', err);
- setError(err);
- setUser(null);
- } finally {
- setIsLoading(false);
- }
- };
-
- // Fonction pour forcer un refresh de l'authentification
- const refreshAuth = () => {
- console.log('🔄 Refreshing authentication...');
- setRefreshCounter(prev => prev + 1);
- };
-
- // Vérifier l'authentification au montage et lors des refresh
- useEffect(() => {
- checkAuth();
- }, [refreshCounter]);
-
- return {
- user,
- isLoading,
- error,
- isAuthenticated: !!user,
- refreshAuth
- };
-}
\ No newline at end of file
+// Conservé pour compatibilité : délègue au hook unifié (même cache React Query
+// partagé, un seul GET /api/user pour toute l'application)
+export { useAuthUnified as useAuthSimple } from './useAuthUnified';
diff --git a/client/src/hooks/useAuthUnified.ts b/client/src/hooks/useAuthUnified.ts
index 4a0e6b6..9f8d64c 100644
--- a/client/src/hooks/useAuthUnified.ts
+++ b/client/src/hooks/useAuthUnified.ts
@@ -1,242 +1,57 @@
-import { useState, useEffect } from 'react';
+import { useCallback } from 'react';
import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { AUTH_QUERY_KEY, fetchCurrentUser } from "@/lib/queryClient";
-// Hook d'authentification unifié qui s'adapte automatiquement
-// En production utilise fetch direct, en développement utilise React Query
+// Conservé dans le retour du hook pour compatibilité (utilisé pour le débogage)
+const environment = import.meta.env.DEV ? 'development' : 'production';
+
+// Hook d'authentification unique (développement et production) : l'utilisateur
+// connecté est lu une seule fois via GET /api/user puis partagé par toute
+// l'application à travers le cache React Query (clé ['/api/user']).
+// Un 401 donne user = null (non connecté), sans redirection.
export function useAuthUnified() {
- // Détection d'environnement plus robuste
- const isDevelopment = typeof window !== 'undefined' &&
- (window.location.hostname === 'localhost' ||
- window.location.hostname.includes('replit.dev')) &&
- import.meta.env.DEV === true;
+ const queryClient = useQueryClient();
- // Debug logging uniquement en développement
- if (import.meta.env.DEV) {
- console.log('🔍 Auth Environment Debug:', {
- hostname: typeof window !== 'undefined' ? window.location.hostname : 'N/A',
- isDev: import.meta.env.DEV,
- environment: isDevelopment ? 'development' : 'production'
- });
- }
-
- // État pour la version production (fetch direct)
- const [productionUser, setProductionUser] = useState(null);
- const [productionLoading, setProductionLoading] = useState(true);
- const [productionError, setProductionError] = useState(null);
- const [refreshTrigger, setRefreshTrigger] = useState(0);
-
- // Hook React Query pour le développement
- const developmentQuery = useQuery({
- queryKey: ["/api/user"],
- retry: (failureCount, error: any) => {
- if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
- return false;
- }
- return failureCount < 2;
- },
- refetchInterval: false,
- refetchOnWindowFocus: false,
- refetchOnMount: true,
- refetchOnReconnect: false,
- staleTime: 10 * 60 * 1000, // 10 minutes de cache pour l'auth
- gcTime: 15 * 60 * 1000, // 15 minutes
- enabled: isDevelopment, // Seulement en développement
+ const authQuery = useQuery({
+ queryKey: AUTH_QUERY_KEY,
+ queryFn: fetchCurrentUser,
+ staleTime: Infinity,
+ retry: false,
+ // Après une erreur serveur, ne pas relancer la requête à chaque montage d'un
+ // composant (AuthPage) : sinon boucle chargement / page de connexion
+ retryOnMount: false,
});
- // Fonction pour rafraîchir l'authentification
- const refreshAuth = () => {
- if (import.meta.env.DEV) {
- console.log('🔄 RefreshAuth called, isDevelopment:', isDevelopment);
- }
- if (!isDevelopment) {
- if (import.meta.env.DEV) {
- console.log('🔄 Triggering production auth refresh');
- }
- setRefreshTrigger(prev => {
- const newValue = prev + 1;
- if (import.meta.env.DEV) {
- console.log('🔄 Production refresh trigger updated:', prev, '->', newValue);
- }
- return newValue;
+ // Rafraîchissement en arrière-plan : tous les composants abonnés reçoivent
+ // le nouvel utilisateur quand la réponse arrive
+ const refreshAuth = useCallback(() => {
+ return queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEY });
+ }, [queryClient]);
+
+ // Rechargement immédiat (après connexion) : attend la réponse du serveur,
+ // met à jour le cache partagé et renvoie l'utilisateur (ou null)
+ const forceAuthRefresh = useCallback(async () => {
+ try {
+ return await queryClient.fetchQuery({
+ queryKey: AUTH_QUERY_KEY,
+ queryFn: fetchCurrentUser,
+ staleTime: 0,
});
- } else {
- if (import.meta.env.DEV) {
- console.log('🔄 Development mode - using React Query refresh');
- }
- developmentQuery.refetch();
+ } catch (error) {
+ console.error("Erreur lors du rafraîchissement de l'authentification:", error);
+ return null;
}
+ }, [queryClient]);
+
+ const user = authQuery.data ?? null;
+
+ return {
+ user,
+ isLoading: authQuery.isLoading,
+ isAuthenticated: !!user,
+ error: authQuery.error,
+ refreshAuth,
+ forceAuthRefresh,
+ environment
};
-
- // Fonction pour rafraîchir de manière synchrone (pour après login)
- const forceAuthRefresh = async () => {
- if (import.meta.env.DEV) {
- console.log('🔄 ForceAuthRefresh called, isDevelopment:', isDevelopment);
- }
-
- if (!isDevelopment) {
- // En production, faire un fetch immédiat et forcer un re-render
- try {
- if (import.meta.env.DEV) {
- console.log('🔄 Production force refresh - fetching user data');
- }
- setProductionLoading(true);
-
- const response = await fetch('/api/user', {
- credentials: 'include',
- cache: 'no-cache',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json'
- }
- });
-
- if (response.ok) {
- const userData = await response.json();
- if (import.meta.env.DEV) {
- console.log('✅ Production force refresh success:', { username: userData?.username, id: userData?.id });
- }
- setProductionUser(userData);
- setProductionError(null);
- setProductionLoading(false);
-
- // Forcer un trigger de refresh pour déclencher les re-renders
- setRefreshTrigger(prev => prev + 1);
-
- return userData;
- } else {
- if (import.meta.env.DEV) {
- console.log('❌ Production force refresh failed:', response.status);
- }
- setProductionUser(null);
- setProductionError(null);
- setProductionLoading(false);
- return null;
- }
- } catch (error) {
- if (import.meta.env.DEV) {
- console.error('❌ Production force refresh error:', error);
- }
- setProductionError(error);
- setProductionUser(null);
- setProductionLoading(false);
- return null;
- }
- } else {
- // En développement, forcer un refetch avec invalidation du cache
- if (import.meta.env.DEV) {
- console.log('🔄 Development mode - invalidating cache and refetching');
- }
- const queryClient = useQueryClient();
- queryClient.invalidateQueries({ queryKey: ['/api/user'] });
-
- // Attendre un court délai pour la propagation
- await new Promise(resolve => setTimeout(resolve, 50));
-
- const result = await developmentQuery.refetch();
- if (import.meta.env.DEV) {
- console.log('🔄 Development refetch result:', {
- success: result.isSuccess,
- hasData: !!result.data,
- userId: (result.data as any)?.id
- });
- }
- return result.data;
- }
- };
-
- // Authentification production (fetch direct)
- useEffect(() => {
- if (isDevelopment) return; // Ne pas exécuter en développement
-
- let isMounted = true;
-
- const checkAuth = async () => {
- try {
- if (import.meta.env.DEV) {
- console.log('🔄 Production auth check starting, refreshTrigger:', refreshTrigger);
- }
- setProductionLoading(true);
-
- const response = await fetch('/api/user', {
- credentials: 'include',
- cache: 'no-cache',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json'
- }
- });
-
- if (import.meta.env.DEV) {
- console.log('🔄 Production auth response:', response.status);
- }
-
- if (!isMounted) return;
-
- if (response.ok) {
- const userData = await response.json();
- if (import.meta.env.DEV) {
- console.log('✅ Production auth success:', { username: userData?.username, id: userData?.id });
- }
- if (isMounted) {
- setProductionUser(userData);
- setProductionError(null);
- }
- } else if (response.status === 401) {
- if (import.meta.env.DEV) {
- console.log('❌ Production auth 401 - user not authenticated');
- }
- if (isMounted) {
- setProductionUser(null);
- setProductionError(null);
- }
- } else {
- throw new Error(`Auth failed: ${response.status}`);
- }
- } catch (err) {
- if (import.meta.env.DEV) {
- console.error('Production auth error:', err);
- }
- if (isMounted) {
- setProductionError(err);
- setProductionUser(null);
- }
- } finally {
- if (isMounted) {
- if (import.meta.env.DEV) {
- console.log('🔄 Production auth check complete, loading set to false');
- }
- setProductionLoading(false);
- }
- }
- };
-
- checkAuth();
-
- return () => {
- isMounted = false;
- };
- }, [isDevelopment, refreshTrigger]); // Ajout du refreshTrigger
-
- // Retourner les bonnes données selon l'environnement
- if (isDevelopment) {
- return {
- user: developmentQuery.data || null,
- isLoading: developmentQuery.isLoading,
- isAuthenticated: !!developmentQuery.data,
- error: developmentQuery.error,
- refreshAuth: refreshAuth,
- forceAuthRefresh: forceAuthRefresh,
- environment: 'development'
- };
- } else {
- return {
- user: productionUser,
- isLoading: productionLoading,
- isAuthenticated: !!productionUser,
- error: productionError,
- refreshAuth: refreshAuth,
- forceAuthRefresh: forceAuthRefresh,
- environment: 'production'
- };
- }
-}
\ No newline at end of file
+}
diff --git a/client/src/lib/queryClient.ts b/client/src/lib/queryClient.ts
index 8befad5..65ecdff 100644
--- a/client/src/lib/queryClient.ts
+++ b/client/src/lib/queryClient.ts
@@ -66,6 +66,26 @@ export const getQueryFn: (options: {
return await res.json();
};
+// Clé de cache de l'utilisateur connecté, partagée par toute l'application
+export const AUTH_QUERY_KEY = ["/api/user"] as const;
+
+// Lecture de l'utilisateur connecté. Un 401 signifie simplement « non connecté » :
+// on renvoie null sans rediriger (sinon boucle sur la page de connexion).
+export async function fetchCurrentUser(): Promise {
+ const res = await fetch("/api/user", {
+ credentials: "include",
+ cache: "no-cache",
+ headers: { Accept: "application/json" },
+ });
+
+ if (res.status === 401) {
+ return null;
+ }
+
+ await throwIfResNotOk(res);
+ return await res.json();
+}
+
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -77,15 +97,22 @@ export const queryClient = new QueryClient({
staleTime: 30 * 1000, // 30 secondes de cache pour de meilleures performances
gcTime: 10 * 60 * 1000, // 10 minutes
retry: (failureCount, error: any) => {
+ const message: string = error?.message ?? '';
// Ne pas retry les erreurs d'authentification et rediriger
- if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
+ if (message.includes('401') || message.includes('Unauthorized')) {
if (typeof window !== 'undefined' && window.location.pathname !== '/auth') {
console.log('🔄 Query 401 - Redirecting to auth page');
window.location.href = '/auth';
}
return false;
}
- return failureCount < 2;
+ // Erreur côté client (403 droit refusé, 404...) : un nouvel essai
+ // donnerait le même résultat et retarderait l'affichage de l'erreur
+ if (/^4\d\d\b/.test(message)) {
+ return false;
+ }
+ // Erreur serveur ou réseau : un seul nouvel essai
+ return failureCount < 1;
},
},
mutations: {
@@ -93,3 +120,19 @@ export const queryClient = new QueryClient({
},
},
});
+
+// Données de référence qui changent rarement (les mutations fournisseurs et
+// magasins invalident déjà ces clés) : pas de rechargement à chaque ouverture
+// de page ou de modale.
+queryClient.setQueryDefaults(["/api/suppliers"], { staleTime: 5 * 60 * 1000 });
+queryClient.setQueryDefaults(["/api/groups"], { staleTime: 5 * 60 * 1000 });
+
+// Utilisateur connecté : chargé une seule fois pour toute l'application, puis
+// rafraîchi explicitement (connexion, refreshAuth). Pas de nouvel essai, ni au
+// montage d'un composant : un échec équivaut à « non connecté ».
+queryClient.setQueryDefaults(AUTH_QUERY_KEY, {
+ queryFn: fetchCurrentUser,
+ staleTime: Infinity,
+ retry: false,
+ retryOnMount: false,
+});
diff --git a/client/src/main.tsx b/client/src/main.tsx
index 696e0d2..91a303b 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -2,4 +2,21 @@ import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
+// Les pages sont chargées à la demande : après une mise en production, les
+// anciens fichiers JS n'existent plus sur le serveur et le chargement d'une page
+// échoue. On recharge alors l'application une seule fois pour récupérer la
+// nouvelle version (au plus une fois par minute, pour ne pas boucler si le
+// serveur est indisponible ; l'erreur est alors affichée normalement).
+window.addEventListener("vite:preloadError", () => {
+ const storageKey = "logiflow-chunk-reload-at";
+ try {
+ const lastReload = Number(sessionStorage.getItem(storageKey) || 0);
+ if (Date.now() - lastReload < 60 * 1000) return;
+ sessionStorage.setItem(storageKey, String(Date.now()));
+ } catch {
+ return;
+ }
+ window.location.reload();
+});
+
createRoot(document.getElementById("root")!).render();
diff --git a/client/src/pages/Analytics.tsx b/client/src/pages/Analytics.tsx
index fe9e398..4267e69 100644
--- a/client/src/pages/Analytics.tsx
+++ b/client/src/pages/Analytics.tsx
@@ -1,5 +1,5 @@
import { useState, useMemo } from "react";
-import { useQuery } from "@tanstack/react-query";
+import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -52,11 +52,13 @@ export default function Analytics() {
return params.toString();
}, [dateRange, selectedSuppliers, selectedStores, selectedStatus]);
- // Récupération des données
- const { data: summary, isLoading: summaryLoading } = useQuery({
+ // Récupération des données. Au changement de filtre (période, fournisseur,
+ // magasin, granularité), les résultats précédents restent affichés, atténués
+ // et signalés par « Mise à jour… », au lieu de remplacer toute la page par un
+ // spinner : le chargement plein écran ne concerne que le premier affichage.
+ const { data: summary, isLoading: summaryLoading, isPlaceholderData: summaryUpdating } = useQuery({
queryKey: ['/api/analytics/summary', queryParams],
queryFn: async () => {
- console.log('📊 [ANALYTICS] Fetching summary with params:', queryParams);
const response = await fetch(`/api/analytics/summary?${queryParams}`, {
credentials: 'include'
});
@@ -64,18 +66,16 @@ export default function Analytics() {
console.error('❌ [ANALYTICS] Summary fetch failed:', response.status, response.statusText);
throw new Error('Failed to fetch summary');
}
- const data = await response.json();
- console.log('✅ [ANALYTICS] Summary data received:', data);
- return data;
+ return response.json();
},
+ placeholderData: keepPreviousData,
refetchInterval: isRefreshing ? 30000 : false
});
- const { data: timeseries } = useQuery({
+ const { data: timeseries, isPlaceholderData: timeseriesUpdating } = useQuery({
queryKey: ['/api/analytics/timeseries', queryParams, granularity],
queryFn: async () => {
const url = `/api/analytics/timeseries?${queryParams}&granularity=${granularity}`;
- console.log('📊 [ANALYTICS] Fetching timeseries:', url);
const response = await fetch(url, {
credentials: 'include'
});
@@ -83,13 +83,12 @@ export default function Analytics() {
console.error('❌ [ANALYTICS] Timeseries fetch failed:', response.status, response.statusText);
throw new Error('Failed to fetch timeseries');
}
- const data = await response.json();
- console.log('✅ [ANALYTICS] Timeseries data received:', data?.length || 0, 'entries');
- return data;
- }
+ return response.json();
+ },
+ placeholderData: keepPreviousData,
});
- const { data: bySupplier } = useQuery({
+ const { data: bySupplier, isPlaceholderData: bySupplierUpdating } = useQuery({
queryKey: ['/api/analytics/by-supplier', queryParams],
queryFn: async () => {
const response = await fetch(`/api/analytics/by-supplier?${queryParams}`, {
@@ -97,10 +96,11 @@ export default function Analytics() {
});
if (!response.ok) throw new Error('Failed to fetch by supplier');
return response.json();
- }
+ },
+ placeholderData: keepPreviousData,
});
- const { data: byStore } = useQuery({
+ const { data: byStore, isPlaceholderData: byStoreUpdating } = useQuery({
queryKey: ['/api/analytics/by-store', queryParams],
queryFn: async () => {
const response = await fetch(`/api/analytics/by-store?${queryParams}`, {
@@ -108,9 +108,14 @@ export default function Analytics() {
});
if (!response.ok) throw new Error('Failed to fetch by store');
return response.json();
- }
+ },
+ placeholderData: keepPreviousData,
});
+ const isUpdating = summaryUpdating || timeseriesUpdating || bySupplierUpdating || byStoreUpdating;
+ // Bloc dont les chiffres viennent encore des filtres précédents
+ const updatingClass = (updating: boolean) => `transition-opacity ${updating ? 'opacity-60' : ''}`;
+
const { data: suppliers } = useQuery({
queryKey: ['/api/suppliers'],
});
@@ -161,6 +166,12 @@ export default function Analytics() {