mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-12 01:37:40 +02:00
Refine user role permissions and data handling logic.
Update backend routes and frontend logic to correctly apply user role-based access controls and ensure accurate data processing for various features, including order and DLC management, while also addressing specific production errors and improving code robustness. Replit-Commit-Author: Agent Replit-Commit-Session-Id: cc8ba926-557c-4bf1-a2ea-ceabbcf38824 Replit-Commit-Checkpoint-Type: full_checkpoint
This commit is contained in:
1 parent
b7adbd9ee8
commit
479577c4ff
299 files changed
+76
-16139
No files matched your search
@@ -1,462 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { safeFormat } from "@/lib/dateUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X } from "lucide-react";
|
||||
|
||||
export default function BLReconciliation() {
|
||||
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">
|
||||
<FileText 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 au module de rapprochement BL/Factures.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [activeTab, setActiveTab] = useState("manual");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// Récupérer les fournisseurs pour la logique automatique
|
||||
const { data: suppliers = [] } = useQuery({
|
||||
queryKey: ['/api/suppliers'],
|
||||
});
|
||||
|
||||
// Récupérer les livraisons validées avec BL
|
||||
const { data: deliveriesWithBL = [], isLoading } = useQuery({
|
||||
queryKey: ['/api/deliveries/bl', selectedStoreId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({});
|
||||
if (selectedStoreId && user?.role === 'admin') {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/deliveries?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch deliveries');
|
||||
}
|
||||
|
||||
const deliveries = await response.json();
|
||||
const filtered = Array.isArray(deliveries) ? deliveries.filter((d: any) => d.status === 'delivered') : [];
|
||||
|
||||
return filtered.sort((a: any, b: any) => new Date(b.deliveredDate || b.updatedAt).getTime() - new Date(a.deliveredDate || a.updatedAt).getTime());
|
||||
},
|
||||
});
|
||||
|
||||
// Séparer les livraisons selon le mode de rapprochement du fournisseur
|
||||
const manualReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
||||
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
|
||||
return !supplier?.automaticReconciliation;
|
||||
});
|
||||
|
||||
const automaticReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
||||
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
|
||||
return supplier?.automaticReconciliation;
|
||||
});
|
||||
|
||||
// Fonction pour dévalider un rapprochement automatique (directeurs et admins uniquement)
|
||||
const handleDevalidateAutoReconciliation = async (deliveryId: number) => {
|
||||
if (user?.role !== 'directeur' && user?.role !== 'admin') {
|
||||
toast({
|
||||
title: "Accès refusé",
|
||||
description: "Seuls les directeurs et administrateurs peuvent dévalider les rapprochements automatiques",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
|
||||
reconciled: false,
|
||||
validatedAt: null
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Rapprochement automatique dévalidé avec succès",
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de dévalider le rapprochement",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrage des livraisons par recherche
|
||||
const filterDeliveries = (deliveries: any[]) => {
|
||||
return deliveries.filter((delivery: any) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
delivery.supplier?.name?.toLowerCase().includes(searchLower) ||
|
||||
delivery.blNumber?.toLowerCase().includes(searchLower) ||
|
||||
delivery.invoiceReference?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const filteredManualDeliveries = filterDeliveries(manualReconciliationDeliveries);
|
||||
const filteredAutomaticDeliveries = filterDeliveries(automaticReconciliationDeliveries);
|
||||
|
||||
const canModify = user?.role === 'directeur' || user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header avec onglets */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<FileText className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Rapprochement BL/Factures
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Gestion des rapprochements manuels et automatiques
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Badge variant="outline" className="text-sm border border-gray-300">
|
||||
{manualReconciliationDeliveries.length} rapprochements manuels
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-sm border border-gray-300 bg-blue-50">
|
||||
{automaticReconciliationDeliveries.length} rapprochements automatiques
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Onglets */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="manual" className="flex items-center space-x-2">
|
||||
<Edit className="w-4 h-4" />
|
||||
<span>Rapprochement Manuel</span>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{manualReconciliationDeliveries.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="automatic" className="flex items-center space-x-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>Rapprochement Automatique</span>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{automaticReconciliationDeliveries.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Filtre de recherche */}
|
||||
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Rechercher par fournisseur, BL ou facture..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border border-gray-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenu des onglets */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsContent value="manual" className="space-y-6">
|
||||
{filteredManualDeliveries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Aucun rapprochement manuel trouvé
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Les livraisons de fournisseurs en mode manuel apparaîtront ici.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fournisseur
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
N° BL
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date Livr.
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Montant BL
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ref. Facture
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Montant Fact.
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Magasin
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredManualDeliveries.map((delivery: any) => (
|
||||
<tr key={delivery.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="font-medium text-gray-900 truncate max-w-32">
|
||||
{delivery.supplier?.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.blNumber || (
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="font-medium text-gray-900">
|
||||
{delivery.blAmount ?
|
||||
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.invoiceReference || (
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.invoiceAmount ?
|
||||
`${parseFloat(delivery.invoiceAmount).toFixed(2)}€` :
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.group?.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
className="text-gray-600 hover:text-blue-600 transition-colors duration-200 p-1 hover:bg-blue-50"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="automatic" className="space-y-6">
|
||||
{/* Message d'information */}
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
<AlertTriangle className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-900">Mode rapprochement automatique</h4>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Les livraisons de fournisseurs en mode automatique sont validées automatiquement lorsqu'elles ont le statut "delivered" et un numéro de BL.
|
||||
{canModify ? (
|
||||
" Vous pouvez dévalider ces rapprochements si nécessaire."
|
||||
) : (
|
||||
" Seuls les directeurs et administrateurs peuvent modifier ces rapprochements."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredAutomaticDeliveries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Settings className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Aucun rapprochement automatique trouvé
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Les livraisons de fournisseurs en mode automatique apparaîtront ici.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fournisseur
|
||||
<Badge variant="secondary" className="ml-2 text-xs">AUTO</Badge>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
N° BL
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date Livr.
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date Valid.
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Montant BL
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ref. Facture
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Montant Fact.
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Magasin
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredAutomaticDeliveries.map((delivery: any) => (
|
||||
<tr key={delivery.id} className="hover:bg-gray-50 bg-green-50">
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="font-medium text-gray-900 truncate max-w-32">
|
||||
{delivery.supplier?.name}
|
||||
</div>
|
||||
<Settings className="w-3 h-3 text-blue-500" title="Mode automatique" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900 font-medium">
|
||||
{delivery.blNumber || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.reconciled && delivery.updatedAt ?
|
||||
safeFormat(delivery.updatedAt, 'dd/MM/yy') :
|
||||
'-'
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="font-medium text-gray-900">
|
||||
{delivery.blAmount ?
|
||||
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
|
||||
'-'
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.invoiceReference || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.invoiceAmount ?
|
||||
`${parseFloat(delivery.invoiceAmount).toFixed(2)}€` :
|
||||
'-'
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.group?.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
{canModify ? (
|
||||
<>
|
||||
<button
|
||||
className="text-gray-600 hover:text-blue-600 transition-colors duration-200 p-1 hover:bg-blue-50"
|
||||
title="Voir les détails"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDevalidateAutoReconciliation(delivery.id)}
|
||||
className="text-gray-600 hover:text-orange-600 transition-colors duration-200 p-1 hover:bg-orange-50"
|
||||
title="Dévalider le rapprochement automatique"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="text-gray-600 hover:text-blue-600 transition-colors duration-200 p-1 hover:bg-blue-50"
|
||||
title="Voir les détails"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,778 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { safeFormat } from "@/lib/dateUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { usePermissions } from "@shared/permissions";
|
||||
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import type { Supplier } from "@shared/schema";
|
||||
|
||||
export default function BLReconciliation() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const permissions = usePermissions(user?.role);
|
||||
|
||||
// Vérification des permissions d'accès au module
|
||||
if (!permissions.canView('reconciliation')) {
|
||||
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">
|
||||
<FileText 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 directeurs et administrateurs peuvent accéder au module de rapprochement BL/Factures.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [activeTab, setActiveTab] = useState("manual");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// Récupérer les fournisseurs pour la logique automatique
|
||||
const { data: suppliers = [], isLoading: suppliersLoading } = useQuery<Supplier[]>({
|
||||
queryKey: ['/api/suppliers'],
|
||||
});
|
||||
|
||||
// Récupérer les livraisons validées avec BL
|
||||
const { data: deliveriesWithBL = [], isLoading } = useQuery({
|
||||
queryKey: ['/api/deliveries/bl', selectedStoreId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({});
|
||||
if (selectedStoreId && user?.role === 'admin') {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/deliveries?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch deliveries');
|
||||
}
|
||||
|
||||
const deliveries = await response.json();
|
||||
const filtered = Array.isArray(deliveries) ? deliveries.filter((d: any) => d.status === 'delivered') : [];
|
||||
|
||||
return filtered.sort((a: any, b: any) => new Date(b.deliveredDate || b.updatedAt).getTime() - new Date(a.deliveredDate || a.updatedAt).getTime());
|
||||
},
|
||||
});
|
||||
|
||||
// Séparer les livraisons selon le mode de rapprochement du fournisseur
|
||||
const manualReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
||||
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
|
||||
return !supplier?.automaticReconciliation;
|
||||
});
|
||||
|
||||
const automaticReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
||||
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
|
||||
return supplier?.automaticReconciliation;
|
||||
});
|
||||
|
||||
// Mutation pour valider un rapprochement manuel
|
||||
const validateManualMutation = useMutation({
|
||||
mutationFn: async (deliveryId: number) => {
|
||||
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
|
||||
reconciled: true,
|
||||
validatedAt: new Date().toISOString()
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Rapprochement validé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de valider le rapprochement",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Mutation pour dévalider un rapprochement (admins uniquement)
|
||||
const devalidateMutation = useMutation({
|
||||
mutationFn: async (deliveryId: number) => {
|
||||
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
|
||||
reconciled: false,
|
||||
validatedAt: null
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Rapprochement dévalidé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de dévalider le rapprochement",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Mutation pour supprimer une ligne (admins uniquement)
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (deliveryId: number) => {
|
||||
return await apiRequest(`/api/deliveries/${deliveryId}`, "DELETE");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Ligne supprimée avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la ligne",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fonctions de gestion des actions
|
||||
const handleValidateManual = (deliveryId: number) => {
|
||||
if (!permissions.canValidate('reconciliation')) {
|
||||
toast({
|
||||
title: "Accès refusé",
|
||||
description: "Vous n'avez pas les permissions pour valider les rapprochements",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
validateManualMutation.mutate(deliveryId);
|
||||
};
|
||||
|
||||
const handleDevalidate = (deliveryId: number) => {
|
||||
if (!permissions.canEdit('reconciliation')) {
|
||||
toast({
|
||||
title: "Accès refusé",
|
||||
description: "Seuls les administrateurs peuvent dévalider les rapprochements",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (confirm("Êtes-vous sûr de vouloir dévalider ce rapprochement ?")) {
|
||||
devalidateMutation.mutate(deliveryId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (deliveryId: number) => {
|
||||
if (!permissions.canDelete('reconciliation')) {
|
||||
toast({
|
||||
title: "Accès refusé",
|
||||
description: "Seuls les administrateurs peuvent supprimer les lignes",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer cette ligne ? Cette action est irréversible.")) {
|
||||
deleteMutation.mutate(deliveryId);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrage des livraisons par recherche
|
||||
const filterDeliveries = (deliveries: any[]) => {
|
||||
return deliveries.filter((delivery: any) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
delivery.supplier?.name?.toLowerCase().includes(searchLower) ||
|
||||
delivery.blNumber?.toLowerCase().includes(searchLower) ||
|
||||
delivery.invoiceReference?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const filteredManualDeliveries = filterDeliveries(manualReconciliationDeliveries);
|
||||
const filteredAutomaticDeliveries = filterDeliveries(automaticReconciliationDeliveries);
|
||||
|
||||
// Pagination
|
||||
const getCurrentPageData = (data: any[]) => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
return data.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const getTotalPages = (data: any[]) => {
|
||||
return Math.ceil(data.length / itemsPerPage);
|
||||
};
|
||||
|
||||
const currentManualData = getCurrentPageData(filteredManualDeliveries);
|
||||
const currentAutomaticData = getCurrentPageData(filteredAutomaticDeliveries);
|
||||
|
||||
const totalManualPages = getTotalPages(filteredManualDeliveries);
|
||||
const totalAutomaticPages = getTotalPages(filteredAutomaticDeliveries);
|
||||
|
||||
// Reset page when changing tabs or searching
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [activeTab, searchTerm]);
|
||||
|
||||
// Vérifier si une ligne est dévalidée (pour la coloration)
|
||||
const isDevalidated = (delivery: any) => {
|
||||
return delivery.reconciled === false && delivery.validatedAt === null;
|
||||
};
|
||||
|
||||
// Composant de pagination
|
||||
const PaginationControls = ({ currentPage, totalPages, onPageChange, totalItems, itemsPerPage }: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
}) => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200">
|
||||
<div className="flex items-center text-sm text-gray-700">
|
||||
<span>
|
||||
Affichage de {startItem} à {endItem} sur {totalItems} éléments
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span>Précédent</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(page => {
|
||||
// Afficher la première page, la dernière page, et les pages autour de la page actuelle
|
||||
return page === 1 ||
|
||||
page === totalPages ||
|
||||
(page >= currentPage - 2 && page <= currentPage + 2);
|
||||
})
|
||||
.map((page, index, array) => {
|
||||
// Ajouter des points de suspension si nécessaire
|
||||
const prevPage = array[index - 1];
|
||||
const showEllipsis = prevPage && page > prevPage + 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={page}>
|
||||
{showEllipsis && (
|
||||
<span className="px-2 text-gray-500">...</span>
|
||||
)}
|
||||
<Button
|
||||
variant={page === currentPage ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page)}
|
||||
className="w-8 h-8 p-0"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<span>Suivant</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading || suppliersLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-64 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header avec onglets */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<FileText className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Rapprochement BL/Factures
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Gestion des rapprochements manuels et automatiques
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Badge variant="outline" className="text-sm border border-gray-300">
|
||||
{manualReconciliationDeliveries.length} rapprochements manuels
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-sm border border-gray-300 bg-blue-50">
|
||||
{automaticReconciliationDeliveries.length} rapprochements automatiques
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Onglets */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="manual" className="flex items-center space-x-2">
|
||||
<Edit className="w-4 h-4" />
|
||||
<span>Rapprochement Manuel</span>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{manualReconciliationDeliveries.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="automatic" className="flex items-center space-x-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>Rapprochement Automatique</span>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{automaticReconciliationDeliveries.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Filtre de recherche */}
|
||||
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Rechercher par fournisseur, BL ou facture..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border border-gray-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenu des onglets */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsContent value="manual" className="space-y-6">
|
||||
{filteredManualDeliveries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Aucun rapprochement manuel trouvé
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Les livraisons de fournisseurs en mode manuel apparaîtront ici.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
|
||||
{/* Pagination du haut */}
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalManualPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={filteredManualDeliveries.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fournisseur
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
N° BL
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date Livr.
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Montant BL
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ref. Facture
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Montant Fact.
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{currentManualData.map((delivery: any) => (
|
||||
<tr
|
||||
key={delivery.id}
|
||||
className={`hover:bg-gray-50 ${
|
||||
delivery.reconciled ? 'bg-green-50' :
|
||||
isDevalidated(delivery) ? 'bg-red-50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="font-medium text-gray-900 truncate max-w-32">
|
||||
{delivery.supplier?.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.blNumber || (
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="font-medium text-gray-900">
|
||||
{delivery.blAmount ?
|
||||
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.invoiceReference || (
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.invoiceAmount ?
|
||||
`${parseFloat(delivery.invoiceAmount).toFixed(2)}€` :
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
{delivery.reconciled ? (
|
||||
<Badge variant="default" className="bg-green-600 text-white">
|
||||
Validé
|
||||
</Badge>
|
||||
) : isDevalidated(delivery) ? (
|
||||
<Badge variant="destructive">
|
||||
Dévalidé
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">
|
||||
En attente
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
{permissions.canEdit('reconciliation') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-blue-600 p-1"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!delivery.reconciled && permissions.canValidate('reconciliation') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleValidateManual(delivery.id)}
|
||||
disabled={validateManualMutation.isPending}
|
||||
className="text-green-600 hover:text-green-700 p-1"
|
||||
title="Valider"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{delivery.reconciled && permissions.canEdit('reconciliation') && user?.role === 'admin' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDevalidate(delivery.id)}
|
||||
disabled={devalidateMutation.isPending}
|
||||
className="text-orange-600 hover:text-orange-700 p-1"
|
||||
title="Dévalider"
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{permissions.canDelete('reconciliation') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(delivery.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-red-600 hover:text-red-700 p-1"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination du bas */}
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalManualPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={filteredManualDeliveries.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="automatic" className="space-y-6">
|
||||
{/* Message d'information */}
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
<AlertTriangle className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-900">Mode rapprochement automatique</h4>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Les livraisons de fournisseurs en mode automatique sont validées automatiquement lorsqu'elles ont le statut "delivered" et un numéro de BL.
|
||||
{permissions.canEdit('reconciliation') ? (
|
||||
" Vous pouvez dévalider ces rapprochements si nécessaire."
|
||||
) : (
|
||||
" Seuls les directeurs et administrateurs peuvent modifier ces rapprochements."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredAutomaticDeliveries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Settings className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Aucun rapprochement automatique trouvé
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Les livraisons de fournisseurs en mode automatique apparaîtront ici.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
|
||||
{/* Pagination du haut */}
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalAutomaticPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={filteredAutomaticDeliveries.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fournisseur
|
||||
<Badge variant="secondary" className="ml-2 text-xs">AUTO</Badge>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
N° BL
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date Livr.
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date Valid.
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Montant BL
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ref. Facture
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{currentAutomaticData.map((delivery: any) => (
|
||||
<tr
|
||||
key={delivery.id}
|
||||
className={`hover:bg-gray-50 ${
|
||||
isDevalidated(delivery) ? 'bg-red-50' : 'bg-green-50'
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="font-medium text-gray-900 truncate max-w-32">
|
||||
{delivery.supplier?.name}
|
||||
</div>
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full" title="Mode automatique" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900 font-medium">
|
||||
{delivery.blNumber || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.reconciled && delivery.validatedAt ?
|
||||
safeFormat(delivery.validatedAt, 'dd/MM/yy') :
|
||||
'-'
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="font-medium text-gray-900">
|
||||
{delivery.blAmount ?
|
||||
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
|
||||
'-'
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.invoiceReference || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
{isDevalidated(delivery) ? (
|
||||
<Badge variant="destructive">
|
||||
Dévalidé
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="bg-green-600 text-white">
|
||||
Auto-validé
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
{isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-blue-600 p-1"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleValidateManual(delivery.id)}
|
||||
disabled={validateManualMutation.isPending}
|
||||
className="text-green-600 hover:text-green-700 p-1"
|
||||
title="Valider"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDevalidate(delivery.id)}
|
||||
disabled={devalidateMutation.isPending}
|
||||
className="text-orange-600 hover:text-orange-700 p-1"
|
||||
title="Dévalider"
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{permissions.canDelete('reconciliation') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(delivery.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-red-600 hover:text-red-700 p-1"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination du bas */}
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalAutomaticPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={filteredAutomaticDeliveries.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,840 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { safeFormat } from "@/lib/dateUtils";
|
||||
import { useToast } from "@/hooks/use-toast-robust";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { usePermissions } from "@shared/permissions";
|
||||
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import type { Supplier } from "@shared/schema";
|
||||
|
||||
// Composants UI robustes sans dépendances shadcn/ui problématiques
|
||||
const RobustButton = ({ children, onClick, disabled = false, variant = "default", size = "default", className = "", title = "" }: {
|
||||
children: React.ReactNode,
|
||||
onClick?: () => void,
|
||||
disabled?: boolean,
|
||||
variant?: "default" | "ghost" | "outline" | "destructive",
|
||||
size?: "default" | "sm" | "lg",
|
||||
className?: string,
|
||||
title?: string
|
||||
}) => {
|
||||
const variantStyles = {
|
||||
default: "bg-blue-600 text-white hover:bg-blue-700 border-blue-600",
|
||||
ghost: "text-gray-600 hover:text-gray-900 hover:bg-gray-100 border-transparent",
|
||||
outline: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50",
|
||||
destructive: "bg-red-600 text-white hover:bg-red-700 border-red-600"
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
default: "px-4 py-2 text-sm",
|
||||
sm: "px-3 py-1.5 text-xs",
|
||||
lg: "px-6 py-3 text-base"
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={`inline-flex items-center justify-center font-medium rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${variantStyles[variant]} ${sizeStyles[size]} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const RobustInput = ({ placeholder, value, onChange, className = "" }: {
|
||||
placeholder?: string,
|
||||
value: string,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void,
|
||||
className?: string
|
||||
}) => {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${className}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const RobustBadge = ({ children, variant = "default", className = "" }: {
|
||||
children: React.ReactNode,
|
||||
variant?: "default" | "secondary" | "destructive" | "outline",
|
||||
className?: string
|
||||
}) => {
|
||||
const variantStyles = {
|
||||
default: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
secondary: "bg-gray-100 text-gray-800 border-gray-200",
|
||||
destructive: "bg-red-100 text-red-800 border-red-200",
|
||||
outline: "bg-white text-gray-700 border-gray-300"
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${variantStyles[variant]} ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const RobustTabs = ({ value, onValueChange, children }: {
|
||||
value: string,
|
||||
onValueChange: (value: string) => void,
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
return (
|
||||
<div data-tab-value={value} onChange={() => onValueChange}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RobustTabsList = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="inline-flex h-10 items-center justify-center rounded-md bg-gray-100 p-1">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RobustTabsTrigger = ({ value, children, isActive, onClick }: {
|
||||
value: string,
|
||||
children: React.ReactNode,
|
||||
isActive: boolean,
|
||||
onClick: () => void
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus:outline-none ${
|
||||
isActive
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default function BLReconciliation() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const permissions = usePermissions(user?.role);
|
||||
|
||||
// Vérification des permissions d'accès au module
|
||||
if (!permissions.canView('reconciliation')) {
|
||||
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">
|
||||
<FileText 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 directeurs et administrateurs peuvent accéder au module de rapprochement BL/Factures.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [activeTab, setActiveTab] = useState("manual");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// Récupérer les fournisseurs pour la logique automatique
|
||||
const { data: suppliers = [], isLoading: suppliersLoading } = useQuery<Supplier[]>({
|
||||
queryKey: ['/api/suppliers'],
|
||||
});
|
||||
|
||||
// Récupérer les livraisons validées avec BL
|
||||
const { data: deliveriesWithBL = [], isLoading } = useQuery({
|
||||
queryKey: ['/api/deliveries/bl', selectedStoreId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({});
|
||||
if (selectedStoreId && user?.role === 'admin') {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/deliveries?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch deliveries');
|
||||
}
|
||||
|
||||
const deliveries = await response.json();
|
||||
const filtered = Array.isArray(deliveries) ? deliveries.filter((d: any) => d.status === 'delivered') : [];
|
||||
|
||||
return filtered.sort((a: any, b: any) => new Date(b.deliveredDate || b.updatedAt).getTime() - new Date(a.deliveredDate || a.updatedAt).getTime());
|
||||
},
|
||||
});
|
||||
|
||||
// Séparer les livraisons selon le mode de rapprochement du fournisseur
|
||||
const manualReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
||||
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
|
||||
return !supplier?.automaticReconciliation;
|
||||
});
|
||||
|
||||
const automaticReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
||||
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
|
||||
return supplier?.automaticReconciliation;
|
||||
});
|
||||
|
||||
// Mutations pour les actions
|
||||
const validateManualMutation = useMutation({
|
||||
mutationFn: async (deliveryId: number) => {
|
||||
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
|
||||
reconciled: true,
|
||||
validatedAt: new Date().toISOString()
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Rapprochement validé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de valider le rapprochement",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devalidateMutation = useMutation({
|
||||
mutationFn: async (deliveryId: number) => {
|
||||
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
|
||||
reconciled: false,
|
||||
validatedAt: null
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Rapprochement dévalidé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de dévalider le rapprochement",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (deliveryId: number) => {
|
||||
return await apiRequest(`/api/deliveries/${deliveryId}`, "DELETE");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Ligne supprimée avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la ligne",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fonctions de gestion des actions
|
||||
const handleValidateManual = (deliveryId: number) => {
|
||||
if (!permissions.canValidate('reconciliation')) {
|
||||
toast({
|
||||
title: "Accès refusé",
|
||||
description: "Vous n'avez pas les permissions pour valider les rapprochements",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
validateManualMutation.mutate(deliveryId);
|
||||
};
|
||||
|
||||
const handleDevalidate = (deliveryId: number) => {
|
||||
if (!permissions.canEdit('reconciliation')) {
|
||||
toast({
|
||||
title: "Accès refusé",
|
||||
description: "Seuls les administrateurs peuvent dévalider les rapprochements",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (confirm("Êtes-vous sûr de vouloir dévalider ce rapprochement ?")) {
|
||||
devalidateMutation.mutate(deliveryId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (deliveryId: number) => {
|
||||
if (!permissions.canDelete('reconciliation')) {
|
||||
toast({
|
||||
title: "Accès refusé",
|
||||
description: "Seuls les administrateurs peuvent supprimer les lignes",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer cette ligne ? Cette action est irréversible.")) {
|
||||
deleteMutation.mutate(deliveryId);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrage des livraisons par recherche
|
||||
const filterDeliveries = (deliveries: any[]) => {
|
||||
return deliveries.filter((delivery: any) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
delivery.supplier?.name?.toLowerCase().includes(searchLower) ||
|
||||
delivery.blNumber?.toLowerCase().includes(searchLower) ||
|
||||
delivery.invoiceReference?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const filteredManualDeliveries = filterDeliveries(manualReconciliationDeliveries);
|
||||
const filteredAutomaticDeliveries = filterDeliveries(automaticReconciliationDeliveries);
|
||||
|
||||
// Pagination
|
||||
const getCurrentPageData = (data: any[]) => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
return data.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const getTotalPages = (data: any[]) => {
|
||||
return Math.ceil(data.length / itemsPerPage);
|
||||
};
|
||||
|
||||
const currentManualData = getCurrentPageData(filteredManualDeliveries);
|
||||
const currentAutomaticData = getCurrentPageData(filteredAutomaticDeliveries);
|
||||
|
||||
const totalManualPages = getTotalPages(filteredManualDeliveries);
|
||||
const totalAutomaticPages = getTotalPages(filteredAutomaticDeliveries);
|
||||
|
||||
// Reset page when changing tabs or searching
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [activeTab, searchTerm]);
|
||||
|
||||
// Vérifier si une ligne est dévalidée (pour la coloration)
|
||||
const isDevalidated = (delivery: any) => {
|
||||
return delivery.reconciled === false && delivery.validatedAt === null;
|
||||
};
|
||||
|
||||
// Composant de pagination robuste
|
||||
const RobustPagination = ({ currentPage, totalPages, onPageChange, totalItems, itemsPerPage }: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
}) => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200">
|
||||
<div className="flex items-center text-sm text-gray-700">
|
||||
<span>
|
||||
Affichage de {startItem} à {endItem} sur {totalItems} éléments
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RobustButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span>Précédent</span>
|
||||
</RobustButton>
|
||||
|
||||
<span className="px-3 py-1 text-sm bg-gray-100 rounded">
|
||||
Page {currentPage} sur {totalPages}
|
||||
</span>
|
||||
|
||||
<RobustButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<span>Suivant</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</RobustButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading || suppliersLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-64 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header avec onglets */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<FileText className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Rapprochement BL/Factures
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Gestion des rapprochements manuels et automatiques
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<RobustBadge variant="outline" className="text-sm border border-gray-300">
|
||||
{manualReconciliationDeliveries.length} rapprochements manuels
|
||||
</RobustBadge>
|
||||
<RobustBadge variant="outline" className="text-sm border border-gray-300 bg-blue-50">
|
||||
{automaticReconciliationDeliveries.length} rapprochements automatiques
|
||||
</RobustBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Onglets robustes */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<RobustTabsTrigger
|
||||
value="manual"
|
||||
isActive={activeTab === "manual"}
|
||||
onClick={() => setActiveTab("manual")}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Edit className="w-4 h-4" />
|
||||
<span>Rapprochement Manuel</span>
|
||||
<RobustBadge variant="secondary" className="ml-2">
|
||||
{manualReconciliationDeliveries.length}
|
||||
</RobustBadge>
|
||||
</div>
|
||||
</RobustTabsTrigger>
|
||||
<RobustTabsTrigger
|
||||
value="automatic"
|
||||
isActive={activeTab === "automatic"}
|
||||
onClick={() => setActiveTab("automatic")}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>Rapprochement Automatique</span>
|
||||
<RobustBadge variant="secondary" className="ml-2">
|
||||
{automaticReconciliationDeliveries.length}
|
||||
</RobustBadge>
|
||||
</div>
|
||||
</RobustTabsTrigger>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtre de recherche */}
|
||||
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<RobustInput
|
||||
placeholder="Rechercher par fournisseur, BL ou facture..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenu des onglets */}
|
||||
{activeTab === "manual" && (
|
||||
<div className="space-y-6">
|
||||
{filteredManualDeliveries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Aucun rapprochement manuel trouvé
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Les livraisons de fournisseurs en mode manuel apparaîtront ici.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
|
||||
{/* Pagination du haut */}
|
||||
<RobustPagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalManualPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={filteredManualDeliveries.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Fournisseur</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">N° BL</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Livr.</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant BL</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ref. Facture</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant Fact.</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{currentManualData.map((delivery: any) => (
|
||||
<tr
|
||||
key={delivery.id}
|
||||
className={`hover:bg-gray-50 ${
|
||||
delivery.reconciled ? 'bg-green-50' :
|
||||
isDevalidated(delivery) ? 'bg-red-50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="font-medium text-gray-900 truncate max-w-32">
|
||||
{delivery.supplier?.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.blNumber || (
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="font-medium text-gray-900">
|
||||
{delivery.blAmount ?
|
||||
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.invoiceReference || (
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.invoiceAmount ?
|
||||
`${parseFloat(delivery.invoiceAmount).toFixed(2)}€` :
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
{delivery.reconciled ? (
|
||||
<RobustBadge variant="default" className="bg-green-600 text-white border-green-600">
|
||||
Validé
|
||||
</RobustBadge>
|
||||
) : isDevalidated(delivery) ? (
|
||||
<RobustBadge variant="destructive">
|
||||
Dévalidé
|
||||
</RobustBadge>
|
||||
) : (
|
||||
<RobustBadge variant="outline">
|
||||
En attente
|
||||
</RobustBadge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
{permissions.canEdit('reconciliation') && (
|
||||
<RobustButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-blue-600 p-1"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</RobustButton>
|
||||
)}
|
||||
|
||||
{!delivery.reconciled && permissions.canValidate('reconciliation') && (
|
||||
<RobustButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleValidateManual(delivery.id)}
|
||||
disabled={validateManualMutation.isPending}
|
||||
className="text-green-600 hover:text-green-700 p-1"
|
||||
title="Valider"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</RobustButton>
|
||||
)}
|
||||
|
||||
{delivery.reconciled && permissions.canEdit('reconciliation') && user?.role === 'admin' && (
|
||||
<RobustButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDevalidate(delivery.id)}
|
||||
disabled={devalidateMutation.isPending}
|
||||
className="text-orange-600 hover:text-orange-700 p-1"
|
||||
title="Dévalider"
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</RobustButton>
|
||||
)}
|
||||
|
||||
{permissions.canDelete('reconciliation') && (
|
||||
<RobustButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(delivery.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-red-600 hover:text-red-700 p-1"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</RobustButton>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination du bas */}
|
||||
<RobustPagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalManualPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={filteredManualDeliveries.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "automatic" && (
|
||||
<div className="space-y-6">
|
||||
{/* Message d'information */}
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
<AlertTriangle className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-900">Mode rapprochement automatique</h4>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Les livraisons de fournisseurs en mode automatique sont validées automatiquement lorsqu'elles ont le statut "delivered" et un numéro de BL.
|
||||
{permissions.canEdit('reconciliation') ? (
|
||||
" Vous pouvez dévalider ces rapprochements si nécessaire."
|
||||
) : (
|
||||
" Seuls les directeurs et administrateurs peuvent modifier ces rapprochements."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredAutomaticDeliveries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Settings className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Aucun rapprochement automatique trouvé
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Les livraisons de fournisseurs en mode automatique apparaîtront ici.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
|
||||
{/* Pagination du haut */}
|
||||
<RobustPagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalAutomaticPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={filteredAutomaticDeliveries.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fournisseur
|
||||
<RobustBadge variant="secondary" className="ml-2 text-xs">AUTO</RobustBadge>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">N° BL</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Livr.</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Valid.</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant BL</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ref. Facture</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{currentAutomaticData.map((delivery: any) => (
|
||||
<tr
|
||||
key={delivery.id}
|
||||
className={`hover:bg-gray-50 ${
|
||||
isDevalidated(delivery) ? 'bg-red-50' : 'bg-green-50'
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="font-medium text-gray-900 truncate max-w-32">
|
||||
{delivery.supplier?.name}
|
||||
</div>
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full" title="Mode automatique" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900 font-medium">
|
||||
{delivery.blNumber || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.reconciled && delivery.validatedAt ?
|
||||
safeFormat(delivery.validatedAt, 'dd/MM/yy') :
|
||||
'-'
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="font-medium text-gray-900">
|
||||
{delivery.blAmount ?
|
||||
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
|
||||
'-'
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.invoiceReference || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
{isDevalidated(delivery) ? (
|
||||
<RobustBadge variant="destructive">
|
||||
Dévalidé
|
||||
</RobustBadge>
|
||||
) : (
|
||||
<RobustBadge variant="default" className="bg-green-600 text-white border-green-600">
|
||||
Auto-validé
|
||||
</RobustBadge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
{isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
|
||||
<>
|
||||
<RobustButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-blue-600 p-1"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</RobustButton>
|
||||
<RobustButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleValidateManual(delivery.id)}
|
||||
disabled={validateManualMutation.isPending}
|
||||
className="text-green-600 hover:text-green-700 p-1"
|
||||
title="Valider"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</RobustButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
|
||||
<RobustButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDevalidate(delivery.id)}
|
||||
disabled={devalidateMutation.isPending}
|
||||
className="text-orange-600 hover:text-orange-700 p-1"
|
||||
title="Dévalider"
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</RobustButton>
|
||||
)}
|
||||
|
||||
{permissions.canDelete('reconciliation') && (
|
||||
<RobustButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(delivery.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-red-600 hover:text-red-700 p-1"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</RobustButton>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination du bas */}
|
||||
<RobustPagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalAutomaticPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={filteredAutomaticDeliveries.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,751 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { safeFormat } from "@/lib/dateUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { usePermissions } from "@shared/permissions";
|
||||
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import type { Supplier } from "@shared/schema";
|
||||
|
||||
export default function BLReconciliation() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const permissions = usePermissions(user?.role);
|
||||
|
||||
// Vérification des permissions d'accès au module
|
||||
if (!permissions.canView('reconciliation')) {
|
||||
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">
|
||||
<FileText 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 directeurs et administrateurs peuvent accéder au module de rapprochement BL/Factures.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [activeTab, setActiveTab] = useState("manual");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// Récupérer les fournisseurs pour la logique automatique
|
||||
const { data: suppliers = [], isLoading: suppliersLoading } = useQuery<Supplier[]>({
|
||||
queryKey: ['/api/suppliers'],
|
||||
});
|
||||
|
||||
// Récupérer les livraisons validées avec BL
|
||||
const { data: deliveriesWithBL = [], isLoading } = useQuery({
|
||||
queryKey: ['/api/deliveries/bl', selectedStoreId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({});
|
||||
if (selectedStoreId && user?.role === 'admin') {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/deliveries?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch deliveries');
|
||||
}
|
||||
|
||||
const deliveries = await response.json();
|
||||
const filtered = Array.isArray(deliveries) ? deliveries.filter((d: any) => d.status === 'delivered') : [];
|
||||
|
||||
return filtered.sort((a: any, b: any) => new Date(b.deliveredDate || b.updatedAt).getTime() - new Date(a.deliveredDate || a.updatedAt).getTime());
|
||||
},
|
||||
});
|
||||
|
||||
// Séparer les livraisons selon le mode de rapprochement du fournisseur
|
||||
const manualReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
||||
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
|
||||
return !supplier?.automaticReconciliation;
|
||||
});
|
||||
|
||||
const automaticReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
||||
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
|
||||
return supplier?.automaticReconciliation;
|
||||
});
|
||||
|
||||
// Mutation pour valider un rapprochement manuel
|
||||
const validateManualMutation = useMutation({
|
||||
mutationFn: async (deliveryId: number) => {
|
||||
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
|
||||
reconciled: true,
|
||||
validatedAt: new Date().toISOString()
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Rapprochement validé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de valider le rapprochement",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Mutation pour dévalider un rapprochement (admins uniquement)
|
||||
const devalidateMutation = useMutation({
|
||||
mutationFn: async (deliveryId: number) => {
|
||||
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
|
||||
reconciled: false,
|
||||
validatedAt: null
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Rapprochement dévalidé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de dévalider le rapprochement",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Mutation pour supprimer une ligne (admins uniquement)
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (deliveryId: number) => {
|
||||
return await apiRequest(`/api/deliveries/${deliveryId}`, "DELETE");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Ligne supprimée avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la ligne",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fonctions de gestion des actions
|
||||
const handleValidateManual = (deliveryId: number) => {
|
||||
if (!permissions.canValidate('reconciliation')) {
|
||||
toast({
|
||||
title: "Accès refusé",
|
||||
description: "Vous n'avez pas les permissions pour valider les rapprochements",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
validateManualMutation.mutate(deliveryId);
|
||||
};
|
||||
|
||||
const handleDevalidate = (deliveryId: number) => {
|
||||
if (!permissions.canEdit('reconciliation')) {
|
||||
toast({
|
||||
title: "Accès refusé",
|
||||
description: "Seuls les administrateurs peuvent dévalider les rapprochements",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (confirm("Êtes-vous sûr de vouloir dévalider ce rapprochement ?")) {
|
||||
devalidateMutation.mutate(deliveryId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (deliveryId: number) => {
|
||||
if (!permissions.canDelete('reconciliation')) {
|
||||
toast({
|
||||
title: "Accès refusé",
|
||||
description: "Seuls les administrateurs peuvent supprimer les lignes",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer cette ligne ? Cette action est irréversible.")) {
|
||||
deleteMutation.mutate(deliveryId);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrage des livraisons par recherche
|
||||
const filterDeliveries = (deliveries: any[]) => {
|
||||
return deliveries.filter((delivery: any) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
delivery.supplier?.name?.toLowerCase().includes(searchLower) ||
|
||||
delivery.blNumber?.toLowerCase().includes(searchLower) ||
|
||||
delivery.invoiceReference?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const filteredManualDeliveries = filterDeliveries(manualReconciliationDeliveries);
|
||||
const filteredAutomaticDeliveries = filterDeliveries(automaticReconciliationDeliveries);
|
||||
|
||||
// Pagination
|
||||
const getCurrentPageData = (data: any[]) => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
return data.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const getTotalPages = (data: any[]) => {
|
||||
return Math.ceil(data.length / itemsPerPage);
|
||||
};
|
||||
|
||||
const currentManualData = getCurrentPageData(filteredManualDeliveries);
|
||||
const currentAutomaticData = getCurrentPageData(filteredAutomaticDeliveries);
|
||||
|
||||
const totalManualPages = getTotalPages(filteredManualDeliveries);
|
||||
const totalAutomaticPages = getTotalPages(filteredAutomaticDeliveries);
|
||||
|
||||
// Reset page when changing tabs or searching
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [activeTab, searchTerm]);
|
||||
|
||||
// Vérifier si une ligne est dévalidée (pour la coloration)
|
||||
const isDevalidated = (delivery: any) => {
|
||||
return delivery.reconciled === false && delivery.validatedAt === null;
|
||||
};
|
||||
|
||||
// Composant de pagination simplifiée
|
||||
const SimplePagination = ({ currentPage, totalPages, onPageChange, totalItems, itemsPerPage }: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
}) => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200">
|
||||
<div className="flex items-center text-sm text-gray-700">
|
||||
<span>
|
||||
Affichage de {startItem} à {endItem} sur {totalItems} éléments
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span>Précédent</span>
|
||||
</Button>
|
||||
|
||||
<span className="px-3 py-1 text-sm bg-gray-100 rounded">
|
||||
Page {currentPage} sur {totalPages}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<span>Suivant</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading || suppliersLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-64 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header avec onglets */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<FileText className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Rapprochement BL/Factures
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Gestion des rapprochements manuels et automatiques
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Badge variant="outline" className="text-sm border border-gray-300">
|
||||
{manualReconciliationDeliveries.length} rapprochements manuels
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-sm border border-gray-300 bg-blue-50">
|
||||
{automaticReconciliationDeliveries.length} rapprochements automatiques
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Onglets */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="manual" className="flex items-center space-x-2">
|
||||
<Edit className="w-4 h-4" />
|
||||
<span>Rapprochement Manuel</span>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{manualReconciliationDeliveries.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="automatic" className="flex items-center space-x-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>Rapprochement Automatique</span>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{automaticReconciliationDeliveries.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Filtre de recherche */}
|
||||
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Rechercher par fournisseur, BL ou facture..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border border-gray-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenu des onglets */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsContent value="manual" className="space-y-6">
|
||||
{filteredManualDeliveries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Aucun rapprochement manuel trouvé
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Les livraisons de fournisseurs en mode manuel apparaîtront ici.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
|
||||
{/* Pagination du haut */}
|
||||
<SimplePagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalManualPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={filteredManualDeliveries.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fournisseur
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
N° BL
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date Livr.
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Montant BL
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ref. Facture
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Montant Fact.
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{currentManualData.map((delivery: any) => (
|
||||
<tr
|
||||
key={delivery.id}
|
||||
className={`hover:bg-gray-50 ${
|
||||
delivery.reconciled ? 'bg-green-50' :
|
||||
isDevalidated(delivery) ? 'bg-red-50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="font-medium text-gray-900 truncate max-w-32">
|
||||
{delivery.supplier?.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.blNumber || (
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="font-medium text-gray-900">
|
||||
{delivery.blAmount ?
|
||||
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.invoiceReference || (
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.invoiceAmount ?
|
||||
`${parseFloat(delivery.invoiceAmount).toFixed(2)}€` :
|
||||
<span className="text-gray-400 italic text-xs">Non renseigné</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
{delivery.reconciled ? (
|
||||
<Badge variant="default" className="bg-green-600 text-white">
|
||||
Validé
|
||||
</Badge>
|
||||
) : isDevalidated(delivery) ? (
|
||||
<Badge variant="destructive">
|
||||
Dévalidé
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">
|
||||
En attente
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
{permissions.canEdit('reconciliation') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-blue-600 p-1"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!delivery.reconciled && permissions.canValidate('reconciliation') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleValidateManual(delivery.id)}
|
||||
disabled={validateManualMutation.isPending}
|
||||
className="text-green-600 hover:text-green-700 p-1"
|
||||
title="Valider"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{delivery.reconciled && permissions.canEdit('reconciliation') && user?.role === 'admin' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDevalidate(delivery.id)}
|
||||
disabled={devalidateMutation.isPending}
|
||||
className="text-orange-600 hover:text-orange-700 p-1"
|
||||
title="Dévalider"
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{permissions.canDelete('reconciliation') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(delivery.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-red-600 hover:text-red-700 p-1"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination du bas */}
|
||||
<SimplePagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalManualPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={filteredManualDeliveries.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="automatic" className="space-y-6">
|
||||
{/* Message d'information */}
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
<AlertTriangle className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-900">Mode rapprochement automatique</h4>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Les livraisons de fournisseurs en mode automatique sont validées automatiquement lorsqu'elles ont le statut "delivered" et un numéro de BL.
|
||||
{permissions.canEdit('reconciliation') ? (
|
||||
" Vous pouvez dévalider ces rapprochements si nécessaire."
|
||||
) : (
|
||||
" Seuls les directeurs et administrateurs peuvent modifier ces rapprochements."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredAutomaticDeliveries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Settings className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Aucun rapprochement automatique trouvé
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Les livraisons de fournisseurs en mode automatique apparaîtront ici.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
|
||||
{/* Pagination du haut */}
|
||||
<SimplePagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalAutomaticPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={filteredAutomaticDeliveries.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fournisseur
|
||||
<Badge variant="secondary" className="ml-2 text-xs">AUTO</Badge>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
N° BL
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date Livr.
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date Valid.
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Montant BL
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ref. Facture
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{currentAutomaticData.map((delivery: any) => (
|
||||
<tr
|
||||
key={delivery.id}
|
||||
className={`hover:bg-gray-50 ${
|
||||
isDevalidated(delivery) ? 'bg-red-50' : 'bg-green-50'
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="font-medium text-gray-900 truncate max-w-32">
|
||||
{delivery.supplier?.name}
|
||||
</div>
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full" title="Mode automatique" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900 font-medium">
|
||||
{delivery.blNumber || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.reconciled && delivery.validatedAt ?
|
||||
safeFormat(delivery.validatedAt, 'dd/MM/yy') :
|
||||
'-'
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="font-medium text-gray-900">
|
||||
{delivery.blAmount ?
|
||||
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
|
||||
'-'
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="text-gray-900">
|
||||
{delivery.invoiceReference || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
{isDevalidated(delivery) ? (
|
||||
<Badge variant="destructive">
|
||||
Dévalidé
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="bg-green-600 text-white">
|
||||
Auto-validé
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
{isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-blue-600 p-1"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleValidateManual(delivery.id)}
|
||||
disabled={validateManualMutation.isPending}
|
||||
className="text-green-600 hover:text-green-700 p-1"
|
||||
title="Valider"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDevalidate(delivery.id)}
|
||||
disabled={devalidateMutation.isPending}
|
||||
className="text-orange-600 hover:text-orange-700 p-1"
|
||||
title="Dévalider"
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{permissions.canDelete('reconciliation') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(delivery.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-red-600 hover:text-red-700 p-1"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination du bas */}
|
||||
<SimplePagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalAutomaticPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={filteredAutomaticDeliveries.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,631 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,661 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,627 +0,0 @@
|
||||
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,
|
||||
Clock
|
||||
} 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,
|
||||
automaticReconciliation: false,
|
||||
});
|
||||
|
||||
const { data: suppliers = [], isLoading } = useQuery<Supplier[]>({
|
||||
queryKey: ['/api/suppliers'],
|
||||
});
|
||||
|
||||
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 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: "",
|
||||
email: "",
|
||||
address: "",
|
||||
hasDlc: false,
|
||||
automaticReconciliation: 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: "",
|
||||
email: "",
|
||||
address: "",
|
||||
hasDlc: false,
|
||||
automaticReconciliation: 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: "",
|
||||
email: "",
|
||||
address: "",
|
||||
hasDlc: false,
|
||||
automaticReconciliation: false
|
||||
});
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (supplier: Supplier) => {
|
||||
setSelectedSupplier(supplier);
|
||||
setFormData({
|
||||
name: supplier.name,
|
||||
contact: supplier.contact || "",
|
||||
phone: supplier.phone || "",
|
||||
email: supplier.email || "",
|
||||
address: supplier.address || "",
|
||||
hasDlc: supplier.hasDlc || false,
|
||||
automaticReconciliation: supplier.automaticReconciliation || 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 justify-between text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
{supplier.hasDlc && (
|
||||
<div className="flex items-center text-green-600 bg-green-50 px-2 py-1 rounded-full">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
<span className="text-xs font-medium">DLC</span>
|
||||
</div>
|
||||
)}
|
||||
{supplier.automaticReconciliation && (
|
||||
<div className="flex items-center text-blue-600 bg-blue-50 px-2 py-1 rounded-full">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
<span className="text-xs font-medium">Auto-rapprochement</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!supplier.hasDlc && !supplier.automaticReconciliation && (
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Aucune option activée
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statistics section follows */}
|
||||
</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: "",
|
||||
email: "",
|
||||
address: "",
|
||||
hasDlc: false,
|
||||
automaticReconciliation: 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>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
placeholder="Adresse email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="address">Adresse</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
placeholder="Adresse complète"
|
||||
/>
|
||||
</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-2">
|
||||
<Checkbox
|
||||
id="automaticReconciliation"
|
||||
checked={formData.automaticReconciliation}
|
||||
onCheckedChange={(checked) => handleChange('automaticReconciliation', checked === true)}
|
||||
/>
|
||||
<Label htmlFor="automaticReconciliation" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
Rapprochement automatique BL/Factures
|
||||
</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: "",
|
||||
email: "",
|
||||
address: "",
|
||||
hasDlc: false,
|
||||
automaticReconciliation: 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user