mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-23 23:18:18 +02:00
perf(client): pages chargées à la demande et un seul appel /api/user
- Routes en React.lazy (sauf connexion et accueil) avec un Suspense dans la zone de contenu ; recharts n'est plus préchargé. JS initial : 1,72 Mo -> 625 Ko (183 Ko gzip). Rechargement automatique unique si un ancien fichier JS a disparu après un déploiement, et ErrorBoundary par page pour garder le menu affiché en cas d'erreur. - Authentification unifiée sur le cache React Query ['/api/user'] : un seul GET /api/user pour toute l'application (au lieu de 4 à 5 par page), plus de rechargement complet après la connexion. - Pas de nouvel essai sur les erreurs 4xx ; fournisseurs et magasins en cache 5 minutes ; valeur du StoreContext mémoïsée et redimensionnement qui ne re-rend que lorsque le palier d'écran change. - Pages : requêtes inutilisées ou en double supprimées, appels lancés seulement une fois l'utilisateur connu, invalidations ciblées au lieu de rechargements complets, filtres et tris mémoïsés, anciennes données gardées (et estompées) pendant un changement de filtre ou de mois, recherche DLC mobile temporisée, vérifications de factures à concurrence bornée, navigation interne sans rechargement de la page. - index.html : script de bannière Replit retiré, lang="fr". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
This commit is contained in:
51 files changed
+2533
-2706
No files matched your search
@@ -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<AnnouncementWithRelations[]> => {
|
||||
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<AnnouncementWithRelations[]>({
|
||||
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 || '',
|
||||
|
||||
@@ -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<string, { orders: OrderWithRelations[]; deliveries: DeliveryWithRelations[] }>();
|
||||
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<string, any[]>();
|
||||
|
||||
// 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 (
|
||||
<div className="bg-white shadow-xl border-2 border-gray-300 overflow-hidden">
|
||||
{/* 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 (
|
||||
<div
|
||||
@@ -694,3 +660,6 @@ export default function CalendarGrid({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mémorisé : l'ouverture d'une modale dans la page Calendrier ne recalcule pas la grille
|
||||
export default memo(CalendarGrid);
|
||||
@@ -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<Date | null>(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<DlcProduct[]>({
|
||||
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 (
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const { user } = useAuthUnified();
|
||||
const [location] = useLocation();
|
||||
@@ -45,11 +63,35 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const { data: stores = [] } = useQuery<Group[]>({
|
||||
const { data: stores = EMPTY_STORES } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Changement de magasin demandé dans le sélecteur : les données dépendantes du
|
||||
// magasin sont invalidées après le rendu (effet ci-dessous) et non dans le
|
||||
// gestionnaire. À ce moment les pages ont déjà basculé sur les clés du nouveau
|
||||
// magasin : les requêtes de l'ancien magasin ne sont plus actives et ne sont
|
||||
// donc plus rechargées pour rien, et cancelRefetch: false réutilise les
|
||||
// requêtes du nouveau magasin déjà en cours. L'invalidation est conservée pour
|
||||
// les requêtes dont la clé ne contient pas le magasin (ex. ['/api/orders'] des
|
||||
// modales de livraison).
|
||||
const storeChangePendingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storeChangePendingRef.current) return;
|
||||
storeChangePendingRef.current = false;
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return Boolean(key[0]?.toString().includes('/api/orders') ||
|
||||
key[0]?.toString().includes('/api/deliveries') ||
|
||||
key[0]?.toString().includes('/api/stats/monthly') ||
|
||||
key[0]?.toString().includes('/api/tasks'));
|
||||
}
|
||||
}, { cancelRefetch: false });
|
||||
}, [selectedStoreId, queryClient]);
|
||||
|
||||
// Effet pour marquer l'initialisation comme terminée
|
||||
useEffect(() => {
|
||||
if (user && stores.length > 0) {
|
||||
@@ -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 (
|
||||
<StoreProvider value={{ selectedStoreId, setSelectedStoreId, stores, sidebarCollapsed, setSidebarCollapsed, mobileMenuOpen, setMobileMenuOpen, storeInitialized }}>
|
||||
<StoreProvider value={storeContextValue}>
|
||||
<div className="layout-container flex bg-gray-50">
|
||||
{/* 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) {
|
||||
|
||||
<div className={`flex-1 bg-gray-50 h-full overflow-y-auto overflow-x-hidden ${isMobileOrTablet ? 'p-3' : 'p-6'
|
||||
}`} style={{ maxWidth: '100%' }}>
|
||||
{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 */}
|
||||
<ErrorBoundary key={location}>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 <Suspense> 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 (
|
||||
<Switch>
|
||||
<Route path="/auth" component={AuthPage} />
|
||||
@@ -128,14 +107,9 @@ function RouterProduction() {
|
||||
<Route path="/analytics" component={Analytics} />
|
||||
<Route path="/payment-schedule" component={PaymentSchedulePage} />
|
||||
|
||||
{/* Redirection /auth vers dashboard */}
|
||||
{/* Redirection /auth vers dashboard (navigation interne, sans rechargement) */}
|
||||
<Route path="/auth">
|
||||
{() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
return <MobileDashboardPage />;
|
||||
}}
|
||||
<Redirect to="/dashboard" replace />
|
||||
</Route>
|
||||
|
||||
<Route path="/" component={MobileDashboardPage} />
|
||||
@@ -175,17 +149,10 @@ function RouterProduction() {
|
||||
<Route path="/database-debug" component={Utilities} />
|
||||
<Route path="/weather-settings" component={Utilities} />
|
||||
|
||||
{/* Redirection depuis /auth vers dashboard après authentification */}
|
||||
{/* Redirection depuis /auth vers dashboard après authentification
|
||||
(navigation interne, sans rechargement complet de l'application) */}
|
||||
<Route path="/auth">
|
||||
{() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔄 Authenticated user on /auth, redirecting to dashboard');
|
||||
}
|
||||
window.location.href = '/';
|
||||
}
|
||||
return <Dashboard />;
|
||||
}}
|
||||
<Redirect to="/" replace />
|
||||
</Route>
|
||||
|
||||
<Route path="/" component={Dashboard} />
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Chiffres du mois précédent estompés pendant le chargement du nouveau mois */}
|
||||
<CardContent className={isPlaceholderData ? "opacity-50 transition-opacity" : "transition-opacity"}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary flex items-center justify-center">
|
||||
|
||||
@@ -121,7 +121,11 @@ export default function WeatherWidget() {
|
||||
const { data: weather, isLoading, error } = useQuery<WeatherResponse>({
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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<Group[]>({
|
||||
const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
|
||||
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<OrderWithRelations[]>({
|
||||
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() || '';
|
||||
|
||||
@@ -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<Group[]>({
|
||||
const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
|
||||
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) => {
|
||||
|
||||
@@ -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<OrderWithRelations[]>({
|
||||
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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user