mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-24 15:38:18 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3be451a8c | ||
|
|
6f1a39e4b2 | ||
|
|
2274c9bb4a | ||
|
|
944d99733c | ||
|
|
8a27b310d2 | ||
|
|
6bb61cdbcc | ||
|
|
d384271a30 | ||
|
|
f4c582b217 | ||
|
|
5d3e5357a0 | ||
|
|
a5785f0244 |
No files matched your search
@@ -30,3 +30,8 @@ ENCRYPTION_KEY=
|
||||
# Route de secours POST /api/emergency-admin-reset : désactivée si vide.
|
||||
# Ne définir qu'en cas de besoin, puis retirer.
|
||||
EMERGENCY_SECRET=
|
||||
|
||||
# API externe de rapprochement BL / factures (docs/API-RAPPROCHEMENT.md).
|
||||
# Une ou plusieurs clés séparées par des virgules ; vide = API désactivée.
|
||||
# Générer une clé : node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
EXTERNAL_API_KEYS=
|
||||
+4
-1
@@ -62,7 +62,8 @@ RUN npx esbuild server/index.production.ts \
|
||||
--external:memoizee \
|
||||
--external:nanoid \
|
||||
--external:date-fns \
|
||||
--external:nodemailer
|
||||
--external:nodemailer \
|
||||
--external:compression
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
@@ -99,6 +100,8 @@ USER nextjs
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
# Fixé ici car db.ts lit NODE_ENV dès l'import, avant le forçage de index.production.ts
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Install wget for health check and postgresql-client for pg_dump
|
||||
USER root
|
||||
|
||||
+1
-3
@@ -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>
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
// Type pour les annonces avec relations
|
||||
type AnnouncementWithRelations = DashboardMessage & {
|
||||
export type AnnouncementWithRelations = DashboardMessage & {
|
||||
author: {
|
||||
id: string | number;
|
||||
firstName?: string;
|
||||
@@ -97,6 +97,45 @@ const getPriorityConfig = (type: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Requête des annonces, partagée avec le tableau de bord (même clé, même URL) :
|
||||
// un seul appel réseau, et les invalidations ['/api/announcements'] des
|
||||
// mutations ci-dessous rafraîchissent les deux affichages.
|
||||
export function getAnnouncementsQueryOptions(selectedStoreId: number | null, role: string | undefined) {
|
||||
return {
|
||||
queryKey: ['/api/announcements', selectedStoreId, role],
|
||||
queryFn: async (): Promise<AnnouncementWithRelations[]> => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Pour les admins, permettre le filtrage par magasin sélectionné
|
||||
if (role === 'admin' && selectedStoreId) {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
const url = `/api/announcements${params.toString() ? `?${params.toString()}` : ''}`;
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ [ANNOUNCEMENTS] Fetch failed:', response.status, response.statusText);
|
||||
if (response.status === 401) {
|
||||
return [];
|
||||
}
|
||||
throw new Error(`Failed to fetch announcements: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
console.warn('⚠️ [ANNOUNCEMENTS] Data is not an array:', data);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function AnnouncementCard() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
@@ -117,48 +156,9 @@ export default function AnnouncementCard() {
|
||||
},
|
||||
});
|
||||
|
||||
// Construire l'URL avec les paramètres appropriés
|
||||
const buildApiUrl = () => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Pour les admins, permettre le filtrage par magasin sélectionné
|
||||
if (user?.role === 'admin' && selectedStoreId) {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
return `/api/announcements${params.toString() ? `?${params.toString()}` : ''}`;
|
||||
};
|
||||
|
||||
// Récupérer les annonces
|
||||
const { data: announcements = [], isLoading } = useQuery<AnnouncementWithRelations[]>({
|
||||
queryKey: ['/api/announcements', selectedStoreId, user?.role],
|
||||
queryFn: async () => {
|
||||
const url = buildApiUrl();
|
||||
console.log('🔍 [ANNOUNCEMENTS] Fetching from:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ [ANNOUNCEMENTS] Fetch failed:', response.status, response.statusText);
|
||||
if (response.status === 401) {
|
||||
console.log('🔑 [ANNOUNCEMENTS] Authentication required');
|
||||
return [];
|
||||
}
|
||||
throw new Error(`Failed to fetch announcements: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('✅ [ANNOUNCEMENTS] Data received:', data);
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
console.warn('⚠️ [ANNOUNCEMENTS] Data is not an array:', data);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
const { data: announcements = [], isLoading } = useQuery({
|
||||
...getAnnouncementsQueryOptions(selectedStoreId, user?.role),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
@@ -170,9 +170,7 @@ export default function AnnouncementCard() {
|
||||
: '/api/announcements';
|
||||
|
||||
const method = editingAnnouncement ? 'PUT' : 'POST';
|
||||
|
||||
console.log(`🔍 [ANNOUNCEMENTS] ${method} ${url}`, announcementData);
|
||||
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -241,8 +239,6 @@ export default function AnnouncementCard() {
|
||||
|
||||
// Soumission du formulaire
|
||||
const onSubmit = (data: InsertAnnouncement) => {
|
||||
console.log('🔍 [ANNOUNCEMENTS] Form submitted:', data);
|
||||
|
||||
const announcementData = {
|
||||
...data,
|
||||
createdBy: user?.username || '',
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, isToday } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday } from "date-fns";
|
||||
import { safeDate } from "@/lib/dateUtils";
|
||||
import { Plus, Check, MoreHorizontal, Package, Link, MessageSquare } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { useState } from "react";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
|
||||
// Composant modal pour afficher les commentaires
|
||||
function CommentModal({ isOpen, onClose, comment }: { isOpen: boolean, onClose: () => void, comment: string }) {
|
||||
@@ -353,7 +352,10 @@ function DayItemsContainer({ dayOrders, dayDeliveries, onItemClick }: { dayOrder
|
||||
);
|
||||
}
|
||||
|
||||
export default function CalendarGrid({
|
||||
// Liste vide partagée pour les jours sans élément (référence stable)
|
||||
const NO_ITEMS: any[] = [];
|
||||
|
||||
function CalendarGrid({
|
||||
currentDate,
|
||||
orders,
|
||||
deliveries,
|
||||
@@ -363,197 +365,158 @@ export default function CalendarGrid({
|
||||
onDateClick,
|
||||
onItemClick,
|
||||
}: CalendarGridProps) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🗓️ CalendarGrid rendered with:', {
|
||||
currentDate: currentDate?.toISOString(),
|
||||
ordersCount: orders?.length || 0,
|
||||
deliveriesCount: deliveries?.length || 0,
|
||||
publicitiesCount: publicities?.length || 0,
|
||||
selectedStoreId,
|
||||
userGroupsCount: userGroups?.length || 0
|
||||
// Jours affichés : 6 semaines complètes commençant un lundi
|
||||
const paddedDays = useMemo(() => {
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
|
||||
// Get all days in the month
|
||||
const monthDays = eachDayOfInterval({
|
||||
start: monthStart,
|
||||
end: monthEnd,
|
||||
});
|
||||
}
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
|
||||
// Pad the calendar to start on Monday
|
||||
const firstDayOfWeek = monthStart.getDay();
|
||||
const startPadding = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
|
||||
const days: Date[] = [];
|
||||
|
||||
// Add padding days from previous month
|
||||
for (let i = startPadding; i > 0; i--) {
|
||||
const paddingDate = new Date(monthStart);
|
||||
paddingDate.setDate(paddingDate.getDate() - i);
|
||||
days.push(paddingDate);
|
||||
}
|
||||
|
||||
// Add current month days
|
||||
days.push(...monthDays);
|
||||
|
||||
// Add padding days from next month to complete the grid
|
||||
const remainingCells = 42 - days.length; // 6 weeks * 7 days
|
||||
for (let i = 1; i <= remainingCells; i++) {
|
||||
const paddingDate = new Date(monthEnd);
|
||||
paddingDate.setDate(paddingDate.getDate() + i);
|
||||
days.push(paddingDate);
|
||||
}
|
||||
|
||||
return days;
|
||||
}, [currentDate]);
|
||||
|
||||
// Commandes et livraisons indexées par jour ('yyyy-MM-dd', heure locale comme
|
||||
// isSameDay) en une seule passe sur chaque liste, au lieu de filtrer toutes
|
||||
// les listes pour chacune des 42 cases à chaque rendu
|
||||
const itemsByDay = useMemo(() => {
|
||||
const index = new Map<string, { orders: OrderWithRelations[]; deliveries: DeliveryWithRelations[] }>();
|
||||
const getDayEntry = (key: string) => {
|
||||
let entry = index.get(key);
|
||||
if (!entry) {
|
||||
entry = { orders: [], deliveries: [] };
|
||||
index.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
let invalidCount = 0;
|
||||
|
||||
for (const order of orders) {
|
||||
// Protection contre undefined/null
|
||||
if (!order || !order.supplier) {
|
||||
invalidCount++;
|
||||
continue;
|
||||
}
|
||||
// Essayer plusieurs champs de date possibles
|
||||
const orderDate = safeDate(order.plannedDate || order.createdAt);
|
||||
if (orderDate) {
|
||||
getDayEntry(format(orderDate, 'yyyy-MM-dd')).orders.push(order);
|
||||
}
|
||||
}
|
||||
|
||||
for (const delivery of deliveries) {
|
||||
// Protection contre undefined/null
|
||||
if (!delivery || !delivery.supplier) {
|
||||
invalidCount++;
|
||||
continue;
|
||||
}
|
||||
// Essayer plusieurs champs de date possibles
|
||||
const deliveryDate = safeDate(delivery.scheduledDate || delivery.deliveredDate || delivery.createdAt);
|
||||
if (deliveryDate) {
|
||||
getDayEntry(format(deliveryDate, 'yyyy-MM-dd')).deliveries.push(delivery);
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidCount > 0) {
|
||||
console.warn(`⚠️ CalendarGrid: ${invalidCount} élément(s) sans fournisseur ignoré(s)`);
|
||||
}
|
||||
|
||||
return index;
|
||||
}, [orders, deliveries]);
|
||||
|
||||
// Publicités par jour affiché : filtrage par magasin fait une seule fois,
|
||||
// puis répartition sur les 42 jours selon la période de chaque publicité
|
||||
const publicitiesByDay = useMemo(() => {
|
||||
const byDay = new Map<string, any[]>();
|
||||
|
||||
// Helper function to filter publicities based on user's assigned stores
|
||||
const getPublicitiesForDate = (date: Date) => {
|
||||
const dateStr = format(date, 'yyyy-MM-dd');
|
||||
|
||||
// Protection contre les données invalides qui causent des plantages en production
|
||||
if (!publicities || !Array.isArray(publicities)) {
|
||||
console.warn('⚠️ Invalid publicities data:', publicities);
|
||||
return [];
|
||||
return byDay;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔍 CalendarGrid getPublicitiesForDate:', {
|
||||
date: dateStr,
|
||||
totalPublicities: publicities.length,
|
||||
selectedStoreId,
|
||||
userGroups: userGroups?.length || 0
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
return publicities.filter(pub => {
|
||||
let invalidCount = 0;
|
||||
const storePublicities = publicities.filter(pub => {
|
||||
// Vérifications de sécurité pour éviter les plantages
|
||||
if (!pub || typeof pub !== 'object') {
|
||||
console.warn('⚠️ Invalid publicity object:', pub);
|
||||
if (!pub || typeof pub !== 'object' || !pub.startDate || !pub.endDate) {
|
||||
invalidCount++;
|
||||
return false;
|
||||
}
|
||||
// Check if the date is within the publicity period
|
||||
const pubStart = pub.startDate;
|
||||
const pubEnd = pub.endDate;
|
||||
|
||||
if (!pubStart || !pubEnd) {
|
||||
console.warn('⚠️ Publicity missing dates:', { pubNumber: pub.pubNumber, startDate: pubStart, endDate: pubEnd });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dateStr < pubStart || dateStr > pubEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no store is selected and user has no assigned groups, show only publicities with participations
|
||||
if (!selectedStoreId && (!userGroups || userGroups.length === 0)) {
|
||||
const hasParticipations = pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
|
||||
if (hasParticipations && import.meta.env.DEV) {
|
||||
console.log('📋 Publicity has participations (no store selected):', { pubNumber: pub.pubNumber, participationCount: pub.participations.length });
|
||||
// If no store is selected and user has no assigned groups, show only publicities with participations
|
||||
if (!selectedStoreId && (!userGroups || userGroups.length === 0)) {
|
||||
return pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
|
||||
}
|
||||
return hasParticipations;
|
||||
}
|
||||
|
||||
// If a specific store is selected, check if that store participates
|
||||
if (selectedStoreId) {
|
||||
const matches = pub.participations && Array.isArray(pub.participations) &&
|
||||
pub.participations.some((pg: any) => pg?.groupId === selectedStoreId);
|
||||
if (matches && import.meta.env.DEV) {
|
||||
console.log('🎯 Publicity matches selected store:', { pubNumber: pub.pubNumber, selectedStoreId });
|
||||
// If a specific store is selected, check if that store participates
|
||||
if (selectedStoreId) {
|
||||
return pub.participations && Array.isArray(pub.participations) &&
|
||||
pub.participations.some((pg: any) => pg?.groupId === selectedStoreId);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
// If no specific store selected but user has assigned stores,
|
||||
// show publicities where any of user's stores participate
|
||||
if (userGroups && Array.isArray(userGroups) && userGroups.length > 0) {
|
||||
const userGroupIds = userGroups.map((ug: any) => ug?.groupId).filter(id => id !== undefined);
|
||||
const matches = pub.participations && Array.isArray(pub.participations) &&
|
||||
pub.participations.some((pg: any) => pg?.groupId && userGroupIds.includes(pg.groupId));
|
||||
if (matches) {
|
||||
console.log('👥 Publicity matches user groups:', { pubNumber: pub.pubNumber, userGroupIds });
|
||||
// If no specific store selected but user has assigned stores,
|
||||
// show publicities where any of user's stores participate
|
||||
if (userGroups && Array.isArray(userGroups) && userGroups.length > 0) {
|
||||
const userGroupIds = userGroups.map((ug: any) => ug?.groupId).filter(id => id !== undefined);
|
||||
return pub.participations && Array.isArray(pub.participations) &&
|
||||
pub.participations.some((pg: any) => pg?.groupId && userGroupIds.includes(pg.groupId));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
// Default case: only show publicities with participations
|
||||
const hasParticipations = pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
|
||||
if (hasParticipations) {
|
||||
console.log('📋 Publicity has participations (default case):', { pubNumber: pub.pubNumber, participationCount: pub.participations.length });
|
||||
}
|
||||
return hasParticipations;
|
||||
// Default case: only show publicities with participations
|
||||
return pub.participations && Array.isArray(pub.participations) && pub.participations.length > 0;
|
||||
});
|
||||
|
||||
if (invalidCount > 0) {
|
||||
console.warn(`⚠️ CalendarGrid: ${invalidCount} publicité(s) invalide(s) ou sans dates ignorée(s)`);
|
||||
}
|
||||
|
||||
if (storePublicities.length > 0) {
|
||||
for (const date of paddedDays) {
|
||||
const dateStr = format(date, 'yyyy-MM-dd');
|
||||
// Check if the date is within the publicity period
|
||||
const dayPublicities = storePublicities.filter(pub => !(dateStr < pub.startDate || dateStr > pub.endDate));
|
||||
if (dayPublicities.length > 0) {
|
||||
byDay.set(dateStr, dayPublicities);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error filtering publicities:', error);
|
||||
return [];
|
||||
byDay.clear();
|
||||
}
|
||||
};
|
||||
|
||||
// Get all days in the month
|
||||
const monthDays = eachDayOfInterval({
|
||||
start: monthStart,
|
||||
end: monthEnd,
|
||||
});
|
||||
|
||||
// Pad the calendar to start on Monday
|
||||
const firstDayOfWeek = monthStart.getDay();
|
||||
const startPadding = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
|
||||
const paddedDays = [];
|
||||
|
||||
// Add padding days from previous month
|
||||
for (let i = startPadding; i > 0; i--) {
|
||||
const paddingDate = new Date(monthStart);
|
||||
paddingDate.setDate(paddingDate.getDate() - i);
|
||||
paddedDays.push(paddingDate);
|
||||
}
|
||||
|
||||
// Add current month days
|
||||
paddedDays.push(...monthDays);
|
||||
|
||||
// Add padding days from next month to complete the grid
|
||||
const remainingCells = 42 - paddedDays.length; // 6 weeks * 7 days
|
||||
for (let i = 1; i <= remainingCells; i++) {
|
||||
const paddingDate = new Date(monthEnd);
|
||||
paddingDate.setDate(paddingDate.getDate() + i);
|
||||
paddedDays.push(paddingDate);
|
||||
}
|
||||
return byDay;
|
||||
}, [publicities, selectedStoreId, userGroups, paddedDays]);
|
||||
|
||||
const weekDays = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
|
||||
|
||||
const getItemsForDate = (date: Date) => {
|
||||
// Debug: Log des commandes reçues (seulement une fois)
|
||||
if (orders.length > 0 && date.getDate() === 1) {
|
||||
console.log('📅 CalendarGrid Debug - Orders received:', orders.length);
|
||||
console.log('📅 First order structure:', orders[0]);
|
||||
console.log('📅 All orders dates:', orders.map(o => ({ id: o.id, plannedDate: o.plannedDate, supplier: o.supplier?.name })));
|
||||
}
|
||||
|
||||
// Debug: Log des livraisons reçues (seulement une fois)
|
||||
if (deliveries.length > 0 && date.getDate() === 1) {
|
||||
console.log('🚛 CalendarGrid Debug - Deliveries received:', deliveries.length);
|
||||
console.log('🚛 First delivery structure:', deliveries[0]);
|
||||
console.log('🚛 All deliveries dates:', deliveries.map(d => ({ id: d.id, scheduledDate: d.scheduledDate, supplier: d.supplier?.name })));
|
||||
}
|
||||
|
||||
const dayOrders = orders.filter(order => {
|
||||
// Protection contre undefined/null
|
||||
if (!order || !order.supplier) {
|
||||
console.warn('📅 Invalid order found:', order);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Essayer plusieurs champs de date possibles
|
||||
const orderDate = safeDate(order.plannedDate || order.createdAt);
|
||||
const matches = orderDate && isSameDay(orderDate, date);
|
||||
|
||||
if (matches) {
|
||||
console.log('📅 Order matches date:', {
|
||||
orderId: order.id,
|
||||
supplier: order.supplier?.name,
|
||||
plannedDate: order.plannedDate,
|
||||
matchingDate: format(date, 'yyyy-MM-dd')
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
});
|
||||
|
||||
const dayDeliveries = deliveries.filter(delivery => {
|
||||
// Protection contre undefined/null
|
||||
if (!delivery || !delivery.supplier) {
|
||||
console.warn('🚛 Invalid delivery found:', delivery);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Essayer plusieurs champs de date possibles
|
||||
const deliveryDate = safeDate(delivery.scheduledDate || delivery.deliveredDate || delivery.createdAt);
|
||||
const matches = deliveryDate && isSameDay(deliveryDate, date);
|
||||
|
||||
if (matches) {
|
||||
console.log('🚛 Delivery matches date:', {
|
||||
deliveryId: delivery.id,
|
||||
supplier: delivery.supplier?.name,
|
||||
scheduledDate: delivery.scheduledDate,
|
||||
matchingDate: format(date, 'yyyy-MM-dd')
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
});
|
||||
|
||||
return { orders: dayOrders, deliveries: dayDeliveries };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow-xl border-2 border-gray-300 overflow-hidden">
|
||||
{/* Calendar Header - Design moderne épuré */}
|
||||
@@ -576,8 +539,11 @@ export default function CalendarGrid({
|
||||
const isCurrentMonth = isSameMonth(date, currentDate);
|
||||
const isTodayDate = isToday(date);
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
|
||||
const { orders: dayOrders, deliveries: dayDeliveries } = getItemsForDate(date);
|
||||
const dayPublicities = getPublicitiesForDate(date);
|
||||
const dayKey = format(date, 'yyyy-MM-dd');
|
||||
const dayItems = itemsByDay.get(dayKey);
|
||||
const dayOrders = dayItems ? dayItems.orders : NO_ITEMS;
|
||||
const dayDeliveries = dayItems ? dayItems.deliveries : NO_ITEMS;
|
||||
const dayPublicities = publicitiesByDay.get(dayKey) || NO_ITEMS;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -694,3 +660,6 @@ export default function CalendarGrid({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mémorisé : l'ouverture d'une modale dans la page Calendrier ne recalcule pas la grille
|
||||
export default memo(CalendarGrid);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocation } from "wouter";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -39,13 +40,9 @@ interface DlcAlertModalProps {
|
||||
export function DlcAlertModal({ isOpen, onClose, dlcStats, selectedStoreId }: DlcAlertModalProps) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [, setLocation] = useLocation();
|
||||
const [snoozeUntil, setSnoozeUntil] = useState<Date | null>(null);
|
||||
|
||||
// Invalider les queries DLC quand le magasin change
|
||||
useEffect(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
||||
}, [selectedStoreId, queryClient]);
|
||||
|
||||
// Fetch detailed DLC products for the modal
|
||||
const { data: expiredProducts = [] } = useQuery<DlcProduct[]>({
|
||||
queryKey: ["/api/dlc-products", "expires", selectedStoreId],
|
||||
@@ -138,8 +135,10 @@ export function DlcAlertModal({ isOpen, onClose, dlcStats, selectedStoreId }: Dl
|
||||
markStockEpuiseMutation.mutate(productId);
|
||||
};
|
||||
|
||||
// Navigation interne : pas de rechargement complet de l'application
|
||||
const handleViewDlcModule = () => {
|
||||
window.location.href = '/dlc';
|
||||
setLocation('/dlc');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useState, useEffect } from "react";
|
||||
import { ReactNode, Suspense, useState, useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocation } from "wouter";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -9,6 +9,7 @@ import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useScreenSize } from "@/hooks/use-screen-size";
|
||||
import { StoreProvider } from "@/contexts/StoreContext";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import WeatherWidget from "./WeatherWidget";
|
||||
import DateWidget from "./DateWidget";
|
||||
import type { Group } from "@shared/schema";
|
||||
@@ -18,6 +19,23 @@ interface LayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Tableau vide stable : évite de recréer la valeur du contexte magasin à chaque
|
||||
// rendu tant que /api/groups n'a pas répondu
|
||||
const EMPTY_STORES: Group[] = [];
|
||||
|
||||
// Affiché dans la zone de contenu pendant le chargement du code d'une page
|
||||
// (le menu et l'en-tête restent visibles)
|
||||
function PageFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const { user } = useAuthUnified();
|
||||
const [location] = useLocation();
|
||||
@@ -45,11 +63,35 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const { data: stores = [] } = useQuery<Group[]>({
|
||||
const { data: stores = EMPTY_STORES } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Changement de magasin demandé dans le sélecteur : les données dépendantes du
|
||||
// magasin sont invalidées après le rendu (effet ci-dessous) et non dans le
|
||||
// gestionnaire. À ce moment les pages ont déjà basculé sur les clés du nouveau
|
||||
// magasin : les requêtes de l'ancien magasin ne sont plus actives et ne sont
|
||||
// donc plus rechargées pour rien, et cancelRefetch: false réutilise les
|
||||
// requêtes du nouveau magasin déjà en cours. L'invalidation est conservée pour
|
||||
// les requêtes dont la clé ne contient pas le magasin (ex. ['/api/orders'] des
|
||||
// modales de livraison).
|
||||
const storeChangePendingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storeChangePendingRef.current) return;
|
||||
storeChangePendingRef.current = false;
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return Boolean(key[0]?.toString().includes('/api/orders') ||
|
||||
key[0]?.toString().includes('/api/deliveries') ||
|
||||
key[0]?.toString().includes('/api/stats/monthly') ||
|
||||
key[0]?.toString().includes('/api/tasks'));
|
||||
}
|
||||
}, { cancelRefetch: false });
|
||||
}, [selectedStoreId, queryClient]);
|
||||
|
||||
// Effet pour marquer l'initialisation comme terminée
|
||||
useEffect(() => {
|
||||
if (user && stores.length > 0) {
|
||||
@@ -82,8 +124,21 @@ export default function Layout({ children }: LayoutProps) {
|
||||
window.location.href = "/api/logout";
|
||||
};
|
||||
|
||||
// Valeur du contexte mémorisée : les consommateurs de useStore() ne sont
|
||||
// re-rendus que lorsqu'une de ces valeurs change réellement
|
||||
const storeContextValue = useMemo(() => ({
|
||||
selectedStoreId,
|
||||
setSelectedStoreId,
|
||||
stores,
|
||||
sidebarCollapsed,
|
||||
setSidebarCollapsed,
|
||||
mobileMenuOpen,
|
||||
setMobileMenuOpen,
|
||||
storeInitialized
|
||||
}), [selectedStoreId, stores, sidebarCollapsed, mobileMenuOpen, storeInitialized]);
|
||||
|
||||
return (
|
||||
<StoreProvider value={{ selectedStoreId, setSelectedStoreId, stores, sidebarCollapsed, setSidebarCollapsed, mobileMenuOpen, setMobileMenuOpen, storeInitialized }}>
|
||||
<StoreProvider value={storeContextValue}>
|
||||
<div className="layout-container flex bg-gray-50">
|
||||
{/* Mobile overlay for normal mode */}
|
||||
{isMobile && mobileMenuOpen && (
|
||||
@@ -135,15 +190,10 @@ export default function Layout({ children }: LayoutProps) {
|
||||
value={selectedStoreId?.toString() || (user.role === 'admin' ? "all" : "")}
|
||||
onValueChange={(value) => {
|
||||
const newStoreId = value === "all" ? null : parseInt(value);
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return Boolean(key[0]?.toString().includes('/api/orders') ||
|
||||
key[0]?.toString().includes('/api/deliveries') ||
|
||||
key[0]?.toString().includes('/api/stats/monthly') ||
|
||||
key[0]?.toString().includes('/api/tasks'));
|
||||
}
|
||||
});
|
||||
// Invalidation des données du magasin après le rendu (voir plus haut)
|
||||
if (newStoreId !== selectedStoreId) {
|
||||
storeChangePendingRef.current = true;
|
||||
}
|
||||
|
||||
// Sauvegarder dans localStorage et mettre à jour l'état
|
||||
if (newStoreId) {
|
||||
@@ -210,7 +260,13 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
<div className={`flex-1 bg-gray-50 h-full overflow-y-auto overflow-x-hidden ${isMobileOrTablet ? 'p-3' : 'p-6'
|
||||
}`} style={{ maxWidth: '100%' }}>
|
||||
{children}
|
||||
{/* Une erreur dans une page (ou l'échec du chargement de son fichier JS)
|
||||
n'efface plus le menu ; réinitialisée à chaque changement d'URL */}
|
||||
<ErrorBoundary key={location}>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -40,13 +40,36 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [editComment, setEditComment] = useState("");
|
||||
|
||||
const commentsQueryKey = [`/api/deliveries/${deliveryId}/reconciliation-comments`];
|
||||
|
||||
// Récupérer les commentaires
|
||||
const { data: comments = [], isLoading } = useQuery({
|
||||
queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`],
|
||||
queryKey: commentsQueryKey,
|
||||
queryFn: () => apiRequest(`/api/deliveries/${deliveryId}/reconciliation-comments`),
|
||||
enabled: !!deliveryId,
|
||||
});
|
||||
|
||||
// Compteur de commentaires affiché dans le rapprochement : mis à jour
|
||||
// localement dans la liste en cache (même clé que BLReconciliation) plutôt
|
||||
// que de retélécharger toute la liste des livraisons pour un compteur.
|
||||
// Les listes qui ne contiennent pas la livraison ne sont pas touchées
|
||||
// (undefined : aucune écriture, leur état « périmé » éventuel est conservé).
|
||||
const adjustCommentsCount = (delta: number) => {
|
||||
queryClient.setQueriesData({ queryKey: ['/api/deliveries', 'reconciliation'] }, (old: unknown) => {
|
||||
if (!Array.isArray(old) || !old.some((delivery: any) => delivery?.id === deliveryId)) {
|
||||
return undefined;
|
||||
}
|
||||
return old.map((delivery: any) =>
|
||||
delivery?.id === deliveryId
|
||||
? {
|
||||
...delivery,
|
||||
reconciliationCommentsCount: Math.max(0, (Number(delivery.reconciliationCommentsCount) || 0) + delta),
|
||||
}
|
||||
: delivery
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Créer un commentaire
|
||||
const createCommentMutation = useMutation({
|
||||
mutationFn: async (content: string) => {
|
||||
@@ -57,10 +80,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
|
||||
title: "Succès",
|
||||
description: "Commentaire ajouté avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
|
||||
// Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
queryClient.invalidateQueries({ queryKey: commentsQueryKey });
|
||||
adjustCommentsCount(1);
|
||||
setIsAdding(false);
|
||||
setNewComment("");
|
||||
},
|
||||
@@ -83,10 +104,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
|
||||
title: "Succès",
|
||||
description: "Commentaire modifié avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
|
||||
// Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
// Le nombre de commentaires ne change pas : la liste des livraisons reste valable
|
||||
queryClient.invalidateQueries({ queryKey: commentsQueryKey });
|
||||
setEditingId(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
@@ -108,10 +127,8 @@ export default function ReconciliationComments({ deliveryId, className = "" }: R
|
||||
title: "Succès",
|
||||
description: "Commentaire supprimé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
|
||||
// Invalider aussi les queries des livraisons pour mettre à jour le compteur de commentaires
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
queryClient.invalidateQueries({ queryKey: commentsQueryKey });
|
||||
adjustCommentsCount(-1);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
|
||||
@@ -1,47 +1,48 @@
|
||||
import { useEffect } from "react";
|
||||
import { Switch, Route, useLocation } from "wouter";
|
||||
import { lazy, useEffect } from "react";
|
||||
import { Switch, Route, Redirect, useLocation } from "wouter";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useScreenSize } from "@/hooks/use-screen-size";
|
||||
import NotFound from "@/pages/not-found";
|
||||
import AuthPage from "@/pages/AuthPage";
|
||||
import Dashboard from "@/pages/Dashboard";
|
||||
import Calendar from "@/pages/Calendar";
|
||||
import Orders from "@/pages/Orders";
|
||||
import Deliveries from "@/pages/Deliveries";
|
||||
import Suppliers from "@/pages/Suppliers";
|
||||
import Groups from "@/pages/Groups";
|
||||
import Users from "@/pages/Users";
|
||||
import BLReconciliation from "@/pages/BLReconciliation";
|
||||
import Publicities from "@/pages/Publicities";
|
||||
|
||||
import NocoDBConfig from "@/pages/NocoDBConfig";
|
||||
import DatabaseDebug from "@/pages/DatabaseDebug";
|
||||
import CustomerOrders from "@/pages/CustomerOrders";
|
||||
|
||||
import DlcPage from "@/pages/DlcPage";
|
||||
import BackupManager from "@/pages/BackupManager";
|
||||
import Utilities from "@/pages/Utilities";
|
||||
import Tasks from "@/pages/Tasks";
|
||||
import SavTickets from "@/pages/SavTickets";
|
||||
import Avoirs from "@/pages/Avoirs";
|
||||
import WeatherSettings from "@/pages/WeatherSettings";
|
||||
import Analytics from "@/pages/Analytics";
|
||||
import PaymentSchedulePage from "@/pages/PaymentSchedulePage";
|
||||
import Contacts from "@/pages/Contacts";
|
||||
import Layout from "@/components/Layout";
|
||||
|
||||
// Mobile pages
|
||||
// Mobile : coque et page d'accueil chargées immédiatement
|
||||
import MobileApp from "@/pages/mobile/MobileApp";
|
||||
import MobileDashboardPage from "@/pages/mobile/DashboardPage";
|
||||
import MobileOrdersPage from "@/pages/mobile/OrdersPage";
|
||||
import MobileDeliveriesPage from "@/pages/mobile/DeliveriesPage";
|
||||
import MobileCalendarPage from "@/pages/mobile/CalendarPage";
|
||||
import MobileTasksPage from "@/pages/mobile/TasksPage";
|
||||
import MobilePublicitiesPage from "@/pages/mobile/PublicitiesPage";
|
||||
import MobileCustomerOrdersPage from "@/pages/mobile/CustomerOrdersPage";
|
||||
import MobileDlcPage from "@/pages/mobile/DlcPage";
|
||||
import MobileSavPage from "@/pages/mobile/SavPage";
|
||||
import MobileAvoirsPage from "@/pages/mobile/AvoirsPage";
|
||||
|
||||
// Les autres pages sont chargées à la demande (un fichier JS par page) : l'écran
|
||||
// de connexion et le tableau de bord n'attendent plus le code de toutes les pages
|
||||
// ni la bibliothèque de graphiques. Le <Suspense> est placé dans la zone de
|
||||
// contenu de Layout et de MobileApp, le menu et l'en-tête restent affichés.
|
||||
const Calendar = lazy(() => import("@/pages/Calendar"));
|
||||
const Orders = lazy(() => import("@/pages/Orders"));
|
||||
const Deliveries = lazy(() => import("@/pages/Deliveries"));
|
||||
const Suppliers = lazy(() => import("@/pages/Suppliers"));
|
||||
const Groups = lazy(() => import("@/pages/Groups"));
|
||||
const Users = lazy(() => import("@/pages/Users"));
|
||||
const BLReconciliation = lazy(() => import("@/pages/BLReconciliation"));
|
||||
const Publicities = lazy(() => import("@/pages/Publicities"));
|
||||
const CustomerOrders = lazy(() => import("@/pages/CustomerOrders"));
|
||||
const DlcPage = lazy(() => import("@/pages/DlcPage"));
|
||||
const Utilities = lazy(() => import("@/pages/Utilities"));
|
||||
const Tasks = lazy(() => import("@/pages/Tasks"));
|
||||
const SavTickets = lazy(() => import("@/pages/SavTickets"));
|
||||
const Avoirs = lazy(() => import("@/pages/Avoirs"));
|
||||
const Analytics = lazy(() => import("@/pages/Analytics"));
|
||||
const PaymentSchedulePage = lazy(() => import("@/pages/PaymentSchedulePage"));
|
||||
const Contacts = lazy(() => import("@/pages/Contacts"));
|
||||
|
||||
// Pages mobiles chargées à la demande
|
||||
const MobileOrdersPage = lazy(() => import("@/pages/mobile/OrdersPage"));
|
||||
const MobileDeliveriesPage = lazy(() => import("@/pages/mobile/DeliveriesPage"));
|
||||
const MobileCalendarPage = lazy(() => import("@/pages/mobile/CalendarPage"));
|
||||
const MobileTasksPage = lazy(() => import("@/pages/mobile/TasksPage"));
|
||||
const MobilePublicitiesPage = lazy(() => import("@/pages/mobile/PublicitiesPage"));
|
||||
const MobileCustomerOrdersPage = lazy(() => import("@/pages/mobile/CustomerOrdersPage"));
|
||||
const MobileDlcPage = lazy(() => import("@/pages/mobile/DlcPage"));
|
||||
const MobileSavPage = lazy(() => import("@/pages/mobile/SavPage"));
|
||||
const MobileAvoirsPage = lazy(() => import("@/pages/mobile/AvoirsPage"));
|
||||
|
||||
// Helper component to handle redirection
|
||||
const RedirectToAuth = () => {
|
||||
@@ -53,28 +54,9 @@ const RedirectToAuth = () => {
|
||||
};
|
||||
|
||||
function RouterProduction() {
|
||||
const { isAuthenticated, isLoading, user, environment, error } = useAuthUnified();
|
||||
const { isAuthenticated, isLoading, user } = useAuthUnified();
|
||||
const { isMobile } = useScreenSize();
|
||||
|
||||
// Debug uniquement en développement
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔍 RouterProduction Debug:', {
|
||||
environment,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
hasUser: !!user,
|
||||
userId: user?.id,
|
||||
username: user?.username,
|
||||
isMobile,
|
||||
error: error?.message
|
||||
});
|
||||
}
|
||||
|
||||
// Debug minimal basé sur l'environnement
|
||||
if (environment === 'production' && error && import.meta.env.DEV) {
|
||||
console.error('🚨 Production Auth Error:', error);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -89,9 +71,6 @@ function RouterProduction() {
|
||||
|
||||
// Not authenticated
|
||||
if (!isAuthenticated || !user) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔐 Not authenticated, showing auth routes');
|
||||
}
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/auth" component={AuthPage} />
|
||||
@@ -128,14 +107,9 @@ function RouterProduction() {
|
||||
<Route path="/analytics" component={Analytics} />
|
||||
<Route path="/payment-schedule" component={PaymentSchedulePage} />
|
||||
|
||||
{/* Redirection /auth vers dashboard */}
|
||||
{/* Redirection /auth vers dashboard (navigation interne, sans rechargement) */}
|
||||
<Route path="/auth">
|
||||
{() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
return <MobileDashboardPage />;
|
||||
}}
|
||||
<Redirect to="/dashboard" replace />
|
||||
</Route>
|
||||
|
||||
<Route path="/" component={MobileDashboardPage} />
|
||||
@@ -175,17 +149,10 @@ function RouterProduction() {
|
||||
<Route path="/database-debug" component={Utilities} />
|
||||
<Route path="/weather-settings" component={Utilities} />
|
||||
|
||||
{/* Redirection depuis /auth vers dashboard après authentification */}
|
||||
{/* Redirection depuis /auth vers dashboard après authentification
|
||||
(navigation interne, sans rechargement complet de l'application) */}
|
||||
<Route path="/auth">
|
||||
{() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔄 Authenticated user on /auth, redirecting to dashboard');
|
||||
}
|
||||
window.location.href = '/';
|
||||
}
|
||||
return <Dashboard />;
|
||||
}}
|
||||
<Redirect to="/" replace />
|
||||
</Route>
|
||||
|
||||
<Route path="/" component={Dashboard} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { useAuthSimple } from "@/hooks/useAuthSimple";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useStore } from "@/contexts/StoreContext";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
export default function Sidebar() {
|
||||
const { user, isLoading, error } = useAuthSimple();
|
||||
const { user, isLoading, error } = useAuthUnified();
|
||||
const [location] = useLocation();
|
||||
const { sidebarCollapsed, setSidebarCollapsed, mobileMenuOpen, setMobileMenuOpen } = useStore();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -218,6 +218,8 @@ export default function Sidebar() {
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === "/" && location === "/") return true;
|
||||
// L'accueil « / » affiche le tableau de bord (même logique que le menu mobile)
|
||||
if (path === "/dashboard" && (location === "/" || location === "/dashboard")) return true;
|
||||
if (path !== "/" && location.startsWith(path)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { TrendingUp, Package, Truck, Clock } from "lucide-react";
|
||||
import { useStore } from "@/contexts/StoreContext";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
|
||||
interface StatsPanelProps {
|
||||
currentDate?: Date;
|
||||
@@ -10,14 +9,13 @@ interface StatsPanelProps {
|
||||
|
||||
export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps) {
|
||||
const { selectedStoreId } = useStore();
|
||||
const { user } = useAuthUnified();
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth() + 1;
|
||||
|
||||
// Construire l'URL avec les paramètres
|
||||
const statsUrl = `/api/stats/monthly?year=${year}&month=${month}${selectedStoreId ? `&storeId=${selectedStoreId}` : ''}`;
|
||||
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
const { data: stats, isLoading, isPlaceholderData } = useQuery({
|
||||
queryKey: [statsUrl, selectedStoreId], // Include selectedStoreId in key to force refetch
|
||||
queryFn: async () => {
|
||||
const response = await fetch(statsUrl, {
|
||||
@@ -30,6 +28,10 @@ export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps
|
||||
|
||||
return response.json();
|
||||
},
|
||||
// Changement de mois : garder les chiffres affichés pendant le chargement au lieu
|
||||
// du squelette. Jamais au changement de magasin (chiffres d'un autre magasin).
|
||||
placeholderData: (previousData, previousQuery) =>
|
||||
previousQuery?.queryKey[1] === selectedStoreId ? keepPreviousData(previousData) : undefined,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -58,7 +60,8 @@ export default function StatsPanel({ currentDate = new Date() }: StatsPanelProps
|
||||
Statistiques du mois
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Chiffres du mois précédent estompés pendant le chargement du nouveau mois */}
|
||||
<CardContent className={isPlaceholderData ? "opacity-50 transition-opacity" : "transition-opacity"}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary flex items-center justify-center">
|
||||
|
||||
@@ -121,7 +121,6 @@ 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,
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,6 +15,9 @@ import { format } from "date-fns";
|
||||
import { safeFormat } from "@/lib/dateUtils";
|
||||
import type { Group, Supplier, OrderWithRelations } from "@shared/schema";
|
||||
|
||||
// Référence stable tant que les magasins ne sont pas chargés
|
||||
const NO_GROUPS: Group[] = [];
|
||||
|
||||
interface CreateDeliveryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -45,20 +48,29 @@ export default function CreateDeliveryModal({
|
||||
queryKey: ['/api/suppliers'],
|
||||
});
|
||||
|
||||
const { data: groupsData = [] } = useQuery<Group[]>({
|
||||
const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
});
|
||||
|
||||
// Filtrer les groupes selon le magasin sélectionné pour les admins
|
||||
const groups = Array.isArray(groupsData) ? (
|
||||
// (mémorisé : la liste est une dépendance de l'effet ci-dessous, qui
|
||||
// tournait sinon à chaque rendu, donc à chaque frappe dans le formulaire)
|
||||
const groups = useMemo(() => Array.isArray(groupsData) ? (
|
||||
user?.role === 'admin' && selectedStoreId
|
||||
? groupsData.filter(g => g.id === selectedStoreId)
|
||||
: groupsData
|
||||
) : [];
|
||||
) : NO_GROUPS, [groupsData, user?.role, selectedStoreId]);
|
||||
|
||||
// Filtrer les commandes par fournisseur sélectionné
|
||||
// Commandes du magasin du formulaire uniquement (paramètre storeId, dont
|
||||
// l'accès est contrôlé par le serveur) au lieu de tout l'historique de tous
|
||||
// les magasins ; filtrées ensuite par fournisseur
|
||||
const { data: allOrders = [] } = useQuery<OrderWithRelations[]>({
|
||||
queryKey: ['/api/orders'],
|
||||
queryKey: ['/api/orders', 'store', formData.groupId],
|
||||
queryFn: async () => {
|
||||
const data = await apiRequest(`/api/orders?storeId=${formData.groupId}`);
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
enabled: !!formData.groupId,
|
||||
});
|
||||
|
||||
// Filtrer les commandes par fournisseur ET magasin sélectionnés - montrer toutes les commandes non livrées
|
||||
@@ -74,21 +86,8 @@ export default function CreateDeliveryModal({
|
||||
|
||||
// Auto-sélectionner le magasin selon les règles
|
||||
useEffect(() => {
|
||||
console.log('🏪 CreateDeliveryModal - Store selection effect:', {
|
||||
groupsLength: groups.length,
|
||||
currentFormGroupId: formData.groupId,
|
||||
selectedStoreId,
|
||||
userRole: user?.role,
|
||||
allGroups: groups.map(g => ({ id: g.id, name: g.name })),
|
||||
filteredGroups: groups.length
|
||||
});
|
||||
|
||||
// Reset le formulaire si le magasin sélectionné change
|
||||
if (user?.role === 'admin' && selectedStoreId && formData.groupId && formData.groupId !== selectedStoreId.toString()) {
|
||||
console.log('🔄 Resetting delivery form because store changed:', {
|
||||
currentGroupId: formData.groupId,
|
||||
newStoreId: selectedStoreId.toString()
|
||||
});
|
||||
setFormData(prev => ({ ...prev, groupId: "", orderId: "" }));
|
||||
return;
|
||||
}
|
||||
@@ -103,21 +102,13 @@ export default function CreateDeliveryModal({
|
||||
} else {
|
||||
defaultGroupId = groups[0].id.toString();
|
||||
}
|
||||
console.log('🏪 Admin delivery store selection:', {
|
||||
selectedStoreId,
|
||||
defaultGroupId,
|
||||
firstGroupId: groups[0].id,
|
||||
groupsAvailable: groups.map(g => g.name)
|
||||
});
|
||||
} else {
|
||||
// Pour les autres rôles : prendre le premier magasin attribué
|
||||
// (La logique existante filtre déjà les groupes selon les permissions)
|
||||
defaultGroupId = groups[0].id.toString();
|
||||
console.log('🏪 Non-admin delivery store selection:', { defaultGroupId, firstGroupId: groups[0].id });
|
||||
}
|
||||
|
||||
if (defaultGroupId) {
|
||||
console.log('🏪 Setting default group ID for delivery:', defaultGroupId, 'for group:', groups.find(g => g.id.toString() === defaultGroupId)?.name);
|
||||
setFormData(prev => ({ ...prev, groupId: defaultGroupId }));
|
||||
}
|
||||
}
|
||||
@@ -148,7 +139,6 @@ export default function CreateDeliveryModal({
|
||||
});
|
||||
|
||||
// Invalider toutes les variantes de queryKey pour assurer cohérence
|
||||
console.log('🚚 Delivery created, clearing cache for consistency');
|
||||
queryClient.invalidateQueries({ predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
const firstKey = key[0]?.toString() || '';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -14,6 +14,9 @@ import { isUnauthorizedError } from "@/lib/authUtils";
|
||||
import { format } from "date-fns";
|
||||
import type { Group, Supplier } from "@shared/schema";
|
||||
|
||||
// Référence stable tant que les magasins ne sont pas chargés
|
||||
const NO_GROUPS: Group[] = [];
|
||||
|
||||
interface CreateOrderModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -41,34 +44,23 @@ export default function CreateOrderModal({
|
||||
queryKey: ['/api/suppliers'],
|
||||
});
|
||||
|
||||
const { data: groupsData = [] } = useQuery<Group[]>({
|
||||
const { data: groupsData = NO_GROUPS } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
});
|
||||
|
||||
// Filtrer les groupes selon le magasin sélectionné pour les admins
|
||||
const groups = Array.isArray(groupsData) ? (
|
||||
// (mémorisé : la liste est une dépendance de l'effet ci-dessous, qui
|
||||
// tournait sinon à chaque rendu, donc à chaque frappe dans le formulaire)
|
||||
const groups = useMemo(() => Array.isArray(groupsData) ? (
|
||||
user?.role === 'admin' && selectedStoreId
|
||||
? groupsData.filter(g => g.id === selectedStoreId)
|
||||
: groupsData
|
||||
) : [];
|
||||
) : NO_GROUPS, [groupsData, user?.role, selectedStoreId]);
|
||||
|
||||
// Auto-sélectionner le magasin selon les règles
|
||||
useEffect(() => {
|
||||
console.log('🏪 CreateOrderModal - Store selection effect:', {
|
||||
groupsLength: groups.length,
|
||||
currentFormGroupId: formData.groupId,
|
||||
selectedStoreId,
|
||||
userRole: user?.role,
|
||||
allGroups: groups.map(g => ({ id: g.id, name: g.name })),
|
||||
filteredGroups: groups.length
|
||||
});
|
||||
|
||||
// Reset le formulaire si le magasin sélectionné change
|
||||
if (user?.role === 'admin' && selectedStoreId && formData.groupId && formData.groupId !== selectedStoreId.toString()) {
|
||||
console.log('🔄 Resetting form because store changed:', {
|
||||
currentGroupId: formData.groupId,
|
||||
newStoreId: selectedStoreId.toString()
|
||||
});
|
||||
setFormData(prev => ({ ...prev, groupId: "" }));
|
||||
return;
|
||||
}
|
||||
@@ -83,20 +75,12 @@ export default function CreateOrderModal({
|
||||
} else {
|
||||
defaultGroupId = groups[0].id.toString();
|
||||
}
|
||||
console.log('🏪 Admin store selection:', {
|
||||
selectedStoreId,
|
||||
defaultGroupId,
|
||||
firstGroupId: groups[0].id,
|
||||
groupsAvailable: groups.map(g => g.name)
|
||||
});
|
||||
} else {
|
||||
// Pour les autres rôles : prendre le premier magasin attribué
|
||||
defaultGroupId = groups[0].id.toString();
|
||||
console.log('🏪 Non-admin store selection:', { defaultGroupId, firstGroupId: groups[0].id });
|
||||
}
|
||||
|
||||
if (defaultGroupId) {
|
||||
console.log('🏪 Setting default group ID:', defaultGroupId, 'for group:', groups.find(g => g.id.toString() === defaultGroupId)?.name);
|
||||
setFormData(prev => ({ ...prev, groupId: defaultGroupId }));
|
||||
}
|
||||
}
|
||||
@@ -104,9 +88,7 @@ export default function CreateOrderModal({
|
||||
|
||||
const createOrderMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
console.log('🚀 Creating order with data:', data);
|
||||
const response = await apiRequest("/api/orders", "POST", data);
|
||||
console.log('✅ Order created successfully:', response);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -114,8 +96,6 @@ export default function CreateOrderModal({
|
||||
title: "Succès",
|
||||
description: "Commande créée avec succès",
|
||||
});
|
||||
// Force un nettoyage complet du cache pour éviter incohérences
|
||||
console.log('🆕 Order created, clearing cache for consistency');
|
||||
|
||||
// Invalider toutes les variantes de queryKey pour assurer cohérence
|
||||
queryClient.invalidateQueries({ predicate: (query) => {
|
||||
|
||||
@@ -47,8 +47,16 @@ export default function EditDeliveryModal({
|
||||
queryKey: ['/api/groups'],
|
||||
});
|
||||
|
||||
// Commandes du magasin du formulaire uniquement (paramètre storeId, dont
|
||||
// l'accès est contrôlé par le serveur) au lieu de tout l'historique de tous
|
||||
// les magasins ; même clé que la modale de création
|
||||
const { data: allOrders = [] } = useQuery<OrderWithRelations[]>({
|
||||
queryKey: ['/api/orders'],
|
||||
queryKey: ['/api/orders', 'store', formData.groupId],
|
||||
queryFn: async () => {
|
||||
const data = await apiRequest(`/api/orders?storeId=${formData.groupId}`);
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
enabled: !!formData.groupId,
|
||||
});
|
||||
|
||||
// Filtrer les commandes par fournisseur sélectionné et groupe - montrer toutes les commandes non livrées du même groupe
|
||||
|
||||
@@ -54,10 +54,10 @@ export default function ValidateDeliveryModal({
|
||||
title: "Succès",
|
||||
description: "Livraison validée avec succès",
|
||||
});
|
||||
// Invalider tous les caches liés aux livraisons
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
// Invalider tous les caches BL/Rapprochement avec toutes les variations de clés
|
||||
queryClient.invalidateQueries({
|
||||
// Invalider tous les caches liés aux livraisons et les caches BL/Rapprochement
|
||||
// avec toutes les variations de clés, en un seul appel (un second appel sur
|
||||
// ['/api/deliveries'] relançait le chargement des listes affichées)
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === '/api/deliveries/bl' ||
|
||||
query.queryKey[0] === '/api/deliveries'
|
||||
|
||||
@@ -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)`)
|
||||
|
||||
@@ -10,30 +10,28 @@ export interface ScreenBreakpoints {
|
||||
|
||||
const defaultBreakpoints: ScreenBreakpoints = {
|
||||
mobile: 768,
|
||||
tablet: 1024,
|
||||
tablet: 1024,
|
||||
desktop: 1280
|
||||
}
|
||||
|
||||
const getScreenSize = (width: number, breakpoints: ScreenBreakpoints): ScreenSize => {
|
||||
if (width < breakpoints.mobile) return 'mobile'
|
||||
if (width <= breakpoints.tablet) return 'tablet' // Inclut 1024px comme tablette
|
||||
return 'desktop'
|
||||
}
|
||||
|
||||
export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoints) {
|
||||
const [screenSize, setScreenSize] = useState<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
|
||||
@@ -41,18 +39,25 @@ export function useScreenSize(breakpoints: ScreenBreakpoints = defaultBreakpoint
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener('resize', updateScreenSize)
|
||||
|
||||
|
||||
// Cleanup
|
||||
return () => window.removeEventListener('resize', updateScreenSize)
|
||||
}, [breakpoints])
|
||||
|
||||
// Dimensions lues au moment du rendu (non stockées dans l'état pour ne pas
|
||||
// re-rendre à chaque redimensionnement)
|
||||
const dimensions = {
|
||||
width: typeof window !== 'undefined' ? window.innerWidth : 1200,
|
||||
height: typeof window !== 'undefined' ? window.innerHeight : 800
|
||||
}
|
||||
|
||||
return {
|
||||
screenSize,
|
||||
dimensions,
|
||||
isMobile: screenSize === 'mobile',
|
||||
isTablet: screenSize === 'tablet',
|
||||
isTablet: screenSize === 'tablet',
|
||||
isDesktop: screenSize === 'desktop',
|
||||
isMobileOrTablet: screenSize === 'mobile' || screenSize === 'tablet',
|
||||
isTabletOrDesktop: screenSize === 'tablet' || screenSize === 'desktop'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 />);
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
+268
-208
@@ -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
|
||||
});
|
||||
|
||||
if (!avoirs || avoirs.length === 0) return;
|
||||
|
||||
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 avoirsToVerify = avoirs.filter(avoir =>
|
||||
avoir.invoiceReference?.trim() &&
|
||||
!avoir.nocodbVerified &&
|
||||
verifiedReferencesRef.current.get(avoir.id) !== avoir.invoiceReference
|
||||
);
|
||||
if (avoirsToVerify.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> = {};
|
||||
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,35 +552,23 @@ 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é",
|
||||
description: "L'avoir a été marqué comme validé avec succès",
|
||||
@@ -553,12 +595,13 @@ export default function Avoirs() {
|
||||
delete updated[avoirId]; // Supprimer complètement l'entrée
|
||||
return updated;
|
||||
});
|
||||
|
||||
console.log('✅ Avoir dévalidé - État local mis à jour:', avoirId);
|
||||
// L'avoir sera revérifié au rechargement de la liste
|
||||
verifiedReferencesRef.current.delete(avoirId);
|
||||
|
||||
// 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();
|
||||
|
||||
// Recharger les avoirs pour voir les changements en base
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/avoirs'] });
|
||||
|
||||
toast({
|
||||
title: "Avoir dévalidé",
|
||||
description: "L'avoir a été marqué comme non-validé avec succès",
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) => {
|
||||
@@ -147,45 +225,20 @@ 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 => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(variables.deliveryId);
|
||||
@@ -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,22 @@ 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) => {
|
||||
return delivery.reconciled === true || delivery.reconciled === 1;
|
||||
});
|
||||
// Fournisseurs en rapprochement automatique : toujours considérés validés,
|
||||
// même si reconciled n'a pas été posé (livraisons antérieures au passage en mode auto)
|
||||
const allValidatedDeliveries = useMemo(() => deliveriesWithBL.filter((delivery: any) => {
|
||||
const isAutomatic = delivery.supplier?.automaticReconciliation === true;
|
||||
return isAutomatic || delivery.reconciled === true || delivery.reconciled === 1;
|
||||
}), [deliveriesWithBL]);
|
||||
|
||||
// Fonctions de gestion
|
||||
const handleOpenModal = (delivery: any) => {
|
||||
@@ -477,9 +517,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 +561,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 +599,6 @@ export default function BLReconciliation() {
|
||||
setShowInvoiceModal(false);
|
||||
setShowWaitingModal(true);
|
||||
setIsUploading(true);
|
||||
startProcessingTimer();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -609,19 +633,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);
|
||||
}
|
||||
@@ -657,29 +675,14 @@ 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 +748,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 +757,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 +795,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 +829,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 +847,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 +1413,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 +1674,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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()));
|
||||
|
||||
// Supplier filter
|
||||
const matchesSupplier = filterSupplier === "all" ||
|
||||
(order.supplier && order.supplier.id.toString() === filterSupplier);
|
||||
|
||||
// Status filter
|
||||
const matchesStatus = filterStatus === "all" || order.status === filterStatus;
|
||||
|
||||
return matchesSearch && matchesSupplier && matchesStatus;
|
||||
}) : [];
|
||||
// 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;
|
||||
|
||||
// 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é");
|
||||
|
||||
// Fonction de tri commune
|
||||
const sortFunction = (a: any, b: any) => {
|
||||
let aValue: string | number;
|
||||
let bValue: string | number;
|
||||
const search = searchTerm.toLowerCase();
|
||||
|
||||
// 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));
|
||||
|
||||
// Supplier filter
|
||||
const matchesSupplier = filterSupplier === "all" ||
|
||||
(order.supplier && order.supplier.id.toString() === filterSupplier);
|
||||
|
||||
// Status filter
|
||||
const matchesStatus = filterStatus === "all" || order.status === filterStatus;
|
||||
|
||||
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);
|
||||
|
||||
// Concaténer : commandes actives d'abord, puis retiré en dernier
|
||||
return [...sortedActiveOrders, ...sortedRetiredOrders];
|
||||
})();
|
||||
|
||||
// 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);
|
||||
|
||||
// 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
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
const matchesStatus = statusFilter === "all" || delivery.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
}) : [];
|
||||
// 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;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [deliveries, searchTerm, statusFilter]);
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function NocoDBConfig() {
|
||||
const [showTokens, setShowTokens] = useState<{ [key: number]: boolean }>({});
|
||||
|
||||
// Queries avec protection complète
|
||||
const { data: rawConfigs, isLoading, error } = useQuery({
|
||||
const { data: rawConfigs, isLoading } = useQuery({
|
||||
queryKey: ['/api/nocodb-config'],
|
||||
enabled: user?.role === 'admin',
|
||||
});
|
||||
@@ -61,21 +61,6 @@ export default function NocoDBConfig() {
|
||||
// Protection quadruple couche pour éviter les erreurs TypeError
|
||||
const configs = rawConfigs || [];
|
||||
const safeConfigs = Array.isArray(configs) ? configs : [];
|
||||
|
||||
// Log pour debug production avec plus de détails
|
||||
console.log('🔍 NocoDBConfig Debug:', {
|
||||
rawConfigs,
|
||||
rawConfigsType: typeof rawConfigs,
|
||||
configs,
|
||||
configsType: typeof configs,
|
||||
isArray: Array.isArray(configs),
|
||||
safeConfigs,
|
||||
safeConfigsType: typeof safeConfigs,
|
||||
length: safeConfigs.length,
|
||||
error,
|
||||
userRole: user?.role,
|
||||
environment: window.location.hostname
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const createConfigMutation = useMutation({
|
||||
|
||||
+22
-53
@@ -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());
|
||||
|
||||
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
}) : [];
|
||||
// Filtrage recalculé seulement quand la liste, la recherche ou le statut changent
|
||||
const filteredOrders = useMemo(() => {
|
||||
const search = searchTerm.toLowerCase();
|
||||
return orders.filter(order => {
|
||||
const matchesSearch = order.supplier?.name.toLowerCase().includes(search) ||
|
||||
order.group?.name.toLowerCase().includes(search) ||
|
||||
order.notes?.toLowerCase().includes(search);
|
||||
|
||||
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [orders, searchTerm, statusFilter]);
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
|
||||
@@ -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">
|
||||
|
||||
+457
-471
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
);
|
||||
|
||||
console.log('🎫 [CLIENT] Non-admin user groups:', {
|
||||
userRole: user.role,
|
||||
userUsername: user.username,
|
||||
allUserGroups: userGroups,
|
||||
filteredGroups: filteredGroups.map(g => ({ id: g.id, name: g.name }))
|
||||
});
|
||||
|
||||
return filteredGroups;
|
||||
};
|
||||
}, [user, groupsData]);
|
||||
|
||||
const availableGroups = getUserGroups();
|
||||
// Filter tickets by search term
|
||||
const filteredTickets = useMemo(() => {
|
||||
if (!searchTerm) return ticketsData;
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return ticketsData.filter(ticket => (
|
||||
ticket.ticketNumber.toLowerCase().includes(searchLower) ||
|
||||
ticket.clientName?.toLowerCase().includes(searchLower) ||
|
||||
ticket.problemDescription?.toLowerCase().includes(searchLower) ||
|
||||
ticket.productDesignation?.toLowerCase().includes(searchLower) ||
|
||||
ticket.supplier?.name.toLowerCase().includes(searchLower)
|
||||
));
|
||||
}, [ticketsData, searchTerm]);
|
||||
|
||||
// Handle form submission
|
||||
const handleCreateTicket = () => {
|
||||
@@ -327,17 +338,9 @@ export default function SavTickets() {
|
||||
// Auto-assign group based on user role and available groups
|
||||
let selectedGroupId = formData.groupId ? parseInt(formData.groupId) : null;
|
||||
|
||||
console.log('🎫 [CLIENT] User groups analysis:', {
|
||||
userRole: user?.role,
|
||||
formDataGroupId: formData.groupId,
|
||||
availableGroups: availableGroups.map(g => ({ id: g.id, name: g.name })),
|
||||
selectedGroupId
|
||||
});
|
||||
|
||||
if (!selectedGroupId && availableGroups.length > 0) {
|
||||
// Auto-assign first available group
|
||||
selectedGroupId = availableGroups[0].id;
|
||||
console.log('🎫 [CLIENT] Auto-assigned group:', selectedGroupId, availableGroups[0].name);
|
||||
}
|
||||
|
||||
const ticketData: InsertSavTicket = {
|
||||
@@ -355,14 +358,6 @@ export default function SavTickets() {
|
||||
clientPhone: formData.clientPhone || undefined,
|
||||
};
|
||||
|
||||
console.log('🎫 [CLIENT] Creating ticket with data:', {
|
||||
...ticketData,
|
||||
userInfo: {
|
||||
role: user?.role,
|
||||
username: user?.username,
|
||||
availableGroups: availableGroups.length
|
||||
}
|
||||
});
|
||||
createTicketMutation.mutate(ticketData);
|
||||
};
|
||||
|
||||
@@ -372,13 +367,6 @@ export default function SavTickets() {
|
||||
setShowDetailModal(true);
|
||||
};
|
||||
|
||||
// Query pour récupérer les détails complets du ticket avec historique
|
||||
const { data: ticketDetails } = useQuery({
|
||||
queryKey: [`/api/sav/tickets/${selectedTicket?.id}`],
|
||||
enabled: !!selectedTicket && showDetailModal,
|
||||
staleTime: 0, // Toujours récupérer les derniers commentaires
|
||||
});
|
||||
|
||||
// Function to check if ticket has recent comments (last 24h)
|
||||
const hasRecentComments = (ticket: SavTicketWithRelations) => {
|
||||
if (!ticket.history || ticket.history.length === 0) return false;
|
||||
@@ -441,19 +429,6 @@ export default function SavTickets() {
|
||||
);
|
||||
}
|
||||
|
||||
// Filter tickets by search term
|
||||
const filteredTickets = ticketsData.filter(ticket => {
|
||||
if (!searchTerm) return true;
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
ticket.ticketNumber.toLowerCase().includes(searchLower) ||
|
||||
ticket.clientName?.toLowerCase().includes(searchLower) ||
|
||||
ticket.problemDescription?.toLowerCase().includes(searchLower) ||
|
||||
ticket.productDesignation?.toLowerCase().includes(searchLower) ||
|
||||
ticket.supplier?.name.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
+214
-193
@@ -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,8 +115,11 @@ function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
|
||||
title: "Succès",
|
||||
description: task ? "Tâche modifiée avec succès" : "Tâche créée avec succès",
|
||||
});
|
||||
window.location.reload();
|
||||
|
||||
// Rechargement ciblé de la liste des tâches (plus de rechargement complet de
|
||||
// la page) : filtres, recherche et pagination sont conservés
|
||||
await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
onClose();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
toast({
|
||||
@@ -277,35 +278,11 @@ function TaskFormInline({ task, onClose, selectedStoreId, user }: any) {
|
||||
|
||||
export default function Tasks() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId, storeInitialized } = useStore();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Debug enablement condition for tasks query
|
||||
const isQueryEnabled = !!user && (user.role === 'admin' || (user.role === 'directeur' || user.role === 'manager' ? !!selectedStoreId : true));
|
||||
console.log('🔍 TASK QUERY ENABLEMENT DEBUG:', {
|
||||
hasUser: !!user,
|
||||
userRole: user?.role,
|
||||
selectedStoreId,
|
||||
isAdmin: user?.role === 'admin',
|
||||
isDirecteurOrManager: user?.role === 'directeur' || user?.role === 'manager',
|
||||
hasSelectedStore: !!selectedStoreId,
|
||||
finalEnabled: isQueryEnabled,
|
||||
storeInitialized,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Force refresh when selectedStoreId changes for directeur/manager
|
||||
useEffect(() => {
|
||||
if (user && (user.role === 'directeur' || user.role === 'manager') && selectedStoreId) {
|
||||
console.log('🔄 FORCE REFRESH: selectedStoreId changed for directeur/manager:', {
|
||||
userRole: user.role,
|
||||
selectedStoreId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
}
|
||||
}, [selectedStoreId, user, queryClient]);
|
||||
// Une seule des deux mises en page (tablette ou bureau) est montée
|
||||
const { isDesktop: isLargeScreen } = useScreenSize(TASKS_LAYOUT_BREAKPOINTS);
|
||||
|
||||
// États locaux
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -322,9 +299,15 @@ export default function Tasks() {
|
||||
const [selectedTask, setSelectedTask] = useState<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,39 +315,17 @@ export default function Tasks() {
|
||||
if (selectedStoreId) {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
console.log('📋 TASKS QUERY - Fetching with params:', {
|
||||
selectedStoreId,
|
||||
userRole: user?.role,
|
||||
storeInitialized,
|
||||
url: `/api/tasks?${params.toString()}`,
|
||||
willFilterByStore: !!selectedStoreId,
|
||||
enabled: !!user,
|
||||
timestamp: new Date().toISOString(),
|
||||
userGroups: user?.userGroups?.map((ug: any) => ug.groupId) || 'NONE'
|
||||
});
|
||||
|
||||
|
||||
const response = await fetch(`/api/tasks?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log('📋 TASKS QUERY - Response received:', {
|
||||
dataType: typeof data,
|
||||
isArray: Array.isArray(data),
|
||||
length: data?.length,
|
||||
firstTask: data?.[0] ? {
|
||||
id: data[0].id,
|
||||
title: data[0].title,
|
||||
hasStartDate: !!data[0].startDate,
|
||||
hasGroup: !!data[0].group
|
||||
} : null
|
||||
});
|
||||
|
||||
|
||||
// Valider et nettoyer les données reçues
|
||||
if (!Array.isArray(data)) {
|
||||
console.error('Tasks API returned non-array data:', data);
|
||||
@@ -372,38 +333,40 @@ export default function Tasks() {
|
||||
}
|
||||
|
||||
return data.filter(task => task && typeof task === 'object' && task.id);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: !!user, // Charger les données dès que l'utilisateur est connecté
|
||||
// Au changement de magasin, la liste précédente reste affichée (estompée et
|
||||
// non cliquable, avec un indicateur) au lieu d'un spinner plein écran
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// Fetch users for task assignment - seulement pour admin/manager/directeur
|
||||
const { data: users = [] } = useQuery({
|
||||
queryKey: ["/api/users"],
|
||||
queryFn: () => fetch('/api/users', {
|
||||
credentials: 'include'
|
||||
}).then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}),
|
||||
enabled: !!user && (user.role === 'admin' || user.role === 'manager' || user.role === 'directeur'),
|
||||
});
|
||||
|
||||
|
||||
|
||||
const handleEditTask = (task: TaskWithRelations) => {
|
||||
setSelectedTask(task);
|
||||
setShowEditModal(true);
|
||||
// Mise à jour optimiste de la liste affichée ; renvoie l'état précédent pour
|
||||
// pouvoir le restaurer si le serveur refuse l'opération
|
||||
const updateCachedTasks = async (update: (list: TaskWithRelations[]) => TaskWithRelations[]) => {
|
||||
const queryKey = tasksQueryKey;
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
const previousTasks = queryClient.getQueryData<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;
|
||||
}
|
||||
|
||||
// Filtre par priorité
|
||||
if (priorityFilter !== "all" && task.priority !== priorityFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtre par date d'échéance
|
||||
if (dueDateFilter !== "all" && task.dueDate) {
|
||||
const dueDate = new Date(task.dueDate);
|
||||
switch (dueDateFilter) {
|
||||
case "today":
|
||||
if (!isToday(dueDate)) return false;
|
||||
break;
|
||||
case "this_week":
|
||||
if (!isThisWeek(dueDate)) return false;
|
||||
break;
|
||||
case "overdue":
|
||||
if (!isPast(dueDate) || task.status === 'completed') return false;
|
||||
break;
|
||||
case "no_due_date":
|
||||
if (task.dueDate) return false;
|
||||
break;
|
||||
}
|
||||
} else if (dueDateFilter === "no_due_date" && task.dueDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.sort((a: TaskWithRelations, b: TaskWithRelations) => {
|
||||
// Faire remonter les tâches non validées (pending) en premier
|
||||
if (a.status === 'pending' && b.status === 'completed') return -1;
|
||||
if (a.status === 'completed' && b.status === 'pending') return 1;
|
||||
|
||||
// Pour les tâches de même statut, trier par priorité (high > medium > low)
|
||||
const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
|
||||
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 2;
|
||||
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 2;
|
||||
// Pour les tâches de même statut, trier par priorité (high > medium > low)
|
||||
const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
|
||||
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 2;
|
||||
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 2;
|
||||
|
||||
if (aPriority !== bPriority) {
|
||||
return bPriority - aPriority; // Ordre décroissant (high en premier)
|
||||
}
|
||||
if (aPriority !== bPriority) {
|
||||
return bPriority - aPriority; // Ordre décroissant (high en premier)
|
||||
}
|
||||
|
||||
// Enfin, trier par date de création (plus récent en premier)
|
||||
const aDate = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const bDate = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return bDate - aDate;
|
||||
});
|
||||
// Enfin, trier par date de création (plus récent en premier)
|
||||
const aDate = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const bDate = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return bDate - aDate;
|
||||
});
|
||||
}, [tasks, deferredSearchTerm, statusFilter, priorityFilter, dueDateFilter]);
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
@@ -541,6 +542,18 @@ export default function Tasks() {
|
||||
totalItems
|
||||
} = usePagination(filteredTasks, 10);
|
||||
|
||||
// Sous-listes calculées une seule fois par rendu (page courante et Kanban)
|
||||
const pagePendingTasks = paginatedTasks.filter((task: TaskWithRelations) => task.status === 'pending');
|
||||
const pageCompletedTasks = paginatedTasks.filter((task: TaskWithRelations) => task.status === 'completed');
|
||||
const { kanbanPendingTasks, kanbanCompletedTasks } = useMemo(() => ({
|
||||
kanbanPendingTasks: filteredTasks.filter((task: TaskWithRelations) => task.status === 'pending'),
|
||||
kanbanCompletedTasks: filteredTasks.filter((task: TaskWithRelations) => task.status === 'completed'),
|
||||
}), [filteredTasks]);
|
||||
|
||||
// Liste du magasin précédent affichée pendant le chargement du nouveau magasin :
|
||||
// estompée et non cliquable pour ne pas agir sur une tâche de l'ancien magasin
|
||||
const listBusyClass = isPlaceholderData ? "opacity-60 pointer-events-none transition-opacity" : "";
|
||||
|
||||
const getPriorityConfig = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
@@ -687,6 +700,12 @@ export default function Tasks() {
|
||||
</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
@@ -28,7 +28,7 @@ import {
|
||||
import type { UserWithGroups, Group } from "@shared/schema";
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user } = useAuthUnified();
|
||||
const { user, refreshAuth } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -132,11 +132,16 @@ export default function UsersPage() {
|
||||
const response = await apiRequest(`/api/users/${data.id}`, "PUT", cleanedUpdates);
|
||||
return response;
|
||||
},
|
||||
onSuccess: (updatedUser) => {
|
||||
onSuccess: (updatedUser, variables) => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Utilisateur mis à jour avec succès",
|
||||
});
|
||||
|
||||
// Compte de l'administrateur connecté : rafraîchir l'utilisateur partagé par l'application
|
||||
if (variables.id === user?.id) {
|
||||
refreshAuth();
|
||||
}
|
||||
|
||||
// Update the form with the response data before closing
|
||||
if (updatedUser) {
|
||||
@@ -224,25 +229,24 @@ export default function UsersPage() {
|
||||
|
||||
const assignGroupMutation = useMutation({
|
||||
mutationFn: async (data: { userId: string; groupId: number }) => {
|
||||
console.log('📤 Assigning group:', data);
|
||||
const response = await apiRequest(
|
||||
`/api/users/${data.userId}/groups`,
|
||||
"POST",
|
||||
{ groupId: data.groupId }
|
||||
);
|
||||
console.log('✅ Group assignment response:', response);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
console.log('✅ Group assigned successfully');
|
||||
onSuccess: (_response, variables) => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Utilisateur assigné au groupe avec succès",
|
||||
});
|
||||
// Invalidation complète du cache
|
||||
// Seule la liste des utilisateurs change : la liste des magasins
|
||||
// (['/api/groups']) ne dépend pas des affectations
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/users'] });
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
if (variables.userId === user?.id) {
|
||||
refreshAuth();
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('❌ Error assigning group:', error);
|
||||
@@ -270,24 +274,23 @@ export default function UsersPage() {
|
||||
|
||||
const removeGroupMutation = useMutation({
|
||||
mutationFn: async (data: { userId: string; groupId: number }) => {
|
||||
console.log('🗑️ Removing group:', data);
|
||||
const response = await apiRequest(
|
||||
`/api/users/${data.userId}/groups/${data.groupId}`,
|
||||
"DELETE"
|
||||
);
|
||||
console.log('✅ Group removal response:', response);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
console.log('✅ Group removed successfully');
|
||||
onSuccess: (_response, variables) => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Utilisateur retiré du groupe avec succès",
|
||||
});
|
||||
// Invalidation complète du cache
|
||||
// Seule la liste des utilisateurs change : la liste des magasins
|
||||
// (['/api/groups']) ne dépend pas des affectations
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/users'] });
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
if (variables.userId === user?.id) {
|
||||
refreshAuth();
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('❌ Error removing group:', error);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -17,6 +17,8 @@ services:
|
||||
POSTGRES_PASSWORD: LogiFlow2025!
|
||||
POSTGRES_DB: logiflow_db
|
||||
BACKUP_DIR: /app/backups
|
||||
# Clé(s) de l'API externe de rapprochement, lues depuis le .env de l'hôte (vide = API désactivée)
|
||||
EXTERNAL_API_KEYS: ${EXTERNAL_API_KEYS:-}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# API externe — Rapprochement BL / factures
|
||||
|
||||
Permet à un outil tiers (logiciel comptable, n8n, script…) de lire les magasins,
|
||||
les fournisseurs et les livraisons livrées avec leur numéro de BL, puis d'écrire
|
||||
la référence et le montant de la facture, comme dans la page **Rapprochement**.
|
||||
|
||||
Code : `server/externalApi.ts`. Préfixe : `/api/ext/v1`.
|
||||
|
||||
## Activation et authentification
|
||||
|
||||
Définir une ou plusieurs clés (séparées par des virgules) dans l'environnement,
|
||||
puis redémarrer :
|
||||
|
||||
```
|
||||
EXTERNAL_API_KEYS=3f9c…a1,7b20…e4
|
||||
```
|
||||
|
||||
Générer une clé :
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
Variable vide ou absente : l'API répond `503` (désactivée).
|
||||
|
||||
Chaque requête envoie la clé dans l'un de ces en-têtes :
|
||||
|
||||
```
|
||||
X-API-Key: <clé>
|
||||
Authorization: Bearer <clé>
|
||||
```
|
||||
|
||||
Clé absente ou fausse : `401`. Une clé donne accès à **tous les magasins** ;
|
||||
pour révoquer un outil, retirer sa clé de la liste et redémarrer.
|
||||
Exposer l'API uniquement en HTTPS (la clé circule dans chaque requête).
|
||||
|
||||
## Points d'accès
|
||||
|
||||
### `GET /stores` — magasins
|
||||
|
||||
```json
|
||||
[{ "id": 1, "name": "Magasin Nancy" }]
|
||||
```
|
||||
|
||||
### `GET /suppliers` — fournisseurs
|
||||
|
||||
```json
|
||||
[{ "id": 4, "name": "Lactalis", "code": "LAC01", "paymentMethod": "Virement", "automaticReconciliation": false }]
|
||||
```
|
||||
|
||||
`code` = code fournisseur (`codefou`).
|
||||
|
||||
### `GET /deliveries` — livraisons à rapprocher
|
||||
|
||||
Seules les livraisons **livrées** sont renvoyées (même périmètre que la page
|
||||
Rapprochement), de la plus récente à la plus ancienne.
|
||||
|
||||
| Paramètre | Exemple | Effet |
|
||||
|--------------|---------------|------------------------------------------------------------|
|
||||
| `storeId` | `1` | Un seul magasin |
|
||||
| `supplierId` | `4` | Un seul fournisseur |
|
||||
| `blNumber` | `BL-4567` | N° de BL exact (casse et espaces de bord ignorés) |
|
||||
| `reconciled` | `false` | `true` = rapprochées, `false` = en attente |
|
||||
| `hasBl` | `true` | Avec / sans numéro de BL |
|
||||
| `hasInvoice` | `false` | Avec / sans référence facture |
|
||||
| `from`, `to` | `2026-10-01` | Bornes incluses sur la date de livraison (sinon date prévue) |
|
||||
| `limit` | `100` | Taille de page, 100 par défaut, 500 maximum |
|
||||
| `offset` | `0` | Décalage de pagination |
|
||||
|
||||
Paramètre mal formé : `400` avec la liste des paramètres fautifs.
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"items": [
|
||||
{
|
||||
"id": 812,
|
||||
"storeId": 1,
|
||||
"storeName": "Magasin Nancy",
|
||||
"supplierId": 4,
|
||||
"supplierName": "Lactalis",
|
||||
"supplierCode": "LAC01",
|
||||
"automaticReconciliation": false,
|
||||
"status": "delivered",
|
||||
"scheduledDate": "2026-10-08",
|
||||
"deliveredDate": "2026-10-08T07:42:00.000Z",
|
||||
"blNumber": "BL-4567",
|
||||
"blAmount": 1234.5,
|
||||
"invoiceReference": null,
|
||||
"invoiceAmount": null,
|
||||
"invoiceAmountTTC": null,
|
||||
"dueDate": null,
|
||||
"reconciled": false,
|
||||
"validatedAt": null,
|
||||
"updatedAt": "2026-10-08T07:42:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Montants en nombres (euros), dates `YYYY-MM-DD`, horodatages ISO 8601 UTC.
|
||||
|
||||
### `GET /deliveries/:id` — une livraison
|
||||
|
||||
Même objet qu'un élément de `items`. Inconnue : `404`.
|
||||
|
||||
### `PATCH /deliveries/:id` — écrire la facture
|
||||
|
||||
Tous les champs sont facultatifs, au moins un est requis ; tout autre champ est refusé.
|
||||
|
||||
| Champ | Type | Effet |
|
||||
|--------------------|-------------------------|------------------------------------------------------------------|
|
||||
| `invoiceReference` | texte (100 car.) / null | Référence facture ; `null` ou `""` l'efface |
|
||||
| `invoiceAmount` | nombre / texte / null | Montant HT. Accepte `1234.5`, `"1234,50"`, `"1 234,50"` |
|
||||
| `invoiceAmountTTC` | nombre / texte / null | Montant TTC, mêmes formats |
|
||||
| `dueDate` | `YYYY-MM-DD` / null | Échéance. Si absente et que la référence change, elle est reprise de NocoDB comme dans le webUI |
|
||||
| `reconciled` | booléen | `true` valide le rapprochement, `false` le dévalide |
|
||||
|
||||
```bash
|
||||
curl -X PATCH https://logiflow.example/api/ext/v1/deliveries/812 \
|
||||
-H "X-API-Key: $LOGIFLOW_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"invoiceReference":"FAC-2026-0042","invoiceAmount":"1234,50"}'
|
||||
```
|
||||
|
||||
Réponse : la livraison mise à jour.
|
||||
|
||||
Règles :
|
||||
|
||||
- livraison non livrée : `409` ;
|
||||
- livraison déjà rapprochée : sa facture est figée (`409`). Envoyer
|
||||
`"reconciled": false` dans la même requête pour la dévalider et la modifier ;
|
||||
- exception, fournisseur en **rapprochement automatique**
|
||||
(`automaticReconciliation: true`) : la livraison est validée d'office dès la
|
||||
saisie du BL, sa facture (référence, montants, échéance) se complète ensuite
|
||||
directement, sans la dévalider ; elle reste validée ;
|
||||
- corps invalide : `400` avec le détail par champ.
|
||||
|
||||
## Exemple : rapprocher une facture reçue
|
||||
|
||||
1. `GET /suppliers` → retrouver l'`id` du fournisseur via son `code`.
|
||||
2. `GET /deliveries?supplierId=4&blNumber=BL-4567` → récupérer l'`id` de la livraison.
|
||||
3. `PATCH /deliveries/812` avec `invoiceReference` et `invoiceAmount`
|
||||
(ajouter `"reconciled": true` pour valider directement).
|
||||
|
||||
## Exemple : compléter la facture d'un fournisseur automatique
|
||||
|
||||
Les livraisons d'un fournisseur en rapprochement automatique sont déjà
|
||||
validées (`reconciled: true`) ; la facture s'y ajoute sans autre champ :
|
||||
|
||||
```bash
|
||||
curl -X PATCH https://logiflow.example/api/ext/v1/deliveries/815 \
|
||||
-H "X-API-Key: $LOGIFLOW_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"invoiceReference":"FAC-2026-0057","invoiceAmount":"842,10","invoiceAmountTTC":"1010,52","dueDate":"2026-11-30"}'
|
||||
```
|
||||
|
||||
Pour les retrouver : `GET /deliveries?supplierId=<id auto>&hasInvoice=false`.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
-- Backfill rapprochement automatique
|
||||
--
|
||||
-- Contexte : l'endpoint POST /api/deliveries/:id/validate ne posait pas
|
||||
-- reconciled = true pour les fournisseurs en rapprochement automatique
|
||||
-- (automatic_reconciliation = true). Ces livraisons n'apparaissaient ni dans
|
||||
-- l'onglet "Manuels" (exclues car fournisseur auto) ni dans "Validées"
|
||||
-- (reconciled = false). Même cas pour les livraisons antérieures au passage
|
||||
-- d'un fournisseur en mode automatique.
|
||||
--
|
||||
-- Applique le même critère que l'auto-validation côté serveur :
|
||||
-- livraison livrée + numéro de BL renseigné.
|
||||
|
||||
UPDATE deliveries d
|
||||
SET reconciled = true,
|
||||
validated_at = COALESCE(d.validated_at, NOW())
|
||||
FROM suppliers s
|
||||
WHERE d.supplier_id = s.id
|
||||
AND s.automatic_reconciliation = true
|
||||
AND d.status = 'delivered'
|
||||
AND d.bl_number IS NOT NULL
|
||||
AND TRIM(d.bl_number) <> ''
|
||||
AND (d.reconciled IS NULL OR d.reconciled = false);
|
||||
Generated
+72
@@ -54,6 +54,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"compression": "^1.8.2",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"csrf-csrf": "^4.0.3",
|
||||
@@ -106,6 +107,7 @@
|
||||
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/connect-pg-simple": "^7.0.3",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
@@ -3643,6 +3645,17 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
@@ -4532,6 +4545,65 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/compressible": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": ">= 1.43.0 < 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
|
||||
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"destroy": "1.2.0",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/compression/node_modules/negotiator": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"compression": "^1.8.2",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"csrf-csrf": "^4.0.3",
|
||||
@@ -108,6 +109,7 @@
|
||||
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/connect-pg-simple": "^7.0.3",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
|
||||
+30
-4
@@ -3,7 +3,7 @@ import { promisify } from 'util';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { eq, desc, sql } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { databaseBackups, utilities, users } from "@shared/schema";
|
||||
import type { DatabaseBackup, InsertDatabaseBackup } from "@shared/schema";
|
||||
@@ -14,6 +14,8 @@ export class BackupService {
|
||||
private backupDir: string;
|
||||
private maxBackups = 10;
|
||||
private lastAutomaticBackupDate: string | null = null;
|
||||
// Vérification quotidienne en cours, partagée par les connexions simultanées
|
||||
private dailyBackupCheck: Promise<{ backupPerformed: boolean; message: string }> | null = null;
|
||||
|
||||
constructor() {
|
||||
// Use /app/backups in production (with proper permissions), or use env variable
|
||||
@@ -130,9 +132,10 @@ export class BackupService {
|
||||
await execAsync(command, { env });
|
||||
|
||||
// Get file stats and count tables
|
||||
const stats = fs.statSync(filepath);
|
||||
const sqlContent = fs.readFileSync(filepath, 'utf8');
|
||||
const tablesCount = (sqlContent.match(/CREATE TABLE/g) || []).length;
|
||||
// Tables comptées en base plutôt qu'en relisant tout le dump en mémoire :
|
||||
// la lecture synchrone et la regex gelaient le serveur sur les grosses bases
|
||||
const stats = await fs.promises.stat(filepath);
|
||||
const tablesCount = await this.countDatabaseTables();
|
||||
|
||||
// Update database record with completion details
|
||||
const [updatedBackup] = await db.update(databaseBackups)
|
||||
@@ -156,6 +159,18 @@ export class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
// Nombre de tables utilisateur (hors schémas système), proche de ce que
|
||||
// pg_dump inclut dans la sauvegarde
|
||||
private async countDatabaseTables(): Promise<number> {
|
||||
const result = await db.execute(sql`
|
||||
SELECT count(*)::int AS count
|
||||
FROM information_schema.tables
|
||||
WHERE table_type = 'BASE TABLE'
|
||||
AND table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
`);
|
||||
return Number(result.rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
async getBackupList(): Promise<DatabaseBackup[]> {
|
||||
try {
|
||||
const backups = await db.select()
|
||||
@@ -238,6 +253,17 @@ export class BackupService {
|
||||
|
||||
// Nouvelle méthode : Vérifier et effectuer une sauvegarde quotidienne si nécessaire
|
||||
async checkAndPerformDailyBackup(userId: string = 'system'): Promise<{ backupPerformed: boolean; message: string }> {
|
||||
// Plusieurs connexions simultanées (ouverture des magasins) partagent la
|
||||
// même vérification : un seul pg_dump est lancé
|
||||
if (!this.dailyBackupCheck) {
|
||||
this.dailyBackupCheck = this.performDailyBackupCheck(userId).finally(() => {
|
||||
this.dailyBackupCheck = null;
|
||||
});
|
||||
}
|
||||
return this.dailyBackupCheck;
|
||||
}
|
||||
|
||||
private async performDailyBackupCheck(userId: string): Promise<{ backupPerformed: boolean; message: string }> {
|
||||
try {
|
||||
// Vérifier si les backups automatiques sont activés
|
||||
const [config] = await db.select()
|
||||
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
// Cache en mémoire simple pour les données fréquemment utilisées
|
||||
class MemoryCache {
|
||||
private cache: Map<string, { data: any; timestamp: number; ttl: number }> = new Map();
|
||||
private readonly DEFAULT_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
set(key: string, data: any, ttl: number = this.DEFAULT_TTL): void {
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
ttl
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string): any | null {
|
||||
const item = this.cache.get(key);
|
||||
if (!item) return null;
|
||||
|
||||
if (Date.now() - item.timestamp > item.ttl) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.data;
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
// Nettoyer les éléments expirés
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
if (now - item.timestamp > item.ttl) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cache = new MemoryCache();
|
||||
|
||||
// Nettoyage automatique toutes les 10 minutes
|
||||
setInterval(() => cache.cleanup(), 10 * 60 * 1000);
|
||||
|
||||
// Middleware de mise en cache pour les réponses API
|
||||
export function cacheMiddleware(ttl: number = 5 * 60 * 1000) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
// Ne pas mettre en cache les requêtes POST/PUT/DELETE
|
||||
if (req.method !== 'GET') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const key = `${req.originalUrl}:${req.user?.id || 'anonymous'}`;
|
||||
const cachedData = cache.get(key);
|
||||
|
||||
if (cachedData) {
|
||||
res.setHeader('X-Cache', 'HIT');
|
||||
return res.json(cachedData);
|
||||
}
|
||||
|
||||
res.setHeader('X-Cache', 'MISS');
|
||||
|
||||
// Intercepter la réponse pour la mettre en cache
|
||||
const originalSend = res.json;
|
||||
res.json = function(data: any) {
|
||||
if (res.statusCode === 200) {
|
||||
cache.set(key, data, ttl);
|
||||
}
|
||||
return originalSend.call(this, data);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Invalidation de cache par pattern
|
||||
export function invalidateCache(pattern: string): void {
|
||||
for (const key of cache['cache'].keys()) {
|
||||
if (key.includes(pattern)) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware de compression des réponses
|
||||
export function setupCompression(app: any) {
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
// Activer la compression pour les réponses JSON
|
||||
if (req.accepts('gzip') && String(res.getHeader('Content-Type') ?? '').includes('json')) {
|
||||
res.setHeader('Content-Encoding', 'gzip');
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Optimisation des requêtes base de données
|
||||
export function createOptimizedQuery(baseQuery: string, params: any[]): { query: string; params: any[] } {
|
||||
// Optimiser les requêtes en évitant les SELECT * inutiles
|
||||
let optimizedQuery = baseQuery;
|
||||
|
||||
// Limiter les résultats pour éviter les grosses requêtes
|
||||
if (!optimizedQuery.includes('LIMIT') && !optimizedQuery.includes('COUNT')) {
|
||||
optimizedQuery += ' LIMIT 1000';
|
||||
}
|
||||
|
||||
// Ajouter des index hints si nécessaire
|
||||
if (optimizedQuery.includes('ORDER BY created_at')) {
|
||||
optimizedQuery = optimizedQuery.replace('ORDER BY created_at', 'ORDER BY created_at DESC');
|
||||
}
|
||||
|
||||
return { query: optimizedQuery, params };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Normalisation des dates d'échéance renvoyées par NocoDB (formats variés)
|
||||
// vers YYYY-MM-DD. Partagée par les routes web et l'API externe.
|
||||
export function normalizeDateString(dateString: string | null | undefined): string | null {
|
||||
if (!dateString || typeof dateString !== 'string') return null;
|
||||
|
||||
const trimmed = dateString.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
// Si déjà au format ISO (YYYY-MM-DD), le retourner tel quel
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Format slash ou tiret : DD/MM/YYYY, MM/DD/YYYY, DD-MM-YYYY, MM-DD-YYYY
|
||||
const slashMatch = trimmed.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
|
||||
if (slashMatch) {
|
||||
const [, first, second, year] = slashMatch;
|
||||
const firstNum = parseInt(first);
|
||||
const secondNum = parseInt(second);
|
||||
|
||||
// Validation basique des valeurs
|
||||
if (firstNum > 31 || secondNum > 31 || firstNum === 0 || secondNum === 0) {
|
||||
console.warn(`⚠️ Date invalide (valeurs hors limites): ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Déterminer le format en fonction des valeurs
|
||||
let day: string, month: string;
|
||||
|
||||
if (firstNum > 12) {
|
||||
// first > 12 → forcément DD/MM (format français/européen)
|
||||
day = first.padStart(2, '0');
|
||||
month = second.padStart(2, '0');
|
||||
} else if (secondNum > 12) {
|
||||
// second > 12 → forcément MM/DD (format américain)
|
||||
day = second.padStart(2, '0');
|
||||
month = first.padStart(2, '0');
|
||||
} else {
|
||||
// Ambiguïté (les deux < 12) → on assume format français DD/MM par défaut
|
||||
// Pour être plus sûr, on pourrait vérifier la configuration du groupe/locale
|
||||
day = first.padStart(2, '0');
|
||||
month = second.padStart(2, '0');
|
||||
}
|
||||
|
||||
// Validation finale : mois entre 1-12, jour entre 1-31
|
||||
const monthNum = parseInt(month);
|
||||
const dayNum = parseInt(day);
|
||||
if (monthNum < 1 || monthNum > 12 || dayNum < 1 || dayNum > 31) {
|
||||
console.warn(`⚠️ Date invalide après parsing: ${trimmed} → month=${month}, day=${day}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Essayer de parser avec Date (format ISO complet avec heures)
|
||||
const date = new Date(trimmed);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Si aucun format reconnu, retourner null (ne pas persister une date invalide)
|
||||
console.warn(`⚠️ Format de date non reconnu: ${trimmed}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur normalisation date:', error);
|
||||
return null; // Retourner null en cas d'erreur pour éviter de persister des données invalides
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,14 @@ if (isProduction && dbUrl && (dbUrl.includes('logiflow-db') || dbUrl.includes('l
|
||||
|
||||
db = drizzle({ client: pool, schema });
|
||||
|
||||
// Une connexion inactive coupée par Postgres (redémarrage ou redéploiement de
|
||||
// la base, coupure réseau) fait émettre 'error' au pool. Sans écouteur, Node
|
||||
// arrête tout le serveur ; le pool écarte déjà la connexion fautive et en
|
||||
// ouvre une neuve à la requête suivante, il suffit donc de journaliser.
|
||||
pool.on('error', (err: any) => {
|
||||
console.error('❌ PostgreSQL idle connection error (connexion écartée):', err?.code || '', err?.message);
|
||||
});
|
||||
|
||||
// Test connection
|
||||
try {
|
||||
await pool.connect().then((client: any) => {
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
// API externe de rapprochement BL / factures.
|
||||
//
|
||||
// Permet à un outil tiers (comptabilité, n8n, script...) de lire les magasins,
|
||||
// les fournisseurs et les livraisons livrées (numéros de BL), puis d'écrire la
|
||||
// référence, les montants et l'échéance de la facture sur une livraison (y
|
||||
// compris déjà validée, pour les fournisseurs en rapprochement automatique).
|
||||
//
|
||||
// Authentification par clé d'API, indépendante des sessions du webUI :
|
||||
// - variable d'environnement EXTERNAL_API_KEYS (une ou plusieurs clés
|
||||
// séparées par des virgules) ; vide => API désactivée ;
|
||||
// - clé transmise dans l'en-tête "X-API-Key" ou "Authorization: Bearer <clé>".
|
||||
//
|
||||
// Documentation : docs/API-RAPPROCHEMENT.md
|
||||
|
||||
import type { Express, Request, Response, NextFunction } from "express";
|
||||
import { createHash, timingSafeEqual } from "crypto";
|
||||
import { z } from "zod";
|
||||
import { storage } from "./storage";
|
||||
import { normalizeDateString } from "./dateUtils";
|
||||
import { invoiceVerificationService } from "./invoiceVerification";
|
||||
|
||||
export const EXTERNAL_API_PREFIX = "/api/ext/v1";
|
||||
|
||||
const MAX_LIMIT = 500;
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
function digest(value: string): Buffer {
|
||||
return createHash("sha256").update(value).digest();
|
||||
}
|
||||
|
||||
// Clés relues à chaque requête : une rotation via l'environnement ne demande
|
||||
// qu'un redémarrage, et aucune clé n'est gardée en clair plus que nécessaire.
|
||||
function configuredKeyDigests(): Buffer[] {
|
||||
return (process.env.EXTERNAL_API_KEYS || "")
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.length > 0)
|
||||
.map(digest);
|
||||
}
|
||||
|
||||
function extractKey(req: Request): string | null {
|
||||
const header = req.headers["x-api-key"];
|
||||
if (typeof header === "string" && header.trim()) return header.trim();
|
||||
|
||||
const auth = req.headers.authorization;
|
||||
if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
|
||||
const token = auth.slice(7).trim();
|
||||
if (token) return token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||
const keys = configuredKeyDigests();
|
||||
if (keys.length === 0) {
|
||||
return res.status(503).json({ error: "API externe désactivée (EXTERNAL_API_KEYS non défini)" });
|
||||
}
|
||||
|
||||
const provided = extractKey(req);
|
||||
// Comparaison à temps constant sur les empreintes (longueur fixe)
|
||||
const providedDigest = provided ? digest(provided) : null;
|
||||
const valid = providedDigest !== null && keys.some((k) => timingSafeEqual(k, providedDigest));
|
||||
|
||||
if (!valid) {
|
||||
console.warn(`🚨 [EXT-API] Clé d'API invalide ou absente : ${req.method} ${req.path} depuis ${req.ip}`);
|
||||
return res.status(401).json({ error: "Clé d'API invalide ou absente" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Montant : nombre, ou chaîne au format "1234.56", "1234,56" ou "1 234,56"
|
||||
const amountSchema = z
|
||||
.union([z.number(), z.string()])
|
||||
.nullable()
|
||||
.transform((value, ctx) => {
|
||||
if (value === null) return null;
|
||||
const raw = typeof value === "number" ? String(value) : value.replace(/[\s ]/g, "").replace(",", ".");
|
||||
if (raw === "") return null;
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num) || Math.abs(num) >= 1e8) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Montant invalide" });
|
||||
return z.NEVER;
|
||||
}
|
||||
return num.toFixed(2); // colonne decimal(10,2)
|
||||
});
|
||||
|
||||
const updateInvoiceSchema = z
|
||||
.object({
|
||||
invoiceReference: z.string().trim().max(100).nullable().optional(),
|
||||
invoiceAmount: amountSchema.optional(),
|
||||
invoiceAmountTTC: amountSchema.optional(),
|
||||
dueDate: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Format attendu : YYYY-MM-DD")
|
||||
.nullable()
|
||||
.optional(),
|
||||
reconciled: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((body) => Object.keys(body).length > 0, { message: "Aucun champ à mettre à jour" });
|
||||
|
||||
const INVOICE_FIELDS = ["invoiceReference", "invoiceAmount", "invoiceAmountTTC", "dueDate"] as const;
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function toIsoDate(value: unknown): string | null {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value as string);
|
||||
return isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toIsoDateTime(value: unknown): string | null {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value as string);
|
||||
return isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
// Format public d'une livraison : champs utiles au rapprochement uniquement
|
||||
function toApiDelivery(d: any) {
|
||||
return {
|
||||
id: d.id,
|
||||
storeId: d.groupId,
|
||||
storeName: d.group?.name ?? null,
|
||||
supplierId: d.supplierId,
|
||||
supplierName: d.supplier?.name ?? null,
|
||||
supplierCode: d.supplier?.codefou ?? null,
|
||||
automaticReconciliation: !!d.supplier?.automaticReconciliation,
|
||||
status: d.status,
|
||||
scheduledDate: toIsoDate(d.scheduledDate),
|
||||
deliveredDate: toIsoDateTime(d.deliveredDate),
|
||||
blNumber: d.blNumber ?? null,
|
||||
blAmount: toNumber(d.blAmount),
|
||||
invoiceReference: d.invoiceReference ?? null,
|
||||
invoiceAmount: toNumber(d.invoiceAmount),
|
||||
invoiceAmountTTC: toNumber(d.invoiceAmountTTC),
|
||||
dueDate: toIsoDate(d.dueDate),
|
||||
reconciled: !!d.reconciled,
|
||||
validatedAt: toIsoDateTime(d.validatedAt),
|
||||
updatedAt: toIsoDateTime(d.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function parseIntParam(value: unknown): number | undefined | null {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
const num = Number(value);
|
||||
return Number.isInteger(num) && num > 0 ? num : null; // null = invalide
|
||||
}
|
||||
|
||||
function parseBoolParam(value: unknown): boolean | undefined | null {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDateParam(value: unknown): string | undefined | null {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
// Échéance lue dans NocoDB pour une référence facture (même logique que le webUI)
|
||||
async function lookupDueDate(invoiceReference: string, groupId: number, reconciled: boolean): Promise<Date | null> {
|
||||
try {
|
||||
const result = await invoiceVerificationService.verifyInvoice(invoiceReference, groupId, true, reconciled);
|
||||
const normalized = result.exists ? normalizeDateString(result.dueDate) : null;
|
||||
return normalized ? new Date(normalized) : null;
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] Erreur récupération échéance NocoDB:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerExternalApi(app: Express) {
|
||||
const base = EXTERNAL_API_PREFIX;
|
||||
app.use(base, requireApiKey);
|
||||
|
||||
// Magasins
|
||||
app.get(`${base}/stores`, async (_req, res) => {
|
||||
try {
|
||||
const groups = await storage.getGroups();
|
||||
res.json(groups.map((g: any) => ({ id: g.id, name: g.name })));
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] stores:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture des magasins" });
|
||||
}
|
||||
});
|
||||
|
||||
// Fournisseurs
|
||||
app.get(`${base}/suppliers`, async (_req, res) => {
|
||||
try {
|
||||
const suppliers = await storage.getSuppliers();
|
||||
res.json(
|
||||
suppliers.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
code: s.codefou ?? null,
|
||||
paymentMethod: s.paymentMethod ?? null,
|
||||
automaticReconciliation: !!s.automaticReconciliation,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] suppliers:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture des fournisseurs" });
|
||||
}
|
||||
});
|
||||
|
||||
// Livraisons livrées (périmètre du rapprochement BL / factures)
|
||||
app.get(`${base}/deliveries`, async (req, res) => {
|
||||
const storeId = parseIntParam(req.query.storeId);
|
||||
const supplierId = parseIntParam(req.query.supplierId);
|
||||
const reconciled = parseBoolParam(req.query.reconciled);
|
||||
const hasBl = parseBoolParam(req.query.hasBl);
|
||||
const hasInvoice = parseBoolParam(req.query.hasInvoice);
|
||||
const from = parseDateParam(req.query.from);
|
||||
const to = parseDateParam(req.query.to);
|
||||
const limit = parseIntParam(req.query.limit);
|
||||
const offsetRaw = req.query.offset;
|
||||
const offset = offsetRaw === undefined || offsetRaw === "" ? 0 : Number(offsetRaw);
|
||||
const blNumber = typeof req.query.blNumber === "string" ? req.query.blNumber.trim().toLowerCase() : "";
|
||||
|
||||
const invalid = Object.entries({ storeId, supplierId, reconciled, hasBl, hasInvoice, from, to, limit })
|
||||
.filter(([, v]) => v === null)
|
||||
.map(([k]) => k);
|
||||
if (!Number.isInteger(offset) || offset < 0) invalid.push("offset");
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({ error: `Paramètre(s) invalide(s) : ${invalid.join(", ")}` });
|
||||
}
|
||||
|
||||
try {
|
||||
let rows: any[] = await storage.getDeliveries(storeId ? [storeId] : undefined, { status: "delivered" });
|
||||
|
||||
rows = rows.filter((d) => {
|
||||
if (supplierId && d.supplierId !== supplierId) return false;
|
||||
if (reconciled !== undefined && !!d.reconciled !== reconciled) return false;
|
||||
if (hasBl !== undefined && !!d.blNumber?.trim() !== hasBl) return false;
|
||||
if (hasInvoice !== undefined && !!d.invoiceReference?.trim() !== hasInvoice) return false;
|
||||
if (blNumber && (d.blNumber || "").trim().toLowerCase() !== blNumber) return false;
|
||||
if (from || to) {
|
||||
// Date de livraison effective, sinon date prévue
|
||||
const day = toIsoDate(d.deliveredDate) ?? toIsoDate(d.scheduledDate);
|
||||
if (!day) return false;
|
||||
if (from && day < from) return false;
|
||||
if (to && day > to) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const da = toIsoDateTime(a.deliveredDate) ?? toIsoDate(a.scheduledDate) ?? "";
|
||||
const db = toIsoDateTime(b.deliveredDate) ?? toIsoDate(b.scheduledDate) ?? "";
|
||||
return da < db ? 1 : da > db ? -1 : b.id - a.id;
|
||||
});
|
||||
|
||||
const pageSize = Math.min(limit ?? DEFAULT_LIMIT, MAX_LIMIT);
|
||||
res.json({
|
||||
total: rows.length,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
items: rows.slice(offset, offset + pageSize).map(toApiDelivery),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] deliveries:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture des livraisons" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get(`${base}/deliveries/:id`, async (req, res) => {
|
||||
const id = parseIntParam(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Identifiant invalide" });
|
||||
|
||||
try {
|
||||
const delivery = await storage.getDelivery(id);
|
||||
if (!delivery) return res.status(404).json({ error: "Livraison introuvable" });
|
||||
res.json(toApiDelivery(delivery));
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] delivery:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture de la livraison" });
|
||||
}
|
||||
});
|
||||
|
||||
// Écriture de la facture (référence, montants, échéance) et validation optionnelle
|
||||
app.patch(`${base}/deliveries/:id`, async (req, res) => {
|
||||
const id = parseIntParam(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Identifiant invalide" });
|
||||
|
||||
const parsed = updateInvoiceSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: "Corps de requête invalide",
|
||||
details: parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })),
|
||||
});
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
try {
|
||||
const delivery = await storage.getDelivery(id);
|
||||
if (!delivery) return res.status(404).json({ error: "Livraison introuvable" });
|
||||
|
||||
if (delivery.status !== "delivered") {
|
||||
return res.status(409).json({ error: "Seules les livraisons livrées peuvent être rapprochées" });
|
||||
}
|
||||
|
||||
const touchesInvoice = INVOICE_FIELDS.some((f) => body[f] !== undefined);
|
||||
// Une livraison rapprochée est figée, comme dans le webUI : il faut la
|
||||
// dévalider (reconciled: false) pour modifier sa facture. Exception : les
|
||||
// fournisseurs en rapprochement automatique, validés d'office dès la
|
||||
// saisie du BL, dont la facture arrive après et se complète sans dévalider.
|
||||
const isAutomatic = !!delivery.supplier?.automaticReconciliation;
|
||||
if (delivery.reconciled && !isAutomatic && touchesInvoice && body.reconciled !== false) {
|
||||
return res.status(409).json({
|
||||
error: "Livraison déjà rapprochée : envoyer reconciled: false pour la dévalider avant de modifier la facture",
|
||||
});
|
||||
}
|
||||
|
||||
const update: Record<string, any> = {};
|
||||
if (body.invoiceReference !== undefined) update.invoiceReference = body.invoiceReference || null;
|
||||
if (body.invoiceAmount !== undefined) update.invoiceAmount = body.invoiceAmount;
|
||||
if (body.invoiceAmountTTC !== undefined) update.invoiceAmountTTC = body.invoiceAmountTTC;
|
||||
|
||||
if (body.dueDate !== undefined) {
|
||||
update.dueDate = body.dueDate ? new Date(body.dueDate) : null;
|
||||
} else if (update.invoiceReference !== undefined && update.invoiceReference !== delivery.invoiceReference) {
|
||||
// Échéance non fournie : reprise depuis NocoDB, ou vidée avec la référence
|
||||
update.dueDate = update.invoiceReference
|
||||
? await lookupDueDate(update.invoiceReference, delivery.groupId, !!delivery.reconciled)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (body.reconciled !== undefined) {
|
||||
update.reconciled = body.reconciled;
|
||||
update.validatedAt = body.reconciled ? new Date() : null;
|
||||
}
|
||||
|
||||
await storage.updateDelivery(id, update);
|
||||
console.log(`🔌 [EXT-API] Livraison #${id} mise à jour : ${Object.keys(update).join(", ")}`);
|
||||
|
||||
const updated = await storage.getDelivery(id);
|
||||
res.json(toApiDelivery(updated));
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] update delivery:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la mise à jour de la livraison" });
|
||||
}
|
||||
});
|
||||
|
||||
// Toute autre route sous le préfixe : 404 JSON plutôt que la page du webUI
|
||||
app.use(base, (_req, res) => {
|
||||
res.status(404).json({ error: "Route inconnue" });
|
||||
});
|
||||
}
|
||||
+44
-25
@@ -1,4 +1,5 @@
|
||||
import express, { type Express, type Request, type Response, type NextFunction } from "express";
|
||||
import compression from "compression";
|
||||
import { createServer, type Server } from "http";
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
@@ -24,6 +25,10 @@ console.log('🐳 Environment:', {
|
||||
|
||||
const app = express();
|
||||
|
||||
// Compression gzip des réponses (assets JS/CSS, index.html et JSON de l'API).
|
||||
// En premier pour couvrir toutes les réponses.
|
||||
app.use(compression());
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: false, limit: '10mb' }));
|
||||
|
||||
@@ -126,48 +131,62 @@ async function registerProductionRoutes(app: Express): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
await registerProductionRoutes(app);
|
||||
|
||||
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||
const status = err.status || err.statusCode || 500;
|
||||
const message = err.message || "Internal Server Error";
|
||||
console.error('Server error:', { status, message, error: err });
|
||||
res.status(status).json({ message });
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Serve static files directly in production (no Vite)
|
||||
// Déclarés AVANT les routes : les fichiers statiques et l'index.html ne passent
|
||||
// ni par la session ni par passport (aucune requête SQL par fichier).
|
||||
const publicPath = join(__dirname, 'public');
|
||||
console.log('🐳 Serving static files from:', publicPath);
|
||||
|
||||
app.use('/assets', express.static(join(publicPath, 'assets')));
|
||||
app.use('/', express.static(publicPath));
|
||||
|
||||
// Add explicit root route handler
|
||||
app.get('/', (req, res) => {
|
||||
console.log('🏠 ROOT: Serving index.html for root request');
|
||||
res.sendFile(join(publicPath, 'index.html'), (err) => {
|
||||
if (err) {
|
||||
console.error('❌ ROOT: Error serving index.html:', err);
|
||||
res.status(500).send('Error loading application');
|
||||
// Les fichiers de /assets ont un nom haché par Vite : cache navigateur d'un an
|
||||
app.use('/assets', express.static(join(publicPath, 'assets'), {
|
||||
maxAge: '1y',
|
||||
immutable: true,
|
||||
index: false,
|
||||
}));
|
||||
// Autres fichiers publics (favicon, manifest...). L'index.html est toujours
|
||||
// revalidé pour qu'un nouveau déploiement soit pris en compte immédiatement.
|
||||
app.use(express.static(publicPath, {
|
||||
index: false,
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// SPA fallback - serve index.html for all non-API routes
|
||||
// SPA fallback - serve index.html for all non-API routes (y compris "/")
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return next();
|
||||
}
|
||||
console.log(`📄 SPA: Serving index.html for ${req.path}`);
|
||||
res.set('Cache-Control', 'no-cache');
|
||||
res.sendFile(join(publicPath, 'index.html'), (err) => {
|
||||
if (err) {
|
||||
console.error(`❌ SPA: Error serving index.html for ${req.path}:`, err);
|
||||
res.status(500).send('Error loading application');
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Error loading application');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await registerProductionRoutes(app);
|
||||
|
||||
// Tâches de maintenance périodiques (purge du cache factures expiré)
|
||||
const { startMaintenanceJobs } = await import('./maintenance.js');
|
||||
startMaintenanceJobs();
|
||||
|
||||
app.use((err: any, _req: Request, res: Response, next: NextFunction) => {
|
||||
// Réponse déjà commencée : on laisse Express clore la connexion
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
}
|
||||
const status = err.status || err.statusCode || 500;
|
||||
const message = err.message || "Internal Server Error";
|
||||
console.error('Server error:', { status, message, error: err });
|
||||
res.status(status).json({ message });
|
||||
});
|
||||
|
||||
const port = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
||||
const server = createServer(app);
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
|
||||
+10
-2
@@ -1,4 +1,5 @@
|
||||
import express, { type Request, Response, NextFunction } from "express";
|
||||
import compression from "compression";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { setupVite, serveStatic } from "./vite.js";
|
||||
@@ -24,6 +25,9 @@ console.log('✅ [STARTUP] Weather system initialized');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Compression gzip des réponses, en premier pour couvrir toutes les réponses
|
||||
app.use(compression());
|
||||
|
||||
// Parse cookies (required for CSRF)
|
||||
app.use(cookieParser());
|
||||
|
||||
@@ -63,11 +67,15 @@ const server = await registerRoutes(app);
|
||||
const { startMaintenanceJobs } = await import('./maintenance.js');
|
||||
startMaintenanceJobs();
|
||||
|
||||
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||
app.use((err: any, _req: Request, res: Response, next: NextFunction) => {
|
||||
// Réponse déjà commencée : on laisse Express clore la connexion
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
}
|
||||
const status = err.status || err.statusCode || 500;
|
||||
const message = err.message || "Internal Server Error";
|
||||
console.error('Server error:', { status, message, error: err });
|
||||
res.status(status).json({ message });
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Setup Vite in development
|
||||
|
||||
+50
-101
@@ -1,5 +1,10 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "./db.js";
|
||||
import { storage } from "./storage.js";
|
||||
|
||||
// Blancs ASCII retirés par String.prototype.trim(), pour reproduire en SQL les clés de cache
|
||||
const ASCII_WHITESPACE = ' \t\n\v\f\r';
|
||||
|
||||
/**
|
||||
* Service de vérification des factures avec NocoDB
|
||||
* Gère la vérification automatique des références de factures
|
||||
@@ -19,26 +24,10 @@ export class InvoiceVerificationService {
|
||||
async checkCache(invoiceReference: string, groupId: number): Promise<any | null> {
|
||||
try {
|
||||
const cacheKey = this.generateCacheKey(invoiceReference, groupId);
|
||||
console.log('🔍 [CACHE] Recherche cache pour:', { invoiceReference, groupId, cacheKey });
|
||||
|
||||
const cached = await storage.getInvoiceVerificationCache(cacheKey);
|
||||
console.log('🔍 [CACHE] Résultat cache:', {
|
||||
found: !!cached,
|
||||
isReconciled: cached?.isReconciled,
|
||||
expired: cached ? new Date() >= new Date(cached.expiresAt) : 'N/A',
|
||||
expiresAt: cached?.expiresAt,
|
||||
currentTime: new Date().toISOString()
|
||||
});
|
||||
|
||||
|
||||
// ✅ CACHE PERMANENT : Facture validée avec coche verte = JAMAIS re-vérifier
|
||||
if (cached && cached.isReconciled && cached.exists) {
|
||||
console.log('🛡️ [CACHE] PERMANENT - Facture validée avec coche verte, AUCUNE vérification:', {
|
||||
invoiceReference,
|
||||
groupId,
|
||||
exists: cached.exists,
|
||||
invoiceAmount: cached.invoiceAmount,
|
||||
dueDate: cached.dueDate
|
||||
});
|
||||
return {
|
||||
exists: cached.exists,
|
||||
matchType: cached.matchType,
|
||||
@@ -55,7 +44,6 @@ export class InvoiceVerificationService {
|
||||
|
||||
// Cache temporaire non expiré
|
||||
if (cached && new Date() < new Date(cached.expiresAt)) {
|
||||
console.log('✅ [CACHE] Cache temporaire hit pour:', { invoiceReference, groupId, exists: cached.exists });
|
||||
return {
|
||||
exists: cached.exists,
|
||||
matchType: cached.matchType,
|
||||
@@ -69,13 +57,7 @@ export class InvoiceVerificationService {
|
||||
permanent: false
|
||||
};
|
||||
}
|
||||
|
||||
if (cached && new Date() >= new Date(cached.expiresAt)) {
|
||||
console.log('⏰ [CACHE] Cache temporaire expiré pour:', { invoiceReference, groupId });
|
||||
} else {
|
||||
console.log('❌ [CACHE] Cache miss pour:', { invoiceReference, groupId });
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ [CACHE] Erreur lecture cache:', error);
|
||||
@@ -92,32 +74,18 @@ export class InvoiceVerificationService {
|
||||
|
||||
// Durée de cache adaptative selon les cas
|
||||
const expiresAt = new Date();
|
||||
let cacheDescription = '';
|
||||
|
||||
|
||||
if (isReconciled) {
|
||||
// Cache PERMANENT pour factures validées - expire dans 50 ans
|
||||
expiresAt.setFullYear(expiresAt.getFullYear() + 50);
|
||||
cacheDescription = 'PERMANENT (validé)';
|
||||
} else if (result.exists) {
|
||||
// Facture trouvée mais non validée - cache 6h pour permettre corrections
|
||||
expiresAt.setHours(expiresAt.getHours() + 6);
|
||||
cacheDescription = 'temporaire 6h (trouvé)';
|
||||
} else {
|
||||
// Facture non trouvée - cache 12h pour éviter spam
|
||||
expiresAt.setHours(expiresAt.getHours() + 12);
|
||||
cacheDescription = 'temporaire 12h (pas trouvé)';
|
||||
}
|
||||
|
||||
console.log('💾 [CACHE] Tentative sauvegarde:', {
|
||||
invoiceReference,
|
||||
groupId,
|
||||
cacheKey,
|
||||
exists: result.exists,
|
||||
isReconciled,
|
||||
cacheType: cacheDescription,
|
||||
expiresAt: expiresAt.toISOString()
|
||||
});
|
||||
|
||||
|
||||
const cacheData = {
|
||||
cacheKey,
|
||||
groupId,
|
||||
@@ -135,17 +103,7 @@ export class InvoiceVerificationService {
|
||||
expiresAt
|
||||
};
|
||||
|
||||
const savedCache = await storage.saveInvoiceVerificationCache(cacheData);
|
||||
|
||||
console.log('✅ [CACHE] Résultat sauvé en cache:', {
|
||||
id: savedCache.id,
|
||||
invoiceReference,
|
||||
groupId,
|
||||
exists: result.exists,
|
||||
isReconciled,
|
||||
cacheType: cacheDescription,
|
||||
cacheKey
|
||||
});
|
||||
await storage.saveInvoiceVerificationCache(cacheData);
|
||||
} catch (error) {
|
||||
// Gérer spécifiquement les erreurs de contrainte unique (duplicate key)
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === '23505') {
|
||||
@@ -211,7 +169,16 @@ export class InvoiceVerificationService {
|
||||
async updateExistingReconciledCaches(): Promise<void> {
|
||||
try {
|
||||
console.log('🔄 [CACHE] Mise à jour en lot des caches pour livraisons validées...');
|
||||
|
||||
|
||||
// Base PostgreSQL : une seule requête ensembliste au lieu de charger toutes
|
||||
// les livraisons puis 2 requêtes (lecture + upsert) par référence
|
||||
if (db) {
|
||||
const updatedCount = await this.markReconciledCachesInDatabase();
|
||||
console.log(`✅ [CACHE] Mise à jour terminée: ${updatedCount} caches mis à jour comme permanents`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stockage mémoire (développement sans base) : parcours livraison par livraison
|
||||
// Récupérer toutes les livraisons validées (reconciled = true)
|
||||
const deliveries = await storage.getDeliveries();
|
||||
const reconciledDeliveries = deliveries.filter(d => d.reconciled);
|
||||
@@ -243,6 +210,36 @@ export class InvoiceVerificationService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Équivalent SQL de updateCacheAsReconciled appliqué à toutes les livraisons validées.
|
||||
* Mêmes clés que generateCacheKey (référence facture et numéro de BL non vides après
|
||||
* trim), mêmes conditions (entrée présente, non expirée, pas encore réconciliée) et
|
||||
* mêmes colonnes modifiées que l'upsert de DatabaseStorage : is_reconciled et
|
||||
* updated_at (expires_at n'y est pas mis à jour). Écart accepté : seuls les blancs
|
||||
* ASCII sont retirés, et lower() suit la locale de la base.
|
||||
* Retourne le nombre d'entrées de cache mises à jour.
|
||||
*/
|
||||
private async markReconciledCachesInDatabase(): Promise<number> {
|
||||
// Heure UTC, comme drizzle écrit et relit les colonnes timestamp
|
||||
const now = new Date().toISOString();
|
||||
const result = await db.execute(sql`
|
||||
UPDATE invoice_verification_cache
|
||||
SET is_reconciled = true, updated_at = ${now}::timestamp
|
||||
WHERE is_reconciled IS NOT TRUE
|
||||
AND expires_at > ${now}::timestamp
|
||||
AND cache_key IN (
|
||||
SELECT group_id::text || '_' || lower(btrim(invoice_reference, ${ASCII_WHITESPACE}))
|
||||
FROM deliveries
|
||||
WHERE reconciled = true AND btrim(invoice_reference, ${ASCII_WHITESPACE}) <> ''
|
||||
UNION
|
||||
SELECT group_id::text || '_' || lower(btrim(bl_number, ${ASCII_WHITESPACE}))
|
||||
FROM deliveries
|
||||
WHERE reconciled = true AND btrim(bl_number, ${ASCII_WHITESPACE}) <> ''
|
||||
)
|
||||
`);
|
||||
return result?.rowCount ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie une référence de facture pour un groupe donné
|
||||
*/
|
||||
@@ -258,8 +255,6 @@ export class InvoiceVerificationService {
|
||||
fromCache?: boolean;
|
||||
}> {
|
||||
try {
|
||||
console.log('🔍 [INVOICE] Début vérification facture:', { invoiceReference, groupId, forceRefresh });
|
||||
|
||||
if (!invoiceReference || !invoiceReference.trim()) {
|
||||
return {
|
||||
exists: false,
|
||||
@@ -270,15 +265,10 @@ export class InvoiceVerificationService {
|
||||
|
||||
// Vérifier le cache d'abord (sauf si refresh forcé)
|
||||
if (!forceRefresh) {
|
||||
console.log('🔍 [INVOICE] Vérification cache...');
|
||||
const cachedResult = await this.checkCache(invoiceReference, groupId);
|
||||
if (cachedResult) {
|
||||
console.log('✅ [INVOICE] Résultat depuis cache:', cachedResult);
|
||||
return cachedResult;
|
||||
}
|
||||
console.log('🔍 [INVOICE] Pas de cache, requête API...');
|
||||
} else {
|
||||
console.log('🔄 [INVOICE] Refresh forcé, ignorant le cache');
|
||||
}
|
||||
|
||||
// Récupérer la configuration du groupe
|
||||
@@ -291,16 +281,8 @@ export class InvoiceVerificationService {
|
||||
};
|
||||
}
|
||||
|
||||
console.log('🔧 Configuration groupe:', {
|
||||
groupName: group.name,
|
||||
hasNocodbConfig: !!group.nocodbConfigId,
|
||||
hasTableName: !!group.nocodbTableName,
|
||||
hasWebhook: !!group.webhookUrl
|
||||
});
|
||||
|
||||
// Si pas de configuration NocoDB, retourner un résultat par défaut
|
||||
if (!group.nocodbConfigId && !group.nocodbTableName && !group.webhookUrl) {
|
||||
console.log('⚠️ Pas de configuration NocoDB pour ce groupe');
|
||||
return {
|
||||
exists: false,
|
||||
matchType: 'none',
|
||||
@@ -353,8 +335,6 @@ export class InvoiceVerificationService {
|
||||
}
|
||||
|
||||
// En production, faire l'appel réel à NocoDB
|
||||
console.log('🔍 Vérification NocoDB en production...');
|
||||
|
||||
try {
|
||||
// Récupérer la configuration NocoDB active
|
||||
const nocodbConfig = await storage.getActiveNocodbConfig();
|
||||
@@ -367,20 +347,8 @@ export class InvoiceVerificationService {
|
||||
};
|
||||
}
|
||||
|
||||
console.log('🔧 Configuration NocoDB trouvée:', {
|
||||
configName: nocodbConfig.name,
|
||||
baseUrl: nocodbConfig.baseUrl,
|
||||
projectId: nocodbConfig.projectId,
|
||||
hasToken: !!nocodbConfig.apiToken
|
||||
});
|
||||
|
||||
// Utiliser l'ID de table configuré dans le groupe
|
||||
const tableId = group.nocodbTableId || 'mrr733dfb8wtt9b'; // Fallback par défaut
|
||||
console.log('🔧 Utilisation table ID:', {
|
||||
groupTable: group.nocodbTableName,
|
||||
configuredId: group.nocodbTableId,
|
||||
resolvedId: tableId
|
||||
});
|
||||
|
||||
// Vérifier d'abord par référence de facture
|
||||
let matchResult = await this.searchInNocoDB(
|
||||
@@ -621,8 +589,6 @@ export class InvoiceVerificationService {
|
||||
fromCache?: boolean;
|
||||
}> {
|
||||
try {
|
||||
console.log('🔍 Début vérification facture par BL:', { blNumber, supplierName, groupId, forceRefresh });
|
||||
|
||||
if (!blNumber || !blNumber.trim()) {
|
||||
return {
|
||||
exists: false,
|
||||
@@ -639,7 +605,6 @@ export class InvoiceVerificationService {
|
||||
try {
|
||||
const cached = await storage.getInvoiceVerificationCache(cacheKey);
|
||||
if (cached && new Date() < new Date(cached.expiresAt)) {
|
||||
console.log('💾 Cache hit pour BL:', { blNumber, groupId });
|
||||
return {
|
||||
exists: cached.exists,
|
||||
matchType: cached.matchType as 'invoice_reference' | 'bl_number' | 'none',
|
||||
@@ -664,17 +629,8 @@ export class InvoiceVerificationService {
|
||||
};
|
||||
}
|
||||
|
||||
console.log('🔧 Configuration groupe pour BL:', {
|
||||
groupName: group.name,
|
||||
hasNocodbConfig: !!group.nocodbConfigId,
|
||||
hasTableName: !!group.nocodbTableName,
|
||||
hasWebhook: !!group.webhookUrl,
|
||||
blColumnName: group.nocodbBlColumnName
|
||||
});
|
||||
|
||||
// Si pas de configuration NocoDB, retourner un résultat par défaut
|
||||
if (!group.nocodbConfigId && !group.nocodbTableName && !group.webhookUrl) {
|
||||
console.log('⚠️ Pas de configuration NocoDB pour ce groupe');
|
||||
return {
|
||||
exists: false,
|
||||
matchType: 'none',
|
||||
@@ -727,8 +683,6 @@ export class InvoiceVerificationService {
|
||||
}
|
||||
|
||||
// En production, faire l'appel réel à NocoDB
|
||||
console.log('🔍 Vérification BL NocoDB en production...');
|
||||
|
||||
try {
|
||||
// Récupérer la configuration NocoDB active
|
||||
const nocodbConfig = await storage.getActiveNocodbConfig();
|
||||
@@ -743,11 +697,6 @@ export class InvoiceVerificationService {
|
||||
|
||||
// Utiliser l'ID de table configuré dans le groupe
|
||||
const tableId = group.nocodbTableId || 'mrr733dfb8wtt9b';
|
||||
console.log('🔧 Utilisation table ID pour BL:', {
|
||||
groupTable: group.nocodbTableName,
|
||||
configuredId: group.nocodbTableId,
|
||||
resolvedId: tableId
|
||||
});
|
||||
|
||||
// Rechercher par numéro de BL
|
||||
const blColumnName = group.nocodbBlColumnName || 'Numero_BL';
|
||||
|
||||
+35
-37
@@ -5,7 +5,7 @@ import session from "express-session";
|
||||
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
|
||||
import { promisify } from "util";
|
||||
import { storage } from "./storage";
|
||||
import { User as SelectUser } from "@shared/schema";
|
||||
import { User as SelectUser, type UserWithGroups } from "@shared/schema";
|
||||
import connectPg from "connect-pg-simple";
|
||||
import { backupService } from "./backupService";
|
||||
|
||||
@@ -141,7 +141,9 @@ export function setupLocalAuth(app: Express) {
|
||||
passport.deserializeUser(async (id: string, done) => {
|
||||
try {
|
||||
const user = await storage.getUserWithGroups(id);
|
||||
done(null, user);
|
||||
// Utilisateur supprimé depuis la connexion : false vide la session (401)
|
||||
// au lieu de l'erreur « Failed to deserialize user out of session » (500)
|
||||
done(null, user || false);
|
||||
} catch (error) {
|
||||
done(error);
|
||||
}
|
||||
@@ -157,20 +159,9 @@ export function setupLocalAuth(app: Express) {
|
||||
return res.status(400).json({ message: info?.message || "Invalid credentials" });
|
||||
}
|
||||
|
||||
req.login(user, async (err) => {
|
||||
req.login(user, (err) => {
|
||||
if (err) return next(err);
|
||||
|
||||
// Vérifier et effectuer une sauvegarde quotidienne si nécessaire
|
||||
try {
|
||||
const backupResult = await backupService.checkAndPerformDailyBackup(user.id);
|
||||
if (backupResult.backupPerformed) {
|
||||
console.log('💾 Sauvegarde quotidienne effectuée lors de la connexion de', user.username);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('⚠️ Erreur lors de la vérification de sauvegarde:', error);
|
||||
// Ne pas faire échouer la connexion si la sauvegarde échoue
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -180,6 +171,21 @@ export function setupLocalAuth(app: Express) {
|
||||
role: user.role,
|
||||
passwordChanged: user.passwordChanged
|
||||
});
|
||||
|
||||
// Vérifier et effectuer une sauvegarde quotidienne si nécessaire, après
|
||||
// la réponse : la connexion n'attend plus le pg_dump
|
||||
setImmediate(() => {
|
||||
backupService.checkAndPerformDailyBackup(user.id)
|
||||
.then((backupResult) => {
|
||||
if (backupResult.backupPerformed) {
|
||||
console.log('💾 Sauvegarde quotidienne effectuée lors de la connexion de', user.username);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
// Ne pas faire échouer la connexion si la sauvegarde échoue
|
||||
console.error('⚠️ Erreur lors de la vérification de sauvegarde:', error);
|
||||
});
|
||||
});
|
||||
});
|
||||
})(req, res, next);
|
||||
});
|
||||
@@ -201,33 +207,25 @@ export function setupLocalAuth(app: Express) {
|
||||
app.get("/api/logout", logoutHandler);
|
||||
|
||||
// Get current user
|
||||
app.get("/api/user", async (req, res) => {
|
||||
app.get("/api/user", (req, res) => {
|
||||
if (!req.isAuthenticated()) {
|
||||
return res.status(401).json({ message: "Non authentifié" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = (req.user as SelectUser).id;
|
||||
const userWithGroups = await storage.getUserWithGroups(userId);
|
||||
|
||||
if (!userWithGroups) {
|
||||
return res.status(404).json({ message: "Utilisateur non trouvé" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: userWithGroups.id,
|
||||
username: userWithGroups.username,
|
||||
email: userWithGroups.email,
|
||||
firstName: userWithGroups.firstName,
|
||||
lastName: userWithGroups.lastName,
|
||||
role: userWithGroups.role,
|
||||
passwordChanged: userWithGroups.passwordChanged,
|
||||
userGroups: userWithGroups.userGroups || []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching user with groups:", error);
|
||||
res.status(500).json({ message: "Erreur lors de la récupération des données utilisateur" });
|
||||
}
|
||||
// req.user est déjà rechargé avec ses magasins par deserializeUser à chaque
|
||||
// requête : inutile de relire l'utilisateur en base
|
||||
const userWithGroups = req.user as UserWithGroups;
|
||||
|
||||
res.json({
|
||||
id: userWithGroups.id,
|
||||
username: userWithGroups.username,
|
||||
email: userWithGroups.email,
|
||||
firstName: userWithGroups.firstName,
|
||||
lastName: userWithGroups.lastName,
|
||||
role: userWithGroups.role,
|
||||
passwordChanged: userWithGroups.passwordChanged,
|
||||
userGroups: userWithGroups.userGroups || []
|
||||
});
|
||||
});
|
||||
|
||||
// Check if default credentials should be shown
|
||||
|
||||
@@ -1,5 +1,283 @@
|
||||
import { Client } from 'pg';
|
||||
import { encryptSecret, isEncryptedSecret } from './crypto.js';
|
||||
import { pool } from './db.js';
|
||||
|
||||
// Index de performance garantis à chaque démarrage.
|
||||
// Union de migrations/20260814_add_performance_indexes.sql, des index d'init.sql
|
||||
// et des index composites issus de l'audit base de données (DB-14, DB-15, DB-34,
|
||||
// DB-35, TASKS-29, SAV-27). Tables et colonnes conformes à shared/schema.ts.
|
||||
// Volontairement absents : IDX_session_expire (table sessions inutilisée),
|
||||
// pg_trgm, et l'index unique de user_groups (dédoublonnage préalable requis).
|
||||
type PerformanceIndex = { name: string; table: string; columns: string; where?: string };
|
||||
|
||||
const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
// user_groups : lu à chaque requête authentifiée via getUserWithGroups()
|
||||
{ name: 'idx_user_groups_user_id', table: 'user_groups', columns: 'user_id' },
|
||||
{ name: 'idx_user_groups_group_id', table: 'user_groups', columns: 'group_id' },
|
||||
|
||||
// orders
|
||||
{ name: 'idx_orders_group_id', table: 'orders', columns: 'group_id' },
|
||||
{ name: 'idx_orders_supplier_id', table: 'orders', columns: 'supplier_id' },
|
||||
{ name: 'idx_orders_created_by', table: 'orders', columns: 'created_by' },
|
||||
{ name: 'idx_orders_planned_date', table: 'orders', columns: 'planned_date' },
|
||||
{ name: 'idx_orders_group_planned_date', table: 'orders', columns: 'group_id, planned_date' },
|
||||
{ name: 'idx_orders_created_at', table: 'orders', columns: 'created_at DESC' },
|
||||
{ name: 'idx_orders_status', table: 'orders', columns: 'status' },
|
||||
// Comptage des commandes en attente par magasin (statistiques)
|
||||
{ name: 'idx_orders_group_status', table: 'orders', columns: 'group_id, status' },
|
||||
|
||||
// deliveries : order_id est la clé de jointure la plus sollicitée
|
||||
{ name: 'idx_deliveries_order_id', table: 'deliveries', columns: 'order_id' },
|
||||
{ name: 'idx_deliveries_group_id', table: 'deliveries', columns: 'group_id' },
|
||||
{ name: 'idx_deliveries_supplier_id', table: 'deliveries', columns: 'supplier_id' },
|
||||
{ name: 'idx_deliveries_created_by', table: 'deliveries', columns: 'created_by' },
|
||||
{ name: 'idx_deliveries_scheduled_date', table: 'deliveries', columns: 'scheduled_date' },
|
||||
{ name: 'idx_deliveries_group_scheduled_date', table: 'deliveries', columns: 'group_id, scheduled_date' },
|
||||
{ name: 'idx_deliveries_created_at', table: 'deliveries', columns: 'created_at DESC' },
|
||||
{ name: 'idx_deliveries_invoice_reference', table: 'deliveries', columns: 'invoice_reference', where: 'invoice_reference IS NOT NULL' },
|
||||
{ name: 'idx_deliveries_status', table: 'deliveries', columns: 'status' },
|
||||
{ name: 'idx_deliveries_bl_number', table: 'deliveries', columns: 'bl_number' },
|
||||
{ name: 'idx_deliveries_due_date', table: 'deliveries', columns: 'due_date' },
|
||||
// Livraisons livrées par magasin (rapprochement, statistiques)
|
||||
{ name: 'idx_deliveries_group_status', table: 'deliveries', columns: 'group_id, status' },
|
||||
{ name: 'idx_deliveries_group_delivered_date', table: 'deliveries', columns: 'group_id, delivered_date' },
|
||||
|
||||
// reconciliation_comments
|
||||
{ name: 'idx_reconciliation_comments_delivery_id', table: 'reconciliation_comments', columns: 'delivery_id' },
|
||||
{ name: 'idx_reconciliation_comments_group_id', table: 'reconciliation_comments', columns: 'group_id' },
|
||||
|
||||
// contacts
|
||||
{ name: 'idx_contacts_group_id', table: 'contacts', columns: 'group_id' },
|
||||
|
||||
// customer_orders
|
||||
{ name: 'idx_customer_orders_group_id', table: 'customer_orders', columns: 'group_id' },
|
||||
{ name: 'idx_customer_orders_supplier_id', table: 'customer_orders', columns: 'supplier_id' },
|
||||
{ name: 'idx_customer_orders_created_at', table: 'customer_orders', columns: 'created_at DESC' },
|
||||
{ name: 'idx_customer_orders_group_status', table: 'customer_orders', columns: 'group_id, status' },
|
||||
// Appels clients en attente : même prédicat que getPendingClientCalls()
|
||||
{ name: 'idx_customer_orders_pending_calls', table: 'customer_orders', columns: 'group_id, created_at DESC', where: 'customer_notified = false' },
|
||||
|
||||
// dlc_products
|
||||
{ name: 'idx_dlc_products_group_id', table: 'dlc_products', columns: 'group_id' },
|
||||
{ name: 'idx_dlc_products_supplier_id', table: 'dlc_products', columns: 'supplier_id' },
|
||||
{ name: 'idx_dlc_products_expiry_date', table: 'dlc_products', columns: 'expiry_date' },
|
||||
{ name: 'idx_dlc_products_status', table: 'dlc_products', columns: 'status' },
|
||||
{ name: 'idx_dlc_products_group_status', table: 'dlc_products', columns: 'group_id, status' },
|
||||
{ name: 'idx_dlc_products_group_expiry', table: 'dlc_products', columns: 'group_id, expiry_date' },
|
||||
|
||||
// tasks
|
||||
{ name: 'idx_tasks_group_id', table: 'tasks', columns: 'group_id' },
|
||||
{ name: 'idx_tasks_created_by', table: 'tasks', columns: 'created_by' },
|
||||
{ name: 'idx_tasks_due_date', table: 'tasks', columns: 'due_date' },
|
||||
{ name: 'idx_tasks_group_status', table: 'tasks', columns: 'group_id, status' },
|
||||
{ name: 'idx_tasks_group_created', table: 'tasks', columns: 'group_id, created_at DESC' },
|
||||
|
||||
// avoirs
|
||||
{ name: 'idx_avoirs_group_id', table: 'avoirs', columns: 'group_id' },
|
||||
{ name: 'idx_avoirs_supplier_id', table: 'avoirs', columns: 'supplier_id' },
|
||||
{ name: 'idx_avoirs_created_at', table: 'avoirs', columns: 'created_at DESC' },
|
||||
{ name: 'idx_avoirs_group_status', table: 'avoirs', columns: 'group_id, status' },
|
||||
|
||||
// sav_tickets
|
||||
{ name: 'idx_sav_tickets_group_id', table: 'sav_tickets', columns: 'group_id' },
|
||||
{ name: 'idx_sav_tickets_supplier_id', table: 'sav_tickets', columns: 'supplier_id' },
|
||||
{ name: 'idx_sav_tickets_created_at', table: 'sav_tickets', columns: 'created_at DESC' },
|
||||
{ name: 'idx_sav_tickets_status', table: 'sav_tickets', columns: 'status' },
|
||||
{ name: 'idx_sav_tickets_priority', table: 'sav_tickets', columns: 'priority' },
|
||||
{ name: 'idx_sav_tickets_group_status', table: 'sav_tickets', columns: 'group_id, status' },
|
||||
{ name: 'idx_sav_tickets_group_created', table: 'sav_tickets', columns: 'group_id, created_at DESC' },
|
||||
|
||||
// invoice_verification_cache : expires_at sert la purge périodique
|
||||
{ name: 'idx_invoice_cache_expires_at', table: 'invoice_verification_cache', columns: 'expires_at' },
|
||||
{ name: 'idx_invoice_cache_group_id', table: 'invoice_verification_cache', columns: 'group_id' },
|
||||
|
||||
// publicity_participations : la clé primaire (publicity_id, group_id) ne couvre pas group_id seul
|
||||
{ name: 'idx_publicity_participations_group_id', table: 'publicity_participations', columns: 'group_id' },
|
||||
|
||||
// publicities
|
||||
{ name: 'idx_publicities_year', table: 'publicities', columns: 'year' },
|
||||
{ name: 'idx_publicities_start_date', table: 'publicities', columns: 'start_date' },
|
||||
|
||||
// dashboard_messages
|
||||
{ name: 'idx_dashboard_messages_store_id', table: 'dashboard_messages', columns: 'store_id' },
|
||||
{ name: 'idx_dashboard_messages_created_at', table: 'dashboard_messages', columns: 'created_at DESC' },
|
||||
|
||||
// weather_data
|
||||
{ name: 'idx_weather_data_date_location', table: 'weather_data', columns: 'date, location' },
|
||||
{ name: 'idx_weather_data_date_year', table: 'weather_data', columns: 'date, is_current_year' },
|
||||
|
||||
// supplier_mail_logs : historique récent par magasin
|
||||
{ name: 'idx_supplier_mail_logs_delivery', table: 'supplier_mail_logs', columns: 'delivery_id' },
|
||||
{ name: 'idx_supplier_mail_logs_group', table: 'supplier_mail_logs', columns: 'group_id' },
|
||||
{ name: 'idx_supplier_mail_logs_group_created', table: 'supplier_mail_logs', columns: 'group_id, created_at DESC' },
|
||||
];
|
||||
|
||||
let performanceIndexesRunning = false;
|
||||
|
||||
/**
|
||||
* Crée les index de performance manquants sans verrouiller les tables en écriture.
|
||||
*
|
||||
* CREATE INDEX CONCURRENTLY est interdit dans une transaction, et pg en ouvre une
|
||||
* implicite pour une chaîne de plusieurs instructions : chaque index part donc
|
||||
* dans sa propre requête, avec son propre try/catch pour qu'un échec (table
|
||||
* absente, etc.) n'empêche pas les suivants.
|
||||
*/
|
||||
export async function ensurePerformanceIndexes(): Promise<void> {
|
||||
if (!pool) {
|
||||
console.warn('⚠️ MIGRATION: No database pool, performance indexes not checked');
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
console.log('🔄 MIGRATION: Checking performance indexes in background...');
|
||||
|
||||
// 1. Une création CONCURRENTLY interrompue laisse un index invalide, que
|
||||
// IF NOT EXISTS considère ensuite comme présent : on purge ces reliquats.
|
||||
// Un index en cours de construction (par exemple par le processus d'avant un
|
||||
// redémarrage) est lui aussi invalide : on l'écarte via
|
||||
// pg_stat_progress_create_index. DROP INDEX CONCURRENTLY ne prend pas de
|
||||
// verrou exclusif, donc la purge ne bloque jamais les lectures de l'app.
|
||||
try {
|
||||
const invalid = await pool.query(`
|
||||
SELECT c.relname
|
||||
FROM pg_index i
|
||||
JOIN pg_class c ON c.oid = i.indexrelid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE NOT i.indisvalid AND n.nspname = 'public' AND c.relname LIKE 'idx_%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pg_stat_progress_create_index p WHERE p.index_relid = i.indexrelid
|
||||
)
|
||||
`);
|
||||
for (const row of invalid.rows) {
|
||||
const indexName = String(row.relname).replace(/"/g, '""');
|
||||
try {
|
||||
await pool.query(`DROP INDEX CONCURRENTLY IF EXISTS public."${indexName}"`);
|
||||
console.log(`🧹 MIGRATION: Invalid index ${row.relname} dropped`);
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ MIGRATION: Invalid index ${row.relname} could not be dropped:`, (error as any)?.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ MIGRATION: Invalid indexes purge failed:', (error as any)?.message);
|
||||
}
|
||||
|
||||
// 2. État actuel : tables présentes, index par nom (valides ou non) et colonnes
|
||||
// déjà indexées sous un autre nom (ex. idx_deliveries_invoice_ref d'init.sql),
|
||||
// pour ne pas créer de doublon. En cas d'échec, chaque index est tenté.
|
||||
let existingTables: Set<string> | null = null;
|
||||
const existingIndexNames = new Set<string>();
|
||||
const invalidIndexNames = new Set<string>();
|
||||
const indexedColumns = new Set<string>();
|
||||
try {
|
||||
const tables = await pool.query(`
|
||||
SELECT c.relname
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')
|
||||
`);
|
||||
const indexes = await pool.query(`
|
||||
SELECT t.relname AS table_name,
|
||||
c.relname AS index_name,
|
||||
i.indisvalid AS valid,
|
||||
(i.indpred IS NULL AND i.indexprs IS NULL) AS plain,
|
||||
array_to_string(ARRAY(
|
||||
SELECT a.attname
|
||||
FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
|
||||
WHERE k.ord <= i.indnkeyatts
|
||||
ORDER BY k.ord
|
||||
), ',') AS columns
|
||||
FROM pg_index i
|
||||
JOIN pg_class c ON c.oid = i.indexrelid
|
||||
JOIN pg_class t ON t.oid = i.indrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
`);
|
||||
existingTables = new Set(tables.rows.map((row: any) => row.relname));
|
||||
for (const row of indexes.rows) {
|
||||
if (!row.valid) {
|
||||
invalidIndexNames.add(row.index_name);
|
||||
continue;
|
||||
}
|
||||
existingIndexNames.add(row.index_name);
|
||||
if (row.plain) indexedColumns.add(`${row.table_name}(${row.columns})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ MIGRATION: Could not read existing indexes:', (error as any)?.message);
|
||||
}
|
||||
|
||||
// 3. Création des index manquants, une requête par index
|
||||
let created = 0;
|
||||
let present = 0;
|
||||
let skipped = 0;
|
||||
const failed: string[] = [];
|
||||
const tablesToAnalyze = new Set<string>();
|
||||
|
||||
for (const index of PERFORMANCE_INDEXES) {
|
||||
// Le sens de tri n'importe pas : un btree se parcourt dans les deux sens
|
||||
const keyColumns = index.columns
|
||||
.split(',')
|
||||
.map((column) => column.trim().split(/\s+/)[0])
|
||||
.join(',');
|
||||
if (existingIndexNames.has(index.name) || indexedColumns.has(`${index.table}(${keyColumns})`)) {
|
||||
present++;
|
||||
continue;
|
||||
}
|
||||
if (existingTables && !existingTables.has(index.table)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// Reliquat invalide que la purge n'a pas pu supprimer : IF NOT EXISTS l'ignorerait
|
||||
if (invalidIndexNames.has(index.name)) {
|
||||
failed.push(index.name);
|
||||
console.warn(`⚠️ MIGRATION: Index ${index.name} is invalid and could not be purged`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${index.name} ON ${index.table} (${index.columns})` +
|
||||
(index.where ? ` WHERE ${index.where}` : '')
|
||||
);
|
||||
created++;
|
||||
tablesToAnalyze.add(index.table);
|
||||
console.log(`✅ MIGRATION: Index ${index.name} created`);
|
||||
} catch (error) {
|
||||
failed.push(index.name);
|
||||
console.warn(`⚠️ MIGRATION: Index ${index.name} failed:`, (error as any)?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Statistiques à jour pour que le planificateur utilise les nouveaux index
|
||||
// sans attendre le prochain autovacuum
|
||||
for (const table of tablesToAnalyze) {
|
||||
try {
|
||||
await pool.query(`ANALYZE ${table}`);
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ MIGRATION: ANALYZE ${table} failed:`, (error as any)?.message);
|
||||
}
|
||||
}
|
||||
|
||||
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
console.log(
|
||||
`📊 MIGRATION: Performance indexes: ${created} created, ${present} already present, ` +
|
||||
`${skipped} skipped (missing table), ${failed.length} failed in ${seconds}s` +
|
||||
(failed.length > 0 ? ` (${failed.join(', ')})` : '')
|
||||
);
|
||||
}
|
||||
|
||||
// Lance la vérification des index en tâche de fond : ni le démarrage ni le
|
||||
// healthcheck n'attendent la fin des créations. La route d'urgence pouvant
|
||||
// relancer les migrations, un seul passage à la fois.
|
||||
function startPerformanceIndexes(): void {
|
||||
if (performanceIndexesRunning) return;
|
||||
performanceIndexesRunning = true;
|
||||
ensurePerformanceIndexes()
|
||||
.catch((error) => console.error('❌ MIGRATION ERROR: Performance indexes check failed:', error))
|
||||
.finally(() => {
|
||||
performanceIndexesRunning = false;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runProductionMigrations() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
@@ -145,4 +423,7 @@ export async function runProductionMigrations() {
|
||||
await client.end();
|
||||
console.log('🔌 MIGRATION: Database connection closed');
|
||||
}
|
||||
|
||||
// Après les migrations de schéma ci-dessus (colonne priority, supplier_mail_logs)
|
||||
startPerformanceIndexes();
|
||||
}
|
||||
+245
-602
File diff suppressed because it is too large.
Load diff
@@ -50,6 +50,7 @@ export function setupCsrfProtection(app: Express) {
|
||||
const csrfExemptPaths = [
|
||||
'/api/health',
|
||||
'/api/webhook', // External webhook callbacks
|
||||
'/api/ext/', // API externe : authentifiée par clé d'API, sans cookie de session
|
||||
];
|
||||
|
||||
if (csrfExemptPaths.some(path => req.path.startsWith(path))) {
|
||||
|
||||
+328
-266
File diff suppressed because it is too large.
Load diff
+15
-1
@@ -76,10 +76,24 @@ export function serveStatic(app: Express) {
|
||||
);
|
||||
}
|
||||
|
||||
app.use(express.static(distPath));
|
||||
// Fichiers de /assets hachés par Vite : cache navigateur d'un an
|
||||
app.use("/assets", express.static(path.resolve(distPath, "assets"), {
|
||||
maxAge: "1y",
|
||||
immutable: true,
|
||||
index: false,
|
||||
}));
|
||||
// index.html toujours revalidé pour prendre en compte un nouveau déploiement
|
||||
app.use(express.static(distPath, {
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith(".html")) {
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// fall through to index.html if the file doesn't exist
|
||||
app.use("*", (_req, res) => {
|
||||
res.set("Cache-Control", "no-cache");
|
||||
res.sendFile(path.resolve(distPath, "index.html"));
|
||||
});
|
||||
}
|
||||
@@ -27,7 +27,8 @@ export class WeatherService {
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
console.error(`Weather API error: ${response.status} ${response.statusText}`);
|
||||
const reason = (await response.text().catch(() => '')).slice(0, 300);
|
||||
console.error(`Weather API error: ${response.status} ${response.statusText} ${reason}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -51,7 +52,9 @@ export class WeatherService {
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
console.error(`🌤️ [ERROR] Weather API error for previous year: ${response.status} ${response.statusText}`);
|
||||
// Visual Crossing explique le refus dans le corps (quota, plan, clé...)
|
||||
const reason = (await response.text().catch(() => '')).slice(0, 300);
|
||||
console.error(`🌤️ [ERROR] Weather API error for previous year: ${response.status} ${response.statusText} ${reason}`);
|
||||
|
||||
// Pour les données historiques, certains comptes peuvent ne pas avoir accès
|
||||
if (response.status === 401) {
|
||||
|
||||
+5
-1
@@ -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"],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user