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"], }, },