Files
LogiFlow/client/src/components/Layout.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

277 lines
11 KiB
TypeScript

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";
import { Button } from "@/components/ui/button";
import { LogOut, Store, Menu, AlertTriangle } from "lucide-react";
import { useAuthUnified } from "@/hooks/useAuthUnified";
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";
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 (
<div className="flex items-center justify-center py-16">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement...</p>
</div>
</div>
);
}
export default function Layout({ children }: LayoutProps) {
const { user } = useAuthUnified();
const [location] = useLocation();
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const { screenSize, isMobileOrTablet, isTablet } = useScreenSize();
const [selectedStoreId, setSelectedStoreId] = useState<number | null>(() => {
// Restaurer le selectedStoreId depuis localStorage si disponible
const saved = localStorage.getItem('selectedStoreId');
return saved ? parseInt(saved) : null;
});
const [storeInitialized, setStoreInitialized] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
// Restaurer l'état de la sidebar depuis localStorage, mais forcer collapsed sur mobile
try {
const saved = localStorage.getItem('sidebarCollapsed');
return isMobile ? true : (saved ? JSON.parse(saved) : false);
} catch {
return isMobile ? true : false;
}
});
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { data: stores = EMPTY_STORES } = useQuery<Group[]>({
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) {
let needsUpdate = false;
// Pour tous les rôles, vérifier que selectedStoreId est cohérent avec les stores disponibles
if (selectedStoreId) {
const storeExists = stores.find(store => store.id === selectedStoreId);
if (!storeExists) {
setSelectedStoreId(null);
localStorage.removeItem('selectedStoreId');
needsUpdate = true;
}
}
// IMPORTANT FIX: Pour les rôles directeur/manager, forcer la sélection d'un magasin spécifique
// si aucun n'est sélectionné et qu'ils n'ont accès qu'à un seul magasin
if ((user.role === 'directeur' || user.role === 'manager') && !selectedStoreId && stores.length === 1 && !needsUpdate) {
const singleStoreId = stores[0].id;
setSelectedStoreId(singleStoreId);
localStorage.setItem('selectedStoreId', singleStoreId.toString());
}
// VALIDATION CRITIQUE: Pour les directeurs/managers, s'assurer qu'un magasin est toujours sélectionné
setStoreInitialized(true);
}
}, [user, stores, selectedStoreId]);
const handleLogout = () => {
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 (
<StoreProvider value={storeContextValue}>
<div className="layout-container flex bg-gray-50">
{/* Mobile overlay for normal mode */}
{isMobile && mobileMenuOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40"
onClick={() => setMobileMenuOpen(false)}
/>
)}
{/* Sidebar for normal mode */}
<Sidebar />
<main className={`flex-1 flex flex-col h-full ${
// Ajustement des marges selon le mode
isMobile
? 'ml-0'
: (isTablet && !sidebarCollapsed) ? 'ml-48' : 'ml-0'
}`}>
{/* Header with mobile menu and store selector */}
<header className={`h-16 bg-white border-b border-gray-200 flex items-center justify-between ${isMobileOrTablet ? 'px-3' : 'px-6'
}`}>
<div className="flex items-center gap-2 sm:gap-4 min-w-0 flex-1">
{/* Mobile menu button (only in normal mode) */}
{isMobile && (
<Button
variant="ghost"
size="sm"
onClick={() => setMobileMenuOpen(true)}
className="min-h-[44px] min-w-[44px] p-0 flex-shrink-0"
data-testid="button-mobile-menu"
>
<Menu className="h-5 w-5" />
</Button>
)}
{/* Weather and Date widgets - responsive */}
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-3'
} min-w-0`}>
<WeatherWidget />
{!isMobile && <DateWidget />}
</div>
</div>
{/* Store selector for admin, directeur, and manager - responsive */}
{user && (user.role === 'admin' || user.role === 'directeur' || user.role === 'manager') && stores.length > 0 && (
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<Store className="h-4 w-4 text-gray-500 hidden sm:block" />
<Select
value={selectedStoreId?.toString() || (user.role === 'admin' ? "all" : "")}
onValueChange={(value) => {
const newStoreId = value === "all" ? null : parseInt(value);
// 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) {
localStorage.setItem('selectedStoreId', newStoreId.toString());
} else {
localStorage.removeItem('selectedStoreId');
}
setSelectedStoreId(newStoreId);
}}
>
<SelectTrigger className={`border border-gray-300 min-h-[44px] ${isMobile ? 'w-28 text-xs' :
isTablet ? 'w-40 text-sm' :
'w-64 text-sm'
}`} data-testid="select-store">
<SelectValue placeholder="Magasin" />
</SelectTrigger>
<SelectContent>
{/* Only show "Tous les magasins" option for admin */}
{user.role === 'admin' && (
<SelectItem value="all">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-gray-400"></div>
<span className={isMobile ? 'text-xs' : 'text-sm'}>
{isMobile ? 'Tous' : 'Tous les magasins'}
</span>
</div>
</SelectItem>
)}
{stores.map((store) => (
<SelectItem key={store.id} value={store.id.toString()}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3"
style={{ backgroundColor: store.color || '#gray-400' }}
/>
<span className={`${isMobile ? 'text-xs' : 'text-sm'} truncate max-w-[120px]`}>
{store.name}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</header>
{/* Alert for directeur/manager without store selection */}
{user && (user.role === 'directeur' || user.role === 'manager') && !selectedStoreId && storeInitialized && (
<div className="bg-red-50 border-l-4 border-red-400 p-4 m-4">
<div className="flex">
<div className="flex-shrink-0">
<AlertTriangle className="h-5 w-5 text-red-400" />
</div>
<div className="ml-3">
<p className="text-sm text-red-700">
<strong>Action requise :</strong> Veuillez sélectionner un magasin pour afficher les données.
Vos permissions ne permettent pas de voir les données de tous les magasins.
</p>
</div>
</div>
</div>
)}
<div className={`flex-1 bg-gray-50 h-full overflow-y-auto overflow-x-hidden ${isMobileOrTablet ? 'p-3' : 'p-6'
}`} style={{ maxWidth: '100%' }}>
{/* 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 */}
<ErrorBoundary key={location}>
<Suspense fallback={<PageFallback />}>
{children}
</Suspense>
</ErrorBoundary>
</div>
</main>
</div>
</StoreProvider>
);
}