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:
Claude committed 2026-10-03 04:29:51 +00:00
1 parent a5785f0244
commit 5d3e5357a0
51 files changed
+2503 -2676

No files matched your search

+1 -3
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
@@ -15,7 +15,5 @@
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<!-- This is a replit script which adds a banner on the top of the page when opened in development mode outside the replit environment -->
<script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js"></script>
</body>
</html>
+42 -46
View File
@@ -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,
});
@@ -171,8 +171,6 @@ export default function AnnouncementCard() {
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 || '',
+140 -171
View File
@@ -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
});
}
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
// Jours affichés : 6 semaines complètes commençant un lundi
const paddedDays = useMemo(() => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
// Helper function to filter publicities based on user's assigned stores
const getPublicitiesForDate = (date: Date) => {
const dateStr = format(date, 'yyyy-MM-dd');
// 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 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[]>();
// Protection contre les données invalides qui causent des plantages en production
if (!publicities || !Array.isArray(publicities)) {
console.warn('⚠️ Invalid publicities data:', publicities);
return [];
}
if (import.meta.env.DEV) {
console.log('🔍 CalendarGrid getPublicitiesForDate:', {
date: dateStr,
totalPublicities: publicities.length,
selectedStoreId,
userGroups: userGroups?.length || 0
});
return byDay;
}
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);
+5 -6
View File
@@ -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 (
+69 -13
View File
@@ -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({
+42 -75
View File
@@ -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} />
+4 -2
View File
@@ -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;
};
+8 -5
View File
@@ -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">
+5 -1
View File
@@ -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,9 +54,9 @@ 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
// 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' ||
+4 -1
View File
@@ -3,7 +3,10 @@ import { useState, useEffect } from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined)
// Valeur calculée dès le premier rendu (évite un second rendu au montage)
const [isMobile, setIsMobile] = useState<boolean | undefined>(() =>
typeof window !== "undefined" ? window.innerWidth < MOBILE_BREAKPOINT : undefined
)
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+21 -16
View File
@@ -14,26 +14,24 @@ const defaultBreakpoints: ScreenBreakpoints = {
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<ScreenSize>('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<ScreenSize>(() =>
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
@@ -46,6 +44,13 @@ export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoint
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,
+3 -28
View File
@@ -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';
+3 -70
View File
@@ -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<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<any>(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'
};
}
// 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';
+3 -63
View File
@@ -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<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<any>(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
};
}
// 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';
+47 -232
View File
@@ -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<any>(null);
const [productionLoading, setProductionLoading] = useState(true);
const [productionError, setProductionError] = useState<any>(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<any>({
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'
};
}
}
+45 -2
View File
@@ -66,6 +66,26 @@ export const getQueryFn: <T>(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<any> {
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,
});
+17
View File
@@ -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(<App />);
+33 -22
View File
@@ -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() {
<p className="text-gray-600 mt-1">Analysez vos performances logistiques</p>
</div>
<div className="flex items-center gap-2">
{isUpdating && (
<span className="flex items-center text-sm text-gray-500" role="status">
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
Mise à jour…
</span>
)}
<Button
variant="outline"
size="sm"
@@ -330,7 +341,7 @@ export default function Analytics() {
</Card>
{/* KPIs */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 ${updatingClass(summaryUpdating)}`}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-gray-600">
@@ -395,7 +406,7 @@ export default function Analytics() {
{/* Graphiques */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Évolution temporelle */}
<Card>
<Card className={updatingClass(timeseriesUpdating)}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
@@ -429,7 +440,7 @@ export default function Analytics() {
</Card>
{/* Répartition par fournisseur */}
<Card>
<Card className={updatingClass(bySupplierUpdating)}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChart className="h-5 w-5" />
@@ -471,7 +482,7 @@ export default function Analytics() {
</Card>
{/* Performance par magasin */}
<Card>
<Card className={updatingClass(byStoreUpdating)}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Store className="h-5 w-5" />
@@ -511,7 +522,7 @@ export default function Analytics() {
</Card>
{/* Top fournisseurs */}
<Card>
<Card className={updatingClass(summaryUpdating)}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
+10 -14
View File
@@ -62,13 +62,19 @@ export default function AuthPage() {
if (response.ok) {
toast({
title: "Connexion réussie",
description: "Redirection en cours...",
description: "Bienvenue dans LogiFlow",
});
// En production, recharger la page pour éviter les problèmes de synchronisation
setTimeout(() => {
// L'utilisateur est relu une fois et placé dans le cache partagé : toute
// l'application bascule directement sur le tableau de bord, sans attente
// ni rechargement complet
const refreshedUserData = await forceAuthRefresh();
if (refreshedUserData && refreshedUserData.id) {
setLocation("/", { replace: true });
} else {
// Session pas encore visible : rechargement complet comme auparavant
window.location.href = "/";
}, 500);
}
} else {
const errorData = await response.json().catch(() => ({}));
toast({
@@ -138,16 +144,6 @@ export default function AuthPage() {
},
});
// Debug logging for authentication state
if (import.meta.env.DEV) {
console.log('🔍 AuthPage Debug:', {
isAuthenticated,
isLoading,
userId: user?.id,
username: user?.username
});
}
useEffect(() => {
if (defaultCredentialsCheck) {
setShowDefaultCredentials(defaultCredentialsCheck.showDefault);
+264 -204
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo, useRef, useDeferredValue } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, Search, FileText, CheckCircle, AlertCircle, Clock, Edit, Trash2, UserCheck, Send, Upload, XCircle, MessageSquare, Settings } from "lucide-react";
import { useStore } from "@/contexts/StoreContext";
@@ -21,6 +21,7 @@ import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { useAuthUnified } from "@/hooks/useAuthUnified";
// Types
interface Avoir {
@@ -108,6 +109,58 @@ type AvoirUpdateData = AvoirFormData & {
nocodbVerifiedAt?: Date | null;
};
// Tableau vide stable tant que la liste n'est pas chargée (évite de relancer
// effets et calculs mémoïsés à chaque rendu)
const NO_AVOIRS: Avoir[] = [];
// Nombre maximal de vérifications de facture simultanées
const MAX_CONCURRENT_VERIFICATIONS = 4;
// Le montant de l'avoir doit-il être remplacé par celui de la facture ?
// Oui s'il est vide ou différent au centime près ; sinon la mise à jour ne
// changerait rien et la requête est évitée.
function amountDiffers(current: unknown, invoiceAmount: number) {
if (current === null || current === undefined || current === '') return true;
return Math.round(Number(current) * 100) !== Math.round(Number(invoiceAmount) * 100);
}
// Filtrage par recherche (fournisseur, référence, commentaire)
function filterAvoirsBySearch(avoirsList: Avoir[], searchTerm: string) {
const searchLower = searchTerm.toLowerCase();
return avoirsList.filter((avoir) => (
avoir.supplier?.name?.toLowerCase().includes(searchLower) ||
avoir.invoiceReference?.toLowerCase().includes(searchLower) ||
avoir.comment?.toLowerCase().includes(searchLower)
));
}
// Durée d'envoi affichée dans le modal d'attente. Le compteur vit dans ce
// composant pour ne pas re-rendre toute la page chaque seconde ; il repart de
// zéro à chaque ouverture du modal et s'arrête à sa fermeture.
function UploadElapsedTime() {
const [processingSeconds, setProcessingSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setProcessingSeconds(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<>
<p className="text-sm text-gray-600 text-center">
Envoi du fichier vers le webhook... {processingSeconds}s
</p>
{processingSeconds > 30 && (
<p className="text-xs text-gray-500 mt-2 text-center">
Le traitement peut prendre quelques minutes
</p>
)}
</>
);
}
export default function Avoirs() {
const [activeTab, setActiveTab] = useState("pending");
const [searchTerm, setSearchTerm] = useState("");
@@ -122,10 +175,8 @@ export default function Avoirs() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
// État pour le modal d'attente du webhook
// État pour le modal d'attente du webhook (compteur dans UploadElapsedTime)
const [showWaitingModal, setShowWaitingModal] = useState(false);
const [processingSeconds, setProcessingSeconds] = useState(0);
const [processingTimeout, setProcessingTimeout] = useState<NodeJS.Timeout | null>(null);
// États pour le système de vérification de facture
const [avoirVerificationResults, setAvoirVerificationResults] = useState<Record<number, any>>({});
@@ -143,10 +194,8 @@ export default function Avoirs() {
const queryClient = useQueryClient();
// Fetch user profile
const { data: user } = useQuery({
queryKey: ['/api/user'],
});
// Utilisateur connecté : cache partagé par toute l'application (pas de requête supplémentaire)
const { user } = useAuthUnified();
// Fetch groups for store filter
const { data: groups = [] } = useQuery<Group[]>({
@@ -162,86 +211,110 @@ export default function Avoirs() {
// Fetch avoirs avec filtrage par groupe (comme Orders/Deliveries)
const avoirsUrl = `/api/avoirs${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
const { data: avoirs = [], isLoading } = useQuery<Avoir[]>({
const { data: avoirs = NO_AVOIRS, isLoading } = useQuery<Avoir[]>({
queryKey: [avoirsUrl, selectedStoreId, (user as any)?.role],
queryFn: async () => {
console.log('💰 Fetching avoirs from:', avoirsUrl);
const response = await fetch(avoirsUrl, { credentials: 'include' });
if (!response.ok) {
throw new Error('Failed to fetch avoirs');
}
const data = await response.json();
console.log('💰 Avoirs received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
// 🔍 Debug: Vérifier les champs nocodbVerified
if (Array.isArray(data) && data.length > 0) {
console.log('🔍 Premiers avoirs avec nocodbVerified:', data.map(a => ({
id: a.id,
invoiceReference: a.invoiceReference,
nocodbVerified: a.nocodbVerified,
nocodbVerifiedAt: a.nocodbVerifiedAt
})));
}
return Array.isArray(data) ? data : [];
},
enabled: !!user,
});
// 🔄 Charger les vérifications depuis le cache serveur au démarrage
// Recharge la liste des avoirs, quelle que soit la variante de clé
// (avec ou sans magasin sélectionné, bureau ou mobile)
const invalidateAvoirs = () => queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
}
});
// Données du PUT qui reporte le montant de la facture sur l'avoir : le serveur
// remet à null les champs absents, on renvoie donc les valeurs actuelles
const buildAmountUpdate = (avoir: Avoir, amount: number): AvoirUpdateData => ({
supplierId: avoir.supplierId,
groupId: avoir.groupId,
invoiceReference: avoir.invoiceReference || "",
amount,
comment: avoir.comment || "",
commercialProcessed: avoir.commercialProcessed || false,
status: avoir.status as "En attente de demande" | "Demandé" | "Reçu",
});
// ---------------------------------------------------------------------------
// File d'attente des vérifications de facture : au plus
// MAX_CONCURRENT_VERIFICATIONS requêtes simultanées, au lieu d'une boucle
// séquentielle au chargement ou d'une rafale de requêtes (« Vérifier toutes »).
// ---------------------------------------------------------------------------
const verificationQueueRef = useRef<Array<() => Promise<void>>>([]);
const activeVerificationsRef = useRef(0);
// Référence déjà vérifiée (ou en cours) pour chaque avoir : un rechargement de
// la liste ne relance pas les vérifications déjà faites
const verifiedReferencesRef = useRef<Map<number, string>>(new Map());
// Dernière liste reçue, relue au moment de remplir un montant
const avoirsRef = useRef<Avoir[]>(avoirs);
avoirsRef.current = avoirs;
const drainVerificationQueue = () => {
while (
activeVerificationsRef.current < MAX_CONCURRENT_VERIFICATIONS &&
verificationQueueRef.current.length > 0
) {
const task = verificationQueueRef.current.shift();
if (!task) break;
activeVerificationsRef.current += 1;
task().finally(() => {
activeVerificationsRef.current -= 1;
drainVerificationQueue();
});
}
};
// Met les tâches en file ; la promesse est résolue quand toutes sont terminées
// (chaque tâche traite elle-même ses erreurs)
const runVerificationTasks = (tasks: Array<() => Promise<void>>) =>
Promise.all(tasks.map(task => new Promise<void>(resolve => {
verificationQueueRef.current.push(() => task().catch(() => undefined).finally(resolve));
drainVerificationQueue();
})));
// 🔄 Charger les vérifications depuis le cache serveur à l'affichage de la liste.
// Seuls les avoirs non validés dont la référence n'a pas encore été vérifiée
// sont envoyés (un avoir validé affiche « Validé » quel que soit le résultat) ;
// les résultats sont appliqués en une seule fois, comme auparavant.
useEffect(() => {
const loadCachedVerifications = async () => {
console.log('🔍 LoadCache - Début vérification:', {
hasAvoirs: !!avoirs,
avoirCount: avoirs?.length || 0
});
const avoirsToVerify = avoirs.filter(avoir =>
avoir.invoiceReference?.trim() &&
!avoir.nocodbVerified &&
verifiedReferencesRef.current.get(avoir.id) !== avoir.invoiceReference
);
if (avoirsToVerify.length === 0) return;
if (!avoirs || avoirs.length === 0) return;
// Marquage immédiat : un rechargement pendant la vérification ne la double pas
avoirsToVerify.forEach(avoir => verifiedReferencesRef.current.set(avoir.id, avoir.invoiceReference || ''));
const cachedResults: Record<number, any> = {};
// Charger les vérifications depuis le cache serveur pour TOUS les avoirs avec référence
for (const avoir of avoirs) {
console.log('🔍 LoadCache - Avoir analysé:', {
id: avoir.id,
hasInvoiceRef: !!avoir.invoiceReference?.trim(),
const cachedResults: Record<number, any> = {};
runVerificationTasks(avoirsToVerify.map(avoir => async () => {
try {
const result = await apiRequest(`/api/avoirs/${avoir.id}/verify-invoice`, 'POST', {
invoiceReference: avoir.invoiceReference,
nocodbVerified: avoir.nocodbVerified
forceRefresh: false // Utiliser le cache si disponible
});
// Si l'avoir a une référence de facture, vérifier le cache serveur
if (avoir.invoiceReference?.trim()) {
try {
const result = await apiRequest(`/api/avoirs/${avoir.id}/verify-invoice`, 'POST', {
invoiceReference: avoir.invoiceReference,
forceRefresh: false // Utiliser le cache si disponible
});
console.log('🔍 LoadCache - Résultat cache serveur pour avoir', avoir.id, ':', result);
if (result.exists !== undefined) {
cachedResults[avoir.id] = result;
console.log('✅ LoadCache - Avoir ajouté au cache depuis serveur:', avoir.id);
}
} catch (error) {
console.log('⚠️ LoadCache - Erreur cache serveur pour avoir', avoir.id, ':', error);
// Continuer sans erreur si le cache échoue
}
if (result?.exists !== undefined) {
cachedResults[avoir.id] = result;
}
} catch {
// Vérification silencieuse : la loupe reste disponible pour réessayer
}
console.log('🔍 LoadCache - Résultats finaux:', {
cachedCount: Object.keys(cachedResults).length,
cachedResults
});
})).then(() => {
if (Object.keys(cachedResults).length > 0) {
setAvoirVerificationResults(prev => ({ ...prev, ...cachedResults }));
console.log('✅ Vérifications chargées depuis le serveur:', cachedResults);
}
};
loadCachedVerifications();
});
}, [avoirs]);
// Create avoir mutation
@@ -262,11 +335,7 @@ export default function Avoirs() {
},
onSuccess: () => {
// ✅ FIX: Invalider toutes les variations de queryKey avoirs
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
}
});
invalidateAvoirs();
setIsCreateDialogOpen(false);
toast({
title: "Avoir créé",
@@ -300,11 +369,7 @@ export default function Avoirs() {
},
onSuccess: () => {
// ✅ FIX: Invalider toutes les variations de queryKey avoirs
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
}
});
invalidateAvoirs();
setIsEditDialogOpen(false);
setSelectedAvoir(null);
toast({
@@ -335,11 +400,7 @@ export default function Avoirs() {
},
onSuccess: () => {
// ✅ FIX: Invalider toutes les variations de queryKey avoirs
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
}
});
invalidateAvoirs();
setIsDeleteDialogOpen(false);
setSelectedAvoir(null);
toast({
@@ -411,13 +472,6 @@ export default function Avoirs() {
amount: finalAmount
};
console.log('💰 Editing avoir - Form data sent:', {
originalCommercial: selectedAvoir.commercialProcessed,
formCommercial: finalData.commercialProcessed,
editAmountValue,
finalAmount,
fullData: finalData
});
editAvoirMutation.mutate({ id: selectedAvoir.id, data: finalData });
}
};
@@ -498,34 +552,22 @@ export default function Avoirs() {
// Handle validation/devalidation
const handleValidateAvoir = async (avoirId: number) => {
try {
// Utiliser la route spécifique pour la vérification NocoDB
// Utiliser la route spécifique pour la vérification NocoDB. Elle marque
// aussi le cache de vérification comme réconcilié (permanent) : un appel
// séparé à /api/cache/mark-reconciled serait redondant.
await apiRequest(`/api/avoirs/${avoirId}/nocodb-verification`, 'PUT', {
verified: true
});
// Marquer le cache comme réconcilié (permanent)
const avoir = avoirs.find(a => a.id === avoirId);
if (avoir?.invoiceReference) {
try {
await apiRequest('/api/cache/mark-reconciled', 'POST', {
invoiceReference: avoir.invoiceReference,
groupId: avoir.groupId
});
} catch (cacheError) {
console.warn('Cache marking failed but verification succeeded:', cacheError);
}
}
// ✅ Mettre à jour l'état local immédiatement pour affichage instantané
setAvoirVerificationResults(prev => ({
...prev,
[avoirId]: { exists: true, fromCache: true, permanent: true, validated: true }
}));
console.log('✅ Avoir validé - État local mis à jour:', avoirId);
// Recharger les avoirs pour voir les changements en base
queryClient.invalidateQueries({ queryKey: ['/api/avoirs'] });
// Recharger les avoirs pour voir les changements en base (toutes les
// variantes de clé : la clé contient l'URL avec le magasin sélectionné)
invalidateAvoirs();
toast({
title: "Avoir validé",
@@ -553,11 +595,12 @@ export default function Avoirs() {
delete updated[avoirId]; // Supprimer complètement l'entrée
return updated;
});
// L'avoir sera revérifié au rechargement de la liste
verifiedReferencesRef.current.delete(avoirId);
console.log('✅ Avoir dévalidé - État local mis à jour:', avoirId);
// Recharger les avoirs pour voir les changements en base
queryClient.invalidateQueries({ queryKey: ['/api/avoirs'] });
// Recharger les avoirs pour voir les changements en base (toutes les
// variantes de clé : la clé contient l'URL avec le magasin sélectionné)
invalidateAvoirs();
toast({
title: "Avoir dévalidé",
@@ -593,26 +636,14 @@ export default function Avoirs() {
}));
// Auto-remplissage sécurisé si facture trouvée et montant disponible
// (seulement si le montant change : sinon la mise à jour serait inutile)
if (result?.exists === true && result?.invoiceAmount !== undefined && result?.invoiceAmount !== null) {
const avoir = avoirs?.find(a => a?.id === variables.avoirId);
if (avoir && avoir.supplierId && avoir.groupId) {
try {
console.log('🔄 Auto-remplissage montant:', { avoirId: variables.avoirId, amount: result.invoiceAmount });
editAvoirMutation.mutate({
id: variables.avoirId,
data: {
supplierId: avoir.supplierId,
groupId: avoir.groupId,
invoiceReference: avoir.invoiceReference || "",
amount: result.invoiceAmount,
comment: avoir.comment || "",
commercialProcessed: avoir.commercialProcessed || false,
status: avoir.status as "En attente de demande" | "Demandé" | "Reçu",
}
});
} catch (autoFillError) {
console.error('❌ Erreur auto-remplissage:', autoFillError);
}
if (avoir && avoir.supplierId && avoir.groupId && amountDiffers(avoir.amount, result.invoiceAmount)) {
editAvoirMutation.mutate({
id: variables.avoirId,
data: buildAmountUpdate(avoir, result.invoiceAmount),
});
}
}
@@ -669,13 +700,8 @@ export default function Avoirs() {
return;
}
console.log('🔍 Déclenchement vérification avoir:', {
avoirId: avoir.id,
invoiceReference: avoir.invoiceReference,
supplier: avoir.supplier?.name,
group: avoir.group?.name
});
// Référence vérifiée : le rechargement de la liste ne la revérifiera pas
verifiedReferencesRef.current.set(avoir.id, avoir.invoiceReference || '');
setVerifyingAvoirs(prev => new Set(prev).add(avoir.id));
verifyAvoirInvoiceMutation.mutate({
@@ -685,8 +711,11 @@ export default function Avoirs() {
});
};
// Fonction pour vérifier tous les avoirs avec une référence facture
const handleVerifyAllAvoirInvoices = () => {
// Fonction pour vérifier tous les avoirs avec une référence facture.
// File d'attente bornée, montants remplis sans recharger la liste à chaque
// avoir (ni toast, ni fermeture de modale), puis un seul rafraîchissement et
// un seul toast récapitulatif à la fin.
const handleVerifyAllAvoirInvoices = async () => {
const avoirsToVerify = avoirs.filter(avoir =>
avoir.invoiceReference?.trim() &&
(avoir.group?.nocodbTableName || avoir.group?.nocodbConfigId)
@@ -700,21 +729,84 @@ export default function Avoirs() {
return;
}
avoirsToVerify.forEach((avoir, index) => {
// Délai échelonné pour éviter la surcharge
setTimeout(() => {
handleVerifyAvoirInvoice(avoir, true); // Force refresh pour tous
}, index * 200); // 200ms entre chaque vérification
});
toast({
title: "Vérification lancée",
description: `Vérification de ${avoirsToVerify.length} avoir(s) en cours...`,
});
};
// ✅ SUPPRIMÉ : Le chargement automatique causait des crashes JavaScript
// Les vérifications se font maintenant uniquement à la demande utilisateur
setVerifyingAvoirs(prev => {
const next = new Set(prev);
avoirsToVerify.forEach(avoir => next.add(avoir.id));
return next;
});
avoirsToVerify.forEach(avoir => verifiedReferencesRef.current.set(avoir.id, avoir.invoiceReference || ''));
let found = 0;
let notFound = 0;
let failed = 0;
let amountsUpdated = 0;
await runVerificationTasks(avoirsToVerify.map(avoir => async () => {
let result: any;
let requestFailed = false;
try {
result = await apiRequest(`/api/avoirs/${avoir.id}/verify-invoice`, 'POST', {
invoiceReference: avoir.invoiceReference,
forceRefresh: true // Force refresh pour tous
});
} catch (error) {
console.error('Erreur vérification facture avoir:', error);
requestFailed = true;
result = {
exists: false,
matchType: 'none',
errorMessage: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
setAvoirVerificationResults(prev => ({ ...prev, [avoir.id]: result }));
setVerifyingAvoirs(prev => {
const next = new Set(prev);
next.delete(avoir.id);
return next;
});
if (requestFailed) {
failed += 1;
return;
}
if (result?.exists !== true) {
notFound += 1;
return;
}
found += 1;
// Report du montant de la facture, à partir des données les plus récentes de l'avoir
if (result.invoiceAmount === undefined || result.invoiceAmount === null) return;
const current = avoirsRef.current.find(a => a?.id === avoir.id) ?? avoir;
if (!current.supplierId || !current.groupId || !amountDiffers(current.amount, result.invoiceAmount)) return;
try {
await apiRequest(`/api/avoirs/${avoir.id}`, 'PUT', buildAmountUpdate(current, result.invoiceAmount));
amountsUpdated += 1;
} catch (error) {
console.error('❌ Erreur auto-remplissage:', error);
}
}));
// Un seul rechargement de la liste, seulement si des montants ont changé
if (amountsUpdated > 0) {
invalidateAvoirs();
}
const details = [`${found} facture(s) trouvée(s)`, `${notFound} introuvable(s)`];
if (failed > 0) details.push(`${failed} erreur(s)`);
if (amountsUpdated > 0) details.push(`${amountsUpdated} montant(s) mis à jour`);
toast({
title: "Vérification terminée",
description: details.join(', '),
variant: failed > 0 ? "destructive" : "default",
});
};
// 🔥 FONCTIONS WEBHOOK MODAL (comme rapprochement)
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -743,22 +835,9 @@ export default function Avoirs() {
setIsUploading(false);
};
const startProcessingTimer = () => {
setProcessingSeconds(0);
const interval = setInterval(() => {
setProcessingSeconds(prev => prev + 1);
}, 1000);
setProcessingTimeout(interval);
};
const handleCloseWaitingModal = () => {
setShowWaitingModal(false);
setIsUploading(false);
setProcessingSeconds(0);
if (processingTimeout) {
clearInterval(processingTimeout);
setProcessingTimeout(null);
}
};
const handleSendAvoir = async () => {
@@ -789,7 +868,6 @@ export default function Avoirs() {
setShowAvoirModal(false);
setShowWaitingModal(true);
setIsUploading(true);
startProcessingTimer();
try {
const formData = new FormData();
@@ -833,11 +911,7 @@ export default function Avoirs() {
setSelectedFile(null);
// Invalider les caches pour recharger
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0]?.toString().includes('/api/avoirs') || false;
}
});
invalidateAvoirs();
} catch (error: any) {
handleCloseWaitingModal();
@@ -867,17 +941,6 @@ export default function Avoirs() {
}
};
// Filter avoirs based on search term
const filteredAvoirs = avoirs.filter(avoir =>
avoir.invoiceReference?.toLowerCase().includes(searchTerm.toLowerCase()) ||
avoir.supplier?.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
avoir.comment?.toLowerCase().includes(searchTerm.toLowerCase()) ||
(avoir.creator.firstName && avoir.creator.lastName
? `${avoir.creator.firstName} ${avoir.creator.lastName}`.toLowerCase().includes(searchTerm.toLowerCase())
: avoir.creator.username.toLowerCase().includes(searchTerm.toLowerCase())
)
);
// Status badge variant
const getStatusVariant = (status: string) => {
switch (status) {
@@ -906,24 +969,27 @@ export default function Avoirs() {
}
};
// Séparer les avoirs par statut : Finalisé = Reçu ET validé
const pendingAvoirs = avoirs.filter(avoir => !(avoir.status === 'Reçu' && avoir.nocodbVerified));
const completedAvoirs = avoirs.filter(avoir => avoir.status === 'Reçu' && avoir.nocodbVerified);
// Séparer les avoirs par statut : Finalisé = Reçu ET validé (recalculé
// seulement quand la liste change, pas à chaque frappe ou résultat de vérification)
const pendingAvoirs = useMemo(
() => avoirs.filter(avoir => !(avoir.status === 'Reçu' && avoir.nocodbVerified)),
[avoirs]
);
const completedAvoirs = useMemo(
() => avoirs.filter(avoir => avoir.status === 'Reçu' && avoir.nocodbVerified),
[avoirs]
);
// Filtrage par recherche
const filterAvoirs = (avoirsList: Avoir[]) => {
return avoirsList.filter((avoir) => {
const searchLower = searchTerm.toLowerCase();
return (
avoir.supplier?.name?.toLowerCase().includes(searchLower) ||
avoir.invoiceReference?.toLowerCase().includes(searchLower) ||
avoir.comment?.toLowerCase().includes(searchLower)
);
});
};
const filteredPendingAvoirs = filterAvoirs(pendingAvoirs);
const filteredCompletedAvoirs = filterAvoirs(completedAvoirs);
// Filtrage par recherche, différée pour garder la saisie fluide
const deferredSearchTerm = useDeferredValue(searchTerm);
const filteredPendingAvoirs = useMemo(
() => filterAvoirsBySearch(pendingAvoirs, deferredSearchTerm),
[pendingAvoirs, deferredSearchTerm]
);
const filteredCompletedAvoirs = useMemo(
() => filterAvoirsBySearch(completedAvoirs, deferredSearchTerm),
[completedAvoirs, deferredSearchTerm]
);
const canEditDelete = ['admin', 'directeur'].includes((user as any)?.role);
@@ -1243,7 +1309,8 @@ export default function Avoirs() {
</CardDescription>
</CardHeader>
<CardContent className="p-0">
{renderAvoirTable(filteredPendingAvoirs)}
{/* Seul le tableau de l'onglet affiché est construit */}
{activeTab === "pending" && renderAvoirTable(filteredPendingAvoirs)}
</CardContent>
</Card>
</TabsContent>
@@ -1260,7 +1327,7 @@ export default function Avoirs() {
</CardDescription>
</CardHeader>
<CardContent className="p-0">
{renderAvoirTable(filteredCompletedAvoirs)}
{activeTab === "completed" && renderAvoirTable(filteredCompletedAvoirs)}
</CardContent>
</Card>
</TabsContent>
@@ -1691,14 +1758,7 @@ export default function Avoirs() {
<div className="flex flex-col items-center p-6">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<p className="text-sm text-gray-600 text-center">
Envoi du fichier vers le webhook... {processingSeconds}s
</p>
{processingSeconds > 30 && (
<p className="text-xs text-gray-500 mt-2 text-center">
Le traitement peut prendre quelques minutes
</p>
)}
<UploadElapsedTime />
</div>
</DialogContent>
</Dialog>
+154 -202
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useMemo, useDeferredValue } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { safeFormat } from "@/lib/dateUtils";
import { Button } from "@/components/ui/button";
@@ -20,6 +20,82 @@ import { Textarea } from "@/components/ui/textarea";
import ReconciliationComments from "@/components/ReconciliationComments";
import ReconciliationModal from "@/components/modals/ReconciliationModal";
// Tableau vide stable tant que la liste n'est pas chargée (évite de relancer
// effets et calculs mémoïsés à chaque rendu)
const NO_DELIVERIES: any[] = [];
// Clé de la liste du rapprochement : elle commence par '/api/deliveries', donc
// toute invalidation des livraisons (ici ou depuis une autre page) l'atteint
const RECONCILIATION_QUERY_KEY = ['/api/deliveries', 'reconciliation'] as const;
// Filtrage des livraisons par recherche uniquement
function filterDeliveriesBySearch(deliveries: any[], searchTerm: string) {
if (!searchTerm) return deliveries;
const searchLower = searchTerm.toLowerCase();
return deliveries.filter((delivery: any) => {
return (
delivery.supplier?.name?.toLowerCase().includes(searchLower) ||
delivery.blNumber?.toLowerCase().includes(searchLower) ||
delivery.invoiceReference?.toLowerCase().includes(searchLower) ||
delivery.group?.name?.toLowerCase().includes(searchLower)
);
});
}
// Progression affichée dans le modal d'attente (60 s max). Le compteur vit dans
// ce composant pour ne pas re-rendre toute la page chaque seconde ; il repart de
// zéro à chaque ouverture du modal et s'arrête à sa fermeture.
function WaitingProgress() {
const [processingSeconds, setProcessingSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setProcessingSeconds(prev => {
if (prev >= 60) {
clearInterval(interval);
return 60;
}
return prev + 1;
});
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 relative">
<div className="w-16 h-16 border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-bold text-blue-600">{processingSeconds}s</span>
</div>
</div>
<div className="space-y-2">
<h3 className="font-medium text-gray-900">
Traitement de votre facture en cours...
</h3>
<p className="text-sm text-gray-600">
Le workflow peut prendre jusqu'à 1 minute.
<br />
Veuillez patienter.
</p>
</div>
<div className="mt-4 bg-gray-100 rounded-full h-2 w-full">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-1000 ease-out"
style={{ width: `${Math.min((processingSeconds / 60) * 100, 100)}%` }}
></div>
</div>
<div className="mt-2 text-xs text-gray-500">
{processingSeconds < 60 ? `${60 - processingSeconds}s restantes (max)` : 'Finalisation...'}
</div>
</div>
);
}
export default function BLReconciliation() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
@@ -59,10 +135,8 @@ export default function BLReconciliation() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
// État pour le modal d'attente du webhook
// État pour le modal d'attente du webhook (compteur dans WaitingProgress)
const [showWaitingModal, setShowWaitingModal] = useState(false);
const [processingSeconds, setProcessingSeconds] = useState(0);
const [processingTimeout, setProcessingTimeout] = useState<NodeJS.Timeout | null>(null);
// État pour le système de vérification de facture
const [verificationResults, setVerificationResults] = useState<Record<number, any>>({});
@@ -74,11 +148,6 @@ export default function BLReconciliation() {
const [showCommentModal, setShowCommentModal] = useState(false);
const [selectedDeliveryForComment, setSelectedDeliveryForComment] = useState<any>(null);
// Récupérer les fournisseurs pour la logique automatique
const { data: suppliers = [] } = useQuery<any[]>({
queryKey: ['/api/suppliers'],
});
// Historique des relances fournisseurs : dernier envoi affiché sur l'icône mail
const { data: supplierMailLogs = [] } = useQuery<any[]>({
queryKey: ['/api/supplier-mail-logs', selectedStoreId],
@@ -125,6 +194,15 @@ export default function BLReconciliation() {
// Invalidation de cache différée : une seule fois quand la file est vidée
const needsCacheInvalidationRef = useRef(false);
// Après une modification de livraison : la liste du rapprochement et les
// autres listes de livraisons sont invalidées par préfixe (seules les requêtes
// affichées sont rechargées) ; l'échéancier, calculé à partir des mêmes
// livraisons, est seulement marqué périmé.
const invalidateDeliveries = () => Promise.all([
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }),
queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] }),
]);
// Fonction de vérification de facture
const verifyInvoiceMutation = useMutation({
mutationFn: async ({ deliveryId, invoiceReference, blNumber, forceRefresh }: { deliveryId: number; invoiceReference?: string; blNumber?: string; forceRefresh?: boolean; silent?: boolean }) => {
@@ -148,42 +226,17 @@ export default function BLReconciliation() {
// Pas de toast - affichage silencieux des coches vertes/rouges
// Auto-remplissage si facture trouvée (référence facture OU numéro BL)
if (result.exists) {
const updateData: any = {};
// Ajouter la référence de facture SEULEMENT si trouvée via BL (pas déjà renseignée)
if (result.invoiceReference && result.matchType === 'bl_number') {
updateData.invoiceReference = result.invoiceReference;
}
// TOUJOURS mettre à jour le montant si disponible (peu importe le matchType)
if (result.invoiceAmount !== undefined && result.invoiceAmount !== null) {
updateData.invoiceAmount = result.invoiceAmount;
}
// TOUJOURS mettre à jour la date d'échéance si disponible (peu importe le matchType)
if (result.dueDate) {
updateData.dueDate = result.dueDate;
}
if (import.meta.env.DEV) {
console.log('📝 Auto-remplissage:', { deliveryId: variables.deliveryId, updateData, matchType: result.matchType });
}
// Ne faire l'appel que si on a des données à mettre à jour
if (Object.keys(updateData).length > 0) {
apiRequest(`/api/deliveries/${variables.deliveryId}`, "PUT", updateData)
.then(() => {
// Invalidation différée : sinon chaque auto-remplissage relance un
// refetch complet de la liste, qui relance l'effet, qui relance des
// vérifications... jusqu'à saturer le navigateur.
needsCacheInvalidationRef.current = true;
})
.catch((error) => {
console.error('❌ Erreur auto-remplissage:', error);
});
}
// Auto-remplissage : le serveur (verify-invoice) enregistre déjà sur la
// livraison la référence, les montants et l'échéance trouvés. Il suffit de
// recharger la liste, une seule fois quand la file est vidée : sinon
// chaque résultat relancerait un refetch complet, qui relancerait l'effet
// de vérification automatique.
if (result.exists && (
(result.invoiceReference && result.matchType === 'bl_number') ||
(result.invoiceAmount !== undefined && result.invoiceAmount !== null) ||
result.dueDate
)) {
needsCacheInvalidationRef.current = true;
}
setVerifyingDeliveries(prev => {
@@ -234,16 +287,6 @@ export default function BLReconciliation() {
const { delivery, forceRefresh, silent } = next;
activeVerificationsRef.current += 1;
if (import.meta.env.DEV) {
console.log('🔍 Déclenchement vérification:', {
deliveryId: delivery.id,
invoiceReference: delivery.invoiceReference,
blNumber: delivery.blNumber,
supplier: delivery.supplier?.name,
enAttente: verificationQueueRef.current.length
});
}
verifyInvoiceMutation
.mutateAsync({
deliveryId: delivery.id,
@@ -266,8 +309,7 @@ export default function BLReconciliation() {
needsCacheInvalidationRef.current
) {
needsCacheInvalidationRef.current = false;
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
invalidateDeliveries();
}
});
}
@@ -345,11 +387,14 @@ export default function BLReconciliation() {
});
};
// Récupérer les livraisons validées avec BL - CACHE INVALIDÉ après modifications
const { data: deliveriesWithBL = [], isLoading, refetch } = useQuery({
queryKey: ['/api/deliveries/bl', selectedStoreId],
// Récupérer les livraisons livrées (onglets manuel et validées). Toute
// modification de livraison invalide ['/api/deliveries'], donc cette liste :
// le cache par défaut (30 s) évite de la retélécharger à chaque visite.
const { data: deliveriesWithBL = NO_DELIVERIES, isLoading } = useQuery<any[]>({
queryKey: [...RECONCILIATION_QUERY_KEY, selectedStoreId],
queryFn: async () => {
const params = new URLSearchParams({});
// Seules les livraisons livrées sont demandées (filtre appliqué en base)
const params = new URLSearchParams({ status: 'delivered' });
if (selectedStoreId && (user?.role === 'admin' || user?.role === 'directeur')) {
params.append('storeId', selectedStoreId.toString());
}
@@ -364,31 +409,23 @@ export default function BLReconciliation() {
const deliveries = await response.json();
// Debug désactivé en production pour éviter latence
if (import.meta.env.DEV && deliveries[0]) {
console.log('🔍 DEBUG - Première livraison:', deliveries[0]);
}
// Filtre conservé par sécurité si le serveur ignore le paramètre status
const filtered = Array.isArray(deliveries) ? deliveries.filter((d: any) => d.status === 'delivered') : [];
return filtered.sort((a: any, b: any) => new Date(b.deliveredDate || b.updatedAt).getTime() - new Date(a.deliveredDate || a.updatedAt).getTime());
},
enabled: !!user,
staleTime: 0 // Éviter la mise en cache pour toujours avoir les dernières données BL
});
// VÉRIFICATION AUTOMATIQUE AU CHARGEMENT avec système de cache
//
// Cet effet ne dépend QUE des données (livraisons + fournisseurs) : y ajouter
// Cet effet ne dépend QUE des livraisons : y ajouter
// verificationResults/verifyingDeliveries le relançait à chaque résultat reçu,
// ce qui reprogrammait en boucle les mêmes vérifications.
// Le dédoublonnage s'appuie sur autoRequestedIdsRef (ref, pas state) pour être
// effectif immédiatement, sans attendre un re-rendu.
useEffect(() => {
if (!deliveriesWithBL.length || !suppliers.length) return;
if (import.meta.env.DEV) {
console.log('🔄 Déclenchement vérifications automatiques...');
}
if (!deliveriesWithBL.length) return;
// Résultats déduits localement (sans appel réseau) pour afficher les coches
const cachedResults: Record<number, any> = {};
@@ -450,19 +487,19 @@ export default function BLReconciliation() {
return changed ? merged : prev;
});
}, [deliveriesWithBL, suppliers]);
}, [deliveriesWithBL]);
// Séparer les livraisons : non validées manuelles et toutes les validées
const manualNotValidatedDeliveries = deliveriesWithBL.filter((delivery: any) => {
const supplier = suppliers.find(s => s.id === delivery.supplierId);
const isManual = supplier?.automaticReconciliation !== true;
// Séparer les livraisons : non validées manuelles et toutes les validées.
// Le fournisseur (dont automaticReconciliation) est joint à chaque livraison.
const manualNotValidatedDeliveries = useMemo(() => deliveriesWithBL.filter((delivery: any) => {
const isManual = delivery.supplier?.automaticReconciliation !== true;
const isNotValidated = delivery.reconciled !== true && delivery.reconciled !== 1;
return isManual && isNotValidated;
});
}), [deliveriesWithBL]);
const allValidatedDeliveries = deliveriesWithBL.filter((delivery: any) => {
const allValidatedDeliveries = useMemo(() => deliveriesWithBL.filter((delivery: any) => {
return delivery.reconciled === true || delivery.reconciled === 1;
});
}), [deliveriesWithBL]);
// Fonctions de gestion
const handleOpenModal = (delivery: any) => {
@@ -477,9 +514,13 @@ export default function BLReconciliation() {
const handleSaveReconciliation = async () => {
try {
// Force refetch immédiat pour mettre à jour l'affichage
await queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
await queryClient.refetchQueries({ queryKey: ['/api/deliveries'] });
// Mise à jour de l'affichage : seules les listes affichées sont rechargées.
// cancelRefetch: false réutilise le rechargement déjà lancé par la modale
// (après l'enregistrement) au lieu d'en relancer un second.
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }, { cancelRefetch: false }),
queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] }),
]);
handleCloseModal();
} catch (error) {
toast({
@@ -517,25 +558,6 @@ export default function BLReconciliation() {
const handleCloseWaitingModal = () => {
setShowWaitingModal(false);
setProcessingSeconds(0);
if (processingTimeout) {
clearTimeout(processingTimeout);
setProcessingTimeout(null);
}
};
const startProcessingTimer = () => {
setProcessingSeconds(0);
const interval = setInterval(() => {
setProcessingSeconds(prev => {
if (prev >= 60) {
clearInterval(interval);
return 60;
}
return prev + 1;
});
}, 1000);
setProcessingTimeout(interval);
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -574,7 +596,6 @@ export default function BLReconciliation() {
setShowInvoiceModal(false);
setShowWaitingModal(true);
setIsUploading(true);
startProcessingTimer();
try {
const formData = new FormData();
@@ -609,19 +630,13 @@ export default function BLReconciliation() {
// Relancer la vérification de la facture qui vient d'être traitée
try {
// Invalidation des caches pour forcer le rechargement des données
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
// Recharger les données
await refetch();
// Recharger les livraisons (une seule requête pour la liste affichée)
await invalidateDeliveries();
// Attendre un peu que les données soient à jour, puis vérifier cette livraison spécifique
setTimeout(() => {
handleVerifyInvoice(selectedDeliveryForInvoice, true);
}, 1000);
console.log('🔄 Vérification automatique relancée pour la livraison traitée par webhook');
} catch (error) {
console.error('Erreur lors de la relance de la vérification:', error);
}
@@ -658,28 +673,13 @@ export default function BLReconciliation() {
// Et il faut qu'il y ait un magasin assigné avec un webhook
const hasValidGroup = delivery.group && delivery.group.webhookUrl;
// Debug uniquement en développement
if (import.meta.env.DEV && delivery && delivery.supplier?.name) {
console.log(`🔍 Debug bouton facture pour ${delivery.supplier.name}:`, {
isNotValidated,
hasNoInvoiceReference,
hasValidGroup,
shouldShow: (isNotValidated || hasNoInvoiceReference) && hasValidGroup
});
}
return (isNotValidated || hasNoInvoiceReference) && hasValidGroup;
};
// Email du fournisseur : donnée jointe à la livraison, avec repli sur la liste des fournisseurs
// Email du fournisseur : la fiche fournisseur complète est jointe à la livraison
const getSupplierEmail = (delivery: any): string => {
const fromDelivery = delivery?.supplier?.email;
if (fromDelivery && String(fromDelivery).trim()) {
return String(fromDelivery).trim();
}
const supplier = suppliers.find((s: any) => s.id === delivery?.supplierId);
return supplier?.email ? String(supplier.email).trim() : '';
const email = delivery?.supplier?.email;
return email && String(email).trim() ? String(email).trim() : '';
};
// Envoi de la demande de facture (PDF) / BL (Excel) au fournisseur.
@@ -745,7 +745,7 @@ export default function BLReconciliation() {
});
// Mise à jour optimiste du cache local pour disparition immédiate
queryClient.setQueryData(['/api/deliveries/bl', selectedStoreId], (oldData: any) => {
queryClient.setQueryData([...RECONCILIATION_QUERY_KEY, selectedStoreId], (oldData: any) => {
if (!oldData) return oldData;
return oldData.map((d: any) =>
d.id === delivery.id
@@ -754,12 +754,11 @@ export default function BLReconciliation() {
);
});
// Force refetch pour synchroniser avec le serveur
queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
queryClient.refetchQueries({ queryKey: ['/api/deliveries'] });
// Synchronisation avec le serveur (listes affichées uniquement)
invalidateDeliveries();
} catch (error) {
// En cas d'erreur, refetch pour annuler la mise à jour optimiste
queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
// En cas d'erreur, rechargement pour resynchroniser la liste
queryClient.invalidateQueries({ queryKey: RECONCILIATION_QUERY_KEY });
toast({
title: "Erreur",
description: "Impossible de valider le rapprochement",
@@ -793,9 +792,8 @@ export default function BLReconciliation() {
description: "Rapprochement dévalidé avec succès",
});
// Force refetch immédiat pour déplacer la facture dans l'onglet manuel
await queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
await queryClient.refetchQueries({ queryKey: ['/api/deliveries'] });
// Rechargement immédiat pour déplacer la facture dans l'onglet manuel
await invalidateDeliveries();
} catch (error) {
toast({
title: "Erreur",
@@ -828,17 +826,16 @@ export default function BLReconciliation() {
});
// Mise à jour optimiste du cache local pour disparition immédiate
queryClient.setQueryData(['/api/deliveries/bl', selectedStoreId], (oldData: any) => {
queryClient.setQueryData([...RECONCILIATION_QUERY_KEY, selectedStoreId], (oldData: any) => {
if (!oldData) return oldData;
return oldData.filter((d: any) => d.id !== deliveryId);
});
// Force refetch pour synchroniser avec le serveur
queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
queryClient.refetchQueries({ queryKey: ['/api/deliveries'] });
// Synchronisation avec le serveur (listes affichées uniquement)
invalidateDeliveries();
} catch (error) {
// En cas d'erreur, refetch pour annuler la mise à jour optimiste
queryClient.refetchQueries({ queryKey: ['/api/deliveries/bl'] });
// En cas d'erreur, rechargement pour resynchroniser la liste
queryClient.invalidateQueries({ queryKey: RECONCILIATION_QUERY_KEY });
toast({
title: "Erreur",
description: "Impossible de supprimer la livraison",
@@ -847,23 +844,17 @@ export default function BLReconciliation() {
}
};
// Filtrage des livraisons par recherche uniquement
const filterDeliveries = (deliveries: any[]) => {
if (!searchTerm) return deliveries;
const searchLower = searchTerm.toLowerCase();
return deliveries.filter((delivery: any) => {
return (
delivery.supplier?.name?.toLowerCase().includes(searchLower) ||
delivery.blNumber?.toLowerCase().includes(searchLower) ||
delivery.invoiceReference?.toLowerCase().includes(searchLower) ||
delivery.group?.name?.toLowerCase().includes(searchLower)
);
});
};
const filteredManualDeliveries = filterDeliveries(manualNotValidatedDeliveries);
const filteredValidatedDeliveries = filterDeliveries(allValidatedDeliveries);
// Filtrage par recherche, recalculé seulement quand la liste ou la recherche
// change ; la recherche différée garde la saisie fluide sur une longue liste
const deferredSearchTerm = useDeferredValue(searchTerm);
const filteredManualDeliveries = useMemo(
() => filterDeliveriesBySearch(manualNotValidatedDeliveries, deferredSearchTerm),
[manualNotValidatedDeliveries, deferredSearchTerm]
);
const filteredValidatedDeliveries = useMemo(
() => filterDeliveriesBySearch(allValidatedDeliveries, deferredSearchTerm),
[allValidatedDeliveries, deferredSearchTerm]
);
// Pagination pour les rapprochements manuels
const {
@@ -1419,21 +1410,11 @@ export default function BLReconciliation() {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedValidatedDeliveries.map((delivery: any) => {
const supplier = suppliers.find(s => s.id === delivery.supplierId);
const isAutomatic = supplier?.automaticReconciliation === true;
const isAutomatic = delivery.supplier?.automaticReconciliation === true;
const ecart = delivery.blAmount && delivery.invoiceAmount ?
((parseFloat(delivery.invoiceAmount) - parseFloat(delivery.blAmount)) / parseFloat(delivery.blAmount) * 100).toFixed(1) :
null;
// Log pour déboguer le problème des commentaires
if (import.meta.env.DEV && delivery.reconciliationCommentsCount) {
console.log('🔍 Validated delivery comments:', {
deliveryId: delivery.id,
commentsCount: delivery.reconciliationCommentsCount,
hasComments: delivery.reconciliationCommentsCount > 0
});
}
return (
<tr key={delivery.id} className="hover:bg-gray-50 bg-green-50">
<td className="px-6 py-4 whitespace-nowrap">
@@ -1690,36 +1671,7 @@ export default function BLReconciliation() {
</DialogTitle>
</DialogHeader>
<div className="grid gap-6 py-6">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 relative">
<div className="w-16 h-16 border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-bold text-blue-600">{processingSeconds}s</span>
</div>
</div>
<div className="space-y-2">
<h3 className="font-medium text-gray-900">
Traitement de votre facture en cours...
</h3>
<p className="text-sm text-gray-600">
Le workflow peut prendre jusqu'à 1 minute.
<br />
Veuillez patienter.
</p>
</div>
<div className="mt-4 bg-gray-100 rounded-full h-2 w-full">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-1000 ease-out"
style={{ width: `${Math.min((processingSeconds / 60) * 100, 100)}%` }}
></div>
</div>
<div className="mt-2 text-xs text-gray-500">
{processingSeconds < 60 ? `${60 - processingSeconds}s restantes (max)` : 'Finalisation...'}
</div>
</div>
<WaitingProgress />
{selectedDeliveryForInvoice && (
<div className="bg-blue-50 p-3 rounded-md">
+9 -1
View File
@@ -54,11 +54,19 @@ export default function BackupManager() {
const canManageBackups = hasPermission(user?.role || '', 'backups', 'manage');
// Fetch backup list
// Rafraîchissement automatique, suspendu quand l'onglet du navigateur est en
// arrière-plan (comportement par défaut de React Query) : toutes les 30 s tant
// qu'une sauvegarde est en cours, sinon toutes les 5 min (la liste ne change
// qu'à la sauvegarde automatique ou après une action, déjà suivie d'un rechargement)
const { data: backups = [], isLoading, error } = useQuery({
queryKey: ['/api/backups'],
queryFn: () => apiRequest('/api/backups'),
enabled: canManageBackups,
refetchInterval: 30000, // Refresh every 30 seconds
refetchInterval: (query) => {
const list = query.state.data;
const hasRunningBackup = Array.isArray(list) && list.some((backup: BackupFile) => backup.status === 'creating');
return hasRunningBackup ? 30 * 1000 : 5 * 60 * 1000;
},
});
// Fetch utilities configuration
+53 -80
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { ChevronLeft, ChevronRight, Plus, RefreshCw, Calendar as CalendarIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import CalendarGrid from "@/components/CalendarGrid";
@@ -16,6 +16,9 @@ import { format, startOfMonth, endOfMonth } from "date-fns";
import { fr } from "date-fns/locale";
import { hasPermission } from "@/lib/permissions";
// Référence stable tant que les données ne sont pas chargées (CalendarGrid est mémorisé)
const NO_ITEMS: any[] = [];
export default function Calendar() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
@@ -30,36 +33,20 @@ export default function Calendar() {
const [selectedItem, setSelectedItem] = useState<any>(null);
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const startDate = format(startOfMonth(currentDate), 'yyyy-MM-dd');
const endDate = format(endOfMonth(currentDate), 'yyyy-MM-dd');
// Fetch orders and deliveries for the current month with store filtering
const { data: orders = [], isLoading: loadingOrders } = useQuery({
queryKey: ['/api/orders', selectedStoreId, {
startDate: format(monthStart, 'yyyy-MM-dd'),
endDate: format(monthEnd, 'yyyy-MM-dd')
}],
const { data: orders = NO_ITEMS, isLoading: loadingOrders, isPlaceholderData: ordersPlaceholder } = useQuery({
queryKey: ['/api/orders', selectedStoreId, { startDate, endDate }],
queryFn: async () => {
const params = new URLSearchParams({
startDate: format(monthStart, 'yyyy-MM-dd'),
endDate: format(monthEnd, 'yyyy-MM-dd')
});
const params = new URLSearchParams({ startDate, endDate });
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
if (selectedStoreId) {
params.append('storeId', selectedStoreId.toString());
}
const url = `/api/orders?${params.toString()}`;
if (import.meta.env.DEV) {
console.log('📅 Calendar fetching orders:', {
url,
selectedStoreId,
userRole: user?.role,
params: params.toString()
});
}
const response = await fetch(url, {
const response = await fetch(`/api/orders?${params.toString()}`, {
credentials: 'include'
});
@@ -68,9 +55,6 @@ export default function Calendar() {
}
const data = await response.json();
if (import.meta.env.DEV) {
console.log('📅 Calendar orders received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
}
// Protection contre les données invalides en production
if (!Array.isArray(data)) {
@@ -81,34 +65,22 @@ export default function Calendar() {
},
staleTime: 5 * 60 * 1000, // 5 minutes de cache pour éviter la disparition des données
gcTime: 10 * 60 * 1000, // 10 minutes en cache
// Changement de mois : garder la grille affichée pendant le chargement au lieu
// du spinner. Jamais au changement de magasin (données d'un autre magasin).
placeholderData: (previousData, previousQuery) =>
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
});
const { data: deliveries = [], isLoading: loadingDeliveries } = useQuery({
queryKey: ['/api/deliveries', selectedStoreId, {
startDate: format(monthStart, 'yyyy-MM-dd'),
endDate: format(monthEnd, 'yyyy-MM-dd')
}],
const { data: deliveries = NO_ITEMS, isLoading: loadingDeliveries, isPlaceholderData: deliveriesPlaceholder } = useQuery({
queryKey: ['/api/deliveries', selectedStoreId, { startDate, endDate }],
queryFn: async () => {
const params = new URLSearchParams({
startDate: format(monthStart, 'yyyy-MM-dd'),
endDate: format(monthEnd, 'yyyy-MM-dd')
});
const params = new URLSearchParams({ startDate, endDate });
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
if (selectedStoreId) {
params.append('storeId', selectedStoreId.toString());
}
const url = `/api/deliveries?${params.toString()}`;
if (import.meta.env.DEV) {
console.log('📅 Calendar fetching deliveries:', {
url,
selectedStoreId,
userRole: user?.role,
params: params.toString()
});
}
const response = await fetch(url, {
const response = await fetch(`/api/deliveries?${params.toString()}`, {
credentials: 'include'
});
@@ -117,9 +89,6 @@ export default function Calendar() {
}
const data = await response.json();
if (import.meta.env.DEV) {
console.log('📅 Calendar deliveries received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
}
// Protection contre les données invalides en production
if (!Array.isArray(data)) {
@@ -130,10 +99,13 @@ export default function Calendar() {
},
staleTime: 5 * 60 * 1000, // 5 minutes de cache pour éviter la disparition des données
gcTime: 10 * 60 * 1000, // 10 minutes en cache
// Même règle que pour les commandes : jamais au changement de magasin
placeholderData: (previousData, previousQuery) =>
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
});
// Fetch publicities for the current year
const { data: publicities = [], isLoading: loadingPublicities } = useQuery({
const { data: publicities = NO_ITEMS } = useQuery({
queryKey: ['/api/ad-campaigns', currentDate.getFullYear(), selectedStoreId],
queryFn: async () => {
const params = new URLSearchParams();
@@ -142,17 +114,7 @@ export default function Calendar() {
params.append('storeId', selectedStoreId.toString());
}
const url = `/api/ad-campaigns?${params.toString()}`;
if (import.meta.env.DEV) {
console.log('📅 Calendar fetching publicities:', {
url,
selectedStoreId,
userRole: user?.role,
year: currentDate.getFullYear()
});
}
const response = await fetch(url, {
const response = await fetch(`/api/ad-campaigns?${params.toString()}`, {
credentials: 'include'
});
@@ -161,11 +123,12 @@ export default function Calendar() {
}
const data = await response.json();
if (import.meta.env.DEV) {
console.log('📅 Calendar publicities received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
}
return Array.isArray(data) ? data : [];
},
// Changement d'année : garder les publicités affichées pendant le chargement,
// sauf au changement de magasin
placeholderData: (previousData, previousQuery) =>
previousQuery?.queryKey[2] === selectedStoreId ? keepPreviousData(previousData) : undefined,
});
const navigateMonth = (direction: 'prev' | 'next') => {
@@ -178,16 +141,18 @@ export default function Calendar() {
setCurrentDate(newDate);
};
const handleDateClick = (date: Date) => {
// Gestionnaires stables : CalendarGrid (mémorisé) n'est pas re-rendu à
// l'ouverture ou à la fermeture des modales
const handleDateClick = useCallback((date: Date) => {
setSelectedDate(date);
setShowQuickCreate(true);
};
}, []);
const handleItemClick = (item: any, type: 'order' | 'delivery') => {
const handleItemClick = useCallback((item: any, type: 'order' | 'delivery') => {
// Ne pas invalider le cache à l'ouverture pour éviter la disparition des données
setSelectedItem({ ...item, type });
setShowOrderDetail(true);
};
}, []);
const handleCreateOrder = () => {
setShowQuickCreate(false);
@@ -201,7 +166,10 @@ export default function Calendar() {
const isLoading = loadingOrders || loadingDeliveries || loadingPublicities;
// La grille n'attend pas les publicités : elles s'affichent dès leur arrivée
const isLoading = loadingOrders || loadingDeliveries;
// Mois précédent encore affiché pendant le chargement du nouveau mois
const isMonthLoading = ordersPlaceholder || deliveriesPlaceholder;
return (
<div className="flex-1 flex flex-col overflow-hidden">
@@ -284,16 +252,21 @@ export default function Calendar() {
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
) : (
<CalendarGrid
currentDate={currentDate}
orders={orders || []}
deliveries={deliveries || []}
publicities={publicities || []}
selectedStoreId={selectedStoreId}
userGroups={user?.userGroups || []}
onDateClick={handleDateClick}
onItemClick={handleItemClick}
/>
<div
className={`transition-opacity ${isMonthLoading ? 'opacity-60' : ''}`}
aria-busy={isMonthLoading}
>
<CalendarGrid
currentDate={currentDate}
orders={orders}
deliveries={deliveries}
publicities={publicities}
selectedStoreId={selectedStoreId}
userGroups={user?.userGroups || NO_ITEMS}
onDateClick={handleDateClick}
onItemClick={handleItemClick}
/>
</div>
)}
</div>
+7 -11
View File
@@ -69,18 +69,14 @@ export default function Contacts() {
queryKey: ["/api/suppliers"],
});
const contactsQueryKey = effectiveGroupId
? [`/api/contacts?groupId=${effectiveGroupId}`]
: ["/api/contacts"];
// Clé structurée : l'invalidation par préfixe ["/api/contacts"] des mutations
// ci-dessous rafraîchit aussi la liste filtrée par magasin
const { data: contacts = [] } = useQuery<Contact[]>({
queryKey: contactsQueryKey,
queryFn: async () => {
const url = effectiveGroupId
? `/api/contacts?groupId=${effectiveGroupId}`
: "/api/contacts";
return await apiRequest(url, "GET");
},
queryKey: ["/api/contacts", effectiveGroupId ?? null],
queryFn: () =>
apiRequest(effectiveGroupId ? `/api/contacts?groupId=${effectiveGroupId}` : "/api/contacts", "GET"),
// Sans magasin, un non-admin ne voit pas la liste : pas de requête
enabled: !!user && (isAdmin || !!effectiveGroupId),
});
const createMutation = useMutation({
+113 -92
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState, useMemo, useEffect } from "react";
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -30,13 +30,12 @@ import {
} from "@/components/ui/select";
import { Pagination, usePagination } from "@/components/ui/pagination";
import { Plus, Edit, Trash2, Phone, PhoneCall, Printer, Eye, Package, AlertCircle, MessageSquare } from "lucide-react";
import JsBarcode from 'jsbarcode';
import { safeFormat, safeDate } from "@/lib/dateUtils";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { apiRequest } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
import type { CustomerOrderWithRelations, Group } from "@shared/schema";
import type { CustomerOrderWithRelations } from "@shared/schema";
import { CustomerOrderForm } from "@/components/CustomerOrderForm";
import { CustomerOrderDetails } from "@/components/CustomerOrderDetails";
import { ConfirmationModal } from "@/components/ConfirmationModal";
@@ -44,6 +43,10 @@ import ClientCallsModal from "@/components/modals/ClientCallsModal";
import { useStore } from "@/contexts/StoreContext";
import { usePermissions } from "@shared/permissions";
// Référence stable tant que la liste n'est pas chargée (évite de recalculer
// filtre et tri à chaque rendu)
const NO_ORDERS: CustomerOrderWithRelations[] = [];
export default function CustomerOrders() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
@@ -65,13 +68,11 @@ export default function CustomerOrders() {
const [filterSupplier, setFilterSupplier] = useState<string>("all");
const [filterStatus, setFilterStatus] = useState<string>("all");
// Fetch groups for store filter
const { data: groups = [] } = useQuery<Group[]>({
queryKey: ['/api/groups'],
});
// Query pour les appels clients en attente
const { data: pendingCalls = [], isLoading: isPendingCallsLoading } = useQuery<CustomerOrderWithRelations[]>({
// Le serveur refuse cette liste aux employés (403) : la requête et son
// rafraîchissement toutes les 30 s ne sont lancés que pour les autres rôles.
const canViewPendingCalls = !!user && user.role !== 'employee';
const { data: pendingCalls = NO_ORDERS, isLoading: isPendingCallsLoading } = useQuery<CustomerOrderWithRelations[]>({
queryKey: ['/api/customer-orders/pending-calls', selectedStoreId],
queryFn: async () => {
const pendingCallsUrl = `/api/customer-orders/pending-calls${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
@@ -79,11 +80,11 @@ export default function CustomerOrders() {
if (!response.ok) {
throw new Error('Failed to fetch pending calls');
}
const data = await response.json();
console.log('📞 Customer Orders - Pending calls received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
return data;
return response.json();
},
enabled: canViewPendingCalls,
refetchInterval: 30000, // Refetch every 30 seconds
refetchIntervalInBackground: false, // pas de rafraîchissement onglet masqué
});
// Fetch suppliers for filter
@@ -93,21 +94,20 @@ export default function CustomerOrders() {
// Fetch customer orders with store filtering for admins
const customerOrdersUrl = `/api/customer-orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
const { data: customerOrders = [], isLoading } = useQuery<CustomerOrderWithRelations[]>({
const { data: customerOrders = NO_ORDERS, isLoading, isPlaceholderData } = useQuery<CustomerOrderWithRelations[]>({
queryKey: [customerOrdersUrl, selectedStoreId],
// Au changement de magasin, la liste précédente reste affichée (estompée,
// non cliquable, avec un indicateur) au lieu de l'écran « Chargement... »
placeholderData: keepPreviousData,
});
// Create mutation
const createMutation = useMutation({
mutationFn: (data: any) => {
console.log("🔥 CREATE MUTATION STARTED with data:", data);
return apiRequest('/api/customer-orders', 'POST', data);
},
onSuccess: (result) => {
console.log("✅ CREATE MUTATION SUCCESS:", result);
// Force refresh of the query with store context
mutationFn: (data: any) => apiRequest('/api/customer-orders', 'POST', data),
onSuccess: () => {
// invalidateQueries recharge les listes affichées et marque les autres
// comme périmées (pas de second rechargement forcé)
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString()?.includes('/api/customer-orders') ?? false });
queryClient.refetchQueries({ predicate: (query) => query.queryKey[0]?.toString()?.includes('/api/customer-orders') ?? false });
setShowCreateModal(false);
toast({
title: "Succès",
@@ -243,15 +243,7 @@ export default function CustomerOrders() {
};
const handleCreateOrder = (data: any) => {
console.log("🎯 HANDLE CREATE ORDER CALLED with data:", data);
console.log("🎯 createMutation state:", {
isIdle: createMutation.isIdle,
isPending: createMutation.isPending,
isError: createMutation.isError,
isSuccess: createMutation.isSuccess
});
createMutation.mutate(data);
console.log("🎯 createMutation.mutate CALLED");
};
const handleEditOrder = (data: any) => {
@@ -298,7 +290,8 @@ export default function CustomerOrders() {
};
// Fonction pour générer un code-barres scannable
const generateEAN13Barcode = (code: string): string => {
// (JsBarcode est chargé à la demande par handlePrintLabel)
const generateEAN13Barcode = (JsBarcode: typeof import('jsbarcode'), code: string): string => {
try {
// Créer un canvas temporaire
const canvas = document.createElement('canvas');
@@ -318,8 +311,6 @@ export default function CustomerOrders() {
const checksum = calculateEAN13Checksum(processedCode);
const ean13 = processedCode + checksum;
console.log('Code original:', code, 'EAN13 généré:', ean13);
// Générer le code-barres avec jsbarcode
JsBarcode(canvas, ean13, {
format: "EAN13",
@@ -378,11 +369,32 @@ export default function CustomerOrders() {
}
};
const handlePrintLabel = (order: CustomerOrderWithRelations) => {
// Ouvrir une nouvelle fenêtre pour imprimer l'étiquette
const handlePrintLabel = async (order: CustomerOrderWithRelations) => {
// Ouvrir une nouvelle fenêtre pour imprimer l'étiquette (immédiatement, dans
// le clic, pour ne pas être bloquée par le navigateur)
const printWindow = window.open('', '_blank');
if (printWindow) {
const barcodeDisplay = order.gencode ? generateEAN13Barcode(order.gencode) : '';
let barcodeDisplay = '';
if (order.gencode) {
// JsBarcode n'est téléchargé qu'au moment d'imprimer une étiquette
let JsBarcode: typeof import('jsbarcode');
try {
({ default: JsBarcode } = await import('jsbarcode'));
} catch (error) {
// Fichier introuvable (nouvelle version en ligne) ou réseau coupé :
// pas d'étiquette avec un faux code-barres
console.error('Chargement du générateur de code-barres impossible:', error);
printWindow.close();
toast({
title: "Erreur",
description: "Impossible de préparer l'étiquette, réessayez",
variant: "destructive",
});
return;
}
barcodeDisplay = generateEAN13Barcode(JsBarcode, order.gencode);
}
if (printWindow.closed) return;
const isImageBarcode = barcodeDisplay.startsWith('data:');
printWindow.document.write(`
@@ -653,74 +665,67 @@ export default function CustomerOrders() {
}
};
// Filter orders based on search term, supplier, and status
const filteredOrders = Array.isArray(customerOrders) ? customerOrders.filter(order => {
// Search term filter
const matchesSearch = !searchTerm ||
order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
order.productDesignation.toLowerCase().includes(searchTerm.toLowerCase()) ||
order.customerPhone.includes(searchTerm) ||
(order.productReference && order.productReference.toLowerCase().includes(searchTerm.toLowerCase())) ||
(order.gencode && order.gencode.toLowerCase().includes(searchTerm.toLowerCase())) ||
(order.supplier && order.supplier.name.toLowerCase().includes(searchTerm.toLowerCase()));
// Filtre et tri recalculés seulement quand la liste ou un critère change
// (et non à chaque rendu, par exemple à chaque frappe dans le commentaire d'appel)
const sortedOrders = useMemo(() => {
if (!Array.isArray(customerOrders)) return NO_ORDERS;
// Supplier filter
const matchesSupplier = filterSupplier === "all" ||
(order.supplier && order.supplier.id.toString() === filterSupplier);
const search = searchTerm.toLowerCase();
// Status filter
const matchesStatus = filterStatus === "all" || order.status === filterStatus;
// Filter orders based on search term, supplier, and status
const filteredOrders = customerOrders.filter(order => {
// Search term filter
const matchesSearch = !searchTerm ||
order.customerName.toLowerCase().includes(search) ||
order.productDesignation.toLowerCase().includes(search) ||
order.customerPhone.includes(searchTerm) ||
(order.productReference && order.productReference.toLowerCase().includes(search)) ||
(order.gencode && order.gencode.toLowerCase().includes(search)) ||
(order.supplier && order.supplier.name.toLowerCase().includes(search));
return matchesSearch && matchesSupplier && matchesStatus;
}) : [];
// Supplier filter
const matchesSupplier = filterSupplier === "all" ||
(order.supplier && order.supplier.id.toString() === filterSupplier);
// Sort orders - Active orders first, then "Retiré" orders last
const sortedOrders = (() => {
// Séparer les commandes en deux groupes
const activeOrders = filteredOrders.filter(order => order.status !== "Retiré");
const retiredOrders = filteredOrders.filter(order => order.status === "Retiré");
// Status filter
const matchesStatus = filterStatus === "all" || order.status === filterStatus;
// Fonction de tri commune
const sortFunction = (a: any, b: any) => {
let aValue: string | number;
let bValue: string | number;
return matchesSearch && matchesSupplier && matchesStatus;
});
// Valeur de tri calculée une seule fois par commande (et non à chaque comparaison)
const getSortValue = (order: CustomerOrderWithRelations): string | number => {
switch (sortBy) {
case "date":
const dateA = safeDate(a.createdAt);
const dateB = safeDate(b.createdAt);
aValue = dateA ? dateA.getTime() : 0;
bValue = dateB ? dateB.getTime() : 0;
break;
case "status":
aValue = a.status;
bValue = b.status;
break;
return order.status;
case "supplier":
aValue = a.supplier?.name || "";
bValue = b.supplier?.name || "";
break;
default:
const defaultDateA = safeDate(a.createdAt);
const defaultDateB = safeDate(b.createdAt);
aValue = defaultDateA ? defaultDateA.getTime() : 0;
bValue = defaultDateB ? defaultDateB.getTime() : 0;
}
if (sortOrder === "desc") {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
} else {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
return order.supplier?.name || "";
case "date":
default: {
const date = safeDate(order.createdAt);
return date ? date.getTime() : 0;
}
}
};
// Trier chaque groupe séparément
const sortedActiveOrders = [...activeOrders].sort(sortFunction);
const sortedRetiredOrders = [...retiredOrders].sort(sortFunction);
// Fonction de tri commune
const sortGroup = (orders: CustomerOrderWithRelations[]) =>
orders
.map(order => ({ order, value: getSortValue(order) }))
.sort((a, b) => {
if (sortOrder === "desc") {
return a.value > b.value ? -1 : a.value < b.value ? 1 : 0;
} else {
return a.value < b.value ? -1 : a.value > b.value ? 1 : 0;
}
})
.map(entry => entry.order);
// Concaténer : commandes actives d'abord, puis retiré en dernier
return [...sortedActiveOrders, ...sortedRetiredOrders];
})();
// Sort orders - Active orders first, then "Retiré" orders last
const activeOrders = filteredOrders.filter(order => order.status !== "Retiré");
const retiredOrders = filteredOrders.filter(order => order.status === "Retiré");
return [...sortGroup(activeOrders), ...sortGroup(retiredOrders)];
}, [customerOrders, searchTerm, filterSupplier, filterStatus, sortBy, sortOrder]);
// Pagination
const {
@@ -733,6 +738,12 @@ export default function CustomerOrders() {
totalItems
} = usePagination(sortedOrders, 10);
// Retour à la première page au changement de magasin (la liste précédente
// reste affichée pendant le chargement, sa longueur ne change donc pas)
useEffect(() => {
setCurrentPage(1);
}, [selectedStoreId, setCurrentPage]);
return (
<div className="p-6 space-y-6">
{/* Alerte pour les appels clients en attente */}
@@ -823,9 +834,19 @@ export default function CustomerOrders() {
<CardHeader>
<CardTitle className="text-lg">
Commandes ({totalItems})
{isPlaceholderData && (
<span className="inline-flex items-center gap-1 ml-2 text-xs font-normal text-gray-500" role="status">
<span className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary" />
Mise à jour…
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
{/* Estompé et non cliquable tant que la liste du nouveau magasin n'est pas arrivée */}
<CardContent
className={isPlaceholderData ? "opacity-60 pointer-events-none transition-opacity" : ""}
aria-busy={isPlaceholderData}
>
{isLoading ? (
<div>Chargement...</div>
) : (
+111 -163
View File
@@ -1,54 +1,47 @@
import { useState, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Calendar, Package, ShoppingCart, TrendingUp, Clock, MapPin, User, AlertTriangle, CheckCircle, Truck, FileText, BarChart3, Megaphone, Shield, XCircle, CheckSquare, Circle, Info, Star, Sparkles, X } from "lucide-react";
import { Calendar, Package, Clock, User, AlertTriangle, Truck, BarChart3, Megaphone, XCircle, CheckSquare, Circle, Info, Star, Sparkles, X } from "lucide-react";
import { safeFormat, safeDate } from "@/lib/dateUtils";
import type { PublicityWithRelations, DashboardMessage } from "@shared/schema";
import AnnouncementCard from "@/components/AnnouncementCard";
import type { PublicityWithRelations } from "@shared/schema";
import AnnouncementCard, { getAnnouncementsQueryOptions, type AnnouncementWithRelations } from "@/components/AnnouncementCard";
import { DlcAlertModal } from "@/components/DlcAlertModal";
// Annonce la plus récente créée il y a moins de 2 jours (null s'il n'y en a pas)
const selectRecentAnnouncement = (announcements: AnnouncementWithRelations[]): AnnouncementWithRelations | null => {
const twoDaysAgo = new Date();
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
const recentAnnouncements = announcements.filter(announcement => {
if (!announcement.createdAt) return false;
const createdAt = safeDate(announcement.createdAt);
return createdAt && createdAt >= twoDaysAgo;
});
// filter() renvoie une copie : le tri ne modifie pas le cache partagé
return recentAnnouncements.length > 0
? recentAnnouncements.sort((a, b) => {
const dateA = safeDate(a.createdAt);
const dateB = safeDate(b.createdAt);
return (dateB?.getTime() || 0) - (dateA?.getTime() || 0);
})[0]
: null;
};
export default function Dashboard() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
const queryClient = useQueryClient();
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false);
const [showDlcAlertModal, setShowDlcAlertModal] = useState(false);
// Invalider les queries DLC quand le magasin change
useEffect(() => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"] });
}, [selectedStoreId, queryClient]);
const { data: stats } = useQuery({
queryKey: ['/api/stats/monthly', selectedStoreId],
queryFn: async () => {
const currentDate = new Date();
const params = new URLSearchParams({
year: currentDate.getFullYear().toString(),
month: (currentDate.getMonth() + 1).toString(),
});
if (selectedStoreId && user?.role === 'admin') {
params.append('storeId', selectedStoreId.toString());
}
const response = await fetch(`/api/stats/monthly?${params.toString()}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch stats');
}
return response.json();
},
});
// Toutes les requêtes attendent l'utilisateur (enabled: !!user) : le storeId envoyé
// dépend de son rôle, et une réponse obtenue sans lui resterait en cache sous la
// clé du magasin sélectionné
// Statistiques annuelles pour la carte de délai moyen
const { data: yearlyStats } = useQuery({
@@ -73,65 +66,29 @@ export default function Dashboard() {
return response.json();
},
enabled: !!user,
});
// Construire les URLs pour récupérer toutes les données (pas de filtrage par date)
const ordersUrl = `/api/orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
const deliveriesUrl = `/api/deliveries${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
const customerOrdersUrl = `/api/customer-orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
// Utiliser les mêmes clés de cache que les autres pages pour assurer la cohérence
const { data: allOrders = [] } = useQuery({
queryKey: [ordersUrl, selectedStoreId],
enabled: !!user,
});
const { data: allDeliveries = [] } = useQuery({
queryKey: [deliveriesUrl, selectedStoreId],
enabled: !!user,
});
const { data: customerOrders = [] } = useQuery({
queryKey: [customerOrdersUrl, selectedStoreId],
});
// Récupérer la dernière annonce récente (créée il y a moins de 2 jours)
const { data: recentAnnouncement } = useQuery<DashboardMessage | null>({
queryKey: ['/api/announcements/recent', selectedStoreId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedStoreId && user?.role === 'admin') {
params.append('storeId', selectedStoreId.toString());
}
params.append('recent', 'true');
const response = await fetch(`/api/announcements?${params.toString()}`, {
credentials: 'include'
});
if (!response.ok) {
return null;
}
const announcements: DashboardMessage[] = await response.json();
// Filtrer les annonces créées il y a moins de 2 jours côté client
const twoDaysAgo = new Date();
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
const recentAnnouncements = announcements.filter(announcement => {
if (!announcement.createdAt) return false;
const createdAt = safeDate(announcement.createdAt);
return createdAt && createdAt >= twoDaysAgo;
});
// Retourner la plus récente
return recentAnnouncements.length > 0
? recentAnnouncements.sort((a, b) => {
const dateA = safeDate(a.createdAt);
const dateB = safeDate(b.createdAt);
return (dateB?.getTime() || 0) - (dateA?.getTime() || 0);
})[0]
: null;
},
// Dernière annonce récente (créée il y a moins de 2 jours), extraite de la
// requête des annonces partagée avec AnnouncementCard
const { data: recentAnnouncement } = useQuery({
...getAnnouncementsQueryOptions(selectedStoreId, user?.role),
select: selectRecentAnnouncement,
enabled: !!user,
});
@@ -193,43 +150,48 @@ export default function Dashboard() {
}
};
// Récupérer les publicités à venir (chercher dans 2024 ET 2025) - TOUTES les publicités
// Récupérer les publicités à venir (années N-1, N et N+1) - TOUTES les publicités
const { data: upcomingPublicities = [] } = useQuery<PublicityWithRelations[]>({
queryKey: ['/api/ad-campaigns', 'upcoming'],
queryFn: async () => {
// Essayer d'abord 2024, puis 2025 pour avoir toutes les publicités
const currentYear = new Date().getFullYear();
const years = [currentYear - 1, currentYear, currentYear + 1];
let allPublicities: PublicityWithRelations[] = [];
for (const year of years) {
// Les trois années sont demandées en parallèle ; une année en échec est ignorée
const publicitiesByYear = await Promise.all(years.map(async (year): Promise<PublicityWithRelations[]> => {
const params = new URLSearchParams();
params.append('year', year.toString());
// NE PAS filtrer par magasin - on veut toutes les publicités
try {
const response = await fetch(`/api/ad-campaigns?${params}`, { credentials: 'include' });
if (response.ok) {
const yearPublicities = await response.json();
allPublicities = [...allPublicities, ...yearPublicities];
}
if (!response.ok) return [];
const yearPublicities = await response.json();
return Array.isArray(yearPublicities) ? yearPublicities : [];
} catch (error) {
console.log(`Erreur lors de la récupération des publicités ${year}:`, error);
console.warn(`Erreur lors de la récupération des publicités ${year}:`, error);
return [];
}
}
}));
// Filtrer les publicités à venir et les trier par date
const futurePublicities = allPublicities
.filter((publicity: any) => new Date(publicity.startDate) > new Date())
const now = new Date();
const futurePublicities = publicitiesByYear
.flat()
.filter((publicity: any) => new Date(publicity.startDate) > now)
.sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
return futurePublicities;
},
enabled: !!user,
});
// Fetch DLC stats
// Clé distincte de celle de DlcPage, qui n'envoie storeId que pour l'admin (réponse
// différente pour un non-admin multi-magasins). Le préfixe reste couvert par les
// invalidations des mutations DLC.
const { data: dlcStats = { active: 0, expiringSoon: 0, expired: 0 } } = useQuery({
queryKey: ["/api/dlc-products/stats", selectedStoreId],
queryKey: ["/api/dlc-products/stats", selectedStoreId, "dashboard"],
queryFn: () => {
const params = new URLSearchParams();
// Envoyer selectedStoreId pour tous les utilisateurs (le backend gère les permissions)
@@ -238,6 +200,7 @@ export default function Dashboard() {
credentials: 'include'
}).then(res => res.json());
},
enabled: !!user,
});
// Effet pour afficher automatiquement le modal DLC pour les utilisateurs non-admin
@@ -287,55 +250,66 @@ export default function Dashboard() {
credentials: 'include'
}).then(res => res.json());
},
enabled: !!user,
});
// Données dérivées pour les sections
const recentOrders = Array.isArray(allOrders) ? allOrders
.sort((a: any, b: any) => {
const dateA = safeDate(a.createdAt);
const dateB = safeDate(b.createdAt);
return (dateB ? dateB.getTime() : 0) - (dateA ? dateA.getTime() : 0);
})
.slice(0, 4) : []; // Afficher les 4 dernières commandes
// Données dérivées pour les sections, recalculées seulement quand les données
// changent. Les tris portent sur des copies : le cache React Query reste intact.
// Toutes les commandes en attente
const pendingOrders = Array.isArray(allOrders) ? allOrders
.filter((order: any) => order.status === 'pending')
.sort((a: any, b: any) => {
const dateA = safeDate(a.createdAt);
const dateB = safeDate(b.createdAt);
return (dateB ? dateB.getTime() : 0) - (dateA ? dateA.getTime() : 0);
}) : []; // Afficher toutes les commandes en attente
// Toutes les commandes en attente, les plus récentes d'abord
const pendingOrders = useMemo(() => {
if (!Array.isArray(allOrders)) return [];
const createdTime = (order: any) => safeDate(order.createdAt)?.getTime() ?? 0;
return allOrders
.filter((order: any) => order.status === 'pending')
.sort((a: any, b: any) => createdTime(b) - createdTime(a));
}, [allOrders]);
const upcomingDeliveries = Array.isArray(allDeliveries) ? allDeliveries
.filter((d: any) => d.status === 'planned')
.sort((a: any, b: any) => new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime())
.slice(0, 4) : [];
// Livraisons : un seul passage pour les livraisons prévues et les compteurs du mois
const { upcomingDeliveries, deliveredThisMonth, totalPalettes } = useMemo(() => {
const planned: any[] = [];
let deliveredCount = 0;
let palettes = 0;
if (Array.isArray(allDeliveries)) {
const now = new Date();
for (const delivery of allDeliveries) {
if (delivery.status === 'planned') {
planned.push(delivery);
} else if (delivery.status === 'delivered') {
const deliveryDate = safeDate(delivery.deliveredDate || delivery.createdAt);
if (deliveryDate && deliveryDate.getMonth() === now.getMonth() && deliveryDate.getFullYear() === now.getFullYear()) {
deliveredCount++;
// Total des palettes pour les livraisons 'delivered' du mois en cours
if (delivery.unit === 'palettes') {
palettes += delivery.quantity || 0;
}
}
}
}
planned.sort((a: any, b: any) => new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime());
}
return {
upcomingDeliveries: planned.slice(0, 4),
deliveredThisMonth: deliveredCount,
totalPalettes: palettes,
};
}, [allDeliveries]);
// Tâches non terminées, les plus anciennes d'abord (5 au maximum)
const openTasks = useMemo(() => {
if (!Array.isArray(tasks)) return [];
const createdTime = (task: any) => safeDate(task.createdAt)?.getTime() ?? 0;
return tasks
.filter((task: any) => task.status !== 'completed')
.sort((a: any, b: any) => createdTime(a) - createdTime(b))
.slice(0, 5);
}, [tasks]);
// Calculs pour les statistiques
const pendingOrdersCount = Array.isArray(allOrders) ? allOrders.filter((order: any) => order.status === 'pending').length : 0;
const pendingOrdersCount = pendingOrders.length;
const averageDeliveryTime = Math.round(yearlyStats?.averageDeliveryTime || 0);
const deliveredThisMonth = Array.isArray(allDeliveries) ? allDeliveries.filter((delivery: any) => {
const deliveryDate = safeDate(delivery.deliveredDate || delivery.createdAt);
if (!deliveryDate) return false;
const now = new Date();
return deliveryDate.getMonth() === now.getMonth() &&
deliveryDate.getFullYear() === now.getFullYear() &&
delivery.status === 'delivered';
}).length : 0;
// Calculer le total des palettes pour les livraisons 'delivered' du mois en cours
const totalPalettes = Array.isArray(allDeliveries) ? allDeliveries.reduce((total: number, delivery: any) => {
if (delivery.status === 'delivered' && delivery.unit === 'palettes') {
const deliveryDate = safeDate(delivery.deliveredDate || delivery.createdAt);
if (!deliveryDate) return total;
const now = new Date();
if (deliveryDate.getMonth() === now.getMonth() && deliveryDate.getFullYear() === now.getFullYear()) {
return total + (delivery.quantity || 0);
}
}
return total;
}, 0) : 0;
// Fonction pour obtenir la configuration des priorités (identique au module Tasks)
const getPriorityConfig = (priority: string) => {
@@ -367,25 +341,6 @@ export default function Dashboard() {
}
};
// Statistiques pour les commandes clients
const ordersByStatus = {
pending: Array.isArray(allOrders) ? allOrders.filter((o: any) => o.status === 'pending').length : 0,
planned: Array.isArray(allOrders) ? allOrders.filter((o: any) => o.status === 'planned').length : 0,
delivered: Array.isArray(allOrders) ? allOrders.filter((o: any) => o.status === 'delivered').length : 0,
total: Array.isArray(allOrders) ? allOrders.length : 0
};
// Statistiques pour les commandes clients (nouveau module)
const customerOrderStats = {
waiting: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'En attente de Commande').length : 0,
inProgress: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Commande en Cours').length : 0,
available: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Disponible').length : 0,
withdrawn: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Retiré').length : 0,
canceled: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Annulé').length : 0,
total: Array.isArray(customerOrders) ? customerOrders.length : 0
};
return (
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* Header */}
@@ -660,14 +615,7 @@ export default function Dashboard() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-6">
{Array.isArray(tasks) && tasks.length > 0 ? tasks
.filter((task: any) => task.status !== 'completed')
.sort((a: any, b: any) => {
const dateA = safeDate(a.createdAt);
const dateB = safeDate(b.createdAt);
return (dateA ? dateA.getTime() : 0) - (dateB ? dateB.getTime() : 0);
})
.slice(0, 5)
{Array.isArray(tasks) && tasks.length > 0 ? openTasks
.map((task: any) => {
const priorityConfig = getPriorityConfig(task.priority);
const PriorityIcon = priorityConfig.icon;
+30 -31
View File
@@ -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";
@@ -35,6 +35,9 @@ import ValidateDeliveryModal from "@/components/modals/ValidateDeliveryModal";
import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal";
import type { DeliveryWithRelations } from "@shared/schema";
// Référence stable pendant le chargement (pour la mémoïsation du filtrage)
const NO_DELIVERIES: DeliveryWithRelations[] = [];
export default function Deliveries() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
@@ -79,38 +82,29 @@ export default function Deliveries() {
// CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin
const deliveriesUrl = `/api/deliveries${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`;
const { data: deliveriesData = [], isLoading } = useQuery<DeliveryWithRelations[]>({
const { data: deliveriesData = NO_DELIVERIES, isLoading } = useQuery<DeliveryWithRelations[]>({
queryKey: ['/api/deliveries', selectedStoreId, user?.role],
queryFn: async () => {
const url = deliveriesUrl;
console.log('🚚 Fetching deliveries from:', url);
const response = await fetch(url, { credentials: 'include' });
const response = await fetch(deliveriesUrl, { credentials: 'include' });
if (!response.ok) {
throw new Error('Failed to fetch deliveries');
}
const data = await response.json();
console.log('🚚 Deliveries received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items', data.slice(0, 2));
console.log('🚚 Sample delivery data:', data[0]);
return Array.isArray(data) ? data : [];
},
});
// Production Bug Fix: Ensure array safety for all data operations
const deliveries = Array.isArray(deliveriesData) ? deliveriesData : [];
const deliveries = Array.isArray(deliveriesData) ? deliveriesData : NO_DELIVERIES;
console.log('🚚 Deliveries Debug:', {
isLoading,
deliveriesCount: deliveries?.length,
deliveries: deliveries?.slice(0, 2),
selectedStoreId,
deliveriesUrl
});
const { data: groupsData = [] } = useQuery({
queryKey: ['/api/groups'],
});
const groups = Array.isArray(groupsData) ? groupsData : [];
// Clé construite sur l'URL (tableau de bord admin avec magasin sélectionné).
// Sans magasin, elle vaut ['/api/deliveries'], déjà invalidée : la réinvalider
// relancerait une seconde fois le chargement complet des livraisons.
const invalidateDeliveriesUrlKey = () => {
if (selectedStoreId) {
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
}
};
const markControlValidatedMutation = useMutation({
@@ -123,7 +117,7 @@ export default function Deliveries() {
description: "Contrôle validé avec succès",
});
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
invalidateDeliveriesUrlKey();
},
onError: (error: any) => {
if (isUnauthorizedError(error)) {
@@ -148,8 +142,10 @@ export default function Deliveries() {
});
// Invalider tous les caches liés aux livraisons
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
invalidateDeliveriesUrlKey();
queryClient.invalidateQueries({ queryKey: ['/api/stats/monthly'] });
// L'échéancier (cache de 5 min) liste les livraisons facturées
queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] });
},
onError: (error) => {
if (isUnauthorizedError(error)) {
@@ -171,16 +167,19 @@ export default function Deliveries() {
},
});
const filteredDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => {
console.log('🔍 Filtering delivery:', delivery.id, { searchTerm, statusFilter });
const matchesSearch = delivery.supplier?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
delivery.group?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
delivery.notes?.toLowerCase().includes(searchTerm.toLowerCase());
// Filtrage recalculé seulement quand la liste, la recherche ou le statut changent
const filteredDeliveries = useMemo(() => {
const search = searchTerm.toLowerCase();
return deliveries.filter(delivery => {
const matchesSearch = delivery.supplier?.name.toLowerCase().includes(search) ||
delivery.group?.name.toLowerCase().includes(search) ||
delivery.notes?.toLowerCase().includes(search);
const matchesStatus = statusFilter === "all" || delivery.status === statusFilter;
const matchesStatus = statusFilter === "all" || delivery.status === statusFilter;
return matchesSearch && matchesStatus;
}) : [];
return matchesSearch && matchesStatus;
});
}, [deliveries, searchTerm, statusFilter]);
// Pagination
const {
+82 -44
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useQuery, useMutation, useMutationState, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -37,6 +37,14 @@ const dlcFormSchema = z.object({
type DlcFormData = z.infer<typeof dlcFormSchema>;
// Référence stable tant que la liste n'est pas chargée (évite de recalculer
// les listes d'impression à chaque rendu)
const NO_PRODUCTS: DlcProductWithRelations[] = [];
// Préfixe des actions faites depuis une ligne du tableau : sert à savoir
// quelles lignes ont une action en cours
const DLC_ROW_ACTION_KEY = "dlc-row-action";
export default function DlcPage() {
const { user, isLoading: authLoading } = useAuthUnified();
const { toast } = useToast();
@@ -76,7 +84,7 @@ export default function DlcPage() {
});
// Fetch DLC products - optimized with server-side search
const { data: dlcProducts = [], isLoading: productsLoading } = useQuery({
const { data: dlcProducts = NO_PRODUCTS, isLoading: productsLoading, isPlaceholderData } = useQuery({
queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, supplierFilter, debouncedSearchTerm],
queryFn: () => {
const params = new URLSearchParams();
@@ -89,6 +97,11 @@ export default function DlcPage() {
},
enabled: !authLoading,
staleTime: 2 * 60 * 1000, // 2 minutes cache for DLC data
// Changement de filtre ou de recherche : le tableau précédent reste affiché
// (estompé, avec un indicateur) au lieu de disparaître. Jamais au
// changement de magasin (produits d'un autre magasin).
placeholderData: (previousData, previousQuery) =>
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
});
// Fetch DLC stats - optimized cache
@@ -175,13 +188,30 @@ export default function DlcPage() {
};
}, [gencodeValue, suppliers]);
// Après une modification, la liste et les compteurs changent : seules les
// requêtes affichées sont rechargées, les autres (autres filtres, tableau de
// bord) sont seulement marquées périmées
const invalidateDlcData = () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], refetchType: "active" }),
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], refetchType: "active" }),
]);
// Actions en cours, par produit : seuls les boutons de la ligne concernée sont
// désactivés (et non ceux de toutes les lignes), y compris si plusieurs
// produits sont traités à la suite
const pendingRowActions = useMutationState({
filters: { mutationKey: [DLC_ROW_ACTION_KEY], status: "pending" },
select: (mutation) => `${String(mutation.options.mutationKey?.[1])}:${String(mutation.state.variables)}`,
});
const isRowActionPending = (action: string, productId: number) =>
pendingRowActions.includes(`${action}:${productId}`);
// Create mutation - optimized cache invalidation
const createMutation = useMutation({
mutationFn: (data: InsertDlcProduct) => apiRequest("/api/dlc-products", "POST", data),
onSuccess: () => {
// Selective cache invalidation
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
invalidateDlcData();
toast({ title: "Produit DLC créé avec succès" });
setIsDialogOpen(false);
form.reset();
@@ -201,8 +231,7 @@ export default function DlcPage() {
mutationFn: ({ id, data }: { id: number; data: Partial<InsertDlcProduct> }) =>
apiRequest(`/api/dlc-products/${id}`, "PUT", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
invalidateDlcData();
toast({ title: "Produit DLC mis à jour avec succès" });
setIsDialogOpen(false);
form.reset();
@@ -219,11 +248,12 @@ export default function DlcPage() {
// Validate mutation - optimized cache invalidation
const validateMutation = useMutation({
mutationKey: [DLC_ROW_ACTION_KEY, "validate"],
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/validate`, "POST"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Produit validé avec succès" });
// La ligne reste désactivée jusqu'au rechargement de la liste
return invalidateDlcData();
},
onError: (error: any) => {
toast({
@@ -236,11 +266,12 @@ export default function DlcPage() {
// Delete mutation - optimized cache invalidation
const deleteMutation = useMutation({
mutationKey: [DLC_ROW_ACTION_KEY, "delete"],
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}`, "DELETE"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Produit supprimé avec succès" });
// La ligne reste désactivée jusqu'au rechargement de la liste
return invalidateDlcData();
},
onError: (error: any) => {
toast({
@@ -253,11 +284,12 @@ export default function DlcPage() {
// Stock épuisé mutation - accessible à tous
const markStockEpuiseMutation = useMutation({
mutationKey: [DLC_ROW_ACTION_KEY, "stock-epuise"],
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/stock-epuise`, "PUT"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Produit marqué comme stock épuisé" });
// La ligne reste désactivée jusqu'au rechargement de la liste
return invalidateDlcData();
},
onError: (error: any) => {
toast({
@@ -270,11 +302,12 @@ export default function DlcPage() {
// Restaurer stock mutation - réservé aux admins, directeurs et managers
const restoreStockMutation = useMutation({
mutationKey: [DLC_ROW_ACTION_KEY, "restore-stock"],
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/restore-stock`, "PUT"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Stock restauré avec succès" });
// La ligne reste désactivée jusqu'au rechargement de la liste
return invalidateDlcData();
},
onError: (error: any) => {
toast({
@@ -287,11 +320,12 @@ export default function DlcPage() {
// Marquer comme traité mutation - accessible à tous
const markProcessedMutation = useMutation({
mutationKey: [DLC_ROW_ACTION_KEY, "mark-processed"],
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/mark-processed`, "PUT"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Produit marqué comme traité" });
// La ligne reste désactivée jusqu'au rechargement de la liste
return invalidateDlcData();
},
onError: (error: any) => {
toast({
@@ -304,11 +338,12 @@ export default function DlcPage() {
// Annuler traitement mutation - réservé aux admins, directeurs et managers
const unmarkProcessedMutation = useMutation({
mutationKey: [DLC_ROW_ACTION_KEY, "unmark-processed"],
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/unmark-processed`, "PUT"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false });
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false });
toast({ title: "Traitement annulé avec succès" });
// La ligne reste désactivée jusqu'au rechargement de la liste
return invalidateDlcData();
},
onError: (error: any) => {
toast({
@@ -330,32 +365,17 @@ export default function DlcPage() {
if (user?.userGroups?.[0]?.groupId) {
// Utilisateur avec groupe assigné : TOUJOURS utiliser ce groupe
groupId = user.userGroups[0].groupId;
console.log("🎯 DLC Frontend: Using user's assigned group (PRIORITY):", groupId);
} else if (user?.role === 'admin' && selectedStoreId) {
// Admin avec magasin sélectionné ET pas de groupe assigné
groupId = selectedStoreId;
console.log("🎯 DLC Frontend: Using admin selected store:", groupId);
} else if (user?.role === 'admin' && stores?.[0]?.id) {
// Admin sans sélection : premier magasin disponible
groupId = stores[0].id;
console.log("🎯 DLC Frontend: Using first available store for admin:", groupId);
} else {
// Fallback d'urgence
groupId = 1;
console.log("🚨 DLC Frontend: Using emergency fallback groupId:", groupId);
}
console.log("🏪 DLC GroupId Selection DEBUG:", {
userRole: user?.role,
selectedStoreId,
userGroups: user?.userGroups?.map((ug: any) => ({groupId: ug.groupId, groupName: ug.group?.name})),
availableStores: stores.map((s: any) => ({id: s.id, name: s.name})),
userGroupsRaw: user?.userGroups,
firstUserGroup: user?.userGroups?.[0],
finalGroupId: groupId,
logicPath: !groupId ? 'need-fallback' : 'already-set'
});
const dlcData: any = {
...data,
name: data.productName, // Copier productName vers name (requis dans la DB)
@@ -615,7 +635,7 @@ export default function DlcPage() {
};
// Products are already filtered server-side, no client filtering needed
const filteredProducts = dlcProducts || [];
const filteredProducts = dlcProducts || NO_PRODUCTS;
// Memoized calculations for print functions
const { expiringSoonProducts, expiredProducts } = useMemo(() => {
@@ -647,6 +667,13 @@ export default function DlcPage() {
totalItems
} = usePagination(filteredProducts, 10);
// Retour à la première page quand le magasin, un filtre ou la recherche change
// (le tableau précédent reste affiché pendant le chargement : sa longueur ne
// change pas forcément)
useEffect(() => {
setCurrentPage(1);
}, [selectedStoreId, statusFilter, supplierFilter, debouncedSearchTerm, setCurrentPage]);
if (authLoading) {
return <div className="flex justify-center items-center h-64">Chargement...</div>;
}
@@ -926,7 +953,15 @@ export default function DlcPage() {
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle>Produits DLC ({totalItems})</CardTitle>
<CardTitle>
Produits DLC ({totalItems})
{isPlaceholderData && (
<span className="inline-flex items-center gap-1 ml-2 text-xs font-normal text-gray-500" role="status">
<span className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary" />
Mise à jour…
</span>
)}
</CardTitle>
<Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Exporter PDF
@@ -934,14 +969,17 @@ export default function DlcPage() {
</div>
</CardHeader>
<CardContent>
{productsLoading ? (
{productsLoading || (isPlaceholderData && totalItems === 0) ? (
<div className="flex justify-center items-center h-32">Chargement des produits...</div>
) : totalItems === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Aucun produit DLC trouvé
</div>
) : (
<div>
<div
className={isPlaceholderData ? "opacity-60 pointer-events-none transition-opacity" : ""}
aria-busy={isPlaceholderData}
>
<Table>
<TableHeader>
<TableRow>
@@ -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"
>
<X className="w-4 h-4" />
@@ -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"
>
<PackageX className="w-4 h-4" />
@@ -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"
>
<RotateCcw className="w-4 h-4" />
@@ -1106,7 +1144,7 @@ export default function DlcPage() {
variant="destructive"
size="sm"
onClick={() => handleDelete(product.id)}
disabled={deleteMutation.isPending}
disabled={isRowActionPending("delete", product.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
+6 -4
View File
@@ -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<NocodbConfig[]>({
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({
+1 -16
View File
@@ -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',
});
@@ -62,21 +62,6 @@ export default function NocoDBConfig() {
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({
mutationFn: (data: NocodbConfigForm) =>
+20 -51
View File
@@ -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<OrderWithRelations[]>({
const { data: ordersData = NO_ORDERS, isLoading } = useQuery<OrderWithRelations[]>({
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());
// 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;
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
return matchesSearch && matchesStatus;
}) : [];
return matchesSearch && matchesStatus;
});
}, [orders, searchTerm, statusFilter]);
// Pagination
const {
+51 -36
View File
@@ -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<string>(() => {
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 = (
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Échéancier des Paiements</h1>
<p className="text-gray-600 mt-1">Gestion des échéances fournisseurs</p>
</div>
<div className="flex items-center gap-4">
<Button
onClick={handleOpenExportModal}
variant="outline"
className="gap-2"
disabled={filteredSchedules.length === 0}
data-testid="button-export-csv"
>
<FileSpreadsheet className="h-4 w-4" />
Exporter CSV
</Button>
<div className="w-64">
<Select value={selectedMonth} onValueChange={setSelectedMonth}>
<SelectTrigger data-testid="select-month">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableMonths.map(month => (
<SelectItem key={month.value} value={month.value}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
<div className="p-6 space-y-6">
{header}
<div className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
</div>
);
}
@@ -292,38 +338,7 @@ export default function PaymentSchedulePage() {
return (
<div className="p-6 space-y-6">
{/* En-tête */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Échéancier des Paiements</h1>
<p className="text-gray-600 mt-1">Gestion des échéances fournisseurs</p>
</div>
<div className="flex items-center gap-4">
<Button
onClick={handleOpenExportModal}
variant="outline"
className="gap-2"
disabled={filteredSchedules.length === 0}
data-testid="button-export-csv"
>
<FileSpreadsheet className="h-4 w-4" />
Exporter CSV
</Button>
<div className="w-64">
<Select value={selectedMonth} onValueChange={setSelectedMonth}>
<SelectTrigger data-testid="select-month">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableMonths.map(month => (
<SelectItem key={month.value} value={month.value}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{header}
{/* Statistiques du mois */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
File diff suppressed because it is too large. Load diff
+35 -56
View File
@@ -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<SavTicketWithRelations[]>({
const { data: ticketsData = NO_TICKETS, isLoading, isPlaceholderData } = useQuery<SavTicketWithRelations[]>({
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<Supplier[]>({
@@ -105,7 +114,7 @@ export default function SavTickets() {
staleTime: 1000 * 60 * 10, // 10 minutes
});
const { data: groupsData = [] } = useQuery<Group[]>({
const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
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)
);
}, [user, groupsData]);
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;
};
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 (
<div className="p-3 sm:p-6 space-y-4 sm:space-y-6">
{/* Header */}
@@ -823,7 +798,11 @@ export default function SavTickets() {
Liste des tickets de service après-vente
</CardDescription>
</CardHeader>
<CardContent className="p-0">
{/* Estompé pendant le chargement d'un nouveau filtre (anciens résultats affichés) */}
<CardContent
className={`p-0 ${isPlaceholderData ? 'opacity-60 transition-opacity' : ''}`}
aria-busy={isPlaceholderData}
>
{isLoading ? (
<div className="p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
+23 -20
View File
@@ -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<Supplier[]>({
const { data: suppliers = NO_SUPPLIERS, isLoading } = useQuery<Supplier[]>({
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<EntityActivityStats[]>({
const { data: supplierStats = NO_STATS } = useQuery<EntityActivityStats[]>({
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,
+208 -187
View File
@@ -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<HTMLFormElement>) => {
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,7 +115,10 @@ 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);
@@ -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<TaskWithRelations | null>(null);
const [taskToDelete, setTaskToDelete] = useState<TaskWithRelations | null>(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,16 +315,6 @@ 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'
@@ -353,18 +326,6 @@ export default function Tasks() {
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);
@@ -379,31 +340,33 @@ export default function Tasks() {
}
},
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<TaskWithRelations[]>(queryKey);
if (Array.isArray(previousTasks)) {
queryClient.setQueryData<TaskWithRelations[]>(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;
}
// 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;
// Filtre par priorité
if (priorityFilter !== "all" && task.priority !== priorityFilter) {
return false;
}
if (aPriority !== bPriority) {
return bPriority - aPriority; // Ordre décroissant (high en premier)
}
// 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;
}
// 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;
});
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;
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;
});
}, [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() {
</h2>
<p className="text-gray-600 mt-1 text-sm sm:text-base">
{totalItems} tâche{totalItems !== 1 ? 's' : ''} trouvée{totalItems !== 1 ? 's' : ''}
{isPlaceholderData && (
<span className="inline-flex items-center gap-1 ml-2 text-xs text-gray-500" role="status">
<span className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary" />
Mise à jour…
</span>
)}
</p>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
@@ -757,8 +776,9 @@ export default function Tasks() {
</div>
</div>
{/* Section filtres - Responsive */}
<div className={`${filtersOpen ? 'block' : 'hidden'} lg:hidden mb-4`}>
{/* Section filtres - Responsive (tablette uniquement) */}
{!isLargeScreen && (
<div className={`${filtersOpen ? 'block' : 'hidden'} mb-4`}>
<Card className="mb-4 sm:mb-6">
<CardHeader className="pb-3 sm:pb-4">
<div className="flex items-center justify-between">
@@ -846,9 +866,11 @@ export default function Tasks() {
</CardContent>
</Card>
</div>
)}
{/* Zone principale mobile/tablet - Contenu pleine largeur */}
<div className="block lg:hidden">
{!isLargeScreen && (
<div className={listBusyClass} aria-busy={isPlaceholderData}>
<Tabs value={viewMode} onValueChange={setViewMode} className="h-full">
<TabsContent value="list" className="mt-0">
{/* Contenu mobile identique au desktop mais optimisé */}
@@ -865,14 +887,13 @@ export default function Tasks() {
) : (
<div className="space-y-3 sm:space-y-4">
{/* Tâches en cours - Mobile optimisé */}
{paginatedTasks.filter(task => task.status === 'pending').length > 0 && (
{pagePendingTasks.length > 0 && (
<div>
<h4 className="text-base sm:text-lg font-semibold text-gray-900 mb-3 px-1">
Tâches en cours ({paginatedTasks.filter(task => task.status === 'pending').length})
Tâches en cours ({pagePendingTasks.length})
</h4>
<div className="space-y-2 sm:space-y-3">
{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 && (
<div>
<h4 className="text-base sm:text-lg font-semibold text-gray-900 mb-3 mt-6 px-1">
Tâches terminées ({paginatedTasks.filter(task => task.status === 'completed').length})
Tâches terminées ({pageCompletedTasks.length})
</h4>
<div className="space-y-2 sm:space-y-3">
{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() {
</TabsContent>
</Tabs>
</div>
)}
{/* Sidebar desktop uniquement */}
<div className="hidden lg:flex gap-6">
{isLargeScreen && (
<div className="flex gap-6">
{/* Sidebar avec filtres desktop */}
<div className="w-80 bg-gray-50 border-r border-gray-200 p-4 flex-shrink-0">
<Card>
@@ -1167,7 +1189,7 @@ export default function Tasks() {
</div>
{/* Zone principale avec les tâches - Desktop */}
<div className="flex-1">
<div className={`flex-1 ${listBusyClass}`} aria-busy={isPlaceholderData}>
{/* Contenu selon la vue sélectionnée */}
<Tabs value={viewMode} onValueChange={setViewMode} className="h-full">
<TabsContent value="list" className="mt-0">
@@ -1186,14 +1208,13 @@ export default function Tasks() {
) : (
<div className="space-y-4">
{/* Tâches en cours */}
{paginatedTasks.filter(task => task.status === 'pending').length > 0 && (
{pagePendingTasks.length > 0 && (
<div>
<h4 className="text-lg font-semibold text-gray-900 mb-3">
Tâches en cours ({paginatedTasks.filter(task => task.status === 'pending').length})
Tâches en cours ({pagePendingTasks.length})
</h4>
<div className="space-y-3">
{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 && (
<div>
<h4 className="text-lg font-semibold text-gray-900 mb-3 mt-8">
Tâches terminées ({paginatedTasks.filter(task => task.status === 'completed').length})
Tâches terminées ({pageCompletedTasks.length})
</h4>
<div className="space-y-3">
{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() {
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Circle className="w-5 h-5 text-yellow-500" />
En cours ({filteredTasks.filter((task: TaskWithRelations) => task.status === 'pending').length})
En cours ({kanbanPendingTasks.length})
</h3>
<div className="space-y-3 max-h-[600px] overflow-y-auto">
{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() {
<div className="bg-green-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-500" />
Terminées ({filteredTasks.filter((task: TaskWithRelations) => task.status === 'completed').length})
Terminées ({kanbanCompletedTasks.length})
</h3>
<div className="space-y-3 max-h-[600px] overflow-y-auto">
{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() {
</Tabs>
</div>
</div>
)}
{/* Modal d'édition */}
{showEditModal && selectedTask && (
+19 -16
View File
@@ -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,12 +132,17 @@ 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) {
setEditForm({
@@ -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);
+37 -18
View File
@@ -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<any[]>({
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 <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">En attente</Badge>;
@@ -238,19 +257,19 @@ export default function MobileAvoirsPage() {
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
id: avoir.id,
data: { ...avoir, status: 'En attente de demande' }
data: buildStatusUpdate(avoir, 'En attente de demande')
})}>
Marquer En attente
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
id: avoir.id,
data: { ...avoir, status: 'Demandé' }
data: buildStatusUpdate(avoir, 'Demandé')
})}>
Marquer Demandé
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateStatusMutation.mutate({
id: avoir.id,
data: { ...avoir, status: 'Reçu' }
data: buildStatusUpdate(avoir, 'Reçu')
})}>
Marquer Reçu
</DropdownMenuItem>
+50 -36
View File
@@ -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<Date | null>(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<string, CalendarEvent[]>();
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)
+15 -7
View File
@@ -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) {
+14 -22
View File
@@ -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 (
<a
<Link
key={item.path}
href={item.path}
className="flex flex-col items-center p-3 rounded-lg bg-gray-50 hover:bg-gray-100"
>
<Icon className="h-6 w-6 text-gray-600 mb-1" />
<span className="text-xs text-gray-600">{item.label}</span>
</a>
</Link>
);
})}
</div>
+24 -14
View File
@@ -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();
+26 -5
View File
@@ -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 (
<MobileLayout>
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</MobileLayout>
);
}
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<Group[]>({
const { data: stores = EMPTY_STORES } = useQuery<Group[]>({
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 (
<StoreProvider value={storeContextValue}>
{children}
<Suspense fallback={<MobilePageFallback />}>
{children}
</Suspense>
</StoreProvider>
);
}
@@ -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) => {
+118 -42
View File
@@ -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<HTMLFormElement>) => {
@@ -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<TaskWithRelations | null>(null);
const [deletingTask, setDeletingTask] = useState<TaskWithRelations | null>(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<TaskWithRelations[]>(queryKey);
if (Array.isArray(previousTasks)) {
queryClient.setQueryData<TaskWithRelations[]>(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() {
<div className="flex items-center gap-2">
<ListTodo className="w-5 h-5 text-blue-600" />
<h1 className="text-lg font-bold">Tâches</h1>
{isPlaceholderData && (
<span className="inline-flex items-center gap-1 text-xs text-gray-500" role="status">
<span className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-600" />
Mise à jour…
</span>
)}
</div>
<Badge variant="secondary">{filteredTasks.length}</Badge>
</div>
@@ -423,8 +495,12 @@ export default function MobileTasksPage() {
</div>
</div>
{/* Task List */}
<div className="px-3 py-2 overflow-hidden" style={{ maxWidth: '100%' }}>
{/* Task List (liste du magasin précédent estompée et non cliquable pendant le chargement) */}
<div
className={`px-3 py-2 overflow-hidden ${isPlaceholderData ? 'opacity-60 pointer-events-none transition-opacity' : ''}`}
style={{ maxWidth: '100%' }}
aria-busy={isPlaceholderData}
>
{filteredTasks.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<ListTodo className="w-12 h-12 mx-auto mb-3 opacity-50" />
+5 -1
View File
@@ -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"],
},
},