From 5d3e5357a081868abc4ddbf6617d875a14916070 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:29:51 +0000 Subject: [PATCH] =?UTF-8?q?perf(client):=20pages=20charg=C3=A9es=20=C3=A0?= =?UTF-8?q?=20la=20demande=20et=20un=20seul=20appel=20/api/user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb --- client/index.html | 4 +- client/src/components/AnnouncementCard.tsx | 90 +- client/src/components/CalendarGrid.tsx | 313 +++--- client/src/components/DlcAlertModal.tsx | 11 +- client/src/components/Layout.tsx | 82 +- .../src/components/ReconciliationComments.tsx | 43 +- client/src/components/RouterProduction.tsx | 117 +-- client/src/components/Sidebar.tsx | 6 +- client/src/components/StatsPanel.tsx | 13 +- client/src/components/WeatherWidget.tsx | 6 +- .../components/modals/CreateDeliveryModal.tsx | 46 +- .../components/modals/CreateOrderModal.tsx | 38 +- .../components/modals/EditDeliveryModal.tsx | 10 +- .../modals/ValidateDeliveryModal.tsx | 8 +- client/src/hooks/use-mobile.tsx | 5 +- client/src/hooks/use-screen-size.ts | 45 +- client/src/hooks/useAuth.ts | 31 +- client/src/hooks/useAuthProduction.ts | 73 +- client/src/hooks/useAuthSimple.ts | 66 +- client/src/hooks/useAuthUnified.ts | 281 +----- client/src/lib/queryClient.ts | 47 +- client/src/main.tsx | 17 + client/src/pages/Analytics.tsx | 55 +- client/src/pages/AuthPage.tsx | 24 +- client/src/pages/Avoirs.tsx | 476 +++++---- client/src/pages/BLReconciliation.tsx | 360 +++---- client/src/pages/BackupManager.tsx | 10 +- client/src/pages/Calendar.tsx | 133 +-- client/src/pages/Contacts.tsx | 18 +- client/src/pages/CustomerOrders.tsx | 217 ++-- client/src/pages/Dashboard.tsx | 274 +++--- client/src/pages/Deliveries.tsx | 65 +- client/src/pages/DlcPage.tsx | 126 ++- client/src/pages/Groups.tsx | 10 +- client/src/pages/NocoDBConfig.tsx | 17 +- client/src/pages/Orders.tsx | 75 +- client/src/pages/PaymentSchedulePage.tsx | 87 +- client/src/pages/Publicities.tsx | 928 +++++++++--------- client/src/pages/SavTickets.tsx | 91 +- client/src/pages/Suppliers.tsx | 43 +- client/src/pages/Tasks.tsx | 407 ++++---- client/src/pages/Users.tsx | 35 +- client/src/pages/mobile/AvoirsPage.tsx | 55 +- client/src/pages/mobile/CalendarPage.tsx | 86 +- .../src/pages/mobile/CustomerOrdersPage.tsx | 22 +- client/src/pages/mobile/DashboardPage.tsx | 36 +- client/src/pages/mobile/DlcPage.tsx | 38 +- client/src/pages/mobile/MobileApp.tsx | 31 +- client/src/pages/mobile/MobileBottomNav.tsx | 2 - client/src/pages/mobile/TasksPage.tsx | 160 ++- vite.config.ts | 6 +- 51 files changed, 2533 insertions(+), 2706 deletions(-) diff --git a/client/index.html b/client/index.html index 0a3b568..1de106a 100644 --- a/client/index.html +++ b/client/index.html @@ -1,5 +1,5 @@ - + @@ -15,7 +15,5 @@
- - \ No newline at end of file diff --git a/client/src/components/AnnouncementCard.tsx b/client/src/components/AnnouncementCard.tsx index f52ac83..f05a55b 100644 --- a/client/src/components/AnnouncementCard.tsx +++ b/client/src/components/AnnouncementCard.tsx @@ -49,7 +49,7 @@ import { import { useToast } from "@/hooks/use-toast"; // Type pour les annonces avec relations -type AnnouncementWithRelations = DashboardMessage & { +export type AnnouncementWithRelations = DashboardMessage & { author: { id: string | number; firstName?: string; @@ -97,6 +97,45 @@ const getPriorityConfig = (type: string) => { } }; +// Requête des annonces, partagée avec le tableau de bord (même clé, même URL) : +// un seul appel réseau, et les invalidations ['/api/announcements'] des +// mutations ci-dessous rafraîchissent les deux affichages. +export function getAnnouncementsQueryOptions(selectedStoreId: number | null, role: string | undefined) { + return { + queryKey: ['/api/announcements', selectedStoreId, role], + queryFn: async (): Promise => { + const params = new URLSearchParams(); + + // Pour les admins, permettre le filtrage par magasin sélectionné + if (role === 'admin' && selectedStoreId) { + params.append('storeId', selectedStoreId.toString()); + } + + const url = `/api/announcements${params.toString() ? `?${params.toString()}` : ''}`; + const response = await fetch(url, { + credentials: 'include' + }); + + if (!response.ok) { + console.error('❌ [ANNOUNCEMENTS] Fetch failed:', response.status, response.statusText); + if (response.status === 401) { + return []; + } + throw new Error(`Failed to fetch announcements: ${response.status}`); + } + + const data = await response.json(); + + if (!Array.isArray(data)) { + console.warn('⚠️ [ANNOUNCEMENTS] Data is not an array:', data); + return []; + } + + return data; + }, + }; +} + export default function AnnouncementCard() { const { user } = useAuthUnified(); const { selectedStoreId } = useStore(); @@ -117,48 +156,9 @@ export default function AnnouncementCard() { }, }); - // Construire l'URL avec les paramètres appropriés - const buildApiUrl = () => { - const params = new URLSearchParams(); - - // Pour les admins, permettre le filtrage par magasin sélectionné - if (user?.role === 'admin' && selectedStoreId) { - params.append('storeId', selectedStoreId.toString()); - } - - return `/api/announcements${params.toString() ? `?${params.toString()}` : ''}`; - }; - // Récupérer les annonces - const { data: announcements = [], isLoading } = useQuery({ - queryKey: ['/api/announcements', selectedStoreId, user?.role], - queryFn: async () => { - const url = buildApiUrl(); - console.log('🔍 [ANNOUNCEMENTS] Fetching from:', url); - - const response = await fetch(url, { - credentials: 'include' - }); - - if (!response.ok) { - console.error('❌ [ANNOUNCEMENTS] Fetch failed:', response.status, response.statusText); - if (response.status === 401) { - console.log('🔑 [ANNOUNCEMENTS] Authentication required'); - return []; - } - throw new Error(`Failed to fetch announcements: ${response.status}`); - } - - const data = await response.json(); - console.log('✅ [ANNOUNCEMENTS] Data received:', data); - - if (!Array.isArray(data)) { - console.warn('⚠️ [ANNOUNCEMENTS] Data is not an array:', data); - return []; - } - - return data; - }, + const { data: announcements = [], isLoading } = useQuery({ + ...getAnnouncementsQueryOptions(selectedStoreId, user?.role), enabled: !!user, }); @@ -170,9 +170,7 @@ export default function AnnouncementCard() { : '/api/announcements'; const method = editingAnnouncement ? 'PUT' : 'POST'; - - console.log(`🔍 [ANNOUNCEMENTS] ${method} ${url}`, announcementData); - + const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, @@ -241,8 +239,6 @@ export default function AnnouncementCard() { // Soumission du formulaire const onSubmit = (data: InsertAnnouncement) => { - console.log('🔍 [ANNOUNCEMENTS] Form submitted:', data); - const announcementData = { ...data, createdBy: user?.username || '', diff --git a/client/src/components/CalendarGrid.tsx b/client/src/components/CalendarGrid.tsx index 20d1587..2bfaa07 100644 --- a/client/src/components/CalendarGrid.tsx +++ b/client/src/components/CalendarGrid.tsx @@ -1,10 +1,9 @@ -import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, isToday } from "date-fns"; -import { fr } from "date-fns/locale"; +import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday } from "date-fns"; import { safeDate } from "@/lib/dateUtils"; import { Plus, Check, MoreHorizontal, Package, Link, MessageSquare } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { useState } from "react"; +import { memo, useMemo, useState } from "react"; // Composant modal pour afficher les commentaires function CommentModal({ isOpen, onClose, comment }: { isOpen: boolean, onClose: () => void, comment: string }) { @@ -353,7 +352,10 @@ function DayItemsContainer({ dayOrders, dayDeliveries, onItemClick }: { dayOrder ); } -export default function CalendarGrid({ +// Liste vide partagée pour les jours sans élément (référence stable) +const NO_ITEMS: any[] = []; + +function CalendarGrid({ currentDate, orders, deliveries, @@ -363,197 +365,158 @@ export default function CalendarGrid({ onDateClick, onItemClick, }: CalendarGridProps) { - if (import.meta.env.DEV) { - console.log('🗓️ CalendarGrid rendered with:', { - currentDate: currentDate?.toISOString(), - ordersCount: orders?.length || 0, - deliveriesCount: deliveries?.length || 0, - publicitiesCount: publicities?.length || 0, - selectedStoreId, - userGroupsCount: userGroups?.length || 0 + // Jours affichés : 6 semaines complètes commençant un lundi + const paddedDays = useMemo(() => { + const monthStart = startOfMonth(currentDate); + const monthEnd = endOfMonth(currentDate); + + // Get all days in the month + const monthDays = eachDayOfInterval({ + start: monthStart, + end: monthEnd, }); - } - const monthStart = startOfMonth(currentDate); - const monthEnd = endOfMonth(currentDate); + + // Pad the calendar to start on Monday + const firstDayOfWeek = monthStart.getDay(); + const startPadding = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1; + const days: Date[] = []; + + // Add padding days from previous month + for (let i = startPadding; i > 0; i--) { + const paddingDate = new Date(monthStart); + paddingDate.setDate(paddingDate.getDate() - i); + days.push(paddingDate); + } + + // Add current month days + days.push(...monthDays); + + // Add padding days from next month to complete the grid + const remainingCells = 42 - days.length; // 6 weeks * 7 days + for (let i = 1; i <= remainingCells; i++) { + const paddingDate = new Date(monthEnd); + paddingDate.setDate(paddingDate.getDate() + i); + days.push(paddingDate); + } + + return days; + }, [currentDate]); + + // Commandes et livraisons indexées par jour ('yyyy-MM-dd', heure locale comme + // isSameDay) en une seule passe sur chaque liste, au lieu de filtrer toutes + // les listes pour chacune des 42 cases à chaque rendu + const itemsByDay = useMemo(() => { + const index = new Map(); + const getDayEntry = (key: string) => { + let entry = index.get(key); + if (!entry) { + entry = { orders: [], deliveries: [] }; + index.set(key, entry); + } + return entry; + }; + let invalidCount = 0; + + for (const order of orders) { + // Protection contre undefined/null + if (!order || !order.supplier) { + invalidCount++; + continue; + } + // Essayer plusieurs champs de date possibles + const orderDate = safeDate(order.plannedDate || order.createdAt); + if (orderDate) { + getDayEntry(format(orderDate, 'yyyy-MM-dd')).orders.push(order); + } + } + + for (const delivery of deliveries) { + // Protection contre undefined/null + if (!delivery || !delivery.supplier) { + invalidCount++; + continue; + } + // Essayer plusieurs champs de date possibles + const deliveryDate = safeDate(delivery.scheduledDate || delivery.deliveredDate || delivery.createdAt); + if (deliveryDate) { + getDayEntry(format(deliveryDate, 'yyyy-MM-dd')).deliveries.push(delivery); + } + } + + if (invalidCount > 0) { + console.warn(`⚠️ CalendarGrid: ${invalidCount} élément(s) sans fournisseur ignoré(s)`); + } + + return index; + }, [orders, deliveries]); + + // Publicités par jour affiché : filtrage par magasin fait une seule fois, + // puis répartition sur les 42 jours selon la période de chaque publicité + const publicitiesByDay = useMemo(() => { + const byDay = new Map(); - // Helper function to filter publicities based on user's assigned stores - const getPublicitiesForDate = (date: Date) => { - const dateStr = format(date, 'yyyy-MM-dd'); - // Protection contre les données invalides qui causent des plantages en production if (!publicities || !Array.isArray(publicities)) { console.warn('⚠️ Invalid publicities data:', publicities); - return []; + return byDay; } - - if (import.meta.env.DEV) { - console.log('🔍 CalendarGrid getPublicitiesForDate:', { - date: dateStr, - totalPublicities: publicities.length, - selectedStoreId, - userGroups: userGroups?.length || 0 - }); - } - + try { - return publicities.filter(pub => { + let invalidCount = 0; + const storePublicities = publicities.filter(pub => { // Vérifications de sécurité pour éviter les plantages - if (!pub || typeof pub !== 'object') { - console.warn('⚠️ Invalid publicity object:', pub); + if (!pub || typeof pub !== 'object' || !pub.startDate || !pub.endDate) { + invalidCount++; return false; } - // Check if the date is within the publicity period - const pubStart = pub.startDate; - const pubEnd = pub.endDate; - - if (!pubStart || !pubEnd) { - console.warn('⚠️ Publicity missing dates:', { pubNumber: pub.pubNumber, startDate: pubStart, endDate: pubEnd }); - return false; - } - - if (dateStr < pubStart || dateStr > pubEnd) { - return false; - } - // If no store is selected and user has no assigned groups, show only publicities with participations - if (!selectedStoreId && (!userGroups || userGroups.length === 0)) { - const hasParticipations = pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0; - if (hasParticipations && import.meta.env.DEV) { - console.log('📋 Publicity has participations (no store selected):', { pubNumber: pub.pubNumber, participationCount: pub.participations.length }); + // If no store is selected and user has no assigned groups, show only publicities with participations + if (!selectedStoreId && (!userGroups || userGroups.length === 0)) { + return pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0; } - return hasParticipations; - } - // If a specific store is selected, check if that store participates - if (selectedStoreId) { - const matches = pub.participations && Array.isArray(pub.participations) && - pub.participations.some((pg: any) => pg?.groupId === selectedStoreId); - if (matches && import.meta.env.DEV) { - console.log('🎯 Publicity matches selected store:', { pubNumber: pub.pubNumber, selectedStoreId }); + // If a specific store is selected, check if that store participates + if (selectedStoreId) { + return pub.participations && Array.isArray(pub.participations) && + pub.participations.some((pg: any) => pg?.groupId === selectedStoreId); } - return matches; - } - // If no specific store selected but user has assigned stores, - // show publicities where any of user's stores participate - if (userGroups && Array.isArray(userGroups) && userGroups.length > 0) { - const userGroupIds = userGroups.map((ug: any) => ug?.groupId).filter(id => id !== undefined); - const matches = pub.participations && Array.isArray(pub.participations) && - pub.participations.some((pg: any) => pg?.groupId && userGroupIds.includes(pg.groupId)); - if (matches) { - console.log('👥 Publicity matches user groups:', { pubNumber: pub.pubNumber, userGroupIds }); + // If no specific store selected but user has assigned stores, + // show publicities where any of user's stores participate + if (userGroups && Array.isArray(userGroups) && userGroups.length > 0) { + const userGroupIds = userGroups.map((ug: any) => ug?.groupId).filter(id => id !== undefined); + return pub.participations && Array.isArray(pub.participations) && + pub.participations.some((pg: any) => pg?.groupId && userGroupIds.includes(pg.groupId)); } - return matches; - } - // Default case: only show publicities with participations - const hasParticipations = pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0; - if (hasParticipations) { - console.log('📋 Publicity has participations (default case):', { pubNumber: pub.pubNumber, participationCount: pub.participations.length }); - } - return hasParticipations; + // Default case: only show publicities with participations + return pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0; }); + + if (invalidCount > 0) { + console.warn(`⚠️ CalendarGrid: ${invalidCount} publicité(s) invalide(s) ou sans dates ignorée(s)`); + } + + if (storePublicities.length > 0) { + for (const date of paddedDays) { + const dateStr = format(date, 'yyyy-MM-dd'); + // Check if the date is within the publicity period + const dayPublicities = storePublicities.filter(pub => !(dateStr < pub.startDate || dateStr > pub.endDate)); + if (dayPublicities.length > 0) { + byDay.set(dateStr, dayPublicities); + } + } + } } catch (error) { console.error('❌ Error filtering publicities:', error); - return []; + byDay.clear(); } - }; - // Get all days in the month - const monthDays = eachDayOfInterval({ - start: monthStart, - end: monthEnd, - }); - - // Pad the calendar to start on Monday - const firstDayOfWeek = monthStart.getDay(); - const startPadding = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1; - const paddedDays = []; - - // Add padding days from previous month - for (let i = startPadding; i > 0; i--) { - const paddingDate = new Date(monthStart); - paddingDate.setDate(paddingDate.getDate() - i); - paddedDays.push(paddingDate); - } - - // Add current month days - paddedDays.push(...monthDays); - - // Add padding days from next month to complete the grid - const remainingCells = 42 - paddedDays.length; // 6 weeks * 7 days - for (let i = 1; i <= remainingCells; i++) { - const paddingDate = new Date(monthEnd); - paddingDate.setDate(paddingDate.getDate() + i); - paddedDays.push(paddingDate); - } + return byDay; + }, [publicities, selectedStoreId, userGroups, paddedDays]); const weekDays = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]; - const getItemsForDate = (date: Date) => { - // Debug: Log des commandes reçues (seulement une fois) - if (orders.length > 0 && date.getDate() === 1) { - console.log('📅 CalendarGrid Debug - Orders received:', orders.length); - console.log('📅 First order structure:', orders[0]); - console.log('📅 All orders dates:', orders.map(o => ({ id: o.id, plannedDate: o.plannedDate, supplier: o.supplier?.name }))); - } - - // Debug: Log des livraisons reçues (seulement une fois) - if (deliveries.length > 0 && date.getDate() === 1) { - console.log('🚛 CalendarGrid Debug - Deliveries received:', deliveries.length); - console.log('🚛 First delivery structure:', deliveries[0]); - console.log('🚛 All deliveries dates:', deliveries.map(d => ({ id: d.id, scheduledDate: d.scheduledDate, supplier: d.supplier?.name }))); - } - - const dayOrders = orders.filter(order => { - // Protection contre undefined/null - if (!order || !order.supplier) { - console.warn('📅 Invalid order found:', order); - return false; - } - - // Essayer plusieurs champs de date possibles - const orderDate = safeDate(order.plannedDate || order.createdAt); - const matches = orderDate && isSameDay(orderDate, date); - - if (matches) { - console.log('📅 Order matches date:', { - orderId: order.id, - supplier: order.supplier?.name, - plannedDate: order.plannedDate, - matchingDate: format(date, 'yyyy-MM-dd') - }); - } - - return matches; - }); - - const dayDeliveries = deliveries.filter(delivery => { - // Protection contre undefined/null - if (!delivery || !delivery.supplier) { - console.warn('🚛 Invalid delivery found:', delivery); - return false; - } - - // Essayer plusieurs champs de date possibles - const deliveryDate = safeDate(delivery.scheduledDate || delivery.deliveredDate || delivery.createdAt); - const matches = deliveryDate && isSameDay(deliveryDate, date); - - if (matches) { - console.log('🚛 Delivery matches date:', { - deliveryId: delivery.id, - supplier: delivery.supplier?.name, - scheduledDate: delivery.scheduledDate, - matchingDate: format(date, 'yyyy-MM-dd') - }); - } - - return matches; - }); - - return { orders: dayOrders, deliveries: dayDeliveries }; - }; - return (
{/* Calendar Header - Design moderne épuré */} @@ -576,8 +539,11 @@ export default function CalendarGrid({ const isCurrentMonth = isSameMonth(date, currentDate); const isTodayDate = isToday(date); const isWeekend = date.getDay() === 0 || date.getDay() === 6; - const { orders: dayOrders, deliveries: dayDeliveries } = getItemsForDate(date); - const dayPublicities = getPublicitiesForDate(date); + const dayKey = format(date, 'yyyy-MM-dd'); + const dayItems = itemsByDay.get(dayKey); + const dayOrders = dayItems ? dayItems.orders : NO_ITEMS; + const dayDeliveries = dayItems ? dayItems.deliveries : NO_ITEMS; + const dayPublicities = publicitiesByDay.get(dayKey) || NO_ITEMS; return (
); } + +// 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() {

Analysez vos performances logistiques

+ {isUpdating && ( + + + Mise à jour… + + )}
- {productsLoading ? ( + {productsLoading || (isPlaceholderData && totalItems === 0) ? (
Chargement des produits...
) : totalItems === 0 ? (
Aucun produit DLC trouvé
) : ( -
+
@@ -992,7 +1030,7 @@ export default function DlcPage() { variant="default" size="sm" onClick={() => handleValidate(product.id)} - disabled={validateMutation.isPending} + disabled={isRowActionPending("validate", product.id)} className="bg-green-600 hover:bg-green-700" data-testid={`button-validate-${product.id}`} title="Valider définitivement ce produit" @@ -1010,7 +1048,7 @@ export default function DlcPage() { variant="outline" size="sm" onClick={() => handleMarkProcessed(product.id)} - disabled={markProcessedMutation.isPending} + disabled={isRowActionPending("mark-processed", product.id)} className="border-blue-300 text-blue-700 hover:bg-blue-50" data-testid={`button-mark-processed-${product.id}`} > @@ -1036,7 +1074,7 @@ export default function DlcPage() { variant="outline" size="sm" onClick={() => handleUnmarkProcessed(product.id)} - disabled={unmarkProcessedMutation.isPending} + disabled={isRowActionPending("unmark-processed", product.id)} className="border-orange-300 text-orange-700 hover:bg-orange-50" > @@ -1062,7 +1100,7 @@ export default function DlcPage() { variant="outline" size="sm" onClick={() => handleMarkStockEpuise(product.id)} - disabled={markStockEpuiseMutation.isPending} + disabled={isRowActionPending("stock-epuise", product.id)} className="border-yellow-300 text-yellow-700 hover:bg-yellow-50" > @@ -1087,7 +1125,7 @@ export default function DlcPage() { variant="outline" size="sm" onClick={() => handleRestoreStock(product.id)} - disabled={restoreStockMutation.isPending} + disabled={isRowActionPending("restore-stock", product.id)} className="border-blue-300 text-blue-700 hover:bg-blue-50" > @@ -1106,7 +1144,7 @@ export default function DlcPage() { variant="destructive" size="sm" onClick={() => handleDelete(product.id)} - disabled={deleteMutation.isPending} + disabled={isRowActionPending("delete", product.id)} > diff --git a/client/src/pages/Groups.tsx b/client/src/pages/Groups.tsx index 60b6f99..7cbb594 100644 --- a/client/src/pages/Groups.tsx +++ b/client/src/pages/Groups.tsx @@ -81,8 +81,13 @@ export default function Groups() { queryKey: ['/api/groups'], }); + // Configurations NocoDB (liste déroulante de la fenêtre magasin) : réservées à + // l'admin, le serveur répond 403 aux autres rôles. Les mutations de la page + // NocoDB invalident déjà cette clé. const { data: rawNocodbConfigs = [] } = useQuery({ queryKey: ['/api/nocodb-config'], + enabled: user?.role === 'admin', + staleTime: 5 * 60 * 1000, }); // Protection renforcée contre les erreurs TypeError @@ -96,10 +101,7 @@ export default function Groups() { const createMutation = useMutation({ mutationFn: async (data: any) => { - console.log('🏪 Frontend: Creating group with data:', data); - const result = await apiRequest("/api/groups", "POST", data); - console.log('🏪 Frontend: Group creation result:', result); - return result; + return await apiRequest("/api/groups", "POST", data); }, onSuccess: () => { toast({ diff --git a/client/src/pages/NocoDBConfig.tsx b/client/src/pages/NocoDBConfig.tsx index e65ee05..575af34 100644 --- a/client/src/pages/NocoDBConfig.tsx +++ b/client/src/pages/NocoDBConfig.tsx @@ -53,7 +53,7 @@ export default function NocoDBConfig() { const [showTokens, setShowTokens] = useState<{ [key: number]: boolean }>({}); // Queries avec protection complète - const { data: rawConfigs, isLoading, error } = useQuery({ + const { data: rawConfigs, isLoading } = useQuery({ queryKey: ['/api/nocodb-config'], enabled: user?.role === 'admin', }); @@ -61,21 +61,6 @@ export default function NocoDBConfig() { // Protection quadruple couche pour éviter les erreurs TypeError const configs = rawConfigs || []; const safeConfigs = Array.isArray(configs) ? configs : []; - - // Log pour debug production avec plus de détails - console.log('🔍 NocoDBConfig Debug:', { - rawConfigs, - rawConfigsType: typeof rawConfigs, - configs, - configsType: typeof configs, - isArray: Array.isArray(configs), - safeConfigs, - safeConfigsType: typeof safeConfigs, - length: safeConfigs.length, - error, - userRole: user?.role, - environment: window.location.hostname - }); // Mutations const createConfigMutation = useMutation({ diff --git a/client/src/pages/Orders.tsx b/client/src/pages/Orders.tsx index 1311322..b3de5ee 100644 --- a/client/src/pages/Orders.tsx +++ b/client/src/pages/Orders.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +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"; @@ -30,6 +30,9 @@ import OrderDetailModal from "@/components/modals/OrderDetailModal"; import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal"; import type { OrderWithRelations } from "@shared/schema"; +// Référence stable pendant le chargement (pour la mémoïsation du filtrage) +const NO_ORDERS: OrderWithRelations[] = []; + export default function Orders() { const { user } = useAuthUnified(); const { selectedStoreId } = useStore(); @@ -73,7 +76,7 @@ export default function Orders() { const ordersUrl = `/api/orders${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`; - const { data: ordersData = [], isLoading } = useQuery({ + const { data: ordersData = NO_ORDERS, isLoading } = useQuery({ queryKey: [ordersUrl, selectedStoreId], queryFn: async () => { const response = await fetch(ordersUrl, { credentials: 'include' }); @@ -81,69 +84,32 @@ export default function Orders() { throw new Error('Failed to fetch orders'); } const data = await response.json(); - console.log('📦 Orders received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items', data.slice(0, 2)); - console.log('📦 Sample order data:', data[0]); return Array.isArray(data) ? data : []; }, }); // Production Bug Fix: Ensure array safety for all data operations - const orders = Array.isArray(ordersData) ? ordersData : []; - - console.log('📦 Orders Debug:', { - isLoading, - ordersCount: orders?.length, - orders: orders?.slice(0, 2), - selectedStoreId, - ordersUrl - }); - - const { data: groupsData = [] } = useQuery({ - queryKey: ['/api/groups'], - }); - - const groups = Array.isArray(groupsData) ? groupsData : []; + const orders = Array.isArray(ordersData) ? ordersData : NO_ORDERS; const deleteMutation = useMutation({ mutationFn: async (id: number) => { - console.log('🗑️ Deleting order:', id); await apiRequest(`/api/orders/${id}`, "DELETE"); }, onSuccess: () => { - console.log('✅ Order deleted successfully, invalidating cache...'); toast({ title: "Succès", description: "Commande supprimée avec succès", }); - console.log('🗑️ Order deleted, clearing ALL cache to avoid inconsistencies:', { - ordersUrl, - selectedStoreId - }); - - // Sauvegarder le selectedStoreId avant le nettoyage - if (selectedStoreId) { - localStorage.setItem('selectedStoreId', selectedStoreId.toString()); - } - - // SOLUTION HYBRIDE : Invalidation sélective pour éviter perte storeId - console.log('🧹 Using selective invalidation to preserve storeId context...'); - - // Invalidation ciblée sans clear() pour préserver le contexte + // Invalidation ciblée sans clear() pour préserver le contexte : les requêtes + // affichées sont rechargées tout de suite, les autres (mois du calendrier + // déjà visités, autres pages) seulement quand elles seront réaffichées queryClient.invalidateQueries({ predicate: (query) => { const key = query.queryKey[0]?.toString() || ''; return key.includes('/api/orders') || key.includes('/api/deliveries'); } }); - - // Force refetch pour garantir synchronisation immédiate - queryClient.refetchQueries({ - predicate: (query) => { - const key = query.queryKey[0]?.toString() || ''; - return key.includes('/api/orders') || key.includes('/api/deliveries'); - } - }); }, onError: (error) => { if (isUnauthorizedError(error)) { @@ -165,16 +131,19 @@ export default function Orders() { }, }); - const filteredOrders = Array.isArray(orders) ? orders.filter(order => { - console.log('🔍 Filtering order:', order.id, { searchTerm, statusFilter }); - const matchesSearch = order.supplier?.name.toLowerCase().includes(searchTerm.toLowerCase()) || - order.group?.name.toLowerCase().includes(searchTerm.toLowerCase()) || - order.notes?.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesStatus = statusFilter === "all" || order.status === statusFilter; - - return matchesSearch && matchesStatus; - }) : []; + // Filtrage recalculé seulement quand la liste, la recherche ou le statut changent + const filteredOrders = useMemo(() => { + const search = searchTerm.toLowerCase(); + return orders.filter(order => { + const matchesSearch = order.supplier?.name.toLowerCase().includes(search) || + order.group?.name.toLowerCase().includes(search) || + order.notes?.toLowerCase().includes(search); + + const matchesStatus = statusFilter === "all" || order.status === statusFilter; + + return matchesSearch && matchesStatus; + }); + }, [orders, searchTerm, statusFilter]); // Pagination const { diff --git a/client/src/pages/PaymentSchedulePage.tsx b/client/src/pages/PaymentSchedulePage.tsx index 886aa1b..d7c9ce5 100644 --- a/client/src/pages/PaymentSchedulePage.tsx +++ b/client/src/pages/PaymentSchedulePage.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useStore } from "@/contexts/StoreContext"; import { useToast } from "@/hooks/use-toast"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -53,6 +53,7 @@ interface PaymentScheduleResponse { export default function PaymentSchedulePage() { const { selectedStoreId } = useStore(); const { toast } = useToast(); + const queryClient = useQueryClient(); const [selectedMonth, setSelectedMonth] = useState(() => { const now = new Date(); return format(now, 'yyyy-MM'); @@ -75,7 +76,12 @@ export default function PaymentSchedulePage() { if (!response.ok) { throw new Error(`Erreur ${response.status}: ${response.statusText}`); } - return response.json(); + const result = await response.json(); + // Ce calcul peut compléter en base les échéances et montants manquants des + // livraisons : les listes de livraisons en cache sont marquées périmées + // (rechargées à leur prochain affichage, pas immédiatement) + queryClient.invalidateQueries({ queryKey: ['/api/deliveries'], refetchType: 'none' }); + return result; }, enabled: !!selectedStoreId, }); @@ -251,10 +257,50 @@ export default function PaymentSchedulePage() { ); } + // En-tête (titre, export, choix du mois) : conservé pendant le chargement, + // notamment au changement de magasin, au lieu d'un écran entièrement vide + const header = ( +
+
+

Échéancier des Paiements

+

Gestion des échéances fournisseurs

+
+
+ +
+ +
+
+
+ ); + if (isLoading) { return ( -
- +
+ {header} +
+ +
); } @@ -292,38 +338,7 @@ export default function PaymentSchedulePage() { return (
{/* En-tête */} -
-
-

Échéancier des Paiements

-

Gestion des échéances fournisseurs

-
-
- -
- -
-
-
+ {header} {/* Statistiques du mois */}
diff --git a/client/src/pages/Publicities.tsx b/client/src/pages/Publicities.tsx index 05a24bc..57a86f5 100644 --- a/client/src/pages/Publicities.tsx +++ b/client/src/pages/Publicities.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query"; import { Plus, Calendar, Edit, Trash2, Eye, Filter, Grid, List, Download } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -9,12 +9,16 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f import { useToast } from "@/hooks/use-toast"; import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useStore } from "@/contexts/StoreContext"; -import { format, startOfMonth, endOfMonth, eachWeekOfInterval, eachDayOfInterval, startOfWeek, endOfWeek, isSameMonth, isWithinInterval, startOfYear, endOfYear, getWeek, getMonth } from "date-fns"; +import { format, startOfMonth, endOfMonth, eachWeekOfInterval, eachDayOfInterval, startOfWeek, endOfWeek, isSameMonth, startOfYear, endOfYear, getMonth } from "date-fns"; import { fr } from "date-fns/locale"; -import { safeFormat, safeDate, safeCompareDate } from "@/lib/dateUtils"; +import { safeFormat, safeDate } from "@/lib/dateUtils"; import type { PublicityWithRelations, Group } from "@shared/schema"; import PublicityForm from "@/components/PublicityForm"; +// Références stables tant que les données ne sont pas chargées (dépendances des useMemo) +const NO_PUBLICITIES: PublicityWithRelations[] = []; +const NO_GROUPS: Group[] = []; + export default function Publicities() { const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); // Année courante par défaut const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth()); // Mois courant par défaut @@ -39,7 +43,7 @@ export default function Publicities() { const startYear = currentYear - 2; const yearOptions = Array.from({ length: 13 }, (_, i) => startYear + i); - const { data: publicities = [], isLoading } = useQuery({ + const { data: publicities = NO_PUBLICITIES, isLoading, isPlaceholderData } = useQuery({ queryKey: ['/api/ad-campaigns', selectedYear, selectedStoreId], queryFn: async () => { const params = new URLSearchParams(); @@ -52,43 +56,18 @@ export default function Publicities() { throw new Error('Failed to fetch publicities'); } const data = await response.json(); - console.log('📢 Publicities received:', data.length, 'items', data.slice(0, 2)); - - // CRITICAL FIX: Force frontend sorting by pubNumber to ensure correct order - const sortedData = Array.isArray(data) - ? data.sort((a, b) => { - const numA = parseInt(a.pubNumber) || 0; - const numB = parseInt(b.pubNumber) || 0; - return numA - numB; - }) - : []; - - console.log('📢 Publicities after frontend sort:', { - first5: sortedData.slice(0, 5).map(p => `N°${p.pubNumber}`), - total: sortedData.length - }); - - return sortedData; + // Liste déjà triée par N° côté serveur (même tri numérique) : pas de second tri ici + return Array.isArray(data) ? data : []; }, + // Les créations, modifications et suppressions invalident déjà cette clé + staleTime: 5 * 60 * 1000, + // Changement d'année : garder la liste affichée (atténuée) pendant le + // chargement, mais jamais au changement de magasin + placeholderData: (previousData, previousQuery) => + previousQuery?.queryKey[2] === selectedStoreId ? keepPreviousData(previousData) : undefined, }); - console.log('📢 Publicities Debug:', { - isLoading, - publicitiesCount: publicities?.length, - selectedYear, - selectedStoreId, - publicities: publicities?.slice(0, 2) - }); - - console.log('📢 Publicities Debug:', { - isLoading, - publicitiesCount: publicities?.length, - selectedYear, - selectedStoreId, - publicities: publicities?.slice(0, 2) - }); - - const { data: groups = [] } = useQuery({ + const { data: groups = NO_GROUPS } = useQuery({ queryKey: ['/api/groups'], enabled: !!user, }); @@ -96,8 +75,6 @@ export default function Publicities() { const deleteMutation = useMutation({ mutationFn: async (id: number) => { // Try POST first (for production environments that block DELETE) - console.log(`🗑️ [Frontend] Attempting to delete publicity ${id} using POST method`); - try { const response = await fetch(`/api/ad-campaigns/${id}/delete`, { method: 'POST', @@ -106,11 +83,9 @@ export default function Publicities() { }); if (response.ok) { - console.log(`✅ [Frontend] Successfully deleted publicity ${id} using POST`); return response.json(); } else if (response.status === 404) { // Route not found, try DELETE method as fallback (for dev environment) - console.log(`⚠️ [Frontend] POST route not found, trying DELETE method`); const deleteResponse = await fetch(`/api/ad-campaigns/${id}`, { method: 'DELETE' }); if (!deleteResponse.ok) { @@ -152,47 +127,34 @@ export default function Publicities() { label: format(new Date(selectedYear, i, 1), 'MMMM', { locale: fr }) })); - // Calendar helper functions - const getWeekParticipation = (weekStart: Date, weekEnd: Date) => { - // Vérifier que publicities est un tableau - if (!Array.isArray(publicities)) { - return { - publicities: [], - participatingStores: [], - storeColors: [] - }; - } + // Magasins indexés par id (couleur et nom des pastilles) + const groupsById = useMemo(() => new Map(groups.map(group => [group.id, group])), [groups]); - const weekPublicities = publicities.filter(pub => { - const pubStart = safeDate(pub.startDate); - const pubEnd = safeDate(pub.endDate); + // Dates de début et de fin converties une seule fois par publicité, et non + // pour chaque semaine de la vue d'ensemble ou chaque jour du calendrier + const publicityPeriods = useMemo(() => { + if (!Array.isArray(publicities)) return []; + return publicities.map(pub => ({ + pub, + start: safeDate(pub.startDate), + end: safeDate(pub.endDate), + })); + }, [publicities]); - // More robust overlap detection: check if any part of the publicity period overlaps with the week - return pubStart && pubEnd && (pubStart <= weekEnd && pubEnd >= weekStart); - }); + // Vue d'ensemble : semaines de l'année regroupées par mois, calculées + // seulement quand la vue est affichée et que l'année ou les données changent + const weeksByMonth = useMemo(() => { + const byMonth = Array.from({ length: 12 }, () => [] as Array<{ + weekStart: Date; + weekEnd: Date; + weekNumber: number; + month: number; + publicities: PublicityWithRelations[]; + participatingStores: number[]; + storeColors: string[]; + }>); + if (!showYearOverview) return byMonth; - const participatingStores = new Set(); - weekPublicities.forEach(pub => { - // Vérifier que participations existe et est un tableau - if (pub.participations && Array.isArray(pub.participations)) { - pub.participations.forEach(participation => { - participatingStores.add(participation.groupId); - }); - } - }); - - return { - publicities: weekPublicities, - participatingStores: Array.from(participatingStores), - storeColors: Array.from(participatingStores).map(storeId => { - const group = groups.find(g => g.id === storeId); - return group?.color || '#666666'; - }) - }; - }; - - // Year overview - Generate weeks for the selected year - const getYearWeeks = () => { const yearStart = startOfYear(new Date(selectedYear, 0, 1)); const yearEnd = endOfYear(new Date(selectedYear, 11, 31)); @@ -201,7 +163,7 @@ export default function Publicities() { { weekStartsOn: 1 } ); - return weeks.map((weekStart, index) => { + weeks.forEach((weekStart, index) => { const weekEnd = endOfWeek(weekStart, { weekStartsOn: 1 }); const weekNumber = index + 1; @@ -214,19 +176,72 @@ export default function Publicities() { month = 11; // Décembre } - const participation = getWeekParticipation(weekStart, weekEnd); + // Une publicité compte pour la semaine dès que sa période chevauche la semaine + const weekPublicities = publicityPeriods + .filter(({ start, end }) => start && end && (start <= weekEnd && end >= weekStart)) + .map(({ pub }) => pub); - return { + const participatingStores = new Set(); + weekPublicities.forEach(pub => { + // Vérifier que participations existe et est un tableau + if (pub.participations && Array.isArray(pub.participations)) { + pub.participations.forEach(participation => { + participatingStores.add(participation.groupId); + }); + } + }); + + byMonth[month].push({ weekStart, weekEnd, weekNumber, month, - ...participation + publicities: weekPublicities, + participatingStores: Array.from(participatingStores), + storeColors: Array.from(participatingStores).map(storeId => + groupsById.get(storeId)?.color || '#666666' + ), + }); + }); + + return byMonth; + }, [showYearOverview, selectedYear, publicityPeriods, groupsById]); + + // Vue calendrier : jours du mois sélectionné avec leurs publicités, calculés + // seulement quand cette vue est affichée + const calendarDays = useMemo(() => { + if (viewMode !== 'calendar') return []; + + const monthStart = startOfMonth(new Date(selectedYear, selectedMonth, 1)); + const monthEnd = endOfMonth(monthStart); + const startDate = startOfWeek(monthStart, { weekStartsOn: 1 }); + const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 }); + + return eachDayOfInterval({ start: startDate, end: endDate }).map(day => { + const dayPublicities = publicityPeriods + .filter(({ start, end }) => start && end && day >= start && day <= end) + .map(({ pub }) => pub); + + const participatingStores = new Set(); + dayPublicities.forEach(pub => { + pub.participations.forEach(participation => { + participatingStores.add(participation.groupId); + }); + }); + + return { + day, + isCurrentMonth: isSameMonth(day, monthStart), + dayPublicities, + participatingStores: Array.from(participatingStores), }; }); - }; + }, [viewMode, selectedYear, selectedMonth, publicityPeriods]); - const yearWeeks = getYearWeeks(); + // Compteurs des cartes : recalculés à chaque rendu car ils dépendent de l'heure courante + const now = new Date(); + const activeCount = publicityPeriods.filter(({ start, end }) => start && end && start <= now && now <= end).length; + const upcomingCount = publicityPeriods.filter(({ start }) => start && start > now).length; const handleView = (publicity: PublicityWithRelations) => { setSelectedPublicity(publicity); @@ -405,235 +420,206 @@ export default function Publicities() {
- {/* Stats */} -
- - -
-
- -
-
-

Total {selectedYear}

-

{Array.isArray(publicities) ? publicities.length : 0}

-
-
-
-
- - - -
-
- -
-
-

En cours

-

- {publicities.filter(p => { - const now = new Date(); - const start = safeDate(p.startDate); - const end = safeDate(p.endDate); - return start && end && start <= now && now <= end; - }).length} -

-
-
-
-
- - - -
-
- -
-
-

À venir

-

- {publicities.filter(p => { - const startDate = safeDate(p.startDate); - return startDate && startDate > new Date(); - }).length} -

-
-
-
-
-
- - {/* Year Overview - Weekly Timeline */} - {showYearOverview && ( - - - - - Vue d'ensemble {selectedYear} - Semaines avec publicités - - - -
- {/* Months Grid */} -
- {Array.from({ length: 12 }, (_, monthIndex) => { - const monthName = format(new Date(selectedYear, monthIndex, 1), 'MMM', { locale: fr }); - const monthWeeks = yearWeeks.filter(week => week.month === monthIndex); - - return ( -
-
- {monthName} -
-
- {monthWeeks.map((week, weekIndex) => { - const hasPublicity = week.publicities.length > 0; - const storeColors = week.storeColors; - - return ( -
p.pubNumber).join(', ')}` : 'Aucune publicité'}`} - > - {week.weekNumber} - {/* Store participation indicators */} - {storeColors.length > 0 && ( -
- {storeColors.slice(0, 4).map((color, idx) => ( -
- ))} - {storeColors.length > 4 && ( -
- + -
- )} -
- )} -
- ); - })} -
-
- ); - })} -
- - {/* Legend */} -
-
-
-
-
- Semaine avec publicité -
-
-
- Semaine sans publicité -
-
-
-
- {groups.slice(0, 3).map(group => ( -
- ))} -
- Indicateurs magasins (coin supérieur droit) -
+ {/* Données de l'année précédente affichées atténuées pendant le chargement de la nouvelle année */} +
+ {/* Stats */} +
+ + +
+
+
-
- • Survolez les semaines pour voir les détails des publicités +
+

Total {selectedYear}

+

{Array.isArray(publicities) ? publicities.length : 0}

-
-
-
- )} + + - {/* Content */} - {isLoading ? ( -
-
+ + +
+
+ +
+
+

En cours

+

{activeCount}

+
+
+
+
+ + + +
+
+ +
+
+

À venir

+

{upcomingCount}

+
+
+
+
- ) : publicities.length === 0 ? ( - - - -

- Aucune publicité pour {selectedYear} -

-

- Commencez par créer votre première campagne publicitaire. -

- {canModify && ( - - )} -
-
- ) : viewMode === 'calendar' ? ( - - - - - Vue calendrier - {monthOptions[selectedMonth].label} {selectedYear} - - - -
- {/* Calendar Grid */} -
- {/* Days of week headers */} - {['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map(day => ( -
- {day} + + {/* Year Overview - Weekly Timeline */} + {showYearOverview && ( + + + + + Vue d'ensemble {selectedYear} - Semaines avec publicités + + + +
+ {/* Months Grid */} +
+ {Array.from({ length: 12 }, (_, monthIndex) => { + const monthName = format(new Date(selectedYear, monthIndex, 1), 'MMM', { locale: fr }); + const monthWeeks = weeksByMonth[monthIndex]; + + return ( +
+
+ {monthName} +
+
+ {monthWeeks.map((week, weekIndex) => { + const hasPublicity = week.publicities.length > 0; + const storeColors = week.storeColors; + + return ( +
p.pubNumber).join(', ')}` : 'Aucune publicité'}`} + > + {week.weekNumber} + {/* Store participation indicators */} + {storeColors.length > 0 && ( +
+ {storeColors.slice(0, 4).map((color, idx) => ( +
+ ))} + {storeColors.length > 4 && ( +
+ + +
+ )} +
+ )} +
+ ); + })} +
+
+ ); + })} +
+ + {/* Legend */} +
+
+
+
+
+ Semaine avec publicité +
+
+
+ Semaine sans publicité +
+
+
+
+ {groups.slice(0, 3).map(group => ( +
+ ))} +
+ Indicateurs magasins (coin supérieur droit) +
- ))} +
+ • Survolez les semaines pour voir les détails des publicités +
+
+
+ + + )} - {/* Calendar days */} - {(() => { - const monthStart = startOfMonth(new Date(selectedYear, selectedMonth, 1)); - const monthEnd = endOfMonth(monthStart); - const startDate = startOfWeek(monthStart, { weekStartsOn: 1 }); - const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 }); - - const days = eachDayOfInterval({ start: startDate, end: endDate }); - - return days.map(day => { - const isCurrentMonth = isSameMonth(day, monthStart); - const dayPublicities = publicities.filter(pub => { - const pubStart = safeDate(pub.startDate); - const pubEnd = safeDate(pub.endDate); - return pubStart && pubEnd && day >= pubStart && day <= pubEnd; - }); - - const participatingStores = new Set(); - dayPublicities.forEach(pub => { - pub.participations.forEach(participation => { - participatingStores.add(participation.groupId); - }); - }); + {/* Content */} + {isLoading ? ( +
+
+
+ ) : publicities.length === 0 ? ( + + + +

+ Aucune publicité pour {selectedYear} +

+

+ Commencez par créer votre première campagne publicitaire. +

+ {canModify && ( + + )} +
+
+ ) : viewMode === 'calendar' ? ( + + + + + Vue calendrier - {monthOptions[selectedMonth].label} {selectedYear} + + + +
+ {/* Calendar Grid */} +
+ {/* Days of week headers */} + {['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map(day => ( +
+ {day} +
+ ))} + {/* Calendar days */} + {calendarDays.map(({ day, isCurrentMonth, dayPublicities, participatingStores }) => { return (
)} {/* Store participation indicators */} - {participatingStores.size > 0 && ( + {participatingStores.length > 0 && (
- {Array.from(participatingStores).slice(0, 3).map(storeId => { - const group = groups.find(g => g.id === storeId); + {participatingStores.slice(0, 3).map(storeId => { + const group = groupsById.get(storeId); return (
); })} - {participatingStores.size > 3 && ( -
+{participatingStores.size - 3}
+ {participatingStores.length > 3 && ( +
+{participatingStores.length - 3}
)}
)}
); - }); - })()} -
+ })} +
- {/* Legend */} -
-

Légende

-
-
-
- Jour avec publicité -
-
-
- {groups.slice(0, 3).map(group => ( -
- ))} + {/* Legend */} +
+

Légende

+
+
+
+ Jour avec publicité +
+
+
+ {groups.slice(0, 3).map(group => ( +
+ ))} +
+ Magasins participants
- Magasins participants
-
- - - ) : ( - - -
-
- - - - - - - - - - - - - {publicities - .map((publicity) => { - const now = new Date(); - const start = safeDate(publicity.startDate); - const end = safeDate(publicity.endDate); - const isActive = start && end && now >= start && now <= end; - const isUpcoming = start && start > now; - const isPast = end && end < now; + + + ) : ( + + +
+
- N° PUB - - Désignation - - Période - - Statut - - Magasins - - Créé par - - Actions -
+ + + + + + + + + + + + + {publicities + .map((publicity) => { + const now = new Date(); + const start = safeDate(publicity.startDate); + const end = safeDate(publicity.endDate); + const isActive = start && end && now >= start && now <= end; + const isUpcoming = start && start > now; + const isPast = end && end < now; - return ( - - - - - - - - + + + + + + + - - ); - })} - -
+ N° PUB + + Désignation + + Période + + Statut + + Magasins + + Créé par + + Actions +
-
- {publicity.pubNumber} -
-
-
- {publicity.designation} -
-
-
- {safeFormat(publicity.startDate, "dd/MM/yy")} - {safeFormat(publicity.endDate, "dd/MM/yy")} -
-
- {isActive && En cours} - {isUpcoming && À venir} - {isPast && Terminée} - -
- {publicity.participations.length === 0 ? ( - - Aucun magasin - - ) : ( - <> - {publicity.participations.slice(0, 2).map((participation) => ( - -
- {participation.group?.name || 'Magasin'} - - ))} - {publicity.participations.length > 2 && ( - - +{publicity.participations.length - 2} - - )} - - )} -
-
-
- {publicity.creator?.name || publicity.creator?.username || 'Utilisateur'} -
-
-
- + return ( +
+
+ {publicity.pubNumber} +
+
+
+ {publicity.designation} +
+
+
+ {safeFormat(publicity.startDate, "dd/MM/yy")} - {safeFormat(publicity.endDate, "dd/MM/yy")} +
+
+ {isActive && En cours} + {isUpcoming && À venir} + {isPast && Terminée} + +
+ {publicity.participations.length === 0 ? ( + + Aucun magasin + + ) : ( + <> + {publicity.participations.slice(0, 2).map((participation) => ( + +
+ {participation.group?.name || 'Magasin'} + + ))} + {publicity.participations.length > 2 && ( + + +{publicity.participations.length - 2} + + )} + + )} +
+
+
+ {publicity.creator?.name || publicity.creator?.username || 'Utilisateur'} +
+
+
+ - {canModify && ( - <> - + {canModify && ( + <> + - - - )} -
-
-
- - - )} + + + )} +
+ + + ); + })} + + +
+ + + )} +
{/* Create Modal */} diff --git a/client/src/pages/SavTickets.tsx b/client/src/pages/SavTickets.tsx index bee4db0..d9c8b4d 100644 --- a/client/src/pages/SavTickets.tsx +++ b/client/src/pages/SavTickets.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState, useMemo } from "react"; +import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -49,6 +49,10 @@ const priorityConfig = { critique: { label: "Critique", color: "bg-red-100 text-red-800" } }; +// Références stables tant que les données ne sont pas chargées (évitent de recalculer les filtres) +const NO_TICKETS: SavTicketWithRelations[] = []; +const NO_GROUPS: Group[] = []; + export default function SavTickets() { const { user } = useAuthUnified(); const { toast } = useToast(); @@ -94,10 +98,15 @@ export default function SavTickets() { const ticketsUrl = `/api/sav/tickets${queryParams.toString() ? '?' + queryParams.toString() : ''}`; - const { data: ticketsData = [], isLoading } = useQuery({ + const { data: ticketsData = NO_TICKETS, isLoading, isPlaceholderData } = useQuery({ queryKey: [ticketsUrl, selectedStoreId], enabled: !!user && (user.role !== 'admin' || storeInitialized), staleTime: 1000 * 60 * 5, // 5 minutes + // Changement de filtre : les tickets précédents restent affichés (estompés) + // pendant le chargement. Jamais au changement de magasin, pour ne pas montrer + // un instant les tickets d'un autre magasin. + placeholderData: (previousData, previousQuery) => + previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined, }); const { data: suppliersData = [] } = useQuery({ @@ -105,7 +114,7 @@ export default function SavTickets() { staleTime: 1000 * 60 * 10, // 10 minutes }); - const { data: groupsData = [] } = useQuery({ + const { data: groupsData = NO_GROUPS } = useQuery({ queryKey: ['/api/groups'], staleTime: 1000 * 60 * 10, // 10 minutes }); @@ -288,30 +297,32 @@ export default function SavTickets() { const canModify = ['admin', 'directeur', 'manager'].includes(user?.role || ''); const canDelete = ['admin', 'directeur'].includes(user?.role || ''); - // Get user's available groups for auto-assignment - const getUserGroups = () => { + // Get user's available groups for auto-assignment (recalculé seulement quand + // l'utilisateur ou la liste des magasins change, et non à chaque frappe) + const availableGroups = useMemo(() => { if (!user || user.role === 'admin') { - console.log('🎫 [CLIENT] Admin user - can access all groups:', groupsData?.length || 0); return groupsData; // Admin can access all groups } // For other users, get their assigned groups const userGroups = (user as any).userGroups || []; - const filteredGroups = groupsData.filter(group => + return groupsData.filter(group => userGroups.some((ug: any) => ug.groupId === group.id) ); - - console.log('🎫 [CLIENT] Non-admin user groups:', { - userRole: user.role, - userUsername: user.username, - allUserGroups: userGroups, - filteredGroups: filteredGroups.map(g => ({ id: g.id, name: g.name })) - }); - - return filteredGroups; - }; + }, [user, groupsData]); - const availableGroups = getUserGroups(); + // Filter tickets by search term + const filteredTickets = useMemo(() => { + if (!searchTerm) return ticketsData; + const searchLower = searchTerm.toLowerCase(); + return ticketsData.filter(ticket => ( + ticket.ticketNumber.toLowerCase().includes(searchLower) || + ticket.clientName?.toLowerCase().includes(searchLower) || + ticket.problemDescription?.toLowerCase().includes(searchLower) || + ticket.productDesignation?.toLowerCase().includes(searchLower) || + ticket.supplier?.name.toLowerCase().includes(searchLower) + )); + }, [ticketsData, searchTerm]); // Handle form submission const handleCreateTicket = () => { @@ -327,17 +338,9 @@ export default function SavTickets() { // Auto-assign group based on user role and available groups let selectedGroupId = formData.groupId ? parseInt(formData.groupId) : null; - console.log('🎫 [CLIENT] User groups analysis:', { - userRole: user?.role, - formDataGroupId: formData.groupId, - availableGroups: availableGroups.map(g => ({ id: g.id, name: g.name })), - selectedGroupId - }); - if (!selectedGroupId && availableGroups.length > 0) { // Auto-assign first available group selectedGroupId = availableGroups[0].id; - console.log('🎫 [CLIENT] Auto-assigned group:', selectedGroupId, availableGroups[0].name); } const ticketData: InsertSavTicket = { @@ -355,14 +358,6 @@ export default function SavTickets() { clientPhone: formData.clientPhone || undefined, }; - console.log('🎫 [CLIENT] Creating ticket with data:', { - ...ticketData, - userInfo: { - role: user?.role, - username: user?.username, - availableGroups: availableGroups.length - } - }); createTicketMutation.mutate(ticketData); }; @@ -372,13 +367,6 @@ export default function SavTickets() { setShowDetailModal(true); }; - // Query pour récupérer les détails complets du ticket avec historique - const { data: ticketDetails } = useQuery({ - queryKey: [`/api/sav/tickets/${selectedTicket?.id}`], - enabled: !!selectedTicket && showDetailModal, - staleTime: 0, // Toujours récupérer les derniers commentaires - }); - // Function to check if ticket has recent comments (last 24h) const hasRecentComments = (ticket: SavTicketWithRelations) => { if (!ticket.history || ticket.history.length === 0) return false; @@ -441,19 +429,6 @@ export default function SavTickets() { ); } - // Filter tickets by search term - const filteredTickets = ticketsData.filter(ticket => { - if (!searchTerm) return true; - const searchLower = searchTerm.toLowerCase(); - return ( - ticket.ticketNumber.toLowerCase().includes(searchLower) || - ticket.clientName?.toLowerCase().includes(searchLower) || - ticket.problemDescription?.toLowerCase().includes(searchLower) || - ticket.productDesignation?.toLowerCase().includes(searchLower) || - ticket.supplier?.name.toLowerCase().includes(searchLower) - ); - }); - return (
{/* Header */} @@ -823,7 +798,11 @@ export default function SavTickets() { Liste des tickets de service après-vente - + {/* Estompé pendant le chargement d'un nouveau filtre (anciens résultats affichés) */} + {isLoading ? (
diff --git a/client/src/pages/Suppliers.tsx b/client/src/pages/Suppliers.tsx index a36a96e..deb784a 100644 --- a/client/src/pages/Suppliers.tsx +++ b/client/src/pages/Suppliers.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +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"; @@ -31,6 +31,10 @@ import { } from "lucide-react"; import type { EntityActivityStats, Supplier } from "@shared/schema"; +// Références stables tant que les données ne sont pas chargées (dépendances des useMemo) +const NO_SUPPLIERS: Supplier[] = []; +const NO_STATS: EntityActivityStats[] = []; + export default function Suppliers() { const { user } = useAuthUnified(); const { toast } = useToast(); @@ -52,22 +56,19 @@ export default function Suppliers() { paymentMethod: "", }); - const { data: suppliers = [], isLoading } = useQuery({ + const { data: suppliers = NO_SUPPLIERS, isLoading } = useQuery({ queryKey: ['/api/suppliers'], }); // Comptages agrégés en base : la page n'affiche que des totaux, inutile de // rapatrier l'historique complet des commandes et des livraisons. - const { data: supplierStats = [] } = useQuery({ + const { data: supplierStats = NO_STATS } = useQuery({ queryKey: ['/api/stats/by-supplier'], }); const createMutation = useMutation({ mutationFn: async (data: any) => { - console.log('🚚 Frontend: Creating supplier with data:', data); - const result = await apiRequest("/api/suppliers", "POST", data); - console.log('🚚 Frontend: Supplier creation result:', result); - return result; + return await apiRequest("/api/suppliers", "POST", data); }, onSuccess: () => { toast({ @@ -100,14 +101,10 @@ export default function Suppliers() { const updateMutation = useMutation({ mutationFn: async (data: any) => { - console.log('🔧 Frontend: Updating supplier with data:', data); - const result = await apiRequest(`/api/suppliers/${selectedSupplier?.id}`, "PUT", data); - console.log('🔧 Frontend: Supplier update result:', result); - return result; + return await apiRequest(`/api/suppliers/${selectedSupplier?.id}`, "PUT", data); }, onMutate: async (newData) => { // Mise à jour optimiste - mettre à jour l'interface immédiatement - console.log('🚀 Frontend: Optimistic update starting...'); await queryClient.cancelQueries({ queryKey: ['/api/suppliers'] }); const previousSuppliers = queryClient.getQueryData(['/api/suppliers']); @@ -119,14 +116,12 @@ export default function Suppliers() { : supplier ); queryClient.setQueryData(['/api/suppliers'], updatedSuppliers); - console.log('✅ Frontend: Optimistic update applied'); } return { previousSuppliers }; }, onError: (error, newData, context) => { // Rollback en cas d'erreur - console.log('⚠️ Frontend: Rolling back optimistic update due to error'); if (context?.previousSuppliers) { queryClient.setQueryData(['/api/suppliers'], context.previousSuppliers); } @@ -153,13 +148,15 @@ export default function Suppliers() { title: "Succès", description: "Fournisseur modifié avec succès", }); - queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] }); setShowEditModal(false); setSelectedSupplier(null); resetForm(); }, + // Une seule invalidation, après succès comme après échec onSettled: () => { queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] }); + // Le mode de paiement est affiché dans l'échéancier (cache de 5 min) + queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] }); }, }); @@ -273,14 +270,20 @@ export default function Suppliers() { } }; - const filteredSuppliers = suppliers.filter(supplier => - supplier.name.toLowerCase().includes(searchTerm.toLowerCase()) + // Filtre recalculé seulement quand la liste ou la recherche change, terme normalisé une seule fois + const filteredSuppliers = useMemo(() => { + const term = searchTerm.toLowerCase(); + return suppliers.filter(supplier => supplier.name.toLowerCase().includes(term)); + }, [suppliers, searchTerm]); + + // Statistiques indexées par fournisseur (au lieu d'une recherche par carte) + const statsBySupplierId = useMemo( + () => new Map(Array.isArray(supplierStats) ? supplierStats.map(s => [s.id, s]) : []), + [supplierStats] ); const getSupplierStats = (supplierId: number) => { - const stats = Array.isArray(supplierStats) - ? supplierStats.find(s => s.id === supplierId) - : undefined; + const stats = statsBySupplierId.get(supplierId); return { orders: stats?.orders ?? 0, diff --git a/client/src/pages/Tasks.tsx b/client/src/pages/Tasks.tsx index 93f15a5..1e9dcf3 100644 --- a/client/src/pages/Tasks.tsx +++ b/client/src/pages/Tasks.tsx @@ -1,15 +1,15 @@ -import { useState, useEffect } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState, useMemo, useDeferredValue } from "react"; +import { useQuery, useQueryClient, useMutation, keepPreviousData } from "@tanstack/react-query"; import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useStore } from "@/contexts/StoreContext"; import { useToast } from "@/hooks/use-toast"; +import { useScreenSize, type ScreenBreakpoints } from "@/hooks/use-screen-size"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; import { Pagination, usePagination } from "@/components/ui/pagination"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ListTodo, @@ -42,9 +42,17 @@ type TaskWithRelations = Task & { isFutureTask?: boolean; // Pour les tâches futures (admin/directeur uniquement) }; +// Référence stable tant que la liste n'est pas chargée (évite de recalculer les filtres) +const NO_TASKS: TaskWithRelations[] = []; + +// Seuils alignés sur le point de rupture « lg » de Tailwind (1024 px) qui séparait +// les deux mises en page jusque-là masquées en CSS : bureau à partir de 1024 px +const TASKS_LAYOUT_BREAKPOINTS: ScreenBreakpoints = { mobile: 768, tablet: 1023, desktop: 1280 }; + // Composant TaskForm inline - Version production ultra-simple function TaskFormInline({ task, onClose, selectedStoreId, user }: any) { const { toast } = useToast(); + const queryClient = useQueryClient(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -91,22 +99,12 @@ function TaskFormInline({ task, onClose, selectedStoreId, user }: any) { (taskData as any).createdBy = user?.username || 'admin'; } - console.log('📤 Tasks.tsx - Sending request:', { - url, - method, - taskData, - selectedStoreId, - finalGroupId: (taskData as any).groupId - }); - const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(taskData), }); - console.log('📥 Response:', { status: response.status, ok: response.ok }); - if (!response.ok) { const errorText = await response.text(); console.error('❌ API Error:', { status: response.status, error: errorText }); @@ -117,8 +115,11 @@ function TaskFormInline({ task, onClose, selectedStoreId, user }: any) { title: "Succès", description: task ? "Tâche modifiée avec succès" : "Tâche créée avec succès", }); - window.location.reload(); - + // Rechargement ciblé de la liste des tâches (plus de rechargement complet de + // la page) : filtres, recherche et pagination sont conservés + await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); + onClose(); + } catch (error) { console.error('Erreur:', error); toast({ @@ -277,35 +278,11 @@ function TaskFormInline({ task, onClose, selectedStoreId, user }: any) { export default function Tasks() { const { user } = useAuthUnified(); - const { selectedStoreId, storeInitialized } = useStore(); + const { selectedStoreId } = useStore(); const { toast } = useToast(); const queryClient = useQueryClient(); - - // Debug enablement condition for tasks query - const isQueryEnabled = !!user && (user.role === 'admin' || (user.role === 'directeur' || user.role === 'manager' ? !!selectedStoreId : true)); - console.log('🔍 TASK QUERY ENABLEMENT DEBUG:', { - hasUser: !!user, - userRole: user?.role, - selectedStoreId, - isAdmin: user?.role === 'admin', - isDirecteurOrManager: user?.role === 'directeur' || user?.role === 'manager', - hasSelectedStore: !!selectedStoreId, - finalEnabled: isQueryEnabled, - storeInitialized, - timestamp: new Date().toISOString() - }); - - // Force refresh when selectedStoreId changes for directeur/manager - useEffect(() => { - if (user && (user.role === 'directeur' || user.role === 'manager') && selectedStoreId) { - console.log('🔄 FORCE REFRESH: selectedStoreId changed for directeur/manager:', { - userRole: user.role, - selectedStoreId, - timestamp: new Date().toISOString() - }); - queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); - } - }, [selectedStoreId, user, queryClient]); + // Une seule des deux mises en page (tablette ou bureau) est montée + const { isDesktop: isLargeScreen } = useScreenSize(TASKS_LAYOUT_BREAKPOINTS); // États locaux const [searchTerm, setSearchTerm] = useState(""); @@ -322,9 +299,15 @@ export default function Tasks() { const [selectedTask, setSelectedTask] = useState(null); const [taskToDelete, setTaskToDelete] = useState(null); - // Fetch tasks - attendre que l'initialisation des stores soit terminée pour les admins - const { data: tasks = [], isLoading, error } = useQuery({ - queryKey: ["/api/tasks", selectedStoreId], + // Clé propre à cette page : le tableau de bord utilise ['/api/tasks', storeId] + // avec une URL différente pour les non-admins (sans storeId), sa réponse ne doit + // donc jamais être réutilisée ici. Le changement de magasin change la clé, ce + // qui suffit à recharger la liste (plus d'invalidation forcée au montage). + const tasksQueryKey = ["/api/tasks", { storeId: selectedStoreId }]; + + // Fetch tasks + const { data: tasks = NO_TASKS, isLoading, isPlaceholderData } = useQuery({ + queryKey: tasksQueryKey, queryFn: async () => { try { const params = new URLSearchParams(); @@ -332,39 +315,17 @@ export default function Tasks() { if (selectedStoreId) { params.append('storeId', selectedStoreId.toString()); } - console.log('📋 TASKS QUERY - Fetching with params:', { - selectedStoreId, - userRole: user?.role, - storeInitialized, - url: `/api/tasks?${params.toString()}`, - willFilterByStore: !!selectedStoreId, - enabled: !!user, - timestamp: new Date().toISOString(), - userGroups: user?.userGroups?.map((ug: any) => ug.groupId) || 'NONE' - }); - + const response = await fetch(`/api/tasks?${params.toString()}`, { credentials: 'include' }); - + if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } - + const data = await response.json(); - - console.log('📋 TASKS QUERY - Response received:', { - dataType: typeof data, - isArray: Array.isArray(data), - length: data?.length, - firstTask: data?.[0] ? { - id: data[0].id, - title: data[0].title, - hasStartDate: !!data[0].startDate, - hasGroup: !!data[0].group - } : null - }); - + // Valider et nettoyer les données reçues if (!Array.isArray(data)) { console.error('Tasks API returned non-array data:', data); @@ -372,38 +333,40 @@ export default function Tasks() { } return data.filter(task => task && typeof task === 'object' && task.id); - + } catch (error) { console.error('Error fetching tasks:', error); throw error; } }, enabled: !!user, // Charger les données dès que l'utilisateur est connecté + // Au changement de magasin, la liste précédente reste affichée (estompée et + // non cliquable, avec un indicateur) au lieu d'un spinner plein écran + placeholderData: keepPreviousData, }); - // Fetch users for task assignment - seulement pour admin/manager/directeur - const { data: users = [] } = useQuery({ - queryKey: ["/api/users"], - queryFn: () => fetch('/api/users', { - credentials: 'include' - }).then(res => { - if (!res.ok) { - throw new Error(`HTTP error! status: ${res.status}`); - } - return res.json(); - }), - enabled: !!user && (user.role === 'admin' || user.role === 'manager' || user.role === 'directeur'), - }); - - - - const handleEditTask = (task: TaskWithRelations) => { - setSelectedTask(task); - setShowEditModal(true); + // Mise à jour optimiste de la liste affichée ; renvoie l'état précédent pour + // pouvoir le restaurer si le serveur refuse l'opération + const updateCachedTasks = async (update: (list: TaskWithRelations[]) => TaskWithRelations[]) => { + const queryKey = tasksQueryKey; + await queryClient.cancelQueries({ queryKey }); + const previousTasks = queryClient.getQueryData(queryKey); + if (Array.isArray(previousTasks)) { + queryClient.setQueryData(queryKey, update(previousTasks)); + } + return { queryKey, previousTasks }; }; - const handleCompleteTask = async (taskId: number) => { - try { + const restoreCachedTasks = (context?: { queryKey: unknown[]; previousTasks?: TaskWithRelations[] }) => { + if (context?.previousTasks) { + queryClient.setQueryData(context.queryKey, context.previousTasks); + } + }; + + // Terminer : la tâche passe tout de suite dans les tâches terminées, puis la + // liste est rechargée depuis le serveur + const completeTaskMutation = useMutation({ + mutationFn: async (taskId: number) => { const response = await fetch(`/api/tasks/${taskId}/complete`, { method: 'POST', headers: { @@ -416,32 +379,39 @@ export default function Tasks() { if (!response.ok) { throw new Error('Erreur lors de la completion de la tâche'); } - - await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); + }, + onMutate: (taskId: number) => { + const now = new Date(); + return updateCachedTasks((list) => list.map((task) => + task.id === taskId + ? { ...task, status: 'completed', completedAt: now, completedBy: user?.id ?? task.completedBy, updatedAt: now } + : task + )); + }, + onSuccess: () => { toast({ title: "Succès", description: "Tâche marquée comme terminée", }); - } catch (error) { + }, + onError: (error, _taskId, context) => { + restoreCachedTasks(context); console.error("Error completing task:", error); toast({ title: "Erreur", description: "Impossible de terminer la tâche", variant: "destructive", }); - } - }; + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); + }, + }); - const handleDeleteClick = (task: TaskWithRelations) => { - setTaskToDelete(task); - setShowDeleteModal(true); - }; - - const handleConfirmDelete = async () => { - if (!taskToDelete) return; - - try { - const response = await fetch(`/api/tasks/${taskToDelete.id}`, { + // Supprimer : la tâche disparaît tout de suite de la liste et la modale se ferme + const deleteTaskMutation = useMutation({ + mutationFn: async (taskId: number) => { + const response = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE', credentials: 'include', }); @@ -449,86 +419,117 @@ export default function Tasks() { if (!response.ok) { throw new Error('Erreur lors de la suppression'); } - - await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); + }, + onMutate: (taskId: number) => updateCachedTasks((list) => list.filter((task) => task.id !== taskId)), + onSuccess: () => { toast({ title: "Succès", description: "Tâche supprimée avec succès", }); - - setShowDeleteModal(false); - setTaskToDelete(null); - } catch (error) { + }, + onError: (error, _taskId, context) => { + restoreCachedTasks(context); console.error("Error deleting task:", error); toast({ title: "Erreur", description: "Impossible de supprimer la tâche", variant: "destructive", }); - } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); + }, + }); + + const handleEditTask = (task: TaskWithRelations) => { + setSelectedTask(task); + setShowEditModal(true); }; - // Filtrer et trier les tâches - const filteredTasks = tasks - .filter((task: TaskWithRelations) => { - // Filtre par recherche - if (searchTerm && !task.title.toLowerCase().includes(searchTerm.toLowerCase()) && - !task.description?.toLowerCase().includes(searchTerm.toLowerCase())) { - return false; - } + const handleCompleteTask = (taskId: number) => { + completeTaskMutation.mutate(taskId); + }; - // Filtre par statut - if (statusFilter !== "all" && task.status !== statusFilter) { - return false; - } + const handleDeleteClick = (task: TaskWithRelations) => { + setTaskToDelete(task); + setShowDeleteModal(true); + }; - // Filtre par priorité - if (priorityFilter !== "all" && task.priority !== priorityFilter) { - return false; - } + const handleConfirmDelete = () => { + if (!taskToDelete) return; - // Filtre par date d'échéance - if (dueDateFilter !== "all" && task.dueDate) { - const dueDate = new Date(task.dueDate); - switch (dueDateFilter) { - case "today": - if (!isToday(dueDate)) return false; - break; - case "this_week": - if (!isThisWeek(dueDate)) return false; - break; - case "overdue": - if (!isPast(dueDate) || task.status === 'completed') return false; - break; - case "no_due_date": - if (task.dueDate) return false; - break; + deleteTaskMutation.mutate(taskToDelete.id); + setShowDeleteModal(false); + setTaskToDelete(null); + }; + + // La recherche est différée : la saisie reste fluide pendant le filtrage + const deferredSearchTerm = useDeferredValue(searchTerm); + + // Filtrer et trier les tâches (recalculé seulement quand la liste ou un filtre change) + const filteredTasks = useMemo(() => { + const search = deferredSearchTerm.toLowerCase(); + return tasks + .filter((task: TaskWithRelations) => { + // Filtre par recherche + if (search && !task.title.toLowerCase().includes(search) && + !task.description?.toLowerCase().includes(search)) { + return false; } - } else if (dueDateFilter === "no_due_date" && task.dueDate) { - return false; - } - return true; - }) - .sort((a: TaskWithRelations, b: TaskWithRelations) => { - // Faire remonter les tâches non validées (pending) en premier - if (a.status === 'pending' && b.status === 'completed') return -1; - if (a.status === 'completed' && b.status === 'pending') return 1; + // Filtre par statut + if (statusFilter !== "all" && task.status !== statusFilter) { + return false; + } + + // Filtre par priorité + if (priorityFilter !== "all" && task.priority !== priorityFilter) { + return false; + } + + // Filtre par date d'échéance + if (dueDateFilter !== "all" && task.dueDate) { + const dueDate = new Date(task.dueDate); + switch (dueDateFilter) { + case "today": + if (!isToday(dueDate)) return false; + break; + case "this_week": + if (!isThisWeek(dueDate)) return false; + break; + case "overdue": + if (!isPast(dueDate) || task.status === 'completed') return false; + break; + case "no_due_date": + if (task.dueDate) return false; + break; + } + } else if (dueDateFilter === "no_due_date" && task.dueDate) { + return false; + } + + return true; + }) + .sort((a: TaskWithRelations, b: TaskWithRelations) => { + // Faire remonter les tâches non validées (pending) en premier + if (a.status === 'pending' && b.status === 'completed') return -1; + if (a.status === 'completed' && b.status === 'pending') return 1; - // Pour les tâches de même statut, trier par priorité (high > medium > low) - const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 }; - const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 2; - const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 2; + // Pour les tâches de même statut, trier par priorité (high > medium > low) + const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 }; + const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 2; + const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 2; - if (aPriority !== bPriority) { - return bPriority - aPriority; // Ordre décroissant (high en premier) - } + if (aPriority !== bPriority) { + return bPriority - aPriority; // Ordre décroissant (high en premier) + } - // Enfin, trier par date de création (plus récent en premier) - const aDate = a.createdAt ? new Date(a.createdAt).getTime() : 0; - const bDate = b.createdAt ? new Date(b.createdAt).getTime() : 0; - return bDate - aDate; - }); + // Enfin, trier par date de création (plus récent en premier) + const aDate = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const bDate = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return bDate - aDate; + }); + }, [tasks, deferredSearchTerm, statusFilter, priorityFilter, dueDateFilter]); // Pagination const { @@ -541,6 +542,18 @@ export default function Tasks() { totalItems } = usePagination(filteredTasks, 10); + // Sous-listes calculées une seule fois par rendu (page courante et Kanban) + const pagePendingTasks = paginatedTasks.filter((task: TaskWithRelations) => task.status === 'pending'); + const pageCompletedTasks = paginatedTasks.filter((task: TaskWithRelations) => task.status === 'completed'); + const { kanbanPendingTasks, kanbanCompletedTasks } = useMemo(() => ({ + kanbanPendingTasks: filteredTasks.filter((task: TaskWithRelations) => task.status === 'pending'), + kanbanCompletedTasks: filteredTasks.filter((task: TaskWithRelations) => task.status === 'completed'), + }), [filteredTasks]); + + // Liste du magasin précédent affichée pendant le chargement du nouveau magasin : + // estompée et non cliquable pour ne pas agir sur une tâche de l'ancien magasin + const listBusyClass = isPlaceholderData ? "opacity-60 pointer-events-none transition-opacity" : ""; + const getPriorityConfig = (priority: string) => { switch (priority) { case 'high': @@ -687,6 +700,12 @@ export default function Tasks() {

{totalItems} tâche{totalItems !== 1 ? 's' : ''} trouvée{totalItems !== 1 ? 's' : ''} + {isPlaceholderData && ( + + + Mise à jour… + + )}

@@ -757,8 +776,9 @@ export default function Tasks() {
- {/* Section filtres - Responsive */} -
+ {/* Section filtres - Responsive (tablette uniquement) */} + {!isLargeScreen && ( +
@@ -846,9 +866,11 @@ export default function Tasks() {
+ )} {/* Zone principale mobile/tablet - Contenu pleine largeur */} -
+ {!isLargeScreen && ( +
{/* Contenu mobile identique au desktop mais optimisé */} @@ -865,14 +887,13 @@ export default function Tasks() { ) : (
{/* Tâches en cours - Mobile optimisé */} - {paginatedTasks.filter(task => task.status === 'pending').length > 0 && ( + {pagePendingTasks.length > 0 && (

- Tâches en cours ({paginatedTasks.filter(task => task.status === 'pending').length}) + Tâches en cours ({pagePendingTasks.length})

- {paginatedTasks - .filter(task => task.status === 'pending') + {pagePendingTasks .map((task) => { const priorityConfig = getPriorityConfig(task.priority); const PriorityIcon = priorityConfig.icon; @@ -1024,14 +1045,13 @@ export default function Tasks() { )} {/* Tâches terminées - Mobile optimisé */} - {paginatedTasks.filter(task => task.status === 'completed').length > 0 && ( + {pageCompletedTasks.length > 0 && (

- Tâches terminées ({paginatedTasks.filter(task => task.status === 'completed').length}) + Tâches terminées ({pageCompletedTasks.length})

- {paginatedTasks - .filter(task => task.status === 'completed') + {pageCompletedTasks .map((task) => { const priorityConfig = getPriorityConfig(task.priority); const PriorityIcon = priorityConfig.icon; @@ -1088,9 +1108,11 @@ export default function Tasks() {
+ )} {/* Sidebar desktop uniquement */} -
+ {isLargeScreen && ( +
{/* Sidebar avec filtres desktop */}
@@ -1167,7 +1189,7 @@ export default function Tasks() {
{/* Zone principale avec les tâches - Desktop */} -
+
{/* Contenu selon la vue sélectionnée */} @@ -1186,14 +1208,13 @@ export default function Tasks() { ) : (
{/* Tâches en cours */} - {paginatedTasks.filter(task => task.status === 'pending').length > 0 && ( + {pagePendingTasks.length > 0 && (

- Tâches en cours ({paginatedTasks.filter(task => task.status === 'pending').length}) + Tâches en cours ({pagePendingTasks.length})

- {paginatedTasks - .filter(task => task.status === 'pending') + {pagePendingTasks .map((task) => { const priorityConfig = getPriorityConfig(task.priority); const PriorityIcon = priorityConfig.icon; @@ -1334,14 +1355,13 @@ export default function Tasks() { )} {/* Tâches terminées */} - {paginatedTasks.filter(task => task.status === 'completed').length > 0 && ( + {pageCompletedTasks.length > 0 && (

- Tâches terminées ({paginatedTasks.filter(task => task.status === 'completed').length}) + Tâches terminées ({pageCompletedTasks.length})

- {paginatedTasks - .filter(task => task.status === 'completed') + {pageCompletedTasks .map((task) => { const priorityConfig = getPriorityConfig(task.priority); const PriorityIcon = priorityConfig.icon; @@ -1426,10 +1446,10 @@ export default function Tasks() {

- En cours ({filteredTasks.filter((task: TaskWithRelations) => task.status === 'pending').length}) + En cours ({kanbanPendingTasks.length})

- {filteredTasks.filter((task: TaskWithRelations) => task.status === 'pending').map((task: TaskWithRelations) => { + {kanbanPendingTasks.map((task: TaskWithRelations) => { const priorityConfig = getPriorityConfig(task.priority); const PriorityIcon = priorityConfig.icon; const dueDateStatus = getDueDateStatus(task.dueDate, task.status); @@ -1506,10 +1526,10 @@ export default function Tasks() {

- Terminées ({filteredTasks.filter((task: TaskWithRelations) => task.status === 'completed').length}) + Terminées ({kanbanCompletedTasks.length})

- {filteredTasks.filter((task: TaskWithRelations) => task.status === 'completed').map((task: TaskWithRelations) => { + {kanbanCompletedTasks.map((task: TaskWithRelations) => { const priorityConfig = getPriorityConfig(task.priority); const PriorityIcon = priorityConfig.icon; @@ -1562,6 +1582,7 @@ export default function Tasks() {
+ )} {/* Modal d'édition */} {showEditModal && selectedTask && ( diff --git a/client/src/pages/Users.tsx b/client/src/pages/Users.tsx index 9e3a324..07ac69b 100644 --- a/client/src/pages/Users.tsx +++ b/client/src/pages/Users.tsx @@ -28,7 +28,7 @@ import { import type { UserWithGroups, Group } from "@shared/schema"; export default function UsersPage() { - const { user } = useAuthUnified(); + const { user, refreshAuth } = useAuthUnified(); const { toast } = useToast(); const queryClient = useQueryClient(); @@ -132,11 +132,16 @@ export default function UsersPage() { const response = await apiRequest(`/api/users/${data.id}`, "PUT", cleanedUpdates); return response; }, - onSuccess: (updatedUser) => { + onSuccess: (updatedUser, variables) => { toast({ title: "Succès", description: "Utilisateur mis à jour avec succès", }); + + // Compte de l'administrateur connecté : rafraîchir l'utilisateur partagé par l'application + if (variables.id === user?.id) { + refreshAuth(); + } // Update the form with the response data before closing if (updatedUser) { @@ -224,25 +229,24 @@ export default function UsersPage() { const assignGroupMutation = useMutation({ mutationFn: async (data: { userId: string; groupId: number }) => { - console.log('📤 Assigning group:', data); const response = await apiRequest( `/api/users/${data.userId}/groups`, "POST", { groupId: data.groupId } ); - console.log('✅ Group assignment response:', response); return response; }, - onSuccess: () => { - console.log('✅ Group assigned successfully'); + onSuccess: (_response, variables) => { toast({ title: "Succès", description: "Utilisateur assigné au groupe avec succès", }); - // Invalidation complète du cache + // Seule la liste des utilisateurs change : la liste des magasins + // (['/api/groups']) ne dépend pas des affectations queryClient.invalidateQueries({ queryKey: ['/api/users'] }); - - queryClient.invalidateQueries({ queryKey: ['/api/groups'] }); + if (variables.userId === user?.id) { + refreshAuth(); + } }, onError: (error: any) => { console.error('❌ Error assigning group:', error); @@ -270,24 +274,23 @@ export default function UsersPage() { const removeGroupMutation = useMutation({ mutationFn: async (data: { userId: string; groupId: number }) => { - console.log('🗑️ Removing group:', data); const response = await apiRequest( `/api/users/${data.userId}/groups/${data.groupId}`, "DELETE" ); - console.log('✅ Group removal response:', response); return response; }, - onSuccess: () => { - console.log('✅ Group removed successfully'); + onSuccess: (_response, variables) => { toast({ title: "Succès", description: "Utilisateur retiré du groupe avec succès", }); - // Invalidation complète du cache + // Seule la liste des utilisateurs change : la liste des magasins + // (['/api/groups']) ne dépend pas des affectations queryClient.invalidateQueries({ queryKey: ['/api/users'] }); - - queryClient.invalidateQueries({ queryKey: ['/api/groups'] }); + if (variables.userId === user?.id) { + refreshAuth(); + } }, onError: (error: any) => { console.error('❌ Error removing group:', error); diff --git a/client/src/pages/mobile/AvoirsPage.tsx b/client/src/pages/mobile/AvoirsPage.tsx index 0f83f8a..e9b2ce5 100644 --- a/client/src/pages/mobile/AvoirsPage.tsx +++ b/client/src/pages/mobile/AvoirsPage.tsx @@ -2,7 +2,7 @@ * MobileAvoirsPage.tsx * Version mobile de la page Gestion des Avoirs */ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useStore } from "@/contexts/StoreContext"; @@ -68,16 +68,20 @@ const avoirSchema = z.object({ status: z.enum(["En attente de demande", "Demandé", "Reçu"]).default("En attente de demande"), }); +// Tableau vide stable tant que la liste n'est pas chargée (calculs mémoïsés) +const NO_AVOIRS: any[] = []; + export default function MobileAvoirsPage() { const { user } = useAuthUnified(); - const { selectedStoreId } = useStore(); + // Magasins déjà chargés par l'application (même liste que /api/groups) + const { selectedStoreId, stores } = useStore(); const [searchTerm, setSearchTerm] = useState(""); const [isCreateOpen, setIsCreateOpen] = useState(false); const { toast } = useToast(); const queryClient = useQueryClient(); // Queries - const { data: avoirs = [], isLoading } = useQuery({ + const { data: avoirs = NO_AVOIRS, isLoading } = useQuery({ queryKey: ["/api/avoirs", selectedStoreId], queryFn: async () => { if (!selectedStoreId) return []; @@ -88,14 +92,11 @@ export default function MobileAvoirsPage() { enabled: !!selectedStoreId && !!user, }); + // Fournisseurs : utiles seulement dans la feuille de création, chargés à son ouverture const { data: suppliers = [] } = useQuery({ queryKey: ['/api/suppliers'], - queryFn: () => apiRequest('/api/suppliers') - }); - - const { data: groups = [] } = useQuery({ - queryKey: ['/api/groups'], - queryFn: () => apiRequest('/api/groups') + queryFn: () => apiRequest('/api/suppliers'), + enabled: isCreateOpen, }); // Mutations @@ -142,7 +143,7 @@ export default function MobileAvoirsPage() { const onSubmit = (data: any) => { let groupId = selectedStoreId; if (!groupId && user?.userGroups?.[0]?.groupId) groupId = user.userGroups[0].groupId; - if (!groupId && groups.length > 0 && user?.role === 'admin') groupId = groups[0].id; + if (!groupId && stores.length > 0 && user?.role === 'admin') groupId = stores[0].id; if (!groupId) { toast({ title: "Erreur", description: "Aucun magasin sélectionné", variant: "destructive" }); @@ -155,13 +156,31 @@ export default function MobileAvoirsPage() { }); }; - const sortedAvoirs = [...avoirs].sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - - const filteredAvoirs = sortedAvoirs.filter((a: any) => - (a.supplier?.name?.toLowerCase().includes(searchTerm.toLowerCase()) || "") || - (a.invoiceReference?.toLowerCase().includes(searchTerm.toLowerCase()) || "") + // Tri et filtrage recalculés seulement quand la liste ou la recherche change + const sortedAvoirs = useMemo( + () => [...avoirs].sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()), + [avoirs] ); + const filteredAvoirs = useMemo(() => { + const searchLower = searchTerm.toLowerCase(); + return sortedAvoirs.filter((a: any) => + (a.supplier?.name?.toLowerCase().includes(searchLower) || "") || + (a.invoiceReference?.toLowerCase().includes(searchLower) || "") + ); + }, [sortedAvoirs, searchTerm]); + + // Changement de statut : le PUT remet à null montant, référence et + // commentaire absents du corps. On envoie donc le statut et les valeurs + // actuelles de ces trois champs (une valeur nulle est omise et reste nulle), + // sans l'avoir complet et ses relations (fournisseur, magasin, créateur). + const buildStatusUpdate = (avoir: any, status: string) => ({ + status, + amount: avoir.amount ?? null, + ...(avoir.invoiceReference != null ? { invoiceReference: avoir.invoiceReference } : {}), + ...(avoir.comment != null ? { comment: avoir.comment } : {}), + }); + const getStatusBadge = (status: string) => { switch (status) { case "En attente de demande": return En attente; @@ -238,19 +257,19 @@ export default function MobileAvoirsPage() { updateStatusMutation.mutate({ id: avoir.id, - data: { ...avoir, status: 'En attente de demande' } + data: buildStatusUpdate(avoir, 'En attente de demande') })}> Marquer En attente updateStatusMutation.mutate({ id: avoir.id, - data: { ...avoir, status: 'Demandé' } + data: buildStatusUpdate(avoir, 'Demandé') })}> Marquer Demandé updateStatusMutation.mutate({ id: avoir.id, - data: { ...avoir, status: 'Reçu' } + data: buildStatusUpdate(avoir, 'Reçu') })}> Marquer Reçu diff --git a/client/src/pages/mobile/CalendarPage.tsx b/client/src/pages/mobile/CalendarPage.tsx index 8af0fb9..fa50b7c 100644 --- a/client/src/pages/mobile/CalendarPage.tsx +++ b/client/src/pages/mobile/CalendarPage.tsx @@ -2,7 +2,7 @@ * CalendarPage Mobile - Calendrier simplifié pour mobile * Affiche les commandes et livraisons comme événements */ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useStore } from "@/contexts/StoreContext"; @@ -42,6 +42,10 @@ type CalendarEvent = { supplier?: { name: string }; }; +// Références stables (évite de recalculer l'index des événements à chaque rendu) +const NO_DATA: any[] = []; +const NO_EVENTS: CalendarEvent[] = []; + export default function MobileCalendarPage() { const { user } = useAuthUnified(); const { selectedStoreId } = useStore(); @@ -49,7 +53,7 @@ export default function MobileCalendarPage() { const [selectedDate, setSelectedDate] = useState(new Date()); // Fetch orders for current month - const { data: orders = [] } = useQuery({ + const { data: orders = NO_DATA } = useQuery({ queryKey: ["/api/orders", selectedStoreId, format(currentMonth, 'yyyy-MM')], queryFn: async () => { const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd'); @@ -74,7 +78,7 @@ export default function MobileCalendarPage() { }); // Fetch deliveries for current month - const { data: deliveries = [] } = useQuery({ + const { data: deliveries = NO_DATA } = useQuery({ queryKey: ["/api/deliveries", selectedStoreId, format(currentMonth, 'yyyy-MM')], queryFn: async () => { const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd'); @@ -98,25 +102,45 @@ export default function MobileCalendarPage() { enabled: !!user, }); - // Combine orders and deliveries into events - const events: CalendarEvent[] = [ - ...orders.map((order: any) => ({ - id: order.id, - title: `Cmd #${order.orderNumber || order.id}`, - date: order.plannedDate, - type: 'order' as const, - status: order.status, - supplier: order.supplier - })), - ...deliveries.map((delivery: any) => ({ - id: delivery.id, - title: `Liv #${delivery.deliveryNumber || delivery.id}`, - date: delivery.scheduledDate, - type: 'delivery' as const, - status: delivery.status, - supplier: delivery.supplier - })) - ]; + // Combine orders and deliveries into events, indexés par jour ('yyyy-MM-dd', + // heure locale comme isSameDay) une seule fois par chargement au lieu d'un + // filtrage complet pour chaque case du calendrier + const { events, eventsByDay } = useMemo(() => { + const events: CalendarEvent[] = [ + ...orders.map((order: any) => ({ + id: order.id, + title: `Cmd #${order.orderNumber || order.id}`, + date: order.plannedDate, + type: 'order' as const, + status: order.status, + supplier: order.supplier + })), + ...deliveries.map((delivery: any) => ({ + id: delivery.id, + title: `Liv #${delivery.deliveryNumber || delivery.id}`, + date: delivery.scheduledDate, + type: 'delivery' as const, + status: delivery.status, + supplier: delivery.supplier + })) + ]; + + const eventsByDay = new Map(); + for (const event of events) { + if (!event.date) continue; + const eventDate = new Date(event.date); + if (isNaN(eventDate.getTime())) continue; + const key = format(eventDate, 'yyyy-MM-dd'); + const dayEvents = eventsByDay.get(key); + if (dayEvents) { + dayEvents.push(event); + } else { + eventsByDay.set(key, [event]); + } + } + + return { events, eventsByDay }; + }, [orders, deliveries]); const monthStart = startOfMonth(currentMonth); const monthEnd = endOfMonth(currentMonth); @@ -126,24 +150,14 @@ export default function MobileCalendarPage() { const calendarEnd = endOfWeek(monthEnd, { locale: fr }); const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd }); - // Get events for selected date - const selectedDateEvents = selectedDate - ? events.filter((event) => { - if (!event.date) return false; - const eventDate = new Date(event.date); - return !isNaN(eventDate.getTime()) && isSameDay(eventDate, selectedDate); - }) - : []; - // Check if a day has events const getEventsForDay = (day: Date) => { - return events.filter((event) => { - if (!event.date) return false; - const eventDate = new Date(event.date); - return !isNaN(eventDate.getTime()) && isSameDay(eventDate, day); - }); + return eventsByDay.get(format(day, 'yyyy-MM-dd')) || NO_EVENTS; }; + // Get events for selected date + const selectedDateEvents = selectedDate ? getEventsForDay(selectedDate) : NO_EVENTS; + const navigateMonth = (direction: 'prev' | 'next') => { setCurrentMonth(direction === 'prev' ? subMonths(currentMonth, 1) diff --git a/client/src/pages/mobile/CustomerOrdersPage.tsx b/client/src/pages/mobile/CustomerOrdersPage.tsx index 55cf82f..d4d63ff 100644 --- a/client/src/pages/mobile/CustomerOrdersPage.tsx +++ b/client/src/pages/mobile/CustomerOrdersPage.tsx @@ -2,7 +2,7 @@ * MobileCustomerOrdersPage.tsx * Version mobile de la page Commandes Clients avec création */ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useMemo } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useStore } from "@/contexts/StoreContext"; @@ -77,6 +77,9 @@ const customerOrderFormSchema = z.object({ deposit: z.coerce.number().optional().default(0), }); +// Référence stable tant que la liste n'est pas chargée +const NO_ORDERS: any[] = []; + export default function MobileCustomerOrdersPage() { const { user } = useAuthUnified(); const { selectedStoreId } = useStore(); @@ -88,7 +91,7 @@ export default function MobileCustomerOrdersPage() { const queryClient = useQueryClient(); // Queries - const { data: orders = [], isLoading } = useQuery({ + const { data: orders = NO_ORDERS, isLoading } = useQuery({ queryKey: ["/api/customer-orders", selectedStoreId], queryFn: async () => { if (!selectedStoreId) return []; @@ -267,11 +270,16 @@ export default function MobileCustomerOrdersPage() { createMutation.mutate(submitData); }; - const filteredOrders = orders.filter((order: any) => - order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) || - order.productDesignation.toLowerCase().includes(searchTerm.toLowerCase()) || - (order.productReference && order.productReference.toLowerCase().includes(searchTerm.toLowerCase())) - ); + // Recalculé seulement quand la liste ou la recherche change (et non à + // chaque frappe dans le commentaire de contact) + const filteredOrders = useMemo(() => { + const search = searchTerm.toLowerCase(); + return orders.filter((order: any) => + order.customerName.toLowerCase().includes(search) || + order.productDesignation.toLowerCase().includes(search) || + (order.productReference && order.productReference.toLowerCase().includes(search)) + ); + }, [orders, searchTerm]); const getStatusColor = (status: string) => { switch (status) { diff --git a/client/src/pages/mobile/DashboardPage.tsx b/client/src/pages/mobile/DashboardPage.tsx index 53c6ef2..70b4f95 100644 --- a/client/src/pages/mobile/DashboardPage.tsx +++ b/client/src/pages/mobile/DashboardPage.tsx @@ -2,6 +2,7 @@ * DashboardPage Mobile - Tableau de bord simplifié pour mobile */ import { useQuery } from "@tanstack/react-query"; +import { Link } from "wouter"; import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useStore } from "@/contexts/StoreContext"; import MobileLayout from "./MobileLayout"; @@ -59,22 +60,6 @@ export default function MobileDashboardPage() { const { user } = useAuthUnified(); const { selectedStoreId } = useStore(); - // Fetch stats - const { data: stats, isLoading } = useQuery({ - queryKey: ["/api/stats/dashboard", selectedStoreId], - queryFn: async () => { - const params = new URLSearchParams(); - if (selectedStoreId) params.append('storeId', selectedStoreId.toString()); - - const response = await fetch(`/api/stats/dashboard?${params}`, { - credentials: 'include' - }); - if (!response.ok) return null; - return response.json(); - }, - enabled: !!user, - }); - // Fetch pending tasks count const { data: tasks = [] } = useQuery({ queryKey: ["/api/tasks", selectedStoreId], @@ -92,13 +77,18 @@ export default function MobileDashboardPage() { enabled: !!user, }); + // Date du jour : le serveur filtre sur startDate/endDate (le paramètre 'date' + // était ignoré et renvoyait tout l'historique) + const today = format(new Date(), 'yyyy-MM-dd'); + // Fetch today's orders const { data: orders = [] } = useQuery({ - queryKey: ["/api/orders/today", selectedStoreId], + queryKey: ["/api/orders/today", selectedStoreId, today], queryFn: async () => { const params = new URLSearchParams(); if (selectedStoreId) params.append('storeId', selectedStoreId.toString()); - params.append('date', format(new Date(), 'yyyy-MM-dd')); + params.append('startDate', today); + params.append('endDate', today); const response = await fetch(`/api/orders?${params}`, { credentials: 'include' @@ -112,11 +102,12 @@ export default function MobileDashboardPage() { // Fetch today's deliveries const { data: deliveries = [] } = useQuery({ - queryKey: ["/api/deliveries/today", selectedStoreId], + queryKey: ["/api/deliveries/today", selectedStoreId, today], queryFn: async () => { const params = new URLSearchParams(); if (selectedStoreId) params.append('storeId', selectedStoreId.toString()); - params.append('date', format(new Date(), 'yyyy-MM-dd')); + params.append('startDate', today); + params.append('endDate', today); const response = await fetch(`/api/deliveries?${params}`, { credentials: 'include' @@ -183,15 +174,16 @@ export default function MobileDashboardPage() { { icon: Calendar, label: "Agenda", path: "/calendar" }, ].map((item) => { const Icon = item.icon; + // Lien wouter : navigation interne sans recharger l'application return ( - {item.label} - + ); })}
diff --git a/client/src/pages/mobile/DlcPage.tsx b/client/src/pages/mobile/DlcPage.tsx index 8798290..7441581 100644 --- a/client/src/pages/mobile/DlcPage.tsx +++ b/client/src/pages/mobile/DlcPage.tsx @@ -70,30 +70,40 @@ const dlcFormSchema = z.object({ notes: z.string().optional(), }); +// Référence stable tant que la liste n'est pas chargée +const NO_PRODUCTS: any[] = []; + export default function MobileDlcPage() { const { user } = useAuthUnified(); const { selectedStoreId } = useStore(); const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("en_cours"); // en_cours, expires_soon, expires, valides const [isCreateOpen, setIsCreateOpen] = useState(false); const { toast } = useToast(); const queryClient = useQueryClient(); + // Recherche appliquée 300 ms après la dernière frappe (filtrage local) + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 300); + + return () => clearTimeout(timer); + }, [searchTerm]); + // Data fetching - const { data: dlcProducts = [], isLoading } = useQuery({ - queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, searchTerm], + // Toute la liste du magasin est chargée une seule fois : l'onglet et la + // recherche filtrent localement, sans nouvelle requête (clé propre au mobile, + // toujours invalidée par le préfixe "/api/dlc-products" des mutations) + const { data: dlcProducts = NO_PRODUCTS, isLoading } = useQuery({ + queryKey: ["/api/dlc-products", "mobile", selectedStoreId], queryFn: async () => { - const params = new URLSearchParams(); - if (selectedStoreId) params.append("storeId", selectedStoreId.toString()); - // Simplify fetching everything client side for mobile smoothness or filter server side? - // Let's filter client side for better UX given the likely smaller dataset per store - if (selectedStoreId) { - const res = await fetch(`/api/dlc-products?storeId=${selectedStoreId}`, { credentials: 'include' }); - return res.json(); - } - return []; + if (!selectedStoreId) return []; + return apiRequest(`/api/dlc-products?storeId=${selectedStoreId}`); }, enabled: !!selectedStoreId && !!user, + staleTime: 2 * 60 * 1000, // 2 minutes, comme sur ordinateur }); const { data: suppliers = [] } = useQuery({ @@ -230,8 +240,8 @@ export default function MobileDlcPage() { let filtered = dlcProducts; // Search - if (searchTerm) { - const lower = searchTerm.toLowerCase(); + if (debouncedSearchTerm) { + const lower = debouncedSearchTerm.toLowerCase(); filtered = filtered.filter((p: any) => p.productName.toLowerCase().includes(lower) || (p.gencode && p.gencode.includes(lower)) @@ -254,7 +264,7 @@ export default function MobileDlcPage() { }); return filtered.sort((a: any, b: any) => new Date(a.expiryDate).getTime() - new Date(b.expiryDate).getTime()); - }, [dlcProducts, searchTerm, statusFilter]); + }, [dlcProducts, debouncedSearchTerm, statusFilter]); const getDaysBadge = (dateStr: string) => { const today = new Date(); diff --git a/client/src/pages/mobile/MobileApp.tsx b/client/src/pages/mobile/MobileApp.tsx index 70c1c07..6083d03 100644 --- a/client/src/pages/mobile/MobileApp.tsx +++ b/client/src/pages/mobile/MobileApp.tsx @@ -2,16 +2,33 @@ * MobileApp - Wrapper pour l'application mobile avec StoreProvider * Fournit le contexte Store à toutes les pages mobiles */ -import { ReactNode, useState, useEffect } from "react"; +import { ReactNode, Suspense, useState, useEffect, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { useAuthUnified } from "@/hooks/useAuthUnified"; import { StoreProvider } from "@/contexts/StoreContext"; +import MobileLayout from "./MobileLayout"; import type { Group } from "@shared/schema"; interface MobileAppProps { 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é pendant le chargement du code d'une page : en-tête et barre de +// navigation restent visibles +function MobilePageFallback() { + return ( + +
+
+
+
+ ); +} + export default function MobileApp({ children }: MobileAppProps) { const { user } = useAuthUnified(); @@ -26,7 +43,7 @@ export default function MobileApp({ children }: MobileAppProps) { const [storeInitialized, setStoreInitialized] = useState(false); // Fetch stores - const { data: stores = [] } = useQuery({ + const { data: stores = EMPTY_STORES } = useQuery({ queryKey: ['/api/groups'], enabled: !!user, }); @@ -52,7 +69,9 @@ export default function MobileApp({ children }: MobileAppProps) { } }, [selectedStoreId]); - const storeContextValue = { + // 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, @@ -61,11 +80,13 @@ export default function MobileApp({ children }: MobileAppProps) { mobileMenuOpen, setMobileMenuOpen, storeInitialized - }; + }), [selectedStoreId, stores, sidebarCollapsed, mobileMenuOpen, storeInitialized]); return ( - {children} + }> + {children} + ); } diff --git a/client/src/pages/mobile/MobileBottomNav.tsx b/client/src/pages/mobile/MobileBottomNav.tsx index 7ba2109..0d85b0c 100644 --- a/client/src/pages/mobile/MobileBottomNav.tsx +++ b/client/src/pages/mobile/MobileBottomNav.tsx @@ -18,7 +18,6 @@ import { Receipt } from "lucide-react"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; -import { useAuthUnified } from "@/hooks/useAuthUnified"; interface NavItem { path: string; @@ -44,7 +43,6 @@ const moreItems: NavItem[] = [ export default function MobileBottomNav() { const [location] = useLocation(); - const { user } = useAuthUnified(); const [moreOpen, setMoreOpen] = useState(false); const isActive = (path: string) => { diff --git a/client/src/pages/mobile/TasksPage.tsx b/client/src/pages/mobile/TasksPage.tsx index 201c4cd..ec1f65f 100644 --- a/client/src/pages/mobile/TasksPage.tsx +++ b/client/src/pages/mobile/TasksPage.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState, useMemo } from "react"; +import { useQuery, useQueryClient, useMutation, keepPreviousData } from "@tanstack/react-query"; import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useStore } from "@/contexts/StoreContext"; import { useToast } from "@/hooks/use-toast"; @@ -32,9 +32,13 @@ type TaskWithRelations = Task & { isFutureTask?: boolean; }; +// Référence stable tant que la liste n'est pas chargée (évite de recalculer les filtres) +const NO_TASKS: TaskWithRelations[] = []; + // Mobile Task Form Component function MobileTaskForm({ task, onClose, selectedStoreId, user }: any) { const { toast } = useToast(); + const queryClient = useQueryClient(); const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async (e: React.FormEvent) => { @@ -84,7 +88,9 @@ function MobileTaskForm({ task, onClose, selectedStoreId, user }: any) { title: "Succès", description: task ? "Tâche modifiée" : "Tâche créée", }); - window.location.reload(); + // Rechargement ciblé de la liste (plus de rechargement complet de la page) + await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); + onClose(); } catch (error) { toast({ title: "Erreur", description: "Erreur lors de l'opération", variant: "destructive" }); @@ -291,9 +297,11 @@ export default function MobileTasksPage() { const [editingTask, setEditingTask] = useState(null); const [deletingTask, setDeletingTask] = useState(null); + const tasksQueryKey = ["/api/tasks", selectedStoreId]; + // Fetch tasks - const { data: tasks = [], isLoading } = useQuery({ - queryKey: ["/api/tasks", selectedStoreId], + const { data: tasks = NO_TASKS, isLoading, isPlaceholderData } = useQuery({ + queryKey: tasksQueryKey, queryFn: async () => { const params = new URLSearchParams(); if (selectedStoreId) params.append('storeId', selectedStoreId.toString()); @@ -305,55 +313,113 @@ export default function MobileTasksPage() { return Array.isArray(data) ? data.filter((t: any) => t && t.id) : []; }, enabled: !!user, + // Au changement de magasin, la liste précédente reste affichée (estompée et + // non cliquable, avec un indicateur) au lieu d'un spinner plein écran + placeholderData: keepPreviousData, }); - // Filter tasks - const filteredTasks = tasks - .filter((task: TaskWithRelations) => { - // Tab filter - if (activeTab === 'pending' && task.status === 'completed') return false; - if (activeTab === 'completed' && task.status !== 'completed') return false; + // Filter tasks (recalculé seulement quand la liste, l'onglet ou la recherche change) + const filteredTasks = useMemo(() => { + const search = searchTerm.toLowerCase(); + return tasks + .filter((task: TaskWithRelations) => { + // Tab filter + if (activeTab === 'pending' && task.status === 'completed') return false; + if (activeTab === 'completed' && task.status !== 'completed') return false; - // Search filter - if (searchTerm && !task.title.toLowerCase().includes(searchTerm.toLowerCase())) { - return false; - } - return true; - }) - .sort((a: TaskWithRelations, b: TaskWithRelations) => { - // Pending first, then by priority - if (a.status !== b.status) return a.status === 'pending' ? -1 : 1; - const priorityOrder = { high: 3, medium: 2, low: 1 }; - return (priorityOrder[b.priority as keyof typeof priorityOrder] || 2) - - (priorityOrder[a.priority as keyof typeof priorityOrder] || 2); - }); + // Search filter + if (search && !task.title.toLowerCase().includes(search)) { + return false; + } + return true; + }) + .sort((a: TaskWithRelations, b: TaskWithRelations) => { + // Pending first, then by priority + if (a.status !== b.status) return a.status === 'pending' ? -1 : 1; + const priorityOrder = { high: 3, medium: 2, low: 1 }; + return (priorityOrder[b.priority as keyof typeof priorityOrder] || 2) - + (priorityOrder[a.priority as keyof typeof priorityOrder] || 2); + }); + }, [tasks, activeTab, searchTerm]); - // Handlers - const handleComplete = async (taskId: number) => { - try { - await fetch(`/api/tasks/${taskId}/complete`, { + // Mise à jour optimiste de la liste affichée ; renvoie l'état précédent pour + // pouvoir le restaurer si le serveur refuse l'opération + const updateCachedTasks = async (update: (list: TaskWithRelations[]) => TaskWithRelations[]) => { + const queryKey = tasksQueryKey; + await queryClient.cancelQueries({ queryKey }); + const previousTasks = queryClient.getQueryData(queryKey); + if (Array.isArray(previousTasks)) { + queryClient.setQueryData(queryKey, update(previousTasks)); + } + return { queryKey, previousTasks }; + }; + + const restoreCachedTasks = (context?: { queryKey: unknown[]; previousTasks?: TaskWithRelations[] }) => { + if (context?.previousTasks) { + queryClient.setQueryData(context.queryKey, context.previousTasks); + } + }; + + // Terminer : la carte passe tout de suite en « terminée », puis la liste est + // rechargée depuis le serveur ; le succès n'est annoncé que si l'API l'accepte + const completeMutation = useMutation({ + mutationFn: async (taskId: number) => { + const response = await fetch(`/api/tasks/${taskId}/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({}), }); - queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); + if (!response.ok) throw new Error(`Erreur ${response.status}`); + }, + onMutate: (taskId: number) => { + const now = new Date(); + return updateCachedTasks((list) => list.map((task) => + task.id === taskId + ? { ...task, status: 'completed', completedAt: now, completedBy: user?.id ?? task.completedBy, updatedAt: now } + : task + )); + }, + onSuccess: () => { toast({ title: "✅ Tâche terminée" }); - } catch (error) { + }, + onError: (_error, _taskId, context) => { + restoreCachedTasks(context); toast({ title: "Erreur", variant: "destructive" }); - } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); + }, + }); + + // Supprimer : la carte disparaît et la feuille se ferme tout de suite + const deleteMutation = useMutation({ + mutationFn: async (taskId: number) => { + const response = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE', credentials: 'include' }); + if (!response.ok) throw new Error(`Erreur ${response.status}`); + }, + onMutate: (taskId: number) => updateCachedTasks((list) => list.filter((task) => task.id !== taskId)), + onSuccess: () => { + toast({ title: "🗑️ Tâche supprimée" }); + }, + onError: (_error, _taskId, context) => { + restoreCachedTasks(context); + toast({ title: "Erreur", variant: "destructive" }); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); + }, + }); + + // Handlers + const handleComplete = (taskId: number) => { + completeMutation.mutate(taskId); }; - const handleDelete = async () => { + const handleDelete = () => { if (!deletingTask) return; - try { - await fetch(`/api/tasks/${deletingTask.id}`, { method: 'DELETE', credentials: 'include' }); - queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); - toast({ title: "🗑️ Tâche supprimée" }); - setDeletingTask(null); - } catch (error) { - toast({ title: "Erreur", variant: "destructive" }); - } + deleteMutation.mutate(deletingTask.id); + setDeletingTask(null); }; const canEdit = user?.role === 'admin' || user?.role === 'manager' || user?.role === 'directeur'; @@ -378,6 +444,12 @@ export default function MobileTasksPage() {

Tâches

+ {isPlaceholderData && ( + + + Mise à jour… + + )}
{filteredTasks.length}
@@ -423,8 +495,12 @@ export default function MobileTasksPage() {
- {/* Task List */} -
+ {/* Task List (liste du magasin précédent estompée et non cliquable pendant le chargement) */} +
{filteredTasks.length === 0 ? (
diff --git a/vite.config.ts b/vite.config.ts index e82e051..859ffb0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -38,10 +38,14 @@ export default defineConfig({ // elles ne changent qu'aux montées de version et restent donc en // cache navigateur entre deux déploiements, au lieu de faire // retélécharger 1,7 Mo à chaque mise en production. + // recharts n'est volontairement pas listé ici : un chunk manuel + // embarque aussi les dépendances partagées (clsx...) importées par + // l'application, ce qui le faisait précharger dès l'écran de + // connexion. Sans entrée, il n'est téléchargé qu'avec la page + // Statistiques (chargée à la demande). manualChunks: { "vendor-react": ["react", "react-dom", "wouter"], "vendor-query": ["@tanstack/react-query"], - "vendor-charts": ["recharts"], "vendor-icons": ["lucide-react"], }, },