mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-16 03:40:06 +02:00
latest
This commit is contained in:
1 parent
e3fb29ab1d
commit
2485f71dd7
95 files changed
+41842
No files matched your search
@@ -0,0 +1,298 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Store, LogIn, User, Info } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
// Fonction pour détecter l'environnement
|
||||
const getEnvironment = () => {
|
||||
const hostname = window.location.hostname;
|
||||
const isDev = hostname.includes('replit.dev') || hostname === 'localhost';
|
||||
return isDev ? 'development' : 'production';
|
||||
};
|
||||
|
||||
export default function AuthPage() {
|
||||
const [, setLocation] = useLocation();
|
||||
const { user, isLoading, refreshAuth, forceAuthRefresh, isAuthenticated } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const [showDefaultCredentials, setShowDefaultCredentials] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [loginData, setLoginData] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const currentEnvironment = getEnvironment();
|
||||
|
||||
// Check if admin user still has default password
|
||||
const { data: defaultCredentialsCheck } = useQuery({
|
||||
queryKey: ['/api/default-credentials-check'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await apiRequest("/api/default-credentials-check", "GET");
|
||||
return response;
|
||||
} catch (error) {
|
||||
// If the endpoint doesn't exist or fails, assume we should show credentials
|
||||
return { showDefault: true };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Fonction de login simple pour production
|
||||
const handleProductionLogin = async (data: typeof loginData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: "Connexion réussie",
|
||||
description: "Redirection en cours...",
|
||||
});
|
||||
|
||||
// En production, recharger la page pour éviter les problèmes de synchronisation
|
||||
setTimeout(() => {
|
||||
window.location.href = "/";
|
||||
}, 500);
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
toast({
|
||||
title: "Erreur de connexion",
|
||||
description: errorData.message || "Identifiant ou mot de passe incorrect",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "Erreur de connexion",
|
||||
description: error.message || "Une erreur est survenue",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Mutation complexe pour développement
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (data: typeof loginData) => {
|
||||
const response = await apiRequest("/api/login", "POST", data);
|
||||
return response;
|
||||
},
|
||||
onSuccess: async (loginResult) => {
|
||||
console.log('✅ Login successful, refreshing auth state...');
|
||||
|
||||
toast({
|
||||
title: "Connexion réussie",
|
||||
description: "Bienvenue dans LogiFlow",
|
||||
});
|
||||
|
||||
// Force invalidation of auth queries and refresh auth state
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/default-credentials-check'] });
|
||||
|
||||
// Force immediate authentication state refresh
|
||||
const refreshedUserData = await forceAuthRefresh();
|
||||
console.log('🔄 Force auth refresh result:', refreshedUserData);
|
||||
|
||||
if (refreshedUserData && refreshedUserData.id) {
|
||||
console.log('🔄 User data confirmed, forcing redirect...');
|
||||
setLocation("/");
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
forceAuthRefresh().then((retryUserData) => {
|
||||
if (retryUserData && retryUserData.id) {
|
||||
setLocation("/");
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur de connexion",
|
||||
description: error.message || "Identifiant ou mot de passe incorrect",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Debug logging for authentication state
|
||||
console.log('🔍 AuthPage Debug:', {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
userId: user?.id,
|
||||
username: user?.username
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultCredentialsCheck) {
|
||||
setShowDefaultCredentials(defaultCredentialsCheck.showDefault);
|
||||
}
|
||||
}, [defaultCredentialsCheck]);
|
||||
|
||||
// Redirect if already authenticated
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated && user) {
|
||||
console.log('🔄 User authenticated, redirecting to dashboard...', { user: user.username, authenticated: isAuthenticated });
|
||||
// Force immediate redirect to home page
|
||||
setLocation("/");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, user, setLocation]);
|
||||
|
||||
const handleLogin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (currentEnvironment === 'production') {
|
||||
// Utiliser la méthode simple pour production
|
||||
handleProductionLogin(loginData);
|
||||
} else {
|
||||
// Utiliser React Query pour développement
|
||||
loginMutation.mutate(loginData);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
{/* Left side - Auth forms */}
|
||||
<div className="flex-1 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center">
|
||||
<Store className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
|
||||
LogiFlow
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Gestion des commandes et livraisons
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<LogIn className="w-5 h-5" />
|
||||
Connexion
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Connectez-vous à votre compte LogiFlow
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="login-username">Identifiant</Label>
|
||||
<Input
|
||||
id="login-username"
|
||||
type="text"
|
||||
value={loginData.username}
|
||||
onChange={(e) => setLoginData({...loginData, username: e.target.value})}
|
||||
required
|
||||
placeholder="Votre identifiant"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="login-password">Mot de passe</Label>
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={loginData.password}
|
||||
onChange={(e) => setLoginData({...loginData, password: e.target.value})}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={currentEnvironment === 'production' ? isSubmitting : loginMutation.isPending}
|
||||
>
|
||||
{(currentEnvironment === 'production' ? isSubmitting : loginMutation.isPending) ? "Connexion..." : "Se connecter"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Default credentials info - only show if admin password hasn't been changed */}
|
||||
{showDefaultCredentials && (
|
||||
<Card className="mt-4 bg-blue-50 border-blue-200">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-blue-800 text-sm">
|
||||
<Info className="w-4 h-4" />
|
||||
Première connexion
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="text-sm text-blue-700 space-y-1">
|
||||
<p><strong>Identifiant :</strong> admin</p>
|
||||
<p><strong>Mot de passe :</strong> admin</p>
|
||||
<p className="text-xs text-blue-600 mt-2">
|
||||
Changez le mot de passe après votre première connexion
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Hero section */}
|
||||
<div className="hidden lg:block relative w-0 flex-1">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary to-primary/80 flex items-center justify-center">
|
||||
<div className="text-center text-white max-w-md">
|
||||
<Store className="w-20 h-20 mx-auto mb-6 opacity-90" />
|
||||
<h1 className="text-4xl font-bold mb-4">
|
||||
Gérez vos commandes efficacement
|
||||
</h1>
|
||||
<p className="text-xl opacity-90 mb-8">
|
||||
LogiFlow centralise la gestion de vos commandes et livraisons across tous vos magasins
|
||||
</p>
|
||||
<div className="space-y-4 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<span>Calendrier centralisé</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<span>Gestion multi-magasins</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<span>Suivi en temps réel</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,265 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronLeft, ChevronRight, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import CalendarGrid from "@/components/CalendarGrid";
|
||||
import QuickCreateMenu from "@/components/modals/QuickCreateMenu";
|
||||
import OrderDetailModal from "@/components/modals/OrderDetailModal";
|
||||
import CreateOrderModal from "@/components/modals/CreateOrderModal";
|
||||
import CreateDeliveryModal from "@/components/modals/CreateDeliveryModal";
|
||||
import StatsPanel from "@/components/StatsPanel";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { format, startOfMonth, endOfMonth } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
|
||||
export default function Calendar() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const queryClient = useQueryClient();
|
||||
const [currentDate, setCurrentDate] = useState(new Date(2025, 6, 1)); // July 2025
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [showQuickCreate, setShowQuickCreate] = useState(false);
|
||||
const [showOrderDetail, setShowOrderDetail] = useState(false);
|
||||
const [showCreateOrder, setShowCreateOrder] = useState(false);
|
||||
const [showCreateDelivery, setShowCreateDelivery] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<any>(null);
|
||||
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
|
||||
// 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')
|
||||
}],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
||||
});
|
||||
if (selectedStoreId && user?.role === 'admin') {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
const url = `/api/orders?${params.toString()}`;
|
||||
console.log('📅 Calendar fetching orders:', {
|
||||
url,
|
||||
selectedStoreId,
|
||||
userRole: user?.role,
|
||||
params: params.toString()
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch orders');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('📅 Calendar orders received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: deliveries = [], isLoading: loadingDeliveries } = useQuery({
|
||||
queryKey: ['/api/deliveries', selectedStoreId, {
|
||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
||||
}],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
||||
});
|
||||
if (selectedStoreId && user?.role === 'admin') {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
const url = `/api/deliveries?${params.toString()}`;
|
||||
console.log('📅 Calendar fetching deliveries:', {
|
||||
url,
|
||||
selectedStoreId,
|
||||
userRole: user?.role,
|
||||
params: params.toString()
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch deliveries');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('📅 Calendar deliveries received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const navigateMonth = (direction: 'prev' | 'next') => {
|
||||
const newDate = new Date(currentDate);
|
||||
if (direction === 'prev') {
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
} else {
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
}
|
||||
setCurrentDate(newDate);
|
||||
};
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
setSelectedDate(date);
|
||||
setShowQuickCreate(true);
|
||||
};
|
||||
|
||||
const handleItemClick = (item: any, type: 'order' | 'delivery') => {
|
||||
// Rafraîchir les données avant d'ouvrir le modal pour s'assurer d'avoir les liaisons à jour
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/orders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/orders', selectedStoreId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries', selectedStoreId] });
|
||||
|
||||
setSelectedItem({ ...item, type });
|
||||
setShowOrderDetail(true);
|
||||
};
|
||||
|
||||
const handleCreateOrder = () => {
|
||||
setShowQuickCreate(false);
|
||||
setShowCreateOrder(true);
|
||||
};
|
||||
|
||||
const handleCreateDelivery = () => {
|
||||
setShowQuickCreate(false);
|
||||
setShowCreateDelivery(true);
|
||||
};
|
||||
|
||||
const isLoading = loadingOrders || loadingDeliveries;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-white shadow-sm border-b border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
Calendrier des Commandes & Livraisons
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
{format(currentDate, 'MMMM yyyy', { locale: fr })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Legend */}
|
||||
<div className="flex items-center space-x-6 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-primary rounded"></div>
|
||||
<span className="text-gray-600">Commandes</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-secondary rounded"></div>
|
||||
<span className="text-gray-600">Livraisons</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-delivered rounded"></div>
|
||||
<span className="text-gray-600">Livré</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Navigation */}
|
||||
<div className="p-6 pb-0">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateMonth('prev')}
|
||||
className="p-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
{format(currentDate, 'MMMM yyyy', { locale: fr })}
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateMonth('next')}
|
||||
className="p-2"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowQuickCreate(true)}
|
||||
className="bg-accent hover:bg-orange-600 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouveau
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
<CalendarGrid
|
||||
currentDate={currentDate}
|
||||
orders={orders}
|
||||
deliveries={deliveries}
|
||||
onDateClick={handleDateClick}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showQuickCreate && (
|
||||
<QuickCreateMenu
|
||||
isOpen={showQuickCreate}
|
||||
onClose={() => setShowQuickCreate(false)}
|
||||
onCreateOrder={handleCreateOrder}
|
||||
onCreateDelivery={handleCreateDelivery}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showOrderDetail && selectedItem && (
|
||||
<OrderDetailModal
|
||||
isOpen={showOrderDetail}
|
||||
onClose={() => setShowOrderDetail(false)}
|
||||
item={selectedItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCreateOrder && (
|
||||
<CreateOrderModal
|
||||
isOpen={showCreateOrder}
|
||||
onClose={() => setShowCreateOrder(false)}
|
||||
selectedDate={selectedDate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCreateDelivery && (
|
||||
<CreateDeliveryModal
|
||||
isOpen={showCreateDelivery}
|
||||
onClose={() => setShowCreateDelivery(false)}
|
||||
selectedDate={selectedDate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats Panel */}
|
||||
<StatsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||
import { Plus, Edit, Trash2, Phone, PhoneCall, Printer, Eye, Package } 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 { CustomerOrderForm } from "@/components/CustomerOrderForm";
|
||||
import { CustomerOrderDetails } from "@/components/CustomerOrderDetails";
|
||||
import { ConfirmationModal } from "@/components/ConfirmationModal";
|
||||
import { useStore } from "@/components/Layout";
|
||||
|
||||
export default function CustomerOrders() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showStatusModal, setShowStatusModal] = useState(false);
|
||||
const [selectedOrder, setSelectedOrder] = useState<CustomerOrderWithRelations | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sortBy, setSortBy] = useState<"date" | "status" | "supplier">("date");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
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'],
|
||||
});
|
||||
|
||||
// Fetch suppliers for filter
|
||||
const { data: suppliers = [] } = useQuery({
|
||||
queryKey: ['/api/suppliers'],
|
||||
});
|
||||
|
||||
// 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[]>({
|
||||
queryKey: [customerOrdersUrl, selectedStoreId],
|
||||
});
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => apiRequest('/api/customer-orders', 'POST', data),
|
||||
onSuccess: () => {
|
||||
// Force refresh of the query with store context
|
||||
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString().includes('/api/customer-orders') });
|
||||
queryClient.refetchQueries({ predicate: (query) => query.queryKey[0]?.toString().includes('/api/customer-orders') });
|
||||
setShowCreateModal(false);
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Commande client créée avec succès",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la création de la commande",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) =>
|
||||
apiRequest(`/api/customer-orders/${id}`, 'PUT', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString().includes('/api/customer-orders') });
|
||||
setShowEditModal(false);
|
||||
setSelectedOrder(null);
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Commande client mise à jour avec succès",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la mise à jour de la commande",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => apiRequest(`/api/customer-orders/${id}`, 'DELETE'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString().includes('/api/customer-orders') });
|
||||
setShowDeleteModal(false);
|
||||
setSelectedOrder(null);
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Commande client supprimée avec succès",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la suppression de la commande",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Status mutation
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: number; status: string }) =>
|
||||
apiRequest(`/api/customer-orders/${id}`, 'PUT', { status }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString().includes('/api/customer-orders') });
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Statut de la commande mis à jour",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Notification mutation
|
||||
const notificationMutation = useMutation({
|
||||
mutationFn: ({ id, customerNotified }: { id: number; customerNotified: boolean }) =>
|
||||
apiRequest(`/api/customer-orders/${id}`, 'PUT', { customerNotified }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString().includes('/api/customer-orders') });
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Statut de notification mis à jour",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "En attente de Commande":
|
||||
return "bg-yellow-50 text-yellow-700 border border-yellow-200";
|
||||
case "Commande en Cours":
|
||||
return "bg-blue-50 text-blue-700 border border-blue-200";
|
||||
case "Disponible":
|
||||
return "bg-green-50 text-green-700 border border-green-200";
|
||||
case "Retiré":
|
||||
return "bg-gray-50 text-gray-700 border border-gray-200";
|
||||
case "Annulé":
|
||||
return "bg-red-50 text-red-700 border border-red-200";
|
||||
default:
|
||||
return "bg-gray-50 text-gray-700 border border-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
const isGrayedOut = (status: string) => {
|
||||
return status === "Retiré" || status === "Annulé";
|
||||
};
|
||||
|
||||
const canShowButtons = (status: string) => {
|
||||
return status === "Disponible";
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
"En attente de Commande",
|
||||
"Commande en Cours",
|
||||
"Disponible",
|
||||
"Retiré",
|
||||
"Annulé"
|
||||
];
|
||||
|
||||
const handleStatusChange = (id: number, newStatus: string) => {
|
||||
statusMutation.mutate({ id, status: newStatus });
|
||||
};
|
||||
|
||||
const handleCreateOrder = (data: any) => {
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleEditOrder = (data: any) => {
|
||||
if (selectedOrder) {
|
||||
updateMutation.mutate({ id: selectedOrder.id, data });
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (order: CustomerOrderWithRelations) => {
|
||||
setSelectedOrder(order);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const openDetailsModal = (order: CustomerOrderWithRelations) => {
|
||||
setSelectedOrder(order);
|
||||
setShowDetailsModal(true);
|
||||
};
|
||||
|
||||
const openDeleteModal = (order: CustomerOrderWithRelations) => {
|
||||
setSelectedOrder(order);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const openStatusModal = (order: CustomerOrderWithRelations) => {
|
||||
setSelectedOrder(order);
|
||||
setShowStatusModal(true);
|
||||
};
|
||||
|
||||
const handleNotificationToggle = (order: CustomerOrderWithRelations) => {
|
||||
notificationMutation.mutate({
|
||||
id: order.id,
|
||||
customerNotified: !order.customerNotified
|
||||
});
|
||||
};
|
||||
|
||||
// Fonction pour calculer le checksum EAN13
|
||||
const calculateEAN13Checksum = (code: string): string => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const digit = parseInt(code[i]);
|
||||
sum += i % 2 === 0 ? digit : digit * 3;
|
||||
}
|
||||
const checksum = (10 - (sum % 10)) % 10;
|
||||
return checksum.toString();
|
||||
};
|
||||
|
||||
// Fonction pour générer un code-barres scannable
|
||||
const generateEAN13Barcode = (code: string): string => {
|
||||
try {
|
||||
// Créer un canvas temporaire
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
let processedCode = code.replace(/[^0-9]/g, ''); // Garder que les chiffres
|
||||
|
||||
// Gérer différentes longueurs de codes
|
||||
if (processedCode.length < 12) {
|
||||
processedCode = processedCode.padStart(12, '0');
|
||||
} else if (processedCode.length > 13) {
|
||||
processedCode = processedCode.substring(0, 12);
|
||||
} else if (processedCode.length === 13) {
|
||||
processedCode = processedCode.substring(0, 12);
|
||||
}
|
||||
|
||||
// Ajouter le checksum pour avoir un EAN13 valide
|
||||
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",
|
||||
width: 2,
|
||||
height: 60,
|
||||
displayValue: false,
|
||||
background: "#ffffff",
|
||||
lineColor: "#000000",
|
||||
margin: 5
|
||||
});
|
||||
|
||||
// Retourner l'image en base64
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch (error) {
|
||||
console.error('Erreur génération code-barres:', error);
|
||||
// Fallback SVG professionnel
|
||||
return 'data:image/svg+xml;base64,' + btoa(`
|
||||
<svg width="250" height="60" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="250" height="60" fill="white" stroke="#ddd"/>
|
||||
<g stroke="black" stroke-width="2">
|
||||
<line x1="15" y1="10" x2="15" y2="45"/>
|
||||
<line x1="20" y1="10" x2="20" y2="45"/>
|
||||
<line x1="25" y1="10" x2="25" y2="45"/>
|
||||
<line x1="35" y1="10" x2="35" y2="45"/>
|
||||
<line x1="40" y1="10" x2="40" y2="45"/>
|
||||
<line x1="50" y1="10" x2="50" y2="45"/>
|
||||
<line x1="55" y1="10" x2="55" y2="45"/>
|
||||
<line x1="65" y1="10" x2="65" y2="45"/>
|
||||
<line x1="70" y1="10" x2="70" y2="45"/>
|
||||
<line x1="80" y1="10" x2="80" y2="45"/>
|
||||
<line x1="85" y1="10" x2="85" y2="45"/>
|
||||
<line x1="95" y1="10" x2="95" y2="45"/>
|
||||
<line x1="100" y1="10" x2="100" y2="45"/>
|
||||
<line x1="110" y1="10" x2="110" y2="45"/>
|
||||
<line x1="115" y1="10" x2="115" y2="45"/>
|
||||
<line x1="125" y1="10" x2="125" y2="45"/>
|
||||
<line x1="130" y1="10" x2="130" y2="45"/>
|
||||
<line x1="140" y1="10" x2="140" y2="45"/>
|
||||
<line x1="145" y1="10" x2="145" y2="45"/>
|
||||
<line x1="155" y1="10" x2="155" y2="45"/>
|
||||
<line x1="160" y1="10" x2="160" y2="45"/>
|
||||
<line x1="170" y1="10" x2="170" y2="45"/>
|
||||
<line x1="175" y1="10" x2="175" y2="45"/>
|
||||
<line x1="185" y1="10" x2="185" y2="45"/>
|
||||
<line x1="190" y1="10" x2="190" y2="45"/>
|
||||
<line x1="200" y1="10" x2="200" y2="45"/>
|
||||
<line x1="205" y1="10" x2="205" y2="45"/>
|
||||
<line x1="215" y1="10" x2="215" y2="45"/>
|
||||
<line x1="220" y1="10" x2="220" y2="45"/>
|
||||
<line x1="230" y1="10" x2="230" y2="45"/>
|
||||
<line x1="235" y1="10" x2="235" y2="45"/>
|
||||
</g>
|
||||
<text x="125" y="55" text-anchor="middle" font-family="monospace" font-size="10" fill="black">${code}</text>
|
||||
</svg>
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrintLabel = (order: CustomerOrderWithRelations) => {
|
||||
// Ouvrir une nouvelle fenêtre pour imprimer l'étiquette
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
const barcodeDisplay = order.gencode ? generateEAN13Barcode(order.gencode) : '';
|
||||
const isImageBarcode = barcodeDisplay.startsWith('data:');
|
||||
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Commande #${order.id}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
margin: 0;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #111827;
|
||||
}
|
||||
.status-badge {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.section-content {
|
||||
color: #1f2937;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.field-row {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.field-label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
min-width: 120px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.field-value {
|
||||
color: #111827;
|
||||
}
|
||||
.reference-code {
|
||||
background-color: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
.quantity-badge {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
.barcode-section {
|
||||
text-align: center;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.barcode {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #000000;
|
||||
letter-spacing: -1px;
|
||||
margin: 15px 0;
|
||||
line-height: 0.8;
|
||||
background: white;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
text-align: center;
|
||||
word-spacing: -2px;
|
||||
}
|
||||
.barcode-image {
|
||||
max-width: 300px;
|
||||
height: auto;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.barcode-number {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.store-badge {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
}
|
||||
.creation-date {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
.deposit-info {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #fbbf24;
|
||||
}
|
||||
.promo-badge {
|
||||
background-color: #fef2f2;
|
||||
color: #dc2626;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="title">Commande #${order.id}</div>
|
||||
<div class="status-badge">${order.status}</div>
|
||||
</div>
|
||||
<div class="creation-date">Créée le ${safeFormat(order.createdAt, 'dd MMMM yyyy')} à ${safeFormat(order.createdAt, 'HH:mm')}</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
Informations Client
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="field-row">
|
||||
<span class="field-label">Nom:</span>
|
||||
<span class="field-value">${order.customerName}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">📞 Téléphone:</span>
|
||||
<span class="field-value">${order.customerPhone}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
Informations Produit
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="field-row">
|
||||
<span class="field-label">Désignation:</span>
|
||||
</div>
|
||||
<div style="background-color: #f3f4f6; padding: 8px; border-radius: 4px; margin-bottom: 12px;">
|
||||
${order.productDesignation}
|
||||
</div>
|
||||
${order.productReference ? `
|
||||
<div class="field-row">
|
||||
<span class="field-label">Référence:</span>
|
||||
<span class="field-value">
|
||||
<span class="reference-code">${order.productReference}</span>
|
||||
</span>
|
||||
</div>` : ''}
|
||||
<div class="field-row">
|
||||
<span class="field-label">Quantité:</span>
|
||||
<span class="field-value">
|
||||
<span class="quantity-badge">${order.quantity || 1}</span>
|
||||
</span>
|
||||
</div>
|
||||
${order.deposit && parseFloat(order.deposit) > 0 ? `
|
||||
<div class="deposit-info">
|
||||
💰 Acompte versé: ${parseFloat(order.deposit).toFixed(2)}€
|
||||
</div>` : ''}
|
||||
${order.isPromotionalPrice ? `
|
||||
<div class="field-row">
|
||||
<span class="field-label">Prix:</span>
|
||||
<span class="field-value">
|
||||
<span class="promo-badge">🏷️ PRIX PUBLICITÉ</span>
|
||||
</span>
|
||||
</div>` : ''}
|
||||
${order.gencode ? `
|
||||
<div class="field-row">
|
||||
<span class="field-label">Code à barres EAN13:</span>
|
||||
</div>
|
||||
<div class="barcode-section">
|
||||
${isImageBarcode ?
|
||||
`<img src="${barcodeDisplay}" alt="Code-barres EAN13" class="barcode-image" style="max-width: 250px; height: auto; margin: 10px auto; display: block;" />` :
|
||||
`<div class="barcode">${barcodeDisplay}</div>`
|
||||
}
|
||||
<div class="barcode-number">${order.gencode}</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
Informations Commande
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="field-row">
|
||||
<span class="field-label">Prise par:</span>
|
||||
<span class="field-value">${order.orderTaker}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">🏪 Magasin:</span>
|
||||
<span class="field-value">
|
||||
<span class="store-badge">${order.group.name}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">📅 Date de création:</span>
|
||||
<span class="field-value">${safeFormat(order.createdAt, 'dd/MM/yyyy')} à ${safeFormat(order.createdAt, 'HH:mm')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>window.print(); window.close();</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
}) : [];
|
||||
|
||||
// Sort orders
|
||||
const sortedOrders = [...filteredOrders].sort((a, b) => {
|
||||
let aValue: string | number;
|
||||
let bValue: 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;
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
totalPages,
|
||||
paginatedData: paginatedOrders,
|
||||
totalItems
|
||||
} = usePagination(sortedOrders, 10);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Package className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Commandes Client
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Gestion des commandes clients et étiquettes
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)} className="bg-blue-600 hover:bg-blue-700 text-white shadow-md">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvelle Commande
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Recherche et Filtres</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<Input
|
||||
placeholder="Nom client, produit, téléphone, référence, gencode, fournisseur..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Select value={filterSupplier} onValueChange={setFilterSupplier}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="Fournisseurs" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Fournisseurs</SelectItem>
|
||||
{suppliers.map((supplier: any) => (
|
||||
<SelectItem key={supplier.id} value={supplier.id.toString()}>
|
||||
{supplier.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Status</SelectItem>
|
||||
{statusOptions.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Orders Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Commandes ({totalItems})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div>Chargement...</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>Téléphone</TableHead>
|
||||
<TableHead>Produit</TableHead>
|
||||
<TableHead>Quantité</TableHead>
|
||||
<TableHead>Fournisseur</TableHead>
|
||||
<TableHead>Gencode</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedOrders.map((order) => (
|
||||
<TableRow
|
||||
key={order.id}
|
||||
className={isGrayedOut(order.status) ? "opacity-50" : ""}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
{order.customerName}
|
||||
</TableCell>
|
||||
<TableCell>{order.customerPhone}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{order.productDesignation}</div>
|
||||
{order.productReference && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Ref: {order.productReference}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{order.quantity || 1}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium text-sm">
|
||||
{order.supplier?.name || "N/A"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="bg-gray-100 px-2 py-1 rounded text-sm">
|
||||
{order.gencode || "-"}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={`${getStatusColor(order.status)} cursor-pointer hover:opacity-80 transition-opacity rounded-none`}
|
||||
onClick={() => openStatusModal(order)}
|
||||
>
|
||||
{order.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{safeFormat(order.createdAt, 'dd/MM/yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDetailsModal(order)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditModal(order)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{canShowButtons(order.status) && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePrintLabel(order)}
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleNotificationToggle(order)}
|
||||
className={order.customerNotified ? "bg-green-100" : ""}
|
||||
>
|
||||
{order.customerNotified ? (
|
||||
<PhoneCall className="h-4 w-4" />
|
||||
) : (
|
||||
<Phone className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{user?.role === 'admin' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDeleteModal(order)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalItems > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={setItemsPerPage}
|
||||
className="mt-4 border-t border-gray-200 pt-4"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogContent className="max-w-3xl" style={{ height: 'auto', maxHeight: 'none' }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nouvelle Commande Client</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div style={{ transform: 'scale(0.9)', transformOrigin: 'top left', width: '111%' }}>
|
||||
<CustomerOrderForm
|
||||
onSubmit={handleCreateOrder}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<DialogContent className="max-w-3xl" style={{ height: 'auto', maxHeight: 'none' }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la Commande</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div style={{ transform: 'scale(0.9)', transformOrigin: 'top left', width: '111%' }}>
|
||||
{selectedOrder && (
|
||||
<CustomerOrderForm
|
||||
order={selectedOrder}
|
||||
onSubmit={handleEditOrder}
|
||||
onCancel={() => setShowEditModal(false)}
|
||||
isLoading={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Details Modal */}
|
||||
<Dialog open={showDetailsModal} onOpenChange={setShowDetailsModal}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Détails de la Commande</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedOrder && (
|
||||
<CustomerOrderDetails order={selectedOrder} />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Status Change Modal */}
|
||||
<Dialog open={showStatusModal} onOpenChange={setShowStatusModal}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Changer le statut</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sélectionnez un nouveau statut pour cette commande client.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Commande: <span className="font-medium">{selectedOrder?.productDesignation}</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Client: <span className="font-medium">{selectedOrder?.customerName}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{statusOptions.map((status) => (
|
||||
<Button
|
||||
key={status}
|
||||
variant={selectedOrder?.status === status ? "default" : "outline"}
|
||||
className="justify-start"
|
||||
onClick={() => {
|
||||
if (selectedOrder) {
|
||||
handleStatusChange(selectedOrder.id, status);
|
||||
setShowStatusModal(false);
|
||||
}
|
||||
}}
|
||||
disabled={statusMutation.isPending}
|
||||
>
|
||||
<Badge className={`${getStatusColor(status)} mr-2 rounded-none`}>
|
||||
{status}
|
||||
</Badge>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<ConfirmationModal
|
||||
open={showDeleteModal}
|
||||
onOpenChange={setShowDeleteModal}
|
||||
onConfirm={() => selectedOrder && deleteMutation.mutate(selectedOrder.id)}
|
||||
title="Supprimer la commande"
|
||||
description={`Êtes-vous sûr de vouloir supprimer la commande de ${selectedOrder?.customerName} ? Cette action est irréversible.`}
|
||||
confirmText="Supprimer"
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar, Package, ShoppingCart, TrendingUp, Clock, MapPin, User, AlertTriangle, CheckCircle, Truck, FileText, BarChart3, Megaphone, Shield, XCircle, CheckSquare } from "lucide-react";
|
||||
import { safeFormat, safeDate } from "@/lib/dateUtils";
|
||||
import type { PublicityWithRelations } from "@shared/schema";
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
// 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],
|
||||
});
|
||||
|
||||
const { data: allDeliveries = [] } = useQuery({
|
||||
queryKey: [deliveriesUrl, selectedStoreId],
|
||||
});
|
||||
|
||||
const { data: customerOrders = [] } = useQuery({
|
||||
queryKey: [customerOrdersUrl, selectedStoreId],
|
||||
});
|
||||
|
||||
// Récupérer les publicités à venir (chercher dans 2024 ET 2025) - TOUTES les publicités
|
||||
const { data: upcomingPublicities = [] } = useQuery<PublicityWithRelations[]>({
|
||||
queryKey: ['/api/publicities', 'upcoming'],
|
||||
queryFn: async () => {
|
||||
// Essayer d'abord 2024, puis 2025 pour avoir toutes les publicités
|
||||
const years = [2024, 2025];
|
||||
let allPublicities: PublicityWithRelations[] = [];
|
||||
|
||||
for (const year of years) {
|
||||
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/publicities?${params}`, { credentials: 'include' });
|
||||
if (response.ok) {
|
||||
const yearPublicities = await response.json();
|
||||
allPublicities = [...allPublicities, ...yearPublicities];
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Erreur lors de la récupération des publicités ${year}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer les publicités à venir et les trier par date
|
||||
const futurePublicities = allPublicities
|
||||
.filter((publicity: any) => new Date(publicity.startDate) > new Date())
|
||||
.sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
|
||||
return futurePublicities;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch DLC stats
|
||||
const { data: dlcStats = { active: 0, expiringSoon: 0, expired: 0 } } = useQuery({
|
||||
queryKey: ["/api/dlc-products/stats", selectedStoreId],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId && user?.role === 'admin') params.append("storeId", selectedStoreId.toString());
|
||||
return fetch(`/api/dlc-products/stats?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
}).then(res => res.json());
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch tasks
|
||||
const { data: tasks = [] } = useQuery({
|
||||
queryKey: ["/api/tasks", selectedStoreId],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId && user?.role === 'admin') params.append("storeId", selectedStoreId.toString());
|
||||
return fetch(`/api/tasks?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
}).then(res => res.json());
|
||||
},
|
||||
});
|
||||
|
||||
// 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, 3) : [];
|
||||
|
||||
const upcomingDeliveries = Array.isArray(allDeliveries) ? allDeliveries
|
||||
.filter((d: any) => {
|
||||
const isPlanned = d.status === 'planned';
|
||||
console.log('🚚 Dashboard - Delivery filter:', { id: d.id, status: d.status, isPlanned, scheduledDate: d.scheduledDate });
|
||||
return isPlanned;
|
||||
})
|
||||
.sort((a: any, b: any) => new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime())
|
||||
.slice(0, 2) : [];
|
||||
|
||||
console.log('🚚 Dashboard - Upcoming deliveries result:', upcomingDeliveries.length, upcomingDeliveries);
|
||||
|
||||
// Calculs pour les statistiques
|
||||
const pendingOrdersCount = Array.isArray(allOrders) ? allOrders.filter((order: any) => order.status === 'pending').length : 0;
|
||||
const averageDeliveryTime = Math.round(stats?.averageDeliveryTime || 0);
|
||||
const deliveredThisMonth = Array.isArray(allDeliveries) ? allDeliveries.filter((delivery: any) => {
|
||||
const deliveryDate = safeDate(delivery.deliveredDate || delivery.createdAt);
|
||||
const now = new Date();
|
||||
return deliveryDate.getMonth() === now.getMonth() &&
|
||||
deliveryDate.getFullYear() === now.getFullYear() &&
|
||||
delivery.status === 'delivered';
|
||||
}).length : 0;
|
||||
|
||||
// Calculer le total réel des palettes
|
||||
const totalPalettes = Array.isArray(allDeliveries) ? allDeliveries.reduce((total: number, delivery: any) => {
|
||||
if (delivery.unit === 'palettes') {
|
||||
return total + (delivery.quantity || 0);
|
||||
}
|
||||
return total;
|
||||
}, 0) : 0;
|
||||
|
||||
console.log('📊 Dashboard Debug - Raw Data:', {
|
||||
allOrders: Array.isArray(allOrders) ? allOrders.length : 'NOT_ARRAY',
|
||||
allDeliveries: Array.isArray(allDeliveries) ? allDeliveries.length : 'NOT_ARRAY',
|
||||
customerOrders: Array.isArray(customerOrders) ? customerOrders.length : 'NOT_ARRAY',
|
||||
upcomingPublicities: Array.isArray(upcomingPublicities) ? upcomingPublicities.length : 'NOT_ARRAY',
|
||||
stats: stats,
|
||||
samples: {
|
||||
order: allOrders[0],
|
||||
delivery: allDeliveries[0],
|
||||
customerOrder: customerOrders[0],
|
||||
publicity: upcomingPublicities[0]
|
||||
}
|
||||
});
|
||||
|
||||
// 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="flex-1 space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">Tableau de Bord</h2>
|
||||
<p className="text-gray-600 mt-1">Vue d'ensemble des performances et statistiques</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
<div className="space-y-3">
|
||||
{pendingOrdersCount > 0 && (
|
||||
<div className="bg-orange-50 border-l-4 border-orange-400 p-4 flex items-center space-x-3 shadow-sm">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-600" />
|
||||
<span className="text-sm font-medium text-orange-800">
|
||||
<strong>{pendingOrdersCount} commande(s) en attente</strong> nécessitent une planification
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dlcStats.expiringSoon > 0 && (
|
||||
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 flex items-center space-x-3 shadow-sm">
|
||||
<Clock className="h-5 w-5 text-yellow-600" />
|
||||
<span className="text-sm font-medium text-yellow-800">
|
||||
<strong>{dlcStats.expiringSoon} produit(s) DLC</strong> expirent dans les 15 prochains jours
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dlcStats.expired > 0 && (
|
||||
<div className="bg-red-50 border-l-4 border-red-400 p-4 flex items-center space-x-3 shadow-sm">
|
||||
<XCircle className="h-5 w-5 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-800">
|
||||
<strong>{dlcStats.expired} produit(s) DLC</strong> sont expirés et nécessitent une action immédiate
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Livraisons ce mois</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">{deliveredThisMonth}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 flex items-center justify-center">
|
||||
<Truck className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Commandes en attente</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">{pendingOrdersCount}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Délai moyen (jours)</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">{averageDeliveryTime}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 flex items-center justify-center">
|
||||
<BarChart3 className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total palettes</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">{totalPalettes}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 flex items-center justify-center">
|
||||
<Package className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Dernières Commandes */}
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center">
|
||||
<FileText className="h-5 w-5 mr-3 text-blue-600" />
|
||||
Dernières Commandes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
{recentOrders.length > 0 ? recentOrders.map((order: any) => (
|
||||
<div key={order.id} className="flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors border-l-3 border-blue-500">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-2 w-2 bg-blue-500"></div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{order.supplier?.name}</p>
|
||||
<p className="text-sm text-gray-600">{order.group?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge
|
||||
variant={order.status === 'delivered' ? 'default' : order.status === 'planned' ? 'secondary' : 'destructive'}
|
||||
className="text-xs"
|
||||
>
|
||||
{order.status === 'delivered' ? 'Livrée' : order.status === 'planned' ? 'Planifiée' : 'En attente'}
|
||||
</Badge>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{safeFormat(order.plannedDate, "d MMM")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="text-gray-600 text-center py-8">Aucune commande récente</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Livraisons à Venir */}
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center">
|
||||
<Calendar className="h-5 w-5 mr-3 text-green-600" />
|
||||
Livraisons à Venir
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
{Array.isArray(upcomingDeliveries) && upcomingDeliveries.length > 0 ? upcomingDeliveries.map((delivery: any) => (
|
||||
<div key={delivery.id} className="flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors border-l-3 border-green-500">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-2 w-2 bg-green-500"></div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{delivery.supplier?.name}</p>
|
||||
<p className="text-sm text-gray-600">{delivery.group?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-gray-900 text-sm">
|
||||
{delivery.quantity} {delivery.unit}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{safeFormat(delivery.scheduledDate, "d MMM")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Aucune livraison programmée</p>
|
||||
<p className="text-xs text-gray-400 mt-1">({Array.isArray(allDeliveries) ? allDeliveries.length : 0} livraisons totales)</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Publicités à Venir */}
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center">
|
||||
<Megaphone className="h-5 w-5 mr-3 text-purple-600" />
|
||||
Publicités à Venir
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
{upcomingPublicities
|
||||
.slice(0, 3)
|
||||
.map((publicity: any) => {
|
||||
const participatingStores = publicity.participations || [];
|
||||
const isCurrentStoreParticipating = selectedStoreId && participatingStores.some((p: any) => p.groupId === parseInt(selectedStoreId));
|
||||
|
||||
return (
|
||||
<div key={publicity.id} className="p-4 bg-gray-50 hover:bg-gray-100 transition-colors border-l-3 border-purple-500 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-2 w-2 bg-purple-500"></div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{publicity.pubNumber}</p>
|
||||
<p className="text-sm text-gray-600 truncate max-w-40">{publicity.designation}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
À venir
|
||||
</Badge>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{safeFormat(publicity.startDate, "d MMM")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Magasins participants */}
|
||||
<div className="flex items-center space-x-2 text-xs">
|
||||
<span className="text-gray-500">Magasins:</span>
|
||||
{participatingStores.length === 0 ? (
|
||||
<span className="text-red-400">Aucun magasin</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{participatingStores.map((participation: any) => (
|
||||
<Badge
|
||||
key={participation.groupId}
|
||||
className={`text-xs ${
|
||||
selectedStoreId && participation.groupId === parseInt(selectedStoreId)
|
||||
? 'bg-green-100 text-green-800 border border-green-300'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{participation.group.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!Array.isArray(upcomingPublicities) || upcomingPublicities.length === 0) && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Aucune publicité à venir</p>
|
||||
<p className="text-xs text-gray-400 mt-1">(API: {Array.isArray(upcomingPublicities) ? upcomingPublicities.length : 'NOT_ARRAY'})</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Section Rapprochement BL */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Statistiques des commandes clients */}
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center">
|
||||
<User className="h-5 w-5 mr-3 text-purple-600" />
|
||||
Commandes Clients
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-3 w-3 bg-red-500"></div>
|
||||
<span className="text-sm font-medium text-gray-700">En attente</span>
|
||||
</div>
|
||||
<span className="font-semibold text-red-600 text-lg">{customerOrderStats.waiting}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-3 w-3 bg-orange-500"></div>
|
||||
<span className="text-sm font-medium text-gray-700">En cours</span>
|
||||
</div>
|
||||
<span className="font-semibold text-orange-600 text-lg">{customerOrderStats.inProgress}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-3 w-3 bg-green-500"></div>
|
||||
<span className="text-sm font-medium text-gray-700">Disponibles</span>
|
||||
</div>
|
||||
<span className="font-semibold text-green-600 text-lg">{customerOrderStats.available}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-3 w-3 bg-blue-500"></div>
|
||||
<span className="text-sm font-medium text-gray-700">Retirées</span>
|
||||
</div>
|
||||
<span className="font-semibold text-blue-600 text-lg">{customerOrderStats.withdrawn}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-gray-200 pt-3 mt-3 p-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-3 w-3 bg-gray-500"></div>
|
||||
<span className="text-sm font-semibold text-gray-800">Total commandes</span>
|
||||
</div>
|
||||
<span className="font-bold text-xl text-gray-800">{customerOrderStats.total}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tâches à faire */}
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center">
|
||||
<CheckSquare className="h-5 w-5 mr-3 text-blue-600" />
|
||||
Tâches à faire
|
||||
</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)
|
||||
.map((task: any) => (
|
||||
<div key={task.id} className="flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors border-l-3 border-blue-500">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-2 w-2 bg-blue-500"></div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-gray-900 truncate">{task.title}</p>
|
||||
<p className="text-sm text-gray-600 truncate">{task.assignedTo}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge
|
||||
variant={task.status === 'completed' ? 'default' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{task.status === 'completed' ? 'Terminé' : 'En cours'}
|
||||
</Badge>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{safeFormat(task.createdAt, "d MMM")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Aucune tâche en cours</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Toutes les tâches sont terminées</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { isUnauthorizedError } from "@/lib/authUtils";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Truck,
|
||||
Calendar,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Building,
|
||||
User,
|
||||
Check,
|
||||
Package
|
||||
} from "lucide-react";
|
||||
import { safeFormat } from "@/lib/dateUtils";
|
||||
import CreateDeliveryModal from "@/components/modals/CreateDeliveryModal";
|
||||
import EditDeliveryModal from "@/components/modals/EditDeliveryModal";
|
||||
import OrderDetailModal from "@/components/modals/OrderDetailModal";
|
||||
import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal";
|
||||
import type { DeliveryWithRelations } from "@shared/schema";
|
||||
|
||||
export default function Deliveries() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Redirection pour les employés
|
||||
if (user?.role === 'employee') {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-orange-50 border-l-4 border-orange-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<Truck className="h-5 w-5 text-orange-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-orange-700">
|
||||
<strong>Accès restreint</strong><br />
|
||||
Seuls les managers et administrateurs peuvent accéder à la page des livraisons.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedDelivery, setSelectedDelivery] = useState<DeliveryWithRelations | null>(null);
|
||||
const [deliveryToDelete, setDeliveryToDelete] = useState<DeliveryWithRelations | null>(null);
|
||||
|
||||
// Construire l'URL pour l'historique complet sans filtrage par date
|
||||
const deliveriesUrl = `/api/deliveries${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
||||
|
||||
const { data: deliveriesData = [], 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' });
|
||||
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 : [];
|
||||
|
||||
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 : [];
|
||||
|
||||
const validateMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest(`/api/deliveries/${id}/validate`, "POST");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Livraison validée avec succès",
|
||||
});
|
||||
// Invalider tous les caches liés aux livraisons
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
|
||||
// Invalider tous les caches BL/Rapprochement
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === '/api/deliveries/bl' ||
|
||||
query.queryKey[0] === '/api/deliveries'
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/orders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/stats/monthly'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de valider la livraison",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest(`/api/deliveries/${id}`, "DELETE");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Livraison supprimée avec succès",
|
||||
});
|
||||
// Invalider tous les caches liés aux livraisons
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/stats/monthly'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la livraison",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
}) : [];
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
totalPages,
|
||||
paginatedData: paginatedDeliveries,
|
||||
totalItems
|
||||
} = usePagination(filteredDeliveries, 20);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Badge variant="secondary">En attente</Badge>;
|
||||
case 'planned':
|
||||
return <Badge className="bg-blue-100 text-blue-800">Planifié</Badge>;
|
||||
case 'delivered':
|
||||
return <Badge className="bg-green-100 text-green-800">Livré</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const formatQuantity = (quantity: number, unit: string) => {
|
||||
return `${quantity} ${unit === 'palettes' ? 'P' : 'C'}`;
|
||||
};
|
||||
|
||||
const handleViewDelivery = (delivery: DeliveryWithRelations) => {
|
||||
setSelectedDelivery({ ...delivery, type: 'delivery' });
|
||||
setShowDetailModal(true);
|
||||
};
|
||||
|
||||
const handleEditDelivery = (delivery: DeliveryWithRelations) => {
|
||||
setSelectedDelivery(delivery);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleValidateDelivery = (id: number) => {
|
||||
if (window.confirm("Êtes-vous sûr de vouloir valider cette livraison ?")) {
|
||||
validateMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDelivery = (delivery: DeliveryWithRelations) => {
|
||||
setDeliveryToDelete(delivery);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDeleteDelivery = () => {
|
||||
if (deliveryToDelete) {
|
||||
deleteMutation.mutate(deliveryToDelete.id);
|
||||
setShowDeleteModal(false);
|
||||
setDeliveryToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const canCreate = true; // All users can create deliveries
|
||||
const canEdit = user?.role === 'admin' || user?.role === 'manager';
|
||||
const canDelete = user?.role === 'admin' || user?.role === 'manager';
|
||||
const canValidate = user?.role === 'admin' || user?.role === 'manager';
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Truck className="w-6 h-6 mr-3 text-green-600" />
|
||||
Gestion des Livraisons
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{filteredDeliveries.length} livraison{filteredDeliveries.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white shadow-md"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvelle Livraison
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex-1 min-w-64">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Rechercher par fournisseur, groupe ou commentaire..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border border-gray-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-48 border border-gray-300 shadow-sm">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
<SelectValue placeholder="Filtrer par statut" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||
<SelectItem value="pending">En attente</SelectItem>
|
||||
<SelectItem value="planned">Planifié</SelectItem>
|
||||
<SelectItem value="delivered">Livré</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deliveries List */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary"></div>
|
||||
</div>
|
||||
) : (console.log('🚚 Filtered deliveries length:', filteredDeliveries.length) || filteredDeliveries.length === 0) ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
|
||||
<Truck className="w-16 h-16 mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium mb-2">Aucune livraison trouvée</h3>
|
||||
<p className="text-center max-w-md">
|
||||
{searchTerm || statusFilter !== "all"
|
||||
? "Aucune livraison ne correspond à vos critères de recherche."
|
||||
: "Vous n'avez pas encore de livraisons. Créez votre première livraison pour commencer."}
|
||||
</p>
|
||||
{canCreate && !searchTerm && statusFilter === "all" && (
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="mt-4 bg-secondary hover:bg-green-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer une livraison
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fournisseur
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Groupe
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-black text-gray-800 uppercase tracking-wider">
|
||||
Date prévue
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-black text-gray-800 uppercase tracking-wider">
|
||||
Quantité
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-black text-gray-800 uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Commande liée
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Créé par
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{paginatedDeliveries.map((delivery) => (
|
||||
<tr key={delivery.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Building className="w-5 h-5 text-gray-400 mr-2" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{delivery.supplier?.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
#{delivery.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: delivery.group?.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{delivery.group?.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 text-gray-400 mr-2" />
|
||||
{safeFormat(delivery.scheduledDate, 'dd MMM yyyy')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{formatQuantity(delivery.quantity, delivery.unit)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(delivery.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{delivery.order ? (
|
||||
<div className="flex items-center">
|
||||
<Package className="w-4 h-4 text-primary mr-2" />
|
||||
<span>#{delivery.order.id}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">Aucune</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div className="flex items-center">
|
||||
<User className="w-4 h-4 text-gray-400 mr-2" />
|
||||
{delivery.creator?.firstName} {delivery.creator?.lastName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewDelivery(delivery)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditDelivery(delivery)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canValidate && delivery.status !== 'delivered' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleValidateDelivery(delivery.id)}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
disabled={validateMutation.isPending}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteDelivery(delivery)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={setItemsPerPage}
|
||||
className="mt-4 p-4 border-t border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showCreateModal && (
|
||||
<CreateDeliveryModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
selectedDate={null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEditModal && selectedDelivery && (
|
||||
<EditDeliveryModal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
delivery={selectedDelivery}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDetailModal && selectedDelivery && (
|
||||
<OrderDetailModal
|
||||
isOpen={showDetailModal}
|
||||
onClose={() => setShowDetailModal(false)}
|
||||
item={selectedDelivery}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal de confirmation de suppression */}
|
||||
<ConfirmDeleteModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeliveryToDelete(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteDelivery}
|
||||
title="Supprimer la livraison"
|
||||
description="Êtes-vous sûr de vouloir supprimer cette livraison ?"
|
||||
itemName={deliveryToDelete ? `${deliveryToDelete.supplier?.name} - ${safeFormat(deliveryToDelete.scheduledDate, 'dd/MM/yyyy')}` : undefined}
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } 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";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AlertTriangle, Plus, Eye, Edit, Trash2, CheckCircle, Package, Clock, AlertCircle, Filter, Download, FileText } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import type { DlcProductWithRelations, InsertDlcProduct } from "@shared/schema";
|
||||
|
||||
const dlcFormSchema = z.object({
|
||||
productName: z.string().min(1, "Le nom du produit est obligatoire"),
|
||||
gencode: z.string().optional(),
|
||||
dlcDate: z.string().min(1, "La date d'expiration est obligatoire"),
|
||||
dateType: z.enum(["dlc", "ddm", "dluo"], { required_error: "Le type de date est obligatoire" }),
|
||||
supplierId: z.number().min(1, "Le fournisseur est obligatoire"),
|
||||
status: z.enum(["en_cours", "expires_soon", "expires", "valides"]).default("en_cours"),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
type DlcFormData = z.infer<typeof dlcFormSchema>;
|
||||
|
||||
export default function DlcPage() {
|
||||
const { user, isLoading: authLoading } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedStoreId } = useStore();
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [supplierFilter, setSupplierFilter] = useState<string>("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<DlcProductWithRelations | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [productToDelete, setProductToDelete] = useState<number | null>(null);
|
||||
|
||||
// Fetch stores/groups
|
||||
const { data: stores = [] } = useQuery({
|
||||
queryKey: ["/api/groups"],
|
||||
enabled: !authLoading,
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Fetch suppliers with DLC enabled only
|
||||
const { data: suppliers = [] } = useQuery({
|
||||
queryKey: ["/api/suppliers", "dlc"],
|
||||
queryFn: () => apiRequest("/api/suppliers?dlc=true"),
|
||||
enabled: !authLoading,
|
||||
});
|
||||
|
||||
// Fetch DLC products
|
||||
const { data: dlcProducts = [], isLoading: productsLoading } = useQuery({
|
||||
queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, supplierFilter],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId && user?.role === 'admin') params.append("storeId", selectedStoreId.toString());
|
||||
if (statusFilter && statusFilter !== "all") params.append("status", statusFilter);
|
||||
if (supplierFilter && supplierFilter !== "all") params.append("supplierId", supplierFilter);
|
||||
|
||||
return apiRequest(`/api/dlc-products?${params.toString()}`);
|
||||
},
|
||||
enabled: !authLoading,
|
||||
});
|
||||
|
||||
// Fetch DLC stats
|
||||
const { data: stats = { active: 0, expiringSoon: 0, expired: 0 } } = useQuery({
|
||||
queryKey: ["/api/dlc-products/stats", selectedStoreId],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId && user?.role === 'admin') params.append("storeId", selectedStoreId.toString());
|
||||
return apiRequest(`/api/dlc-products/stats?${params.toString()}`);
|
||||
},
|
||||
enabled: !authLoading,
|
||||
});
|
||||
|
||||
// Form setup
|
||||
const form = useForm<DlcFormData>({
|
||||
resolver: zodResolver(dlcFormSchema),
|
||||
defaultValues: {
|
||||
productName: "",
|
||||
gencode: "",
|
||||
dateType: "dlc",
|
||||
status: "en_cours",
|
||||
notes: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: InsertDlcProduct) => apiRequest("/api/dlc-products", "POST", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"] });
|
||||
toast({ title: "Produit DLC créé avec succès" });
|
||||
setIsDialogOpen(false);
|
||||
form.reset();
|
||||
setEditingProduct(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur lors de la création",
|
||||
description: error.message || "Une erreur est survenue",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<InsertDlcProduct> }) =>
|
||||
apiRequest(`/api/dlc-products/${id}`, "PUT", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"] });
|
||||
toast({ title: "Produit DLC mis à jour avec succès" });
|
||||
setIsDialogOpen(false);
|
||||
form.reset();
|
||||
setEditingProduct(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur lors de la mise à jour",
|
||||
description: error.message || "Une erreur est survenue",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Validate mutation
|
||||
const validateMutation = useMutation({
|
||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/validate`, "PUT"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"] });
|
||||
toast({ title: "Produit validé avec succès" });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur lors de la validation",
|
||||
description: error.message || "Une erreur est survenue",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}`, "DELETE"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"] });
|
||||
toast({ title: "Produit supprimé avec succès" });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur lors de la suppression",
|
||||
description: error.message || "Une erreur est survenue",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: DlcFormData) => {
|
||||
// Calculer la date d'expiration et le seuil d'alerte (15 jours avant)
|
||||
const dlcDate = new Date(data.dlcDate);
|
||||
|
||||
const dlcData: InsertDlcProduct = {
|
||||
...data,
|
||||
dlcDate,
|
||||
quantity: 1, // Valeur par défaut
|
||||
unit: "unité", // Valeur par défaut
|
||||
location: "Magasin", // Valeur par défaut
|
||||
alertThreshold: 15, // Toujours 15 jours
|
||||
groupId: selectedStoreId || stores[0]?.id || 2, // Utilise le magasin sélectionné ou le premier disponible
|
||||
};
|
||||
|
||||
if (editingProduct) {
|
||||
updateMutation.mutate({ id: editingProduct.id, data: dlcData });
|
||||
} else {
|
||||
createMutation.mutate(dlcData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (product: DlcProductWithRelations) => {
|
||||
setEditingProduct(product);
|
||||
form.reset({
|
||||
productName: product.productName,
|
||||
gencode: product.gencode || "",
|
||||
dlcDate: product.dlcDate ? format(new Date(product.dlcDate), "yyyy-MM-dd") : "",
|
||||
dateType: product.dateType as "dlc" | "ddm" | "dluo",
|
||||
supplierId: product.supplierId,
|
||||
status: product.status as "en_cours" | "expires_soon" | "expires" | "valides",
|
||||
notes: product.notes || "",
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
setProductToDelete(id);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (productToDelete) {
|
||||
deleteMutation.mutate(productToDelete);
|
||||
setDeleteDialogOpen(false);
|
||||
setProductToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = (id: number) => {
|
||||
validateMutation.mutate(id);
|
||||
};
|
||||
|
||||
// Fonction pour déterminer si un produit doit afficher le bouton de validation
|
||||
const shouldShowValidateButton = (product: DlcProductWithRelations) => {
|
||||
const today = new Date();
|
||||
const expiry = new Date(product.dlcDate || new Date());
|
||||
const daysUntilExpiry = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Afficher le bouton si le produit expire dans 15 jours ou moins, ou est déjà expiré
|
||||
return (daysUntilExpiry <= 15 && product.status !== "valide");
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string, dlcDate: string | null) => {
|
||||
if (!dlcDate) return <Badge variant="outline">Non défini</Badge>;
|
||||
|
||||
// LOGIQUE SIMPLIFIÉE ET COHÉRENTE :
|
||||
// 1. Si le statut en base est "valides", afficher "Validé" (peu importe la date)
|
||||
// 2. Sinon, calculer selon la date d'expiration
|
||||
|
||||
if (status === "valides") {
|
||||
return <Badge variant="outline" className="bg-gray-100 text-gray-800">Validé</Badge>;
|
||||
}
|
||||
|
||||
// Pour tous les autres statuts, calculer selon la date
|
||||
const today = new Date();
|
||||
const expiry = new Date(dlcDate);
|
||||
const daysUntilExpiry = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysUntilExpiry < 0) {
|
||||
return <Badge variant="destructive">Expiré</Badge>;
|
||||
} else if (daysUntilExpiry <= 15) {
|
||||
return <Badge variant="secondary" className="bg-orange-100 text-orange-800">Expire bientôt</Badge>;
|
||||
} else {
|
||||
return <Badge variant="default" className="bg-green-100 text-green-800">Actif</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const printExpiringSoon = () => {
|
||||
const expiringSoon = filteredProducts.filter(product => {
|
||||
const today = new Date();
|
||||
const expiry = new Date(product.dlcDate || new Date());
|
||||
const diffTime = expiry.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays > 0 && diffDays <= 15;
|
||||
});
|
||||
|
||||
const printContent = `
|
||||
<html>
|
||||
<head>
|
||||
<title>Produits expirant bientôt</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f2f2f2; }
|
||||
h1 { color: #ff6b35; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Produits expirant dans les 15 prochains jours</h1>
|
||||
<p>Date d'impression: ${format(new Date(), "dd/MM/yyyy HH:mm")}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Produit</th>
|
||||
<th>Code EAN13</th>
|
||||
<th>Date d'expiration</th>
|
||||
<th>Type</th>
|
||||
<th>Fournisseur</th>
|
||||
<th>Jours restants</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${expiringSoon.map(product => {
|
||||
const today = new Date();
|
||||
const expiry = new Date(product.dlcDate || new Date());
|
||||
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
return `
|
||||
<tr>
|
||||
<td>${product.productName}</td>
|
||||
<td>${product.gencode || '-'}</td>
|
||||
<td>${product.dlcDate ? format(new Date(product.dlcDate), "dd/MM/yyyy") : 'N/A'}</td>
|
||||
<td>${product.dateType.toUpperCase()}</td>
|
||||
<td>${product.supplier?.name || 'N/A'}</td>
|
||||
<td>${diffDays}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.document.write(printContent);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
}
|
||||
};
|
||||
|
||||
const printExpired = () => {
|
||||
const expired = filteredProducts.filter(product => {
|
||||
const today = new Date();
|
||||
const expiry = new Date(product.dlcDate || new Date());
|
||||
return expiry < today;
|
||||
});
|
||||
|
||||
const printContent = `
|
||||
<html>
|
||||
<head>
|
||||
<title>Produits expirés</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f2f2f2; }
|
||||
h1 { color: #dc2626; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Produits expirés</h1>
|
||||
<p>Date d'impression: ${format(new Date(), "dd/MM/yyyy HH:mm")}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Produit</th>
|
||||
<th>Code EAN13</th>
|
||||
<th>Date d'expiration</th>
|
||||
<th>Type</th>
|
||||
<th>Fournisseur</th>
|
||||
<th>Jours dépassés</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${expired.map(product => {
|
||||
const today = new Date();
|
||||
const expiry = new Date(product.dlcDate || new Date());
|
||||
const diffDays = Math.ceil((today.getTime() - expiry.getTime()) / (1000 * 60 * 60 * 24));
|
||||
return `
|
||||
<tr>
|
||||
<td>${product.productName}</td>
|
||||
<td>${product.gencode || '-'}</td>
|
||||
<td>${product.dlcDate ? format(new Date(product.dlcDate), "dd/MM/yyyy") : 'N/A'}</td>
|
||||
<td>${product.dateType.toUpperCase()}</td>
|
||||
<td>${product.supplier?.name || 'N/A'}</td>
|
||||
<td>${diffDays}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.document.write(printContent);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
}
|
||||
};
|
||||
|
||||
const filteredProducts = dlcProducts.filter(product => {
|
||||
if (searchTerm) {
|
||||
return product.productName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(product.supplier?.name && product.supplier.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
totalPages,
|
||||
paginatedData: paginatedProducts,
|
||||
totalItems
|
||||
} = usePagination(filteredProducts, 10);
|
||||
|
||||
if (authLoading) {
|
||||
return <div className="flex justify-center items-center h-64">Chargement...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Clock className="w-6 h-6 mr-3 text-orange-600" />
|
||||
Gestion DLC
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Gestion des dates limites de consommation
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white shadow-md">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouveau produit DLC
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingProduct ? "Modifier le produit DLC" : "Nouveau produit DLC"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="productName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nom du produit</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Nom du produit" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="supplierId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Fournisseur</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(parseInt(value))}
|
||||
value={field.value?.toString()}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionner un fournisseur" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{suppliers.map((supplier: any) => (
|
||||
<SelectItem key={supplier.id} value={supplier.id.toString()}>
|
||||
{supplier.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gencode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Code EAN13 (optionnel)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
placeholder="1234567890123"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dlcDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Date d'expiration</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dateType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type de date</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionner le type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="dlc">DLC (Date Limite de Consommation)</SelectItem>
|
||||
<SelectItem value="ddm">DDM (Date de Durabilité Minimale)</SelectItem>
|
||||
<SelectItem value="dluo">DLUO (Date Limite d'Utilisation Optimale)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notes (optionnel)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} placeholder="Notes ou observations..." />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingProduct ? "Mettre à jour" : "Créer"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Filter className="w-5 h-5" />
|
||||
Filtres
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Statut</label>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||
<SelectItem value="en_cours">Actifs</SelectItem>
|
||||
<SelectItem value="expires_soon">Expire bientôt</SelectItem>
|
||||
<SelectItem value="expires">Expirés</SelectItem>
|
||||
<SelectItem value="valides">Validés</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Fournisseur</label>
|
||||
<Select value={supplierFilter} onValueChange={setSupplierFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Tous les fournisseurs" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les fournisseurs</SelectItem>
|
||||
{suppliers.map((supplier: any) => (
|
||||
<SelectItem key={supplier.id} value={supplier.id.toString()}>
|
||||
{supplier.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Recherche</label>
|
||||
<Input
|
||||
placeholder="Rechercher un produit..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Produits Actifs</CardTitle>
|
||||
<Package className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
|
||||
<p className="text-xs text-muted-foreground">Produits en cours</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Expire Bientôt</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-orange-600" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={printExpiringSoon}
|
||||
className="h-6 w-6 p-0 hover:bg-orange-100"
|
||||
>
|
||||
<FileText className="h-3 w-3 text-orange-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-orange-600">{stats.expiringSoon}</div>
|
||||
<p className="text-xs text-muted-foreground">Dans les 15 prochains jours</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Expirés</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={printExpired}
|
||||
className="h-6 w-6 p-0 hover:bg-red-100"
|
||||
>
|
||||
<FileText className="h-3 w-3 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{stats.expired}</div>
|
||||
<p className="text-xs text-muted-foreground">Nécessitent une action</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Products Table Section */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle>Produits DLC ({totalItems})</CardTitle>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exporter PDF
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{productsLoading ? (
|
||||
<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 className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Produit</TableHead>
|
||||
<TableHead>Code EAN13</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Date d'expiration</TableHead>
|
||||
<TableHead>Fournisseur</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedProducts.map((product) => (
|
||||
<TableRow
|
||||
key={product.id}
|
||||
className={product.status === "valide" ? "opacity-60 bg-gray-50 dark:bg-gray-800" : ""}
|
||||
>
|
||||
<TableCell className="font-medium">{product.productName}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{product.gencode || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="uppercase">
|
||||
{product.dateType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{product.dlcDate ? format(new Date(product.dlcDate), "dd/MM/yyyy", { locale: fr }) : 'Date non définie'}
|
||||
</TableCell>
|
||||
<TableCell>{product.supplier?.name || 'Non défini'}</TableCell>
|
||||
<TableCell>{getStatusBadge(product.status, product.dlcDate)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(product)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{shouldShowValidateButton(product) && (user?.role === 'admin' || user?.role === 'manager') && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleValidate(product.id)}
|
||||
disabled={validateMutation.isPending}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(product.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalItems > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={setItemsPerPage}
|
||||
className="mt-4 border-t border-gray-200 pt-4"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Modal de confirmation de suppression */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmer la suppression</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Êtes-vous sûr de vouloir supprimer ce produit DLC ? Cette action est irréversible.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
||||
Supprimer
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { isUnauthorizedError } from "@/lib/authUtils";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Users,
|
||||
Edit,
|
||||
Trash2,
|
||||
Package,
|
||||
Truck,
|
||||
Palette
|
||||
} from "lucide-react";
|
||||
import type { Group, NocodbConfig } from "@shared/schema";
|
||||
|
||||
const colorOptions = [
|
||||
{ value: '#1976D2', label: 'Bleu' },
|
||||
{ value: '#388E3C', label: 'Vert' },
|
||||
{ value: '#FF6F00', label: 'Orange' },
|
||||
{ value: '#D32F2F', label: 'Rouge' },
|
||||
{ value: '#7B1FA2', label: 'Violet' },
|
||||
{ value: '#F57C00', label: 'Ambre' },
|
||||
{ value: '#5D4037', label: 'Marron' },
|
||||
{ value: '#455A64', label: 'Bleu gris' },
|
||||
{ value: '#E91E63', label: 'Rose' },
|
||||
{ value: '#00796B', label: 'Sarcelle' },
|
||||
];
|
||||
|
||||
export default function Groups() {
|
||||
const { user } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
|
||||
const { data: groups = [], isLoading } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
});
|
||||
|
||||
const { data: rawNocodbConfigs = [] } = useQuery<NocodbConfig[]>({
|
||||
queryKey: ['/api/nocodb-config'],
|
||||
});
|
||||
|
||||
// Protection renforcée contre les erreurs TypeError
|
||||
const nocodbConfigs = Array.isArray(rawNocodbConfigs) ? rawNocodbConfigs : [];
|
||||
|
||||
console.log('🔍 Groups NocoDB Debug:', {
|
||||
rawNocodbConfigs,
|
||||
rawType: typeof rawNocodbConfigs,
|
||||
nocodbConfigs,
|
||||
configsType: typeof nocodbConfigs,
|
||||
isArray: Array.isArray(rawNocodbConfigs),
|
||||
length: nocodbConfigs.length,
|
||||
environment: window.location.hostname
|
||||
});
|
||||
|
||||
const { data: orders = [] } = useQuery({
|
||||
queryKey: ['/api/orders'],
|
||||
});
|
||||
|
||||
const { data: deliveries = [] } = useQuery({
|
||||
queryKey: ['/api/deliveries'],
|
||||
});
|
||||
|
||||
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;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Groupe créé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
setShowCreateModal(false);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de créer le groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
await apiRequest(`/api/groups/${selectedGroup?.id}`, "PUT", data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Groupe modifié avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de modifier le groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest(`/api/groups/${id}`, "DELETE");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Groupe supprimé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer le groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const filteredGroups = Array.isArray(groups) ? groups.filter(group =>
|
||||
group.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [];
|
||||
|
||||
console.log('🏪 Groups Debug:', {
|
||||
groups: Array.isArray(groups) ? groups.length : 'NOT_ARRAY',
|
||||
orders: Array.isArray(orders) ? orders.length : 'NOT_ARRAY',
|
||||
deliveries: Array.isArray(deliveries) ? deliveries.length : 'NOT_ARRAY',
|
||||
groupsData: groups?.slice(0, 2),
|
||||
ordersData: orders?.slice(0, 2),
|
||||
deliveriesData: deliveries?.slice(0, 2)
|
||||
});
|
||||
|
||||
const getGroupStats = (groupId: number) => {
|
||||
const groupOrders = Array.isArray(orders) ? orders.filter(order => {
|
||||
const match = order.groupId === groupId;
|
||||
if (match) console.log(`🎯 Order ${order.id} matches group ${groupId}`, order);
|
||||
return match;
|
||||
}) : [];
|
||||
|
||||
const groupDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => {
|
||||
const match = delivery.groupId === groupId;
|
||||
if (match) console.log(`🎯 Delivery ${delivery.id} matches group ${groupId}`, delivery);
|
||||
return match;
|
||||
}) : [];
|
||||
|
||||
console.log(`🏪 Group ${groupId} stats:`, {
|
||||
groupOrders: groupOrders.length,
|
||||
groupDeliveries: groupDeliveries.length,
|
||||
delivered: groupDeliveries.filter(d => d.status === 'delivered').length,
|
||||
ordersSample: groupOrders.slice(0, 2),
|
||||
deliveriesSample: groupDeliveries.slice(0, 2),
|
||||
allOrdersCount: orders?.length,
|
||||
allDeliveriesCount: deliveries?.length
|
||||
});
|
||||
|
||||
return {
|
||||
orders: groupOrders.length,
|
||||
deliveries: groupDeliveries.length,
|
||||
delivered: groupDeliveries.filter(d => d.status === 'delivered').length,
|
||||
};
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (group: Group) => {
|
||||
setSelectedGroup(group);
|
||||
setFormData({
|
||||
name: group.name,
|
||||
color: group.color,
|
||||
nocodbConfigId: group.nocodbConfigId?.toString() || "",
|
||||
nocodbTableId: group.nocodbTableId || "",
|
||||
nocodbTableName: group.nocodbTableName || "",
|
||||
invoiceColumnName: group.invoiceColumnName || "Ref Facture",
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (group: Group) => {
|
||||
const stats = getGroupStats(group.id);
|
||||
|
||||
if (stats.orders > 0 || stats.deliveries > 0) {
|
||||
toast({
|
||||
title: "Suppression impossible",
|
||||
description: "Ce groupe a des commandes ou livraisons associées",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`Êtes-vous sûr de vouloir supprimer le groupe "${group.name}" ?`)) {
|
||||
deleteMutation.mutate(group.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Le nom du groupe est requis",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare data with proper type conversion
|
||||
const submitData = {
|
||||
...formData,
|
||||
nocodbConfigId: formData.nocodbConfigId ? parseInt(formData.nocodbConfigId) : null,
|
||||
};
|
||||
|
||||
if (selectedGroup) {
|
||||
updateMutation.mutate(submitData);
|
||||
} else {
|
||||
createMutation.mutate(submitData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const canManage = user?.role === 'admin' || user?.role === 'manager';
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Users className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Gestion des Groupes/Magasins
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{filteredGroups.length} groupe{filteredGroups.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white shadow-md"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouveau Groupe
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="bg-gray-50 border-b border-gray-200 p-4">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Rechercher un groupe..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border border-gray-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Groups List */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
|
||||
<Users className="w-16 h-16 mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{searchTerm ? "Aucun groupe trouvé" : "Aucun groupe"}
|
||||
</h3>
|
||||
<p className="text-center max-w-md">
|
||||
{searchTerm
|
||||
? "Aucun groupe ne correspond à votre recherche."
|
||||
: "Vous n'avez pas encore de groupes. Créez votre premier groupe pour commencer."}
|
||||
</p>
|
||||
{!searchTerm && canManage && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="mt-4 bg-primary hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer un groupe
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredGroups.map((group) => {
|
||||
const stats = getGroupStats(group.id);
|
||||
return (
|
||||
<div key={group.id} className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-10 h-10 flex items-center justify-center mr-3"
|
||||
style={{ backgroundColor: group.color }}
|
||||
>
|
||||
<Users className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{group.name}</h3>
|
||||
<p className="text-sm text-gray-500">#{group.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(group)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(group)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mb-4">
|
||||
<Palette className="w-4 h-4 mr-2 text-gray-500" />
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-4 h-4 rounded mr-2"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">{group.color}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center text-primary mb-1">
|
||||
<Package className="w-4 h-4 mr-1" />
|
||||
<span className="font-semibold">{stats.orders}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Commandes</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center text-secondary mb-1">
|
||||
<Truck className="w-4 h-4 mr-1" />
|
||||
<span className="font-semibold">{stats.deliveries}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Livraisons</p>
|
||||
</div>
|
||||
</div>
|
||||
{stats.deliveries > 0 && (
|
||||
<div className="mt-2 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
{stats.delivered} livrées ({Math.round((stats.delivered / stats.deliveries) * 100)}%)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Dialog open={showCreateModal || showEditModal} onOpenChange={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="group-modal-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedGroup ? 'Modifier' : 'Nouveau'} Groupe
|
||||
</DialogTitle>
|
||||
<p id="group-modal-description" className="text-sm text-gray-600 mt-1">
|
||||
{selectedGroup ? 'Modifier les informations du groupe' : 'Créer un nouveau groupe/magasin'}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nom du groupe *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="Nom du groupe/magasin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="color">Couleur</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded border"
|
||||
style={{ backgroundColor: formData.color }}
|
||||
/>
|
||||
<Input
|
||||
id="color"
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => handleChange('color', e.target.value)}
|
||||
className="w-20 h-8"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{formData.color}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{colorOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`w-8 h-8 rounded border-2 ${
|
||||
formData.color === option.value ? 'border-gray-400' : 'border-gray-200'
|
||||
}`}
|
||||
style={{ backgroundColor: option.value }}
|
||||
onClick={() => handleChange('color', option.value)}
|
||||
title={option.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Configuration NocoDB */}
|
||||
<div className="border-t pt-4 space-y-4">
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<div className="w-4 h-4 bg-blue-500 rounded"></div>
|
||||
<h3 className="font-medium text-gray-900">Configuration NocoDB (Optionnel)</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Configurez la vérification automatique des références de facture dans une table NocoDB.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="nocodbConfig">Configuration NocoDB</Label>
|
||||
<select
|
||||
id="nocodbConfig"
|
||||
value={formData.nocodbConfigId}
|
||||
onChange={(e) => handleChange('nocodbConfigId', e.target.value)}
|
||||
className="w-full mt-1 p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Aucune configuration</option>
|
||||
{(nocodbConfigs || []).map(config => (
|
||||
<option key={config.id} value={config.id.toString()}>
|
||||
{config.name} - {config.baseUrl}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{formData.nocodbConfigId && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="nocodbTableId">ID de la table</Label>
|
||||
<Input
|
||||
id="nocodbTableId"
|
||||
value={formData.nocodbTableId}
|
||||
onChange={(e) => handleChange('nocodbTableId', e.target.value)}
|
||||
placeholder="m_xxxxxxxxxxxx (ID de la table NocoDB)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="nocodbTableName">Nom de la table</Label>
|
||||
<Input
|
||||
id="nocodbTableName"
|
||||
value={formData.nocodbTableName}
|
||||
onChange={(e) => handleChange('nocodbTableName', e.target.value)}
|
||||
placeholder="Nom de la table (pour affichage)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="invoiceColumnName">Nom de la colonne des références facture</Label>
|
||||
<Input
|
||||
id="invoiceColumnName"
|
||||
value={formData.invoiceColumnName}
|
||||
onChange={(e) => handleChange('invoiceColumnName', e.target.value)}
|
||||
placeholder="Ref Facture"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
className="bg-primary hover:bg-blue-700"
|
||||
>
|
||||
{createMutation.isPending || updateMutation.isPending
|
||||
? "Enregistrement..."
|
||||
: (selectedGroup ? "Modifier" : "Créer")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { isUnauthorizedError } from "@/lib/authUtils";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Users,
|
||||
Edit,
|
||||
Trash2,
|
||||
Package,
|
||||
Truck,
|
||||
Palette
|
||||
} from "lucide-react";
|
||||
import type { Group, NocodbConfig } from "@shared/schema";
|
||||
|
||||
const colorOptions = [
|
||||
{ value: '#1976D2', label: 'Bleu' },
|
||||
{ value: '#388E3C', label: 'Vert' },
|
||||
{ value: '#FF6F00', label: 'Orange' },
|
||||
{ value: '#D32F2F', label: 'Rouge' },
|
||||
{ value: '#7B1FA2', label: 'Violet' },
|
||||
{ value: '#F57C00', label: 'Ambre' },
|
||||
{ value: '#5D4037', label: 'Marron' },
|
||||
{ value: '#455A64', label: 'Bleu gris' },
|
||||
{ value: '#E91E63', label: 'Rose' },
|
||||
{ value: '#00796B', label: 'Sarcelle' },
|
||||
];
|
||||
|
||||
export default function Groups() {
|
||||
const { user } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
|
||||
const { data: groups = [], isLoading } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
});
|
||||
|
||||
const { data: rawNocodbConfigs = [] } = useQuery<NocodbConfig[]>({
|
||||
queryKey: ['/api/nocodb-config'],
|
||||
});
|
||||
|
||||
// Protection contre les erreurs TypeError
|
||||
const nocodbConfigs = Array.isArray(rawNocodbConfigs) ? rawNocodbConfigs : [];
|
||||
|
||||
console.log('🔍 Groups NocoDB Debug:', {
|
||||
rawNocodbConfigs,
|
||||
nocodbConfigs,
|
||||
isArray: Array.isArray(rawNocodbConfigs)
|
||||
});
|
||||
|
||||
const { data: orders = [] } = useQuery({
|
||||
queryKey: ['/api/orders'],
|
||||
});
|
||||
|
||||
const { data: deliveries = [] } = useQuery({
|
||||
queryKey: ['/api/deliveries'],
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
await apiRequest("/api/groups", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Groupe créé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
setShowCreateModal(false);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de créer le groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
await apiRequest(`/api/groups/${selectedGroup?.id}`, {
|
||||
method: "PUT",
|
||||
body: data,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Groupe modifié avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de modifier le groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest("DELETE", `/api/groups/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Groupe supprimé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer le groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const filteredGroups = Array.isArray(groups) ? groups.filter(group =>
|
||||
group.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [];
|
||||
|
||||
console.log('🏪 Groups Debug:', {
|
||||
groups: Array.isArray(groups) ? groups.length : 'NOT_ARRAY',
|
||||
orders: Array.isArray(orders) ? orders.length : 'NOT_ARRAY',
|
||||
deliveries: Array.isArray(deliveries) ? deliveries.length : 'NOT_ARRAY',
|
||||
groupsData: groups?.slice(0, 2),
|
||||
ordersData: orders?.slice(0, 2),
|
||||
deliveriesData: deliveries?.slice(0, 2)
|
||||
});
|
||||
|
||||
const getGroupStats = (groupId: number) => {
|
||||
const groupOrders = Array.isArray(orders) ? orders.filter(order => {
|
||||
const match = order.groupId === groupId;
|
||||
if (match) console.log(`🎯 Order ${order.id} matches group ${groupId}`, order);
|
||||
return match;
|
||||
}) : [];
|
||||
|
||||
const groupDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => {
|
||||
const match = delivery.groupId === groupId;
|
||||
if (match) console.log(`🎯 Delivery ${delivery.id} matches group ${groupId}`, delivery);
|
||||
return match;
|
||||
}) : [];
|
||||
|
||||
console.log(`🏪 Group ${groupId} stats:`, {
|
||||
groupOrders: groupOrders.length,
|
||||
groupDeliveries: groupDeliveries.length,
|
||||
delivered: groupDeliveries.filter(d => d.status === 'delivered').length,
|
||||
ordersSample: groupOrders.slice(0, 2),
|
||||
deliveriesSample: groupDeliveries.slice(0, 2),
|
||||
allOrdersCount: orders?.length,
|
||||
allDeliveriesCount: deliveries?.length
|
||||
});
|
||||
|
||||
return {
|
||||
orders: groupOrders.length,
|
||||
deliveries: groupDeliveries.length,
|
||||
delivered: groupDeliveries.filter(d => d.status === 'delivered').length,
|
||||
};
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (group: Group) => {
|
||||
setSelectedGroup(group);
|
||||
setFormData({
|
||||
name: group.name,
|
||||
color: group.color,
|
||||
nocodbConfigId: group.nocodbConfigId?.toString() || "",
|
||||
nocodbTableId: group.nocodbTableId || "",
|
||||
nocodbTableName: group.nocodbTableName || "",
|
||||
invoiceColumnName: group.invoiceColumnName || "Ref Facture",
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (group: Group) => {
|
||||
const stats = getGroupStats(group.id);
|
||||
|
||||
if (stats.orders > 0 || stats.deliveries > 0) {
|
||||
toast({
|
||||
title: "Suppression impossible",
|
||||
description: "Ce groupe a des commandes ou livraisons associées",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`Êtes-vous sûr de vouloir supprimer le groupe "${group.name}" ?`)) {
|
||||
deleteMutation.mutate(group.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Le nom du groupe est requis",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare data with proper type conversion
|
||||
const submitData = {
|
||||
...formData,
|
||||
nocodbConfigId: formData.nocodbConfigId ? parseInt(formData.nocodbConfigId) : null,
|
||||
};
|
||||
|
||||
if (selectedGroup) {
|
||||
updateMutation.mutate(submitData);
|
||||
} else {
|
||||
createMutation.mutate(submitData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const canManage = user?.role === 'admin' || user?.role === 'manager';
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Users className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Gestion des Groupes/Magasins
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{filteredGroups.length} groupe{filteredGroups.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white shadow-md"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouveau Groupe
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="bg-gray-50 border-b border-gray-200 p-4">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Rechercher un groupe..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border border-gray-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Groups List */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
|
||||
<Users className="w-16 h-16 mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{searchTerm ? "Aucun groupe trouvé" : "Aucun groupe"}
|
||||
</h3>
|
||||
<p className="text-center max-w-md">
|
||||
{searchTerm
|
||||
? "Aucun groupe ne correspond à votre recherche."
|
||||
: "Vous n'avez pas encore de groupes. Créez votre premier groupe pour commencer."}
|
||||
</p>
|
||||
{!searchTerm && canManage && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="mt-4 bg-primary hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer un groupe
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredGroups.map((group) => {
|
||||
const stats = getGroupStats(group.id);
|
||||
return (
|
||||
<div key={group.id} className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-10 h-10 flex items-center justify-center mr-3"
|
||||
style={{ backgroundColor: group.color }}
|
||||
>
|
||||
<Users className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{group.name}</h3>
|
||||
<p className="text-sm text-gray-500">#{group.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(group)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(group)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mb-4">
|
||||
<Palette className="w-4 h-4 mr-2 text-gray-500" />
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-4 h-4 rounded mr-2"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">{group.color}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center text-primary mb-1">
|
||||
<Package className="w-4 h-4 mr-1" />
|
||||
<span className="font-semibold">{stats.orders}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Commandes</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center text-secondary mb-1">
|
||||
<Truck className="w-4 h-4 mr-1" />
|
||||
<span className="font-semibold">{stats.deliveries}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Livraisons</p>
|
||||
</div>
|
||||
</div>
|
||||
{stats.deliveries > 0 && (
|
||||
<div className="mt-2 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
{stats.delivered} livrées ({Math.round((stats.delivered / stats.deliveries) * 100)}%)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Dialog open={showCreateModal || showEditModal} onOpenChange={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="group-modal-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedGroup ? 'Modifier' : 'Nouveau'} Groupe
|
||||
</DialogTitle>
|
||||
<p id="group-modal-description" className="text-sm text-gray-600 mt-1">
|
||||
{selectedGroup ? 'Modifier les informations du groupe' : 'Créer un nouveau groupe/magasin'}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nom du groupe *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="Nom du groupe/magasin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="color">Couleur</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded border"
|
||||
style={{ backgroundColor: formData.color }}
|
||||
/>
|
||||
<Input
|
||||
id="color"
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => handleChange('color', e.target.value)}
|
||||
className="w-20 h-8"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{formData.color}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{colorOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`w-8 h-8 rounded border-2 ${
|
||||
formData.color === option.value ? 'border-gray-400' : 'border-gray-200'
|
||||
}`}
|
||||
style={{ backgroundColor: option.value }}
|
||||
onClick={() => handleChange('color', option.value)}
|
||||
title={option.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Configuration NocoDB */}
|
||||
<div className="border-t pt-4 space-y-4">
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<div className="w-4 h-4 bg-blue-500 rounded"></div>
|
||||
<h3 className="font-medium text-gray-900">Configuration NocoDB (Optionnel)</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Configurez la vérification automatique des références de facture dans une table NocoDB.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="nocodbConfig">Configuration NocoDB</Label>
|
||||
<select
|
||||
id="nocodbConfig"
|
||||
value={formData.nocodbConfigId}
|
||||
onChange={(e) => handleChange('nocodbConfigId', e.target.value)}
|
||||
className="w-full mt-1 p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Aucune configuration</option>
|
||||
{(nocodbConfigs || []).map(config => (
|
||||
<option key={config.id} value={config.id.toString()}>
|
||||
{config.name} - {config.baseUrl}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{formData.nocodbConfigId && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="nocodbTableId">ID de la table</Label>
|
||||
<Input
|
||||
id="nocodbTableId"
|
||||
value={formData.nocodbTableId}
|
||||
onChange={(e) => handleChange('nocodbTableId', e.target.value)}
|
||||
placeholder="m_xxxxxxxxxxxx (ID de la table NocoDB)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="nocodbTableName">Nom de la table</Label>
|
||||
<Input
|
||||
id="nocodbTableName"
|
||||
value={formData.nocodbTableName}
|
||||
onChange={(e) => handleChange('nocodbTableName', e.target.value)}
|
||||
placeholder="Nom de la table (pour affichage)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="invoiceColumnName">Nom de la colonne des références facture</Label>
|
||||
<Input
|
||||
id="invoiceColumnName"
|
||||
value={formData.invoiceColumnName}
|
||||
onChange={(e) => handleChange('invoiceColumnName', e.target.value)}
|
||||
placeholder="Ref Facture"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
className="bg-primary hover:bg-blue-700"
|
||||
>
|
||||
{createMutation.isPending || updateMutation.isPending
|
||||
? "Enregistrement..."
|
||||
: (selectedGroup ? "Modifier" : "Créer")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Store, Truck, Package, BarChart3 } from "lucide-react";
|
||||
|
||||
export default function Landing() {
|
||||
const handleLogin = () => {
|
||||
window.location.href = "/api/login";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-accent rounded-lg flex items-center justify-center">
|
||||
<Store className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">LogiFlow</h1>
|
||||
<p className="text-sm text-gray-500">La Foir'Fouille</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleLogin} className="bg-primary hover:bg-blue-700">
|
||||
Se connecter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Système de Gestion Logistique
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8 max-w-3xl mx-auto">
|
||||
Gérez efficacement vos commandes et livraisons avec notre plateforme
|
||||
centralisée pour tous vos magasins La Foir'Fouille.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
size="lg"
|
||||
className="bg-primary hover:bg-blue-700 text-white px-8 py-3 text-lg"
|
||||
>
|
||||
Commencer maintenant
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="mt-20 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<Card className="text-center">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 bg-primary rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Package className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Gestion des Commandes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600">
|
||||
Suivez et gérez toutes vos commandes fournisseurs en temps réel
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="text-center">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 bg-secondary rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Truck className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Suivi des Livraisons</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600">
|
||||
Planifiez et validez vos livraisons avec quantités précises
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="text-center">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 bg-accent rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<BarChart3 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Tableaux de Bord</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600">
|
||||
Analysez vos performances avec des statistiques détaillées
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="text-center">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 bg-delivered rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Store className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Multi-Magasins</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600">
|
||||
Gérez plusieurs magasins avec des permissions granulaires
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Call to Action */}
|
||||
<div className="mt-20 text-center">
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 max-w-2xl mx-auto">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Prêt à optimiser votre logistique ?
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Connectez-vous pour accéder à votre tableau de bord personnalisé et
|
||||
commencer à gérer vos commandes et livraisons dès maintenant.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
size="lg"
|
||||
className="bg-primary hover:bg-blue-700 text-white px-8 py-3"
|
||||
>
|
||||
Accéder à LogiFlow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Database,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Settings,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Link,
|
||||
} from "lucide-react";
|
||||
import type { NocodbConfig } from "@shared/schema";
|
||||
|
||||
// Form schemas
|
||||
const nocodbConfigFormSchema = z.object({
|
||||
name: z.string().min(1, "Le nom est obligatoire"),
|
||||
baseUrl: z.string().url("L'URL doit être valide").min(1, "L'URL de base est obligatoire"),
|
||||
apiToken: z.string().min(1, "Le token API est obligatoire"),
|
||||
projectId: z.string().min(1, "L'ID du projet est obligatoire"),
|
||||
description: z.string().optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type NocodbConfigForm = z.infer<typeof nocodbConfigFormSchema>;
|
||||
|
||||
export default function NocoDBConfig() {
|
||||
const { user } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedConfig, setSelectedConfig] = useState<NocodbConfig | null>(null);
|
||||
const [showTokens, setShowTokens] = useState<{ [key: number]: boolean }>({});
|
||||
|
||||
// Queries avec protection complète
|
||||
const { data: rawConfigs, isLoading, error } = useQuery({
|
||||
queryKey: ['/api/nocodb-config'],
|
||||
enabled: user?.role === 'admin',
|
||||
});
|
||||
|
||||
// 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({
|
||||
mutationFn: (data: NocodbConfigForm) =>
|
||||
apiRequest('/api/nocodb-config', 'POST', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/nocodb-config'] });
|
||||
setShowCreateModal(false);
|
||||
toast({
|
||||
title: "Configuration créée",
|
||||
description: "La configuration NocoDB a été créée avec succès.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la création de la configuration.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<NocodbConfigForm> }) =>
|
||||
apiRequest(`/api/nocodb-config/${id}`, 'PUT', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/nocodb-config'] });
|
||||
setShowEditModal(false);
|
||||
setSelectedConfig(null);
|
||||
toast({
|
||||
title: "Configuration mise à jour",
|
||||
description: "La configuration NocoDB a été mise à jour avec succès.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la mise à jour de la configuration.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteConfigMutation = useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
apiRequest(`/api/nocodb-config/${id}`, 'DELETE'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/nocodb-config'] });
|
||||
setShowDeleteModal(false);
|
||||
setSelectedConfig(null);
|
||||
toast({
|
||||
title: "Configuration supprimée",
|
||||
description: "La configuration NocoDB a été supprimée avec succès.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la suppression de la configuration.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Forms
|
||||
const createForm = useForm<NocodbConfigForm>({
|
||||
resolver: zodResolver(nocodbConfigFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
apiToken: "",
|
||||
projectId: "",
|
||||
description: "",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const editForm = useForm<NocodbConfigForm>({
|
||||
resolver: zodResolver(nocodbConfigFormSchema),
|
||||
});
|
||||
|
||||
// Handle create config
|
||||
const handleCreateConfig = (data: NocodbConfigForm) => {
|
||||
createConfigMutation.mutate(data);
|
||||
};
|
||||
|
||||
// Handle edit config
|
||||
const handleEditConfig = (data: NocodbConfigForm) => {
|
||||
if (!selectedConfig) return;
|
||||
updateConfigMutation.mutate({ id: selectedConfig.id, data });
|
||||
};
|
||||
|
||||
// Handle delete config
|
||||
const handleDeleteConfig = () => {
|
||||
if (!selectedConfig) return;
|
||||
deleteConfigMutation.mutate(selectedConfig.id);
|
||||
};
|
||||
|
||||
// Open edit modal
|
||||
const openEditModal = (config: NocodbConfig) => {
|
||||
setSelectedConfig(config);
|
||||
editForm.reset({
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
apiToken: config.apiToken,
|
||||
projectId: config.projectId,
|
||||
description: config.description || "",
|
||||
isActive: config.isActive,
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// Open delete modal
|
||||
const openDeleteModal = (config: NocodbConfig) => {
|
||||
setSelectedConfig(config);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// Toggle token visibility
|
||||
const toggleTokenVisibility = (configId: number) => {
|
||||
setShowTokens(prev => ({
|
||||
...prev,
|
||||
[configId]: !prev[configId]
|
||||
}));
|
||||
};
|
||||
|
||||
// Format token for display
|
||||
const formatToken = (token: string, isVisible: boolean) => {
|
||||
if (isVisible) return token;
|
||||
return '•'.repeat(Math.min(token.length, 20));
|
||||
};
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<AlertTriangle className="h-12 w-12 text-yellow-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Accès restreint</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Seuls les administrateurs peuvent gérer les configurations NocoDB.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Database className="h-8 w-8 text-blue-600" />
|
||||
Configuration NocoDB
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gérez les connexions aux bases de données NocoDB externes pour récupérer des informations.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Nouvelle Configuration
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Configurations List */}
|
||||
<div className="grid gap-6">
|
||||
{safeConfigs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center">
|
||||
<Database className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Aucune configuration</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Commencez par créer votre première configuration NocoDB.
|
||||
</p>
|
||||
<Button onClick={() => setShowCreateModal(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Créer une configuration
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
safeConfigs.map((config: NocodbConfig) => (
|
||||
<Card key={config.id} className="border shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-lg">{config.name}</CardTitle>
|
||||
<Badge variant={config.isActive ? "default" : "secondary"}>
|
||||
{config.isActive ? (
|
||||
<><CheckCircle className="h-3 w-3 mr-1" /> Actif</>
|
||||
) : (
|
||||
<><XCircle className="h-3 w-3 mr-1" /> Inactif</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
{config.description && (
|
||||
<CardDescription className="mt-1">
|
||||
{config.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditModal(config)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDeleteModal(config)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">URL de base</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Link className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||
{config.baseUrl}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">ID du projet</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||
{config.projectId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">Token API</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm font-mono bg-gray-100 px-2 py-1 rounded flex-1">
|
||||
{formatToken(config.apiToken, showTokens[config.id])}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleTokenVisibility(config.id)}
|
||||
>
|
||||
{showTokens[config.id] ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="create-config-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nouvelle Configuration NocoDB</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div id="create-config-description" className="sr-only">
|
||||
Créer une nouvelle configuration pour se connecter à une base de données NocoDB externe
|
||||
</div>
|
||||
<Form {...createForm}>
|
||||
<form onSubmit={createForm.handleSubmit(handleCreateConfig)} className="space-y-4">
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nom de la configuration</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Ex: Production NocoDB" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="baseUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL de base</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://your-nocodb-instance.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Personal API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="xc-token-xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="projectId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID du projet</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="p_xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (optionnel)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description de cette configuration..."
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Configuration active</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Activer cette configuration pour l'utiliser
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
disabled={createConfigMutation.isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createConfigMutation.isPending}
|
||||
>
|
||||
{createConfigMutation.isPending ? "Création..." : "Créer"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="edit-config-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la Configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div id="edit-config-description" className="sr-only">
|
||||
Modifier les paramètres de la configuration NocoDB sélectionnée
|
||||
</div>
|
||||
<Form {...editForm}>
|
||||
<form onSubmit={editForm.handleSubmit(handleEditConfig)} className="space-y-4">
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nom de la configuration</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Ex: Production NocoDB" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="baseUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL de base</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://your-nocodb-instance.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Personal API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="xc-token-xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="projectId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID du projet</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="p_xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (optionnel)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description de cette configuration..."
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Configuration active</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Activer cette configuration pour l'utiliser
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowEditModal(false)}
|
||||
disabled={updateConfigMutation.isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateConfigMutation.isPending}
|
||||
>
|
||||
{updateConfigMutation.isPending ? "Mise à jour..." : "Mettre à jour"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="delete-config-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmer la suppression</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div id="delete-config-description" className="sr-only">
|
||||
Confirmer la suppression définitive de la configuration NocoDB
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Êtes-vous sûr de vouloir supprimer la configuration "{selectedConfig?.name}" ?
|
||||
Cette action est irréversible.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
disabled={deleteConfigMutation.isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfig}
|
||||
disabled={deleteConfigMutation.isPending}
|
||||
>
|
||||
{deleteConfigMutation.isPending ? "Suppression..." : "Supprimer"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Database,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Settings,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Link,
|
||||
} from "lucide-react";
|
||||
import type { NocodbConfig } from "@shared/schema";
|
||||
|
||||
// Form schemas
|
||||
const nocodbConfigFormSchema = z.object({
|
||||
name: z.string().min(1, "Le nom est obligatoire"),
|
||||
baseUrl: z.string().url("L'URL doit être valide").min(1, "L'URL de base est obligatoire"),
|
||||
apiToken: z.string().min(1, "Le token API est obligatoire"),
|
||||
projectId: z.string().min(1, "L'ID du projet est obligatoire"),
|
||||
description: z.string().optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type NocodbConfigForm = z.infer<typeof nocodbConfigFormSchema>;
|
||||
|
||||
export default function NocoDBConfig() {
|
||||
const { user } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedConfig, setSelectedConfig] = useState<NocodbConfig | null>(null);
|
||||
const [showTokens, setShowTokens] = useState<{ [key: number]: boolean }>({});
|
||||
|
||||
// Queries avec protection complète
|
||||
const { data: rawConfigs, isLoading, error } = useQuery({
|
||||
queryKey: ['/api/nocodb-config'],
|
||||
enabled: user?.role === 'admin',
|
||||
});
|
||||
|
||||
// Protection triple couche pour éviter les erreurs TypeError
|
||||
const configs = rawConfigs || [];
|
||||
const safeConfigs = Array.isArray(configs) ? configs : [];
|
||||
|
||||
// Log pour debug production
|
||||
console.log('🔍 NocoDBConfig Debug:', {
|
||||
rawConfigs,
|
||||
configs,
|
||||
isArray: Array.isArray(configs),
|
||||
safeConfigs,
|
||||
length: safeConfigs.length,
|
||||
error,
|
||||
userRole: user?.role
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const createConfigMutation = useMutation({
|
||||
mutationFn: (data: NocodbConfigForm) =>
|
||||
apiRequest('/api/nocodb-config', 'POST', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/nocodb-config'] });
|
||||
setShowCreateModal(false);
|
||||
toast({
|
||||
title: "Configuration créée",
|
||||
description: "La configuration NocoDB a été créée avec succès.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la création de la configuration.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<NocodbConfigForm> }) =>
|
||||
apiRequest(`/api/nocodb-config/${id}`, 'PUT', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/nocodb-config'] });
|
||||
setShowEditModal(false);
|
||||
setSelectedConfig(null);
|
||||
toast({
|
||||
title: "Configuration mise à jour",
|
||||
description: "La configuration NocoDB a été mise à jour avec succès.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la mise à jour de la configuration.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteConfigMutation = useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
apiRequest(`/api/nocodb-config/${id}`, 'DELETE'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/nocodb-config'] });
|
||||
setShowDeleteModal(false);
|
||||
setSelectedConfig(null);
|
||||
toast({
|
||||
title: "Configuration supprimée",
|
||||
description: "La configuration NocoDB a été supprimée avec succès.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la suppression de la configuration.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Forms
|
||||
const createForm = useForm<NocodbConfigForm>({
|
||||
resolver: zodResolver(nocodbConfigFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
apiToken: "",
|
||||
projectId: "",
|
||||
description: "",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const editForm = useForm<NocodbConfigForm>({
|
||||
resolver: zodResolver(nocodbConfigFormSchema),
|
||||
});
|
||||
|
||||
// Handle create config
|
||||
const handleCreateConfig = (data: NocodbConfigForm) => {
|
||||
createConfigMutation.mutate(data);
|
||||
};
|
||||
|
||||
// Handle edit config
|
||||
const handleEditConfig = (data: NocodbConfigForm) => {
|
||||
if (!selectedConfig) return;
|
||||
updateConfigMutation.mutate({ id: selectedConfig.id, data });
|
||||
};
|
||||
|
||||
// Handle delete config
|
||||
const handleDeleteConfig = () => {
|
||||
if (!selectedConfig) return;
|
||||
deleteConfigMutation.mutate(selectedConfig.id);
|
||||
};
|
||||
|
||||
// Open edit modal
|
||||
const openEditModal = (config: NocodbConfig) => {
|
||||
setSelectedConfig(config);
|
||||
editForm.reset({
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
apiToken: config.apiToken,
|
||||
projectId: config.projectId,
|
||||
description: config.description || "",
|
||||
isActive: config.isActive,
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// Open delete modal
|
||||
const openDeleteModal = (config: NocodbConfig) => {
|
||||
setSelectedConfig(config);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// Toggle token visibility
|
||||
const toggleTokenVisibility = (configId: number) => {
|
||||
setShowTokens(prev => ({
|
||||
...prev,
|
||||
[configId]: !prev[configId]
|
||||
}));
|
||||
};
|
||||
|
||||
// Format token for display
|
||||
const formatToken = (token: string, isVisible: boolean) => {
|
||||
if (isVisible) return token;
|
||||
return '•'.repeat(Math.min(token.length, 20));
|
||||
};
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<AlertTriangle className="h-12 w-12 text-yellow-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Accès restreint</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Seuls les administrateurs peuvent gérer les configurations NocoDB.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Database className="h-8 w-8 text-blue-600" />
|
||||
Configuration NocoDB
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gérez les connexions aux bases de données NocoDB externes pour récupérer des informations.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Nouvelle Configuration
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Configurations List */}
|
||||
<div className="grid gap-6">
|
||||
{safeConfigs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center">
|
||||
<Database className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Aucune configuration</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Commencez par créer votre première configuration NocoDB.
|
||||
</p>
|
||||
<Button onClick={() => setShowCreateModal(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Créer une configuration
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
safeConfigs.map((config: NocodbConfig) => (
|
||||
<Card key={config.id} className="border shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-lg">{config.name}</CardTitle>
|
||||
<Badge variant={config.isActive ? "default" : "secondary"}>
|
||||
{config.isActive ? (
|
||||
<><CheckCircle className="h-3 w-3 mr-1" /> Actif</>
|
||||
) : (
|
||||
<><XCircle className="h-3 w-3 mr-1" /> Inactif</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
{config.description && (
|
||||
<CardDescription className="mt-1">
|
||||
{config.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditModal(config)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDeleteModal(config)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">URL de base</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Link className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||
{config.baseUrl}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">ID du projet</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||
{config.projectId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">Token API</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm font-mono bg-gray-100 px-2 py-1 rounded flex-1">
|
||||
{formatToken(config.apiToken, showTokens[config.id])}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleTokenVisibility(config.id)}
|
||||
>
|
||||
{showTokens[config.id] ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="create-config-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nouvelle Configuration NocoDB</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div id="create-config-description" className="sr-only">
|
||||
Créer une nouvelle configuration pour se connecter à une base de données NocoDB externe
|
||||
</div>
|
||||
<Form {...createForm}>
|
||||
<form onSubmit={createForm.handleSubmit(handleCreateConfig)} className="space-y-4">
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nom de la configuration</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Ex: Production NocoDB" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="baseUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL de base</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://your-nocodb-instance.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Personal API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="xc-token-xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="projectId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID du projet</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="p_xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (optionnel)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description de cette configuration..."
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Configuration active</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Activer cette configuration pour l'utiliser
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
disabled={createConfigMutation.isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createConfigMutation.isPending}
|
||||
>
|
||||
{createConfigMutation.isPending ? "Création..." : "Créer"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="edit-config-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la Configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div id="edit-config-description" className="sr-only">
|
||||
Modifier les paramètres de la configuration NocoDB sélectionnée
|
||||
</div>
|
||||
<Form {...editForm}>
|
||||
<form onSubmit={editForm.handleSubmit(handleEditConfig)} className="space-y-4">
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nom de la configuration</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Ex: Production NocoDB" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="baseUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL de base</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://your-nocodb-instance.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Personal API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="xc-token-xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="projectId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID du projet</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="p_xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (optionnel)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description de cette configuration..."
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Configuration active</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Activer cette configuration pour l'utiliser
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowEditModal(false)}
|
||||
disabled={updateConfigMutation.isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateConfigMutation.isPending}
|
||||
>
|
||||
{updateConfigMutation.isPending ? "Mise à jour..." : "Mettre à jour"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="delete-config-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmer la suppression</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div id="delete-config-description" className="sr-only">
|
||||
Confirmer la suppression définitive de la configuration NocoDB
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Êtes-vous sûr de vouloir supprimer la configuration "{selectedConfig?.name}" ?
|
||||
Cette action est irréversible.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
disabled={deleteConfigMutation.isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfig}
|
||||
disabled={deleteConfigMutation.isPending}
|
||||
>
|
||||
{deleteConfigMutation.isPending ? "Suppression..." : "Supprimer"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { isUnauthorizedError } from "@/lib/authUtils";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Package,
|
||||
Calendar,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Building,
|
||||
User
|
||||
} from "lucide-react";
|
||||
import { safeFormat } from "@/lib/dateUtils";
|
||||
import CreateOrderModal from "@/components/modals/CreateOrderModal";
|
||||
import EditOrderModal from "@/components/modals/EditOrderModal";
|
||||
import OrderDetailModal from "@/components/modals/OrderDetailModal";
|
||||
import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal";
|
||||
import type { OrderWithRelations } from "@shared/schema";
|
||||
|
||||
export default function Orders() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Redirection pour les employés
|
||||
if (user?.role === 'employee') {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-orange-50 border-l-4 border-orange-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<Package className="h-5 w-5 text-orange-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-orange-700">
|
||||
<strong>Accès restreint</strong><br />
|
||||
Seuls les managers et administrateurs peuvent accéder à la page des commandes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedOrder, setSelectedOrder] = useState<OrderWithRelations | null>(null);
|
||||
const [orderToDelete, setOrderToDelete] = useState<OrderWithRelations | null>(null);
|
||||
|
||||
// Construire l'URL pour l'historique complet sans filtrage par date
|
||||
const ordersUrl = `/api/orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
||||
|
||||
const { data: ordersData = [], isLoading } = useQuery<OrderWithRelations[]>({
|
||||
queryKey: [ordersUrl, selectedStoreId],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(ordersUrl, { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
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 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
|
||||
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)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la commande",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
}) : [];
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
totalPages,
|
||||
paginatedData: paginatedOrders,
|
||||
totalItems
|
||||
} = usePagination(filteredOrders, 20);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Badge variant="secondary">En attente</Badge>;
|
||||
case 'planned':
|
||||
return <Badge className="bg-blue-100 text-blue-800">Planifié</Badge>;
|
||||
case 'delivered':
|
||||
return <Badge className="bg-green-100 text-green-800">Livré</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewOrder = (order: OrderWithRelations) => {
|
||||
setSelectedOrder({ ...order, type: 'order' });
|
||||
setShowDetailModal(true);
|
||||
};
|
||||
|
||||
const handleEditOrder = (order: OrderWithRelations) => {
|
||||
setSelectedOrder(order);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteOrder = (order: OrderWithRelations) => {
|
||||
setOrderToDelete(order);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDeleteOrder = () => {
|
||||
if (orderToDelete) {
|
||||
deleteMutation.mutate(orderToDelete.id);
|
||||
setShowDeleteModal(false);
|
||||
setOrderToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const canCreate = true; // All users can create orders
|
||||
const canEdit = user?.role === 'admin' || user?.role === 'manager';
|
||||
const canDelete = user?.role === 'admin' || user?.role === 'manager';
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Package className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Gestion des Commandes
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{filteredOrders.length} commande{filteredOrders.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white shadow-md"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvelle Commande
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex-1 min-w-64">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Rechercher par fournisseur, groupe ou commentaire..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border border-gray-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-48 border border-gray-300 shadow-sm">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
<SelectValue placeholder="Filtrer par statut" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||
<SelectItem value="pending">En attente</SelectItem>
|
||||
<SelectItem value="planned">Planifié</SelectItem>
|
||||
<SelectItem value="delivered">Livré</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orders List */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (console.log('📦 Filtered orders length:', filteredOrders.length) || filteredOrders.length === 0) ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
|
||||
<Package className="w-16 h-16 mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium mb-2">Aucune commande trouvée</h3>
|
||||
<p className="text-center max-w-md">
|
||||
{searchTerm || statusFilter !== "all"
|
||||
? "Aucune commande ne correspond à vos critères de recherche."
|
||||
: "Vous n'avez pas encore de commandes. Créez votre première commande pour commencer."}
|
||||
</p>
|
||||
{canCreate && !searchTerm && statusFilter === "all" && (
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="mt-4 bg-primary hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer une commande
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fournisseur
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Groupe
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date prévue
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Créé par
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{paginatedOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Building className="w-5 h-5 text-gray-400 mr-2" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{order.supplier?.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
#{order.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: order.group?.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{order.group?.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 text-gray-400 mr-2" />
|
||||
{safeFormat(order.plannedDate, 'dd MMM yyyy')}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(order.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div className="flex items-center">
|
||||
<User className="w-4 h-4 text-gray-400 mr-2" />
|
||||
{order.creator?.firstName} {order.creator?.lastName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewOrder(order)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditOrder(order)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteOrder(order)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={setItemsPerPage}
|
||||
className="mt-4 p-4 border-t border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showCreateModal && (
|
||||
<CreateOrderModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
selectedDate={null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEditModal && selectedOrder && (
|
||||
<EditOrderModal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
order={selectedOrder}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDetailModal && selectedOrder && (
|
||||
<OrderDetailModal
|
||||
isOpen={showDetailModal}
|
||||
onClose={() => setShowDetailModal(false)}
|
||||
item={selectedOrder}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal de confirmation de suppression */}
|
||||
<ConfirmDeleteModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setOrderToDelete(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteOrder}
|
||||
title="Supprimer la commande"
|
||||
description="Êtes-vous sûr de vouloir supprimer cette commande ?"
|
||||
itemName={orderToDelete ? `${orderToDelete.supplier?.name} - ${safeFormat(orderToDelete.plannedDate, 'dd/MM/yyyy')}` : undefined}
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,936 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Calendar, Edit, Trash2, Eye, Filter, Grid, List } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { format, startOfMonth, endOfMonth, eachWeekOfInterval, eachDayOfInterval, startOfWeek, endOfWeek, isSameMonth, isWithinInterval, startOfYear, endOfYear, getWeek, getMonth } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { safeFormat, safeDate, safeCompareDate } from "@/lib/dateUtils";
|
||||
import type { PublicityWithRelations, Group } from "@shared/schema";
|
||||
import PublicityForm from "@/components/PublicityForm";
|
||||
|
||||
export default function Publicities() {
|
||||
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); // Année courante par défaut
|
||||
const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth()); // Mois courant par défaut
|
||||
const [viewMode, setViewMode] = useState<'list' | 'calendar'>('list'); // Mode d'affichage
|
||||
const [showYearOverview, setShowYearOverview] = useState(true); // Affichage vue d'ensemble
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
||||
const [selectedPublicity, setSelectedPublicity] = useState<PublicityWithRelations | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Seuls les admins peuvent modifier/supprimer
|
||||
const canModify = user?.role === 'admin';
|
||||
|
||||
// Generate year options (current year ± 5 years)
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = Array.from({ length: 11 }, (_, i) => currentYear - 5 + i);
|
||||
|
||||
const { data: publicities = [], isLoading } = useQuery({
|
||||
queryKey: ['/api/publicities', selectedYear, selectedStoreId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('year', selectedYear.toString());
|
||||
if (selectedStoreId) {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
const response = await fetch(`/api/publicities?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch publicities');
|
||||
}
|
||||
const data = await response.json();
|
||||
console.log('📢 Publicities received:', data.length, 'items', data.slice(0, 2));
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
});
|
||||
|
||||
console.log('📢 Publicities Debug:', {
|
||||
isLoading,
|
||||
publicitiesCount: publicities?.length,
|
||||
selectedYear,
|
||||
selectedStoreId,
|
||||
publicities: publicities?.slice(0, 2)
|
||||
});
|
||||
|
||||
console.log('📢 Publicities Debug:', {
|
||||
isLoading,
|
||||
publicitiesCount: publicities?.length,
|
||||
selectedYear,
|
||||
selectedStoreId,
|
||||
publicities: publicities?.slice(0, 2)
|
||||
});
|
||||
|
||||
const { data: groups = [] } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const response = await fetch(`/api/publicities/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Erreur lors de la suppression');
|
||||
}
|
||||
// Handle empty response (204 No Content) in production
|
||||
if (response.status === 204) {
|
||||
return { message: "Publicity deleted successfully" };
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/publicities'] });
|
||||
toast({ description: "Publicité supprimée avec succès" });
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelectedPublicity(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
description: error.message || "Erreur lors de la suppression"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Generate month options
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => ({
|
||||
value: i,
|
||||
label: format(new Date(2024, i, 1), 'MMMM', { locale: fr })
|
||||
}));
|
||||
|
||||
// Calendar helper functions
|
||||
const getWeekParticipation = (weekStart: Date, weekEnd: Date) => {
|
||||
// Vérifier que publicities est un tableau
|
||||
if (!Array.isArray(publicities)) {
|
||||
return {
|
||||
publicities: [],
|
||||
participatingStores: [],
|
||||
storeColors: []
|
||||
};
|
||||
}
|
||||
|
||||
const weekPublicities = publicities.filter(pub => {
|
||||
const pubStart = safeDate(pub.startDate);
|
||||
const pubEnd = safeDate(pub.endDate);
|
||||
|
||||
// More robust overlap detection: check if any part of the publicity period overlaps with the week
|
||||
return pubStart && pubEnd && (pubStart <= weekEnd && pubEnd >= weekStart);
|
||||
});
|
||||
|
||||
const participatingStores = new Set<number>();
|
||||
weekPublicities.forEach(pub => {
|
||||
// Vérifier que participations existe et est un tableau
|
||||
if (pub.participations && Array.isArray(pub.participations)) {
|
||||
pub.participations.forEach(participation => {
|
||||
participatingStores.add(participation.groupId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
publicities: weekPublicities,
|
||||
participatingStores: Array.from(participatingStores),
|
||||
storeColors: Array.from(participatingStores).map(storeId => {
|
||||
const group = groups.find(g => g.id === storeId);
|
||||
return group?.color || '#666666';
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
// Year overview - Generate weeks for the selected year
|
||||
const getYearWeeks = () => {
|
||||
const yearStart = startOfYear(new Date(selectedYear, 0, 1));
|
||||
const yearEnd = endOfYear(new Date(selectedYear, 11, 31));
|
||||
|
||||
const weeks = eachWeekOfInterval(
|
||||
{ start: yearStart, end: yearEnd },
|
||||
{ weekStartsOn: 1 }
|
||||
);
|
||||
|
||||
console.log(`📅 getYearWeeks debug pour ${selectedYear}:`, {
|
||||
yearStart: format(yearStart, 'yyyy-MM-dd EEEE', { locale: fr }),
|
||||
yearEnd: format(yearEnd, 'yyyy-MM-dd EEEE', { locale: fr }),
|
||||
totalWeeks: weeks.length
|
||||
});
|
||||
|
||||
return weeks.map((weekStart, index) => {
|
||||
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 1 });
|
||||
// Utiliser l'index + 1 pour une numérotation séquentielle cohérente
|
||||
// plutôt que getWeek() qui peut créer des doublons en fin d'année
|
||||
const weekNumber = index + 1;
|
||||
|
||||
// Déterminer le mois basé sur la majorité des jours de la semaine
|
||||
// Si la semaine chevauche deux mois, prendre le mois qui contient le plus de jours
|
||||
const weekMidpoint = new Date(weekStart.getTime() + (3.5 * 24 * 60 * 60 * 1000)); // Milieu de la semaine
|
||||
let month = getMonth(weekMidpoint);
|
||||
|
||||
// Si c'est une semaine qui chevauche décembre de l'année précédente et janvier de l'année sélectionnée,
|
||||
// la placer en janvier (mois 0) si elle contient plus de jours de janvier
|
||||
if (weekStart.getFullYear() < selectedYear && weekEnd.getFullYear() === selectedYear) {
|
||||
month = 0; // Janvier
|
||||
}
|
||||
// Si c'est une semaine qui chevauche décembre et janvier de l'année suivante,
|
||||
// la placer en décembre (mois 11) si elle contient plus de jours de décembre
|
||||
else if (weekStart.getFullYear() === selectedYear && weekEnd.getFullYear() > selectedYear) {
|
||||
month = 11; // Décembre
|
||||
}
|
||||
|
||||
const participation = getWeekParticipation(weekStart, weekEnd);
|
||||
|
||||
// Debug première et dernières semaines pour vérifier
|
||||
if (index < 3 || index >= weeks.length - 3) {
|
||||
console.log(`📅 Semaine ${weekNumber} (index ${index}):`, {
|
||||
debut: format(weekStart, 'yyyy-MM-dd EEEE', { locale: fr }),
|
||||
fin: format(weekEnd, 'yyyy-MM-dd EEEE', { locale: fr }),
|
||||
weekStartYear: weekStart.getFullYear(),
|
||||
weekEndYear: weekEnd.getFullYear(),
|
||||
selectedYear,
|
||||
mois: month + 1, // +1 car getMonth retourne 0-11
|
||||
participations: participation.publicities.length
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
weekStart,
|
||||
weekEnd,
|
||||
weekNumber,
|
||||
month,
|
||||
...participation
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const yearWeeks = getYearWeeks();
|
||||
|
||||
const handleView = (publicity: PublicityWithRelations) => {
|
||||
setSelectedPublicity(publicity);
|
||||
setIsViewModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (publicity: PublicityWithRelations) => {
|
||||
setSelectedPublicity(publicity);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (publicity: PublicityWithRelations) => {
|
||||
setSelectedPublicity(publicity);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (selectedPublicity) {
|
||||
deleteMutation.mutate(selectedPublicity.id);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModals = () => {
|
||||
setIsCreateModalOpen(false);
|
||||
setIsEditModalOpen(false);
|
||||
setIsViewModalOpen(false);
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelectedPublicity(null);
|
||||
};
|
||||
|
||||
const canCreateOrEdit = user?.role === 'admin' || user?.role === 'manager';
|
||||
const canDelete = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Calendar className="w-6 h-6 mr-3 text-green-600" />
|
||||
Publicités
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Gestion des campagnes publicitaires
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center bg-gray-100 rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
className="h-8"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'calendar' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('calendar')}
|
||||
className="h-8"
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Year Overview Toggle */}
|
||||
<Button
|
||||
variant={showYearOverview ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowYearOverview(!showYearOverview)}
|
||||
className="h-8"
|
||||
>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Vue d'ensemble
|
||||
</Button>
|
||||
|
||||
{/* Year Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-gray-500" />
|
||||
<Select
|
||||
value={selectedYear.toString()}
|
||||
onValueChange={(value) => setSelectedYear(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map((year) => (
|
||||
<SelectItem key={year} value={year.toString()}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Month Filter (only for calendar view) */}
|
||||
{viewMode === 'calendar' && (
|
||||
<Select
|
||||
value={selectedMonth.toString()}
|
||||
onValueChange={(value) => setSelectedMonth(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthOptions.map((month) => (
|
||||
<SelectItem key={month.value} value={month.value.toString()}>
|
||||
{month.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{canModify && (
|
||||
<Button onClick={() => setIsCreateModalOpen(true)} className="bg-green-600 hover:bg-green-700 text-white shadow-md">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nouvelle publicité
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Calendar className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total {selectedYear}</p>
|
||||
<p className="text-2xl font-semibold">{Array.isArray(publicities) ? publicities.length : 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<Calendar className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">En cours</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{publicities.filter(p => {
|
||||
const now = new Date();
|
||||
const start = safeDate(p.startDate);
|
||||
const end = safeDate(p.endDate);
|
||||
return start && end && start <= now && now <= end;
|
||||
}).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<Calendar className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">À venir</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{publicities.filter(p => {
|
||||
const startDate = safeDate(p.startDate);
|
||||
return startDate && startDate > new Date();
|
||||
}).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Year Overview - Weekly Timeline */}
|
||||
{showYearOverview && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Vue d'ensemble {selectedYear} - Semaines avec publicités
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Months Grid */}
|
||||
<div className="grid grid-cols-12 gap-2">
|
||||
{Array.from({ length: 12 }, (_, monthIndex) => {
|
||||
const monthName = format(new Date(2024, monthIndex, 1), 'MMM', { locale: fr });
|
||||
const monthWeeks = yearWeeks.filter(week => week.month === monthIndex);
|
||||
|
||||
return (
|
||||
<div key={monthIndex} className="space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600 text-center">
|
||||
{monthName}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{monthWeeks.map((week, weekIndex) => {
|
||||
const hasPublicity = week.publicities.length > 0;
|
||||
const storeColors = week.storeColors;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${monthIndex}-${weekIndex}-${week.weekNumber}`}
|
||||
className={`
|
||||
h-8 rounded border text-xs flex flex-col items-center justify-center cursor-pointer relative
|
||||
${hasPublicity
|
||||
? 'bg-blue-50 border-blue-200 text-blue-800'
|
||||
: 'bg-gray-50 border-gray-200 text-gray-500'
|
||||
}
|
||||
hover:shadow-sm transition-shadow
|
||||
`}
|
||||
title={`Semaine ${week.weekNumber} (${format(week.weekStart, 'dd/MM', { locale: fr })} - ${format(week.weekEnd, 'dd/MM', { locale: fr })}) - ${hasPublicity ? `${week.publicities.map(p => p.pubNumber).join(', ')}` : 'Aucune publicité'}`}
|
||||
>
|
||||
<span className="text-xs font-medium">{week.weekNumber}</span>
|
||||
{/* Store participation indicators */}
|
||||
{storeColors.length > 0 && (
|
||||
<div className="absolute top-0.5 right-0.5 flex flex-wrap gap-0.5 max-w-[16px]">
|
||||
{storeColors.slice(0, 4).map((color, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
title={`Magasin ${idx + 1}`}
|
||||
/>
|
||||
))}
|
||||
{storeColors.length > 4 && (
|
||||
<div
|
||||
className="w-1.5 h-1.5 rounded-full bg-gray-400 text-white text-xs flex items-center justify-center"
|
||||
title={`+${storeColors.length - 4} autres magasins`}
|
||||
>
|
||||
+
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-blue-50 border border-blue-200 rounded"></div>
|
||||
<span>Semaine avec publicité</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-gray-50 border border-gray-200 rounded"></div>
|
||||
<span>Semaine sans publicité</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{groups.slice(0, 3).map(group => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: group?.color || '#666666' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span>Indicateurs magasins (coin supérieur droit)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>• Survolez les semaines pour voir les détails des publicités</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : publicities.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Calendar className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Aucune publicité pour {selectedYear}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Commencez par créer votre première campagne publicitaire.
|
||||
</p>
|
||||
{canModify && (
|
||||
<Button onClick={() => setIsCreateModalOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Créer une publicité
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : viewMode === 'calendar' ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Vue calendrier - {monthOptions[selectedMonth].label} {selectedYear}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Calendar Grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{/* Days of week headers */}
|
||||
{['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map(day => (
|
||||
<div key={day} className="p-2 text-center text-sm font-medium text-gray-500">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Calendar days */}
|
||||
{(() => {
|
||||
const monthStart = startOfMonth(new Date(selectedYear, selectedMonth, 1));
|
||||
const monthEnd = endOfMonth(monthStart);
|
||||
const startDate = startOfWeek(monthStart, { weekStartsOn: 1 });
|
||||
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
||||
|
||||
const days = eachDayOfInterval({ start: startDate, end: endDate });
|
||||
|
||||
return days.map(day => {
|
||||
const isCurrentMonth = isSameMonth(day, monthStart);
|
||||
const dayPublicities = publicities.filter(pub => {
|
||||
const pubStart = safeDate(pub.startDate);
|
||||
const pubEnd = safeDate(pub.endDate);
|
||||
return pubStart && pubEnd && day >= pubStart && day <= pubEnd;
|
||||
});
|
||||
|
||||
const participatingStores = new Set<number>();
|
||||
dayPublicities.forEach(pub => {
|
||||
pub.participations.forEach(participation => {
|
||||
participatingStores.add(participation.groupId);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={`
|
||||
p-2 min-h-[60px] border border-gray-200 rounded-lg
|
||||
${isCurrentMonth ? 'bg-white' : 'bg-gray-50'}
|
||||
${dayPublicities.length > 0 ? 'ring-2 ring-blue-200' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900 mb-1">
|
||||
{format(day, 'd')}
|
||||
</div>
|
||||
{dayPublicities.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{dayPublicities.slice(0, 2).map(pub => (
|
||||
<div
|
||||
key={pub.id}
|
||||
className="text-xs bg-blue-100 text-blue-800 rounded px-1 py-0.5 truncate"
|
||||
title={pub.designation}
|
||||
>
|
||||
{pub.pubNumber}
|
||||
</div>
|
||||
))}
|
||||
{dayPublicities.length > 2 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
+{dayPublicities.length - 2} autre{dayPublicities.length > 3 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Store participation indicators */}
|
||||
{participatingStores.size > 0 && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{Array.from(participatingStores).slice(0, 3).map(storeId => {
|
||||
const group = groups.find(g => g.id === storeId);
|
||||
return (
|
||||
<div
|
||||
key={storeId}
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: group?.color || '#666666' }}
|
||||
title={group?.name || 'Magasin'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{participatingStores.size > 3 && (
|
||||
<div className="text-xs text-gray-500">+{participatingStores.size - 3}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Légende</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-blue-100 border border-blue-200 rounded"></div>
|
||||
<span>Jour avec publicité</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{groups.slice(0, 3).map(group => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: group?.color || '#666666' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span>Magasins participants</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
N° PUB
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Désignation
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Période
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Magasins
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Créé par
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{publicities
|
||||
.sort((a, b) => safeCompareDate(b.startDate, a.startDate))
|
||||
.map((publicity) => {
|
||||
const now = new Date();
|
||||
const start = safeDate(publicity.startDate);
|
||||
const end = safeDate(publicity.endDate);
|
||||
const isActive = start && end && now >= start && now <= end;
|
||||
const isUpcoming = start && start > now;
|
||||
const isPast = end && end < now;
|
||||
|
||||
return (
|
||||
<tr key={publicity.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{publicity.pubNumber}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-900 max-w-xs truncate">
|
||||
{publicity.designation}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">
|
||||
{safeFormat(publicity.startDate, "dd/MM/yy")} - {safeFormat(publicity.endDate, "dd/MM/yy")}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{isActive && <Badge className="bg-green-100 text-green-800">En cours</Badge>}
|
||||
{isUpcoming && <Badge className="bg-blue-100 text-blue-800">À venir</Badge>}
|
||||
{isPast && <Badge variant="secondary">Terminée</Badge>}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{publicity.participations.length === 0 ? (
|
||||
<Badge className="text-xs bg-red-100 text-red-800 hover:bg-red-100">
|
||||
Aucun magasin
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
{publicity.participations.slice(0, 2).map((participation) => (
|
||||
<Badge key={participation.groupId} variant="outline" className="text-xs">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full mr-1"
|
||||
style={{ backgroundColor: participation.group?.color || '#666666' }}
|
||||
/>
|
||||
{participation.group?.name || 'Magasin'}
|
||||
</Badge>
|
||||
))}
|
||||
{publicity.participations.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{publicity.participations.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">
|
||||
{publicity.creator?.name || publicity.creator?.username || 'Utilisateur'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleView(publicity)}
|
||||
title="Voir les détails"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{canModify && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(publicity)}
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(publicity)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer une nouvelle publicité</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remplissez les informations de la campagne publicitaire et sélectionnez les magasins participants.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<PublicityForm
|
||||
groups={groups}
|
||||
selectedYear={selectedYear}
|
||||
onSuccess={closeModals}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la publicité</DialogTitle>
|
||||
<DialogDescription>
|
||||
Modifiez les informations de la campagne publicitaire.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<PublicityForm
|
||||
publicity={selectedPublicity}
|
||||
groups={groups}
|
||||
selectedYear={selectedYear}
|
||||
onSuccess={closeModals}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* View Modal */}
|
||||
<Dialog open={isViewModalOpen} onOpenChange={setIsViewModalOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Détails de la publicité</DialogTitle>
|
||||
<DialogDescription>
|
||||
Informations complètes de la campagne publicitaire.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedPublicity && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">N° PUB</label>
|
||||
<p className="text-lg">{selectedPublicity.pubNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Année</label>
|
||||
<p className="text-lg">{selectedPublicity.year}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Désignation</label>
|
||||
<p className="mt-1">{selectedPublicity.designation}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Date de début</label>
|
||||
<p>{safeFormat(selectedPublicity.startDate, "dd/MM/yyyy")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Date de fin</label>
|
||||
<p>{safeFormat(selectedPublicity.endDate, "dd/MM/yyyy")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Magasins participants</label>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{selectedPublicity.participations.length === 0 ? (
|
||||
<Badge className="bg-red-100 text-red-800 hover:bg-red-100">Aucun magasin</Badge>
|
||||
) : (
|
||||
selectedPublicity.participations.map((participation) => (
|
||||
<Badge key={participation.groupId} variant="outline">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: participation.group?.color || '#666666' }}
|
||||
/>
|
||||
{participation.group?.name || 'Magasin'}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<label className="font-medium">Créé par</label>
|
||||
<p>{selectedPublicity.creator?.name || selectedPublicity.creator?.username || 'Utilisateur'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-medium">Créé le</label>
|
||||
<p>{safeFormat(selectedPublicity.createdAt, "dd/MM/yyyy à HH:mm")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Supprimer la publicité</DialogTitle>
|
||||
<DialogDescription>
|
||||
Êtes-vous sûr de vouloir supprimer cette publicité ? Cette action est irréversible.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedPublicity && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<p className="font-medium">{selectedPublicity.pubNumber}</p>
|
||||
<p className="text-sm text-gray-600">{selectedPublicity.designation}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsDeleteModalOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Suppression...' : 'Supprimer'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { getRoleColor, getRoleDisplayName, ROLE_COLORS } from "@/lib/roleUtils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Plus, Edit, Trash2, Shield, Users, Settings } from "lucide-react";
|
||||
import type { Role, Permission, User } from "@shared/schema";
|
||||
|
||||
// Fonction pour forcer l'affichage des noms français même si displayName est incorrect
|
||||
function getPermissionDisplayName(permission: any): string {
|
||||
// Si displayName existe et n'est pas le même que name, l'utiliser
|
||||
if (permission.displayName && permission.displayName !== permission.name) {
|
||||
return permission.displayName;
|
||||
}
|
||||
|
||||
// Sinon, mapper manuellement selon le nom technique
|
||||
const frenchNames: Record<string, string> = {
|
||||
'dashboard_read': 'Voir tableau de bord',
|
||||
'groups_read': 'Voir magasins',
|
||||
'groups_create': 'Créer magasins',
|
||||
'groups_update': 'Modifier magasins',
|
||||
'groups_delete': 'Supprimer magasins',
|
||||
'suppliers_read': 'Voir fournisseurs',
|
||||
'suppliers_create': 'Créer fournisseurs',
|
||||
'suppliers_update': 'Modifier fournisseurs',
|
||||
'suppliers_delete': 'Supprimer fournisseurs',
|
||||
'orders_read': 'Voir commandes',
|
||||
'orders_create': 'Créer commandes',
|
||||
'orders_update': 'Modifier commandes',
|
||||
'orders_delete': 'Supprimer commandes',
|
||||
'deliveries_read': 'Voir livraisons',
|
||||
'deliveries_create': 'Créer livraisons',
|
||||
'deliveries_update': 'Modifier livraisons',
|
||||
'deliveries_delete': 'Supprimer livraisons',
|
||||
'deliveries_validate': 'Valider livraisons',
|
||||
'deliveries_reconcile': 'Rapprocher livraisons',
|
||||
'publicities_read': 'Voir publicités',
|
||||
'publicities_create': 'Créer publicités',
|
||||
'publicities_update': 'Modifier publicités',
|
||||
'publicities_delete': 'Supprimer publicités',
|
||||
'customer_orders_read': 'Voir commandes clients',
|
||||
'customer_orders_create': 'Créer commandes clients',
|
||||
'customer_orders_update': 'Modifier commandes clients',
|
||||
'customer_orders_delete': 'Supprimer commandes clients',
|
||||
'customer_orders_print': 'Imprimer commandes clients',
|
||||
'customer_orders_export': 'Exporter commandes clients',
|
||||
'users_read': 'Voir utilisateurs',
|
||||
'users_create': 'Créer utilisateurs',
|
||||
'users_update': 'Modifier utilisateurs',
|
||||
'users_delete': 'Supprimer utilisateurs',
|
||||
'roles_read': 'Voir rôles',
|
||||
'roles_create': 'Créer rôles',
|
||||
'roles_update': 'Modifier rôles',
|
||||
'roles_delete': 'Supprimer rôles',
|
||||
'permissions_assign': 'Assigner permissions',
|
||||
'calendar_read': 'Voir calendrier',
|
||||
'calendar_create': 'Créer événement calendrier',
|
||||
'calendar_update': 'Modifier événement calendrier',
|
||||
'calendar_delete': 'Supprimer événement calendrier',
|
||||
'reconciliation_read': 'Voir rapprochement',
|
||||
'reconciliation_update': 'Modifier rapprochement',
|
||||
'tasks_read': 'Voir tâches',
|
||||
'tasks_create': 'Créer tâches',
|
||||
'tasks_update': 'Modifier tâches',
|
||||
'tasks_delete': 'Supprimer tâches',
|
||||
'tasks_assign': 'Assigner tâches',
|
||||
'dlc_read': 'Voir DLC',
|
||||
'dlc_create': 'Créer DLC',
|
||||
'dlc_update': 'Modifier DLC',
|
||||
'dlc_delete': 'Supprimer DLC',
|
||||
'dlc_validate': 'Valider DLC',
|
||||
'dlc_print': 'Imprimer DLC',
|
||||
'dlc_stats': 'Statistiques DLC',
|
||||
'nocodb_config': 'Configuration NocoDB',
|
||||
'magasins_read': 'Voir magasins',
|
||||
'magasins_create': 'Créer magasins',
|
||||
'magasins_update': 'Modifier magasins',
|
||||
'magasins_delete': 'Supprimer magasins'
|
||||
};
|
||||
|
||||
return frenchNames[permission.name] || permission.displayName || permission.name;
|
||||
}
|
||||
|
||||
interface RoleWithPermissions extends Role {
|
||||
rolePermissions: Array<{
|
||||
roleId: number;
|
||||
permissionId: number;
|
||||
createdAt: Date;
|
||||
permission: Permission;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface UserWithRoles extends User {
|
||||
userRoles: Array<{
|
||||
userId: string;
|
||||
roleId: number;
|
||||
assignedBy: string;
|
||||
assignedAt: Date;
|
||||
role: Role;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function RoleManagement() {
|
||||
const [selectedRole, setSelectedRole] = useState<RoleWithPermissions | null>(null);
|
||||
const [createRoleOpen, setCreateRoleOpen] = useState(false);
|
||||
const [editRoleOpen, setEditRoleOpen] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch roles
|
||||
const { data: rolesData = [], isLoading: rolesLoading, error: rolesError, refetch: refetchRoles } = useQuery<Role[]>({
|
||||
queryKey: ['/api/roles'],
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: false
|
||||
});
|
||||
|
||||
// Fetch permissions with forced refetch
|
||||
const { data: permissionsData = [], isLoading: permissionsLoading, refetch: refetchPermissions, error: permissionsError } = useQuery<Permission[]>({
|
||||
queryKey: ['/api/permissions'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/permissions', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch permissions: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: false
|
||||
});
|
||||
|
||||
// Force refresh permissions on mount
|
||||
useEffect(() => {
|
||||
queryClient.removeQueries({ queryKey: ['/api/permissions'] });
|
||||
refetchPermissions();
|
||||
}, [refetchPermissions, queryClient]);
|
||||
|
||||
// Force refresh permissions function
|
||||
const forceRefreshPermissions = () => {
|
||||
queryClient.removeQueries({ queryKey: ['/api/permissions'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/permissions'] });
|
||||
queryClient.resetQueries({ queryKey: ['/api/permissions'] });
|
||||
refetchPermissions();
|
||||
};
|
||||
|
||||
// Debug function removed after successful DLC permissions fix
|
||||
|
||||
|
||||
|
||||
// Protection Array.isArray et logs debug RENFORCÉS
|
||||
const roles = Array.isArray(rolesData) ? rolesData : [];
|
||||
const permissions = Array.isArray(permissionsData) ? permissionsData : [];
|
||||
|
||||
// Auto-select first role if none selected and roles are available
|
||||
useEffect(() => {
|
||||
if (!selectedRole && roles.length > 0) {
|
||||
setSelectedRole(roles[0] as RoleWithPermissions);
|
||||
}
|
||||
}, [roles, selectedRole]);
|
||||
|
||||
|
||||
|
||||
// Check for task permissions availability
|
||||
const taskPermissions = permissions.filter(p => p.category === 'gestion_taches');
|
||||
|
||||
// Get role with permissions
|
||||
const { data: roleWithPermissions } = useQuery<RoleWithPermissions>({
|
||||
queryKey: ['/api/roles', selectedRole?.id],
|
||||
enabled: !!selectedRole,
|
||||
});
|
||||
|
||||
// Get role permissions specifically
|
||||
const { data: rolePermissions = [] } = useQuery<any[]>({
|
||||
queryKey: [`/api/roles/${selectedRole?.id}/permissions`],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/roles/${selectedRole?.id}/permissions`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch role permissions: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!selectedRole,
|
||||
});
|
||||
|
||||
// Task permissions check for role
|
||||
const taskPermissionsInRole = rolePermissions?.filter(rp => {
|
||||
const permission = permissions.find(p => p.id === rp.permissionId);
|
||||
return permission?.category === 'gestion_taches';
|
||||
});
|
||||
|
||||
// Traduction des catégories en français
|
||||
const categoryTranslations: Record<string, string> = {
|
||||
'administration': 'Administration',
|
||||
'calendrier': 'Calendrier',
|
||||
'commandes': 'Commandes',
|
||||
'commandes_clients': 'Commandes Clients',
|
||||
'fournisseurs': 'Fournisseurs',
|
||||
'gestion_dlc': 'Gestion DLC',
|
||||
'gestion_roles': 'Gestion des Rôles',
|
||||
'gestion_taches': 'Gestion des Tâches',
|
||||
'livraisons': 'Livraisons',
|
||||
'magasins': 'Magasins',
|
||||
'publicites': 'Publicités',
|
||||
'rapprochement': 'Rapprochement',
|
||||
'tableau_de_bord': 'Tableau de Bord',
|
||||
'utilisateurs': 'Utilisateurs',
|
||||
// Fallbacks pour les anciennes catégories techniques
|
||||
'customer_orders': 'Commandes Clients',
|
||||
'groups': 'Magasins',
|
||||
'roles': 'Gestion des Rôles',
|
||||
// Fallbacks pour les anciennes catégories en anglais
|
||||
'Calendar': 'Calendrier',
|
||||
'Dashboard': 'Tableau de Bord',
|
||||
'Deliveries': 'Livraisons',
|
||||
'Orders': 'Commandes',
|
||||
'Publicities': 'Publicités',
|
||||
'Reconciliation': 'Rapprochement',
|
||||
'Suppliers': 'Fournisseurs',
|
||||
'Customer Orders': 'Commandes Clients',
|
||||
'Users': 'Utilisateurs',
|
||||
'Roles': 'Gestion des Rôles',
|
||||
'Administration': 'Administration'
|
||||
};
|
||||
|
||||
// Group permissions by category
|
||||
const permissionsByCategory = Array.isArray(permissions) ? permissions.reduce((acc, permission) => {
|
||||
if (!acc[permission.category]) {
|
||||
acc[permission.category] = [];
|
||||
}
|
||||
acc[permission.category].push(permission);
|
||||
return acc;
|
||||
}, {} as Record<string, Permission[]>) : {};
|
||||
|
||||
// Check permission categories availability
|
||||
const hasTasksCategory = !!permissionsByCategory['gestion_taches'];
|
||||
const hasAdminCategory = !!permissionsByCategory['administration'];
|
||||
|
||||
// Debug permissions (cleaned up after successful DLC fix)
|
||||
|
||||
// Create role mutation
|
||||
const createRoleMutation = useMutation({
|
||||
mutationFn: async (data: { name: string; displayName: string; description: string; color: string }) => {
|
||||
return await apiRequest('/api/roles', 'POST', data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/roles'] });
|
||||
setCreateRoleOpen(false);
|
||||
toast({ title: "Rôle créé avec succès" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({ title: "Erreur lors de la création du rôle", description: error.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
// Update role mutation
|
||||
const updateRoleMutation = useMutation({
|
||||
mutationFn: async (data: { id: number; name: string; displayName: string; description: string; color: string }) => {
|
||||
return await apiRequest(`/api/roles/${data.id}`, 'PUT', data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/roles'] });
|
||||
setEditRoleOpen(false);
|
||||
toast({ title: "Rôle modifié avec succès" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({ title: "Erreur lors de la modification du rôle", description: error.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
// Delete role mutation
|
||||
const deleteRoleMutation = useMutation({
|
||||
mutationFn: async (roleId: number) => {
|
||||
return await apiRequest(`/api/roles/${roleId}`, 'DELETE');
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/roles'] });
|
||||
toast({ title: "Rôle supprimé avec succès" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({ title: "Erreur lors de la suppression du rôle", description: error.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
// Update role permissions mutation
|
||||
const updateRolePermissionsMutation = useMutation({
|
||||
mutationFn: async (data: { roleId: number; permissionIds: number[] }) => {
|
||||
return await apiRequest(`/api/roles/${data.roleId}/permissions`, 'POST', { permissionIds: data.permissionIds });
|
||||
},
|
||||
onSuccess: () => {
|
||||
console.log("🚀 Permission mutation success - invalidating cache");
|
||||
|
||||
// Invalider seulement les queries nécessaires
|
||||
if (selectedRole) {
|
||||
queryClient.invalidateQueries({ queryKey: [`/api/roles/${selectedRole.id}/permissions`] });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/permissions'] });
|
||||
|
||||
toast({ title: "Permissions mises à jour avec succès" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({ title: "Erreur lors de la mise à jour des permissions", description: error.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
// Fix admin permissions mutation
|
||||
const fixAdminPermissionsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return await apiRequest('/api/admin/fix-permissions', 'POST');
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// Rafraîchir toutes les données
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/roles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/permissions'] });
|
||||
if (selectedRole) {
|
||||
queryClient.invalidateQueries({ queryKey: [`/api/roles/${selectedRole.id}/permissions`] });
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Permissions administrateur corrigées",
|
||||
description: `${data.added} permissions ajoutées. Total: ${data.after}/${data.total}`
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Erreur lors de la correction",
|
||||
description: error.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
const handleCreateRole = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
createRoleMutation.mutate({
|
||||
name: formData.get('name') as string,
|
||||
displayName: formData.get('displayName') as string,
|
||||
description: formData.get('description') as string,
|
||||
color: formData.get('color') as string,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateRole = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!selectedRole) return;
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
updateRoleMutation.mutate({
|
||||
id: selectedRole.id,
|
||||
name: formData.get('name') as string,
|
||||
displayName: formData.get('displayName') as string,
|
||||
description: formData.get('description') as string,
|
||||
color: formData.get('color') as string,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePermissionToggle = (permissionId: number, checked: boolean) => {
|
||||
if (!selectedRole) return;
|
||||
|
||||
const currentPermissions = rolePermissions?.map(rp => rp.permissionId) || [];
|
||||
const newPermissions = checked
|
||||
? [...currentPermissions, permissionId]
|
||||
: currentPermissions.filter(id => id !== permissionId);
|
||||
|
||||
console.log("🔍 Permission toggle:", {
|
||||
permissionId,
|
||||
checked,
|
||||
currentPermissions,
|
||||
newPermissions,
|
||||
roleId: selectedRole.id
|
||||
});
|
||||
|
||||
updateRolePermissionsMutation.mutate({
|
||||
roleId: selectedRole.id,
|
||||
permissionIds: newPermissions,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Gestion des Rôles et Permissions</h1>
|
||||
<p className="text-muted-foreground">Gérez les rôles utilisateurs et leurs permissions</p>
|
||||
</div>
|
||||
<Dialog open={createRoleOpen} onOpenChange={setCreateRoleOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer un Rôle
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer un Nouveau Rôle</DialogTitle>
|
||||
<DialogDescription>Ajoutez un nouveau rôle au système</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleCreateRole} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nom technique</Label>
|
||||
<Input id="name" name="name" required />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="displayName">Nom d'affichage</Label>
|
||||
<Input id="displayName" name="displayName" required />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input id="description" name="description" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="color">Couleur</Label>
|
||||
<Input id="color" name="color" type="color" defaultValue="#3B82F6" />
|
||||
</div>
|
||||
<Button type="submit" disabled={createRoleMutation.isPending}>
|
||||
{createRoleMutation.isPending ? 'Création...' : 'Créer'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="roles" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="roles">
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Rôles
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="permissions">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Permissions
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="roles" className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Roles List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Liste des Rôles</CardTitle>
|
||||
<CardDescription>Sélectionnez un rôle pour voir ses permissions</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fixAdminPermissionsMutation.mutate()}
|
||||
disabled={fixAdminPermissionsMutation.isPending}
|
||||
className="text-xs"
|
||||
>
|
||||
{fixAdminPermissionsMutation.isPending ? 'Correction...' : '🔧 Corriger Admin'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.log("🔄 Manual refetch triggered");
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/roles'] });
|
||||
refetchRoles();
|
||||
}}
|
||||
>
|
||||
🔄 Actualiser
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rolesLoading ? (
|
||||
<div className="text-center py-4">Chargement des rôles...</div>
|
||||
) : rolesError ? (
|
||||
<div className="text-center py-4 text-red-500">
|
||||
Erreur: {rolesError.message}
|
||||
</div>
|
||||
) : roles.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
Aucun rôle trouvé
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{roles.map((role) => {
|
||||
// Utiliser la couleur de la base de données, avec fallback sur getRoleColor
|
||||
const roleColor = role.color || getRoleColor(role.name);
|
||||
return (
|
||||
<div
|
||||
key={role.id}
|
||||
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||
selectedRole?.id === role.id ? 'border-primary bg-primary/5' : 'hover:bg-muted'
|
||||
}`}
|
||||
onClick={() => setSelectedRole(role)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border"
|
||||
style={{
|
||||
backgroundColor: roleColor,
|
||||
borderColor: roleColor
|
||||
}}
|
||||
title={`Couleur: ${roleColor}`}
|
||||
/>
|
||||
<span className="font-medium">{role.displayName}</span>
|
||||
{role.isSystem && (
|
||||
<Badge variant="secondary" className="text-xs">Système</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedRole(role);
|
||||
setEditRoleOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{!role.isSystem && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteRoleMutation.mutate(role.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{role.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Role Permissions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Permissions du Rôle</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedRole ? `Permissions pour ${selectedRole.displayName}` : 'Sélectionnez un rôle'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedRole ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(permissionsByCategory)
|
||||
.filter(([category, categoryPermissions]) => {
|
||||
return Array.isArray(categoryPermissions) && categoryPermissions.length > 0;
|
||||
})
|
||||
.map(([category, categoryPermissions]) => (
|
||||
<div key={category} className="space-y-2 border-l-2 border-blue-200 pl-3">
|
||||
<h4 className="font-medium text-sm text-blue-800">
|
||||
{categoryTranslations[category] || category}
|
||||
<span className="text-xs text-gray-400 ml-2 bg-gray-100 px-1 rounded">({categoryPermissions.length})</span>
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{categoryPermissions.map((permission) => {
|
||||
const hasPermission = rolePermissions?.some(
|
||||
rp => rp.permissionId === permission.id
|
||||
);
|
||||
return (
|
||||
<div key={permission.id} className="flex items-center space-x-2 bg-gray-50 p-1 rounded">
|
||||
<Checkbox
|
||||
id={`permission-${permission.id}`}
|
||||
checked={hasPermission || false}
|
||||
onCheckedChange={(checked) => handlePermissionToggle(permission.id, checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`permission-${permission.id}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 flex-1"
|
||||
>
|
||||
{getPermissionDisplayName(permission)}
|
||||
</label>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{permission.action}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8">Sélectionnez un rôle pour voir ses permissions</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
|
||||
<TabsContent value="permissions" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Liste des Permissions</CardTitle>
|
||||
<CardDescription>Toutes les permissions disponibles dans le système</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={forceRefreshPermissions}
|
||||
>
|
||||
🔄 Actualiser Permissions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{Object.entries(permissionsByCategory)
|
||||
.filter(([category, categoryPermissions]) => {
|
||||
return Array.isArray(categoryPermissions) && categoryPermissions.length > 0;
|
||||
})
|
||||
.map(([category, categoryPermissions]) => (
|
||||
<div key={category} className="space-y-2">
|
||||
<h3 className="font-medium text-lg border-b pb-1">
|
||||
{categoryTranslations[category] || category}
|
||||
<span className="text-xs text-gray-500 ml-2 bg-blue-100 px-2 py-1 rounded">
|
||||
({categoryPermissions.length})
|
||||
</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{categoryPermissions.map((permission) => (
|
||||
<div key={permission.id} className="flex items-center justify-between p-3 border rounded-lg shadow-sm">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{getPermissionDisplayName(permission)}</p>
|
||||
<p className="text-xs text-muted-foreground">{permission.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{permission.action}
|
||||
</Badge>
|
||||
{permission.isSystem && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Système
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Edit Role Dialog */}
|
||||
<Dialog open={editRoleOpen} onOpenChange={setEditRoleOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier le Rôle</DialogTitle>
|
||||
<DialogDescription>Modifiez les informations du rôle</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedRole && (
|
||||
<form onSubmit={handleUpdateRole} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-name">Nom technique</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
defaultValue={selectedRole.name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-displayName">Nom d'affichage</Label>
|
||||
<Input
|
||||
id="edit-displayName"
|
||||
name="displayName"
|
||||
defaultValue={selectedRole.displayName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
<Input
|
||||
id="edit-description"
|
||||
name="description"
|
||||
defaultValue={selectedRole.description || ''}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-color">Couleur</Label>
|
||||
<Input
|
||||
id="edit-color"
|
||||
name="color"
|
||||
type="color"
|
||||
defaultValue={selectedRole.color}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={updateRoleMutation.isPending}>
|
||||
{updateRoleMutation.isPending ? 'Modification...' : 'Modifier'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { isUnauthorizedError } from "@/lib/authUtils";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Building,
|
||||
Phone,
|
||||
Mail,
|
||||
Edit,
|
||||
Trash2,
|
||||
Package,
|
||||
Truck,
|
||||
CheckCircle,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import type { Supplier } from "@shared/schema";
|
||||
|
||||
export default function Suppliers() {
|
||||
const { user } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
contact: "",
|
||||
phone: "",
|
||||
hasDlc: false,
|
||||
});
|
||||
|
||||
const { data: suppliers = [], isLoading } = useQuery<Supplier[]>({
|
||||
queryKey: ['/api/suppliers'],
|
||||
enabled: user?.role === 'admin' || user?.role === 'manager',
|
||||
});
|
||||
|
||||
const { data: orders = [] } = useQuery({
|
||||
queryKey: ['/api/orders'],
|
||||
enabled: user?.role === 'admin' || user?.role === 'manager',
|
||||
});
|
||||
|
||||
const { data: deliveries = [] } = useQuery({
|
||||
queryKey: ['/api/deliveries'],
|
||||
enabled: user?.role === 'admin' || user?.role === 'manager',
|
||||
});
|
||||
|
||||
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;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Fournisseur créé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] });
|
||||
setShowCreateModal(false);
|
||||
setFormData({ name: "", contact: "", phone: "", hasDlc: false });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de créer le fournisseur",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
},
|
||||
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']);
|
||||
|
||||
if (previousSuppliers && selectedSupplier) {
|
||||
const updatedSuppliers = (previousSuppliers as any[]).map(supplier =>
|
||||
supplier.id === selectedSupplier.id
|
||||
? { ...supplier, ...newData, updatedAt: new Date().toISOString() }
|
||||
: supplier
|
||||
);
|
||||
queryClient.setQueryData(['/api/suppliers'], updatedSuppliers);
|
||||
console.log('✅ Frontend: Optimistic update applied');
|
||||
}
|
||||
|
||||
return { previousSuppliers };
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
console.log('✅ Frontend: Update mutation successful, result:', result);
|
||||
console.log('🔄 Frontend: Starting cache invalidation...');
|
||||
|
||||
// Forcer une actualisation complète du cache
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] });
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ['/api/suppliers'],
|
||||
type: 'active'
|
||||
});
|
||||
|
||||
// Attendre un petit délai pour s'assurer que les données sont fraîches
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
console.log('✅ Frontend: Cache invalidation complete');
|
||||
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Fournisseur modifié avec succès",
|
||||
});
|
||||
|
||||
setShowEditModal(false);
|
||||
setSelectedSupplier(null);
|
||||
setFormData({ name: "", contact: "", phone: "", hasDlc: false });
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
// Rollback optimiste en cas d'erreur
|
||||
if (context?.previousSuppliers) {
|
||||
console.log('❌ Frontend: Error occurred, rolling back optimistic update');
|
||||
queryClient.setQueryData(['/api/suppliers'], context.previousSuppliers);
|
||||
}
|
||||
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de modifier le fournisseur",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest(`/api/suppliers/${id}`, "DELETE");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Fournisseur supprimé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer le fournisseur",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const filteredSuppliers = Array.isArray(suppliers) ? suppliers.filter(supplier =>
|
||||
supplier.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supplier.contact?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supplier.phone?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [];
|
||||
|
||||
const getSupplierStats = (supplierId: number) => {
|
||||
const supplierOrders = Array.isArray(orders) ? orders.filter(order => order.supplierId === supplierId) : [];
|
||||
const supplierDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => delivery.supplierId === supplierId) : [];
|
||||
|
||||
return {
|
||||
orders: supplierOrders.length,
|
||||
deliveries: supplierDeliveries.length,
|
||||
delivered: supplierDeliveries.filter(d => d.status === 'delivered').length,
|
||||
};
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormData({ name: "", contact: "", phone: "", hasDlc: false });
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (supplier: Supplier) => {
|
||||
setSelectedSupplier(supplier);
|
||||
setFormData({
|
||||
name: supplier.name,
|
||||
contact: supplier.contact || "",
|
||||
phone: supplier.phone || "",
|
||||
hasDlc: supplier.hasDlc || false,
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (supplier: Supplier) => {
|
||||
const stats = getSupplierStats(supplier.id);
|
||||
|
||||
if (stats.orders > 0 || stats.deliveries > 0) {
|
||||
toast({
|
||||
title: "Suppression impossible",
|
||||
description: "Ce fournisseur a des commandes ou livraisons associées",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`Êtes-vous sûr de vouloir supprimer le fournisseur "${supplier.name}" ?`)) {
|
||||
deleteMutation.mutate(supplier.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Le nom du fournisseur est requis",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedSupplier) {
|
||||
updateMutation.mutate(formData);
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: string, value: string | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const canManage = user?.role === 'admin' || user?.role === 'manager';
|
||||
|
||||
if (!canManage) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Building className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Accès restreint
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Vous n'avez pas les permissions nécessaires pour accéder à cette page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Building className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Gestion des Fournisseurs
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{filteredSuppliers.length} fournisseur{filteredSuppliers.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white shadow-md"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouveau Fournisseur
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="bg-gray-50 border-b border-gray-200 p-4">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Rechercher un fournisseur..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border border-gray-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suppliers List */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : filteredSuppliers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
|
||||
<Building className="w-16 h-16 mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{searchTerm ? "Aucun fournisseur trouvé" : "Aucun fournisseur"}
|
||||
</h3>
|
||||
<p className="text-center max-w-md">
|
||||
{searchTerm
|
||||
? "Aucun fournisseur ne correspond à votre recherche."
|
||||
: "Vous n'avez pas encore de fournisseurs. Créez votre premier fournisseur pour commencer."}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="mt-4 bg-primary hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer un fournisseur
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredSuppliers.map((supplier) => {
|
||||
const stats = getSupplierStats(supplier.id);
|
||||
return (
|
||||
<div key={supplier.id} className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 bg-blue-600 flex items-center justify-center mr-3">
|
||||
<Building className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{supplier.name}</h3>
|
||||
<p className="text-sm text-gray-500">#{supplier.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(supplier)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(supplier)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
{supplier.contact && (
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
{supplier.contact}
|
||||
</div>
|
||||
)}
|
||||
{supplier.phone && (
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<Phone className="w-4 h-4 mr-2" />
|
||||
{supplier.phone}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center text-sm">
|
||||
{supplier.hasDlc ? (
|
||||
<div className="flex items-center text-green-600">
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
DLC activé
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-gray-400">
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
DLC désactivé
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center text-primary mb-1">
|
||||
<Package className="w-4 h-4 mr-1" />
|
||||
<span className="font-semibold">{stats.orders}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Commandes</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center text-secondary mb-1">
|
||||
<Truck className="w-4 h-4 mr-1" />
|
||||
<span className="font-semibold">{stats.deliveries}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Livraisons</p>
|
||||
</div>
|
||||
</div>
|
||||
{stats.deliveries > 0 && (
|
||||
<div className="mt-2 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
{stats.delivered} livrées ({Math.round((stats.delivered / stats.deliveries) * 100)}%)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Dialog open={showCreateModal || showEditModal} onOpenChange={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedSupplier(null);
|
||||
setFormData({ name: "", contact: "", phone: "", hasDlc: false });
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="supplier-modal-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedSupplier ? 'Modifier' : 'Nouveau'} Fournisseur
|
||||
</DialogTitle>
|
||||
<p id="supplier-modal-description" className="text-sm text-gray-600 mt-1">
|
||||
{selectedSupplier ? 'Modifier les informations du fournisseur' : 'Créer un nouveau fournisseur'}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nom du fournisseur *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="Nom du fournisseur"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="contact">Contact</Label>
|
||||
<Input
|
||||
id="contact"
|
||||
value={formData.contact}
|
||||
onChange={(e) => handleChange('contact', e.target.value)}
|
||||
placeholder="Nom du contact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone">Téléphone</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
placeholder="Numéro de téléphone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="hasDlc"
|
||||
checked={formData.hasDlc}
|
||||
onCheckedChange={(checked) => handleChange('hasDlc', checked === true)}
|
||||
/>
|
||||
<Label htmlFor="hasDlc" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
Gestion DLC
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedSupplier(null);
|
||||
setFormData({ name: "", contact: "", phone: "", hasDlc: false });
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
className="bg-primary hover:bg-blue-700"
|
||||
>
|
||||
{createMutation.isPending || updateMutation.isPending
|
||||
? "Enregistrement..."
|
||||
: (selectedSupplier ? "Modifier" : "Créer")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
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 {
|
||||
ListTodo,
|
||||
Plus,
|
||||
Search,
|
||||
Circle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Edit,
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import TaskForm from "@/components/tasks/TaskForm";
|
||||
import { Task } from "@shared/schema";
|
||||
|
||||
type TaskWithRelations = Task & {
|
||||
assignedUser: { id: string; username: string; firstName?: string; lastName?: string; };
|
||||
creator: { id: string; username: string; firstName?: string; lastName?: string; };
|
||||
group: { id: number; name: string; color: string; };
|
||||
};
|
||||
|
||||
export default function Tasks() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// États locaux
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>("all");
|
||||
|
||||
// Modals
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<TaskWithRelations | null>(null);
|
||||
const [taskToDelete, setTaskToDelete] = useState<TaskWithRelations | null>(null);
|
||||
|
||||
// Fetch tasks
|
||||
const { data: tasks = [], isLoading } = useQuery({
|
||||
queryKey: ["/api/tasks", selectedStoreId],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId) {
|
||||
params.append('storeId', selectedStoreId);
|
||||
}
|
||||
return fetch(`/api/tasks?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
}).then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Fetch users for task assignment
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
|
||||
const handleEditTask = (task: TaskWithRelations) => {
|
||||
setSelectedTask(task);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleCompleteTask = async (taskId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${taskId}/complete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la completion de la tâche');
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Tâche marquée comme terminée",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error completing task:", error);
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de terminer la tâche",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (task: TaskWithRelations) => {
|
||||
setTaskToDelete(task);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!taskToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${taskToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la suppression');
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Tâche supprimée avec succès",
|
||||
});
|
||||
|
||||
setShowDeleteModal(false);
|
||||
setTaskToDelete(null);
|
||||
} catch (error) {
|
||||
console.error("Error deleting task:", error);
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la tâche",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrer 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;
|
||||
}
|
||||
|
||||
// Filtre par statut
|
||||
if (statusFilter !== "all" && task.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtre par priorité
|
||||
if (priorityFilter !== "all" && task.priority !== priorityFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
totalPages,
|
||||
paginatedData: paginatedTasks,
|
||||
totalItems
|
||||
} = usePagination(filteredTasks, 10);
|
||||
|
||||
const getPriorityConfig = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return {
|
||||
color: 'destructive' as const,
|
||||
icon: AlertTriangle,
|
||||
label: 'Élevée'
|
||||
};
|
||||
case 'medium':
|
||||
return {
|
||||
color: 'default' as const,
|
||||
icon: Clock,
|
||||
label: 'Moyenne'
|
||||
};
|
||||
case 'low':
|
||||
return {
|
||||
color: 'secondary' as const,
|
||||
icon: Circle,
|
||||
label: 'Faible'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'secondary' as const,
|
||||
icon: Circle,
|
||||
label: 'Moyenne'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const canCreateTasks = user?.role === 'admin' || user?.role === 'manager';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<ListTodo className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Gestion des Tâches
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{totalItems} tâche{totalItems !== 1 ? 's' : ''} trouvée{totalItems !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{canCreateTasks && (
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white shadow-md">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvelle Tâche
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer une nouvelle tâche</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TaskForm
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Sidebar avec filtres */}
|
||||
<div className="w-80 bg-gray-50 border-r border-gray-200 p-4 flex-shrink-0">
|
||||
{/* Filtres */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">Filtres</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Recherche */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Rechercher..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filtre par statut */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
Statut
|
||||
</label>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toutes</SelectItem>
|
||||
<SelectItem value="pending">En cours</SelectItem>
|
||||
<SelectItem value="completed">Terminées</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Filtre par priorité */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
Priorité
|
||||
</label>
|
||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toutes</SelectItem>
|
||||
<SelectItem value="high">Élevée</SelectItem>
|
||||
<SelectItem value="medium">Moyenne</SelectItem>
|
||||
<SelectItem value="low">Faible</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Zone principale avec les tâches */}
|
||||
<div className="flex-1">
|
||||
{/* Liste des tâches */}
|
||||
<div className="p-6">
|
||||
{totalItems === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<ListTodo className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Aucune tâche
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Aucune tâche trouvée avec les filtres sélectionnés.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Tâches en cours */}
|
||||
{paginatedTasks.filter(task => task.status === 'pending').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})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{paginatedTasks
|
||||
.filter(task => task.status === 'pending')
|
||||
.map((task) => {
|
||||
const priorityConfig = getPriorityConfig(task.priority);
|
||||
const PriorityIcon = priorityConfig.icon;
|
||||
|
||||
return (
|
||||
<Card key={task.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h5 className="font-medium text-gray-900 truncate">
|
||||
{task.title}
|
||||
</h5>
|
||||
<Badge variant={priorityConfig.color} className="flex items-center gap-1">
|
||||
<PriorityIcon className="w-3 h-3" />
|
||||
{priorityConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-sm text-gray-600 mb-2 line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>
|
||||
Assigné à: {task.assignedTo}
|
||||
</span>
|
||||
<span>
|
||||
Créée: {format(new Date(task.createdAt), 'dd/MM/yyyy HH:mm', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCompleteTask(task.id)}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEditTask(task)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDeleteClick(task)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tâches terminées */}
|
||||
{paginatedTasks.filter(task => task.status === 'completed').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})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{paginatedTasks
|
||||
.filter(task => task.status === 'completed')
|
||||
.map((task) => {
|
||||
const priorityConfig = getPriorityConfig(task.priority);
|
||||
const PriorityIcon = priorityConfig.icon;
|
||||
|
||||
return (
|
||||
<Card key={task.id} className="opacity-60 bg-gray-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h5 className="font-medium text-gray-500 truncate line-through">
|
||||
{task.title}
|
||||
</h5>
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Terminée
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-sm text-gray-400 mb-2 line-clamp-2 line-through">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>
|
||||
Assigné à: {task.assignedTo}
|
||||
</span>
|
||||
<span>
|
||||
Créée: {format(new Date(task.createdAt), 'dd/MM/yyyy HH:mm', { locale: fr })}
|
||||
</span>
|
||||
{task.completedAt && (
|
||||
<span>
|
||||
Terminée: {format(new Date(task.completedAt), 'dd/MM/yyyy HH:mm', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDeleteClick(task)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalItems > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={setItemsPerPage}
|
||||
className="mt-4 border-t border-gray-200 pt-4 mx-6"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal d'édition */}
|
||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la tâche</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedTask && (
|
||||
<TaskForm
|
||||
task={selectedTask}
|
||||
onClose={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedTask(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Modal de confirmation de suppression */}
|
||||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Confirmer la suppression
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-gray-700">
|
||||
Êtes-vous sûr de vouloir supprimer la tâche "{taskToDelete?.title}" ?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Cette action est irréversible.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setTaskToDelete(null);
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import {
|
||||
ListTodo,
|
||||
Plus,
|
||||
Search,
|
||||
Circle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Edit,
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { Task } from "@shared/schema";
|
||||
|
||||
type TaskWithRelations = Task & {
|
||||
creator: { id: string; username: string; name: string; };
|
||||
group: { id: number; name: string; color: string; };
|
||||
};
|
||||
|
||||
export default function TasksSimplified() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// États locaux
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>("all");
|
||||
|
||||
// Modals et tâche sélectionnée
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<TaskWithRelations | null>(null);
|
||||
|
||||
// Récupération des tâches
|
||||
const { data: tasks = [], isLoading } = useQuery({
|
||||
queryKey: ["/api/tasks", selectedStoreId],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId) {
|
||||
params.append('storeId', selectedStoreId);
|
||||
}
|
||||
return fetch(`/api/tasks?${params.toString()}`).then(res => res.json());
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Récupération des magasins
|
||||
const { data: stores = [] } = useQuery({
|
||||
queryKey: ["/api/groups"],
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Filtrer les tâches
|
||||
const filteredTasks = tasks.filter((task: TaskWithRelations) => {
|
||||
const matchesSearch = task.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.assignedTo?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
|
||||
const matchesPriority = priorityFilter === "all" || task.priority === priorityFilter;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesPriority;
|
||||
});
|
||||
|
||||
// Création d'une tâche
|
||||
const handleCreateTask = async (taskData: any) => {
|
||||
try {
|
||||
await apiRequest('/api/tasks', 'POST', {
|
||||
...taskData,
|
||||
groupId: selectedStoreId ? parseInt(selectedStoreId) : stores[0]?.id
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
setShowCreateModal(false);
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Tâche créée avec succès",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de créer la tâche",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Modification d'une tâche
|
||||
const handleUpdateTask = async (taskData: any) => {
|
||||
if (!selectedTask) return;
|
||||
|
||||
try {
|
||||
await apiRequest(`/api/tasks/${selectedTask.id}`, 'PUT', taskData);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
setShowEditModal(false);
|
||||
setSelectedTask(null);
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Tâche modifiée avec succès",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de modifier la tâche",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Complétion d'une tâche
|
||||
const handleCompleteTask = async (taskId: number) => {
|
||||
try {
|
||||
await apiRequest(`/api/tasks/${taskId}/complete`, 'POST');
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Tâche marquée comme terminée",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de compléter la tâche",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Suppression d'une tâche
|
||||
const handleDeleteTask = async (taskId: number) => {
|
||||
try {
|
||||
await apiRequest(`/api/tasks/${taskId}`, 'DELETE');
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Tâche supprimée avec succès",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la tâche",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTask = (task: TaskWithRelations) => {
|
||||
setSelectedTask(task);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed": return "bg-green-100 text-green-800";
|
||||
case "pending": return "bg-yellow-100 text-yellow-800";
|
||||
default: return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case "high": return "bg-red-100 text-red-800";
|
||||
case "medium": return "bg-blue-100 text-blue-800";
|
||||
case "low": return "bg-green-100 text-green-800";
|
||||
default: return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-lg">Chargement des tâches...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-lg">Vous devez être connecté pour accéder aux tâches.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ListTodo className="h-6 w-6 text-blue-600" />
|
||||
<h1 className="text-2xl font-bold">Gestion des Tâches</h1>
|
||||
</div>
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nouvelle Tâche
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer une nouvelle tâche</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TaskForm onSubmit={handleCreateTask} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Filtres</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Rechercher une tâche..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-[150px]">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Statut" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||
<SelectItem value="pending">En attente</SelectItem>
|
||||
<SelectItem value="completed">Terminée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="min-w-[150px]">
|
||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Priorité" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toutes priorités</SelectItem>
|
||||
<SelectItem value="high">Haute</SelectItem>
|
||||
<SelectItem value="medium">Moyenne</SelectItem>
|
||||
<SelectItem value="low">Basse</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Liste des tâches */}
|
||||
<div className="space-y-4">
|
||||
{filteredTasks.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center h-32">
|
||||
<div className="text-center">
|
||||
<ListTodo className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">Aucune tâche trouvée</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredTasks.map((task: TaskWithRelations) => (
|
||||
<Card key={task.id} className={`hover:shadow-md transition-shadow ${task.status === 'completed' ? 'opacity-60 bg-gray-50' : ''}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className={`font-semibold text-lg ${task.status === 'completed' ? 'line-through text-gray-500' : ''}`}>{task.title}</h3>
|
||||
<Badge className={getStatusColor(task.status)}>
|
||||
{task.status === "completed" ? "Terminée" : "En attente"}
|
||||
</Badge>
|
||||
<Badge className={getPriorityColor(task.priority)}>
|
||||
{task.priority === "high" ? "Haute" : task.priority === "medium" ? "Moyenne" : "Basse"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className={`mb-3 ${task.status === 'completed' ? 'text-gray-400 line-through' : 'text-gray-600'}`}>{task.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="font-medium">Assigné à:</span>
|
||||
<span>{task.assignedTo || "Non assigné"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="font-medium">Magasin:</span>
|
||||
<span className="px-2 py-1 rounded text-xs" style={{ backgroundColor: task.group.color + '20', color: task.group.color }}>
|
||||
{task.group.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="font-medium">Créé par:</span>
|
||||
<span>{task.creator.name || task.creator.username}</span>
|
||||
</div>
|
||||
|
||||
{task.dueDate && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>
|
||||
Échéance: {format(new Date(task.dueDate), 'dd/MM/yyyy HH:mm', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{task.status !== "completed" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCompleteTask(task.id)}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEditTask(task)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDeleteTask(task.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal de modification */}
|
||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la tâche</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedTask && (
|
||||
<TaskForm
|
||||
initialData={selectedTask}
|
||||
onSubmit={handleUpdateTask}
|
||||
isEditing={true}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant de formulaire simplifié
|
||||
function TaskForm({ onSubmit, initialData, isEditing = false }: {
|
||||
onSubmit: (data: any) => void;
|
||||
initialData?: TaskWithRelations;
|
||||
isEditing?: boolean;
|
||||
}) {
|
||||
const [title, setTitle] = useState(initialData?.title || "");
|
||||
const [description, setDescription] = useState(initialData?.description || "");
|
||||
const [assignedTo, setAssignedTo] = useState(initialData?.assignedTo || "");
|
||||
const [priority, setPriority] = useState(initialData?.priority || "medium");
|
||||
const [dueDate, setDueDate] = useState(
|
||||
initialData?.dueDate ? format(new Date(initialData.dueDate), 'yyyy-MM-dd') : ""
|
||||
);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title.trim() || !assignedTo.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskData = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
assignedTo: assignedTo.trim(),
|
||||
priority,
|
||||
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
|
||||
};
|
||||
|
||||
onSubmit(taskData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium mb-1">
|
||||
Titre de la tâche *
|
||||
</label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Titre de la tâche"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description de la tâche"
|
||||
className="w-full p-2 border rounded-md"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="assignedTo" className="block text-sm font-medium mb-1">
|
||||
Assigné à *
|
||||
</label>
|
||||
<Input
|
||||
id="assignedTo"
|
||||
value={assignedTo}
|
||||
onChange={(e) => setAssignedTo(e.target.value)}
|
||||
placeholder="Nom de la personne responsable"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="priority" className="block text-sm font-medium mb-1">
|
||||
Priorité
|
||||
</label>
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Basse</SelectItem>
|
||||
<SelectItem value="medium">Moyenne</SelectItem>
|
||||
<SelectItem value="high">Haute</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="dueDate" className="block text-sm font-medium mb-1">
|
||||
Date d'échéance
|
||||
</label>
|
||||
<Input
|
||||
id="dueDate"
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="submit">
|
||||
{isEditing ? "Modifier" : "Créer"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,21 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex mb-4 gap-2">
|
||||
<AlertCircle className="h-8 w-8 text-red-500" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Did you forget to add the page to the router?
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user