Merge pull request #5 from R0m1k3/1.1

1.1
This commit is contained in:
LogiFlow authored and GitHub committed 2025-08-12 12:39:24 +02:00
commit c089da3c4b
5 files changed
+1421 -1000

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

+385 -69
View File
@@ -9,16 +9,19 @@ import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient"; import { apiRequest } from "@/lib/queryClient";
import { useStore } from "@/components/Layout"; import { useStore } from "@/components/Layout";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X } from "lucide-react"; 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() { export default function BLReconciliation() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { selectedStoreId } = useStore(); const { selectedStoreId } = useStore();
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const permissions = usePermissions(user?.role);
// Redirection pour les employés // Vérification des permissions d'accès au module
if (user?.role === 'employee') { if (!permissions.canView('reconciliation')) {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="bg-orange-50 border-l-4 border-orange-400 p-4"> <div className="bg-orange-50 border-l-4 border-orange-400 p-4">
@@ -29,7 +32,7 @@ export default function BLReconciliation() {
<div className="ml-3"> <div className="ml-3">
<p className="text-sm text-orange-700"> <p className="text-sm text-orange-700">
<strong>Accès restreint</strong><br /> <strong>Accès restreint</strong><br />
Seuls les managers et administrateurs peuvent accéder au module de rapprochement BL/Factures. Seuls les directeurs et administrateurs peuvent accéder au module de rapprochement BL/Factures.
</p> </p>
</div> </div>
</div> </div>
@@ -40,9 +43,11 @@ export default function BLReconciliation() {
const [activeTab, setActiveTab] = useState("manual"); const [activeTab, setActiveTab] = useState("manual");
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// Récupérer les fournisseurs pour la logique automatique // Récupérer les fournisseurs pour la logique automatique
const { data: suppliers = [] } = useQuery({ const { data: suppliers = [], isLoading: suppliersLoading } = useQuery<Supplier[]>({
queryKey: ['/api/suppliers'], queryKey: ['/api/suppliers'],
}); });
@@ -81,36 +86,114 @@ export default function BLReconciliation() {
return supplier?.automaticReconciliation; return supplier?.automaticReconciliation;
}); });
// Fonction pour dévalider un rapprochement automatique (directeurs et admins uniquement) // Mutation pour valider un rapprochement manuel
const handleDevalidateAutoReconciliation = async (deliveryId: number) => { const validateManualMutation = useMutation({
if (user?.role !== 'directeur' && user?.role !== 'admin') { mutationFn: async (deliveryId: number) => {
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
reconciled: true,
validatedAt: new Date().toISOString()
});
},
onSuccess: () => {
toast({ toast({
title: "Accès refusé", title: "Succès",
description: "Seuls les directeurs et administrateurs peuvent dévalider les rapprochements automatiques", description: "Rapprochement validé avec succès",
});
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de valider le rapprochement",
variant: "destructive", variant: "destructive",
}); });
return;
} }
});
try { // Mutation pour dévalider un rapprochement (admins uniquement)
await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", { const devalidateMutation = useMutation({
mutationFn: async (deliveryId: number) => {
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
reconciled: false, reconciled: false,
validatedAt: null validatedAt: null
}); });
},
onSuccess: () => {
toast({ toast({
title: "Succès", title: "Succès",
description: "Rapprochement automatique dévalidé avec succès", description: "Rapprochement dévalidé avec succès",
}); });
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] }); queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
} catch (error) { },
onError: () => {
toast({ toast({
title: "Erreur", title: "Erreur",
description: "Impossible de dévalider le rapprochement", description: "Impossible de dévalider le rapprochement",
variant: "destructive", 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 // Filtrage des livraisons par recherche
@@ -128,7 +211,121 @@ export default function BLReconciliation() {
const filteredManualDeliveries = filterDeliveries(manualReconciliationDeliveries); const filteredManualDeliveries = filterDeliveries(manualReconciliationDeliveries);
const filteredAutomaticDeliveries = filterDeliveries(automaticReconciliationDeliveries); const filteredAutomaticDeliveries = filterDeliveries(automaticReconciliationDeliveries);
const canModify = user?.role === 'directeur' || user?.role === 'admin'; // 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
React.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 ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
@@ -203,6 +400,15 @@ export default function BLReconciliation() {
</div> </div>
) : ( ) : (
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg"> <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"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 border-b border-gray-200"> <thead className="bg-gray-50 border-b border-gray-200">
@@ -226,7 +432,7 @@ export default function BLReconciliation() {
Montant Fact. Montant Fact.
</th> </th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Magasin Statut
</th> </th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions Actions
@@ -234,8 +440,14 @@ export default function BLReconciliation() {
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{filteredManualDeliveries.map((delivery: any) => ( {currentManualData.map((delivery: any) => (
<tr key={delivery.id} className="hover:bg-gray-50"> <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"> <td className="px-3 py-2 text-sm">
<div className="font-medium text-gray-900 truncate max-w-32"> <div className="font-medium text-gray-900 truncate max-w-32">
{delivery.supplier?.name} {delivery.supplier?.name}
@@ -277,18 +489,71 @@ export default function BLReconciliation() {
</div> </div>
</td> </td>
<td className="px-3 py-2 text-sm"> <td className="px-3 py-2 text-sm">
<div className="text-gray-900"> {delivery.reconciled ? (
{delivery.group?.name} <Badge variant="default" className="bg-green-600 text-white">
</div> Validé
</Badge>
) : isDevalidated(delivery) ? (
<Badge variant="destructive">
Dévalidé
</Badge>
) : (
<Badge variant="outline">
En attente
</Badge>
)}
</td> </td>
<td className="px-3 py-2 text-sm"> <td className="px-3 py-2 text-sm">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<button {permissions.canEdit('reconciliation') && (
className="text-gray-600 hover:text-blue-600 transition-colors duration-200 p-1 hover:bg-blue-50" <Button
title="Modifier" variant="ghost"
> size="sm"
<Edit className="w-4 h-4" /> className="text-gray-600 hover:text-blue-600 p-1"
</button> 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> </div>
</td> </td>
</tr> </tr>
@@ -296,6 +561,15 @@ export default function BLReconciliation() {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination du bas */}
<PaginationControls
currentPage={currentPage}
totalPages={totalManualPages}
onPageChange={setCurrentPage}
totalItems={filteredManualDeliveries.length}
itemsPerPage={itemsPerPage}
/>
</div> </div>
)} )}
</TabsContent> </TabsContent>
@@ -309,7 +583,7 @@ export default function BLReconciliation() {
<h4 className="text-sm font-medium text-blue-900">Mode rapprochement automatique</h4> <h4 className="text-sm font-medium text-blue-900">Mode rapprochement automatique</h4>
<p className="text-sm text-blue-700 mt-1"> <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. Les livraisons de fournisseurs en mode automatique sont validées automatiquement lorsqu'elles ont le statut "delivered" et un numéro de BL.
{canModify ? ( {permissions.canEdit('reconciliation') ? (
" Vous pouvez dévalider ces rapprochements si nécessaire." " Vous pouvez dévalider ces rapprochements si nécessaire."
) : ( ) : (
" Seuls les directeurs et administrateurs peuvent modifier ces rapprochements." " Seuls les directeurs et administrateurs peuvent modifier ces rapprochements."
@@ -331,6 +605,15 @@ export default function BLReconciliation() {
</div> </div>
) : ( ) : (
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg"> <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"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 border-b border-gray-200"> <thead className="bg-gray-50 border-b border-gray-200">
@@ -355,10 +638,7 @@ export default function BLReconciliation() {
Ref. Facture Ref. Facture
</th> </th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Montant Fact. Statut
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Magasin
</th> </th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions Actions
@@ -366,14 +646,19 @@ export default function BLReconciliation() {
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{filteredAutomaticDeliveries.map((delivery: any) => ( {currentAutomaticData.map((delivery: any) => (
<tr key={delivery.id} className="hover:bg-gray-50 bg-green-50"> <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"> <td className="px-3 py-2 text-sm">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="font-medium text-gray-900 truncate max-w-32"> <div className="font-medium text-gray-900 truncate max-w-32">
{delivery.supplier?.name} {delivery.supplier?.name}
</div> </div>
<Settings className="w-3 h-3 text-blue-500" title="Mode automatique" /> <div className="w-3 h-3 bg-blue-500 rounded-full" title="Mode automatique" />
</div> </div>
</td> </td>
<td className="px-3 py-2 text-sm"> <td className="px-3 py-2 text-sm">
@@ -388,8 +673,8 @@ export default function BLReconciliation() {
</td> </td>
<td className="px-3 py-2 text-sm"> <td className="px-3 py-2 text-sm">
<div className="text-gray-900"> <div className="text-gray-900">
{delivery.reconciled && delivery.updatedAt ? {delivery.reconciled && delivery.validatedAt ?
safeFormat(delivery.updatedAt, 'dd/MM/yy') : safeFormat(delivery.validatedAt, 'dd/MM/yy') :
'-' '-'
} }
</div> </div>
@@ -408,43 +693,65 @@ export default function BLReconciliation() {
</div> </div>
</td> </td>
<td className="px-3 py-2 text-sm"> <td className="px-3 py-2 text-sm">
<div className="text-gray-900"> {isDevalidated(delivery) ? (
{delivery.invoiceAmount ? <Badge variant="destructive">
`${parseFloat(delivery.invoiceAmount).toFixed(2)}€` : Dévalidé
'-' </Badge>
} ) : (
</div> <Badge variant="default" className="bg-green-600 text-white">
</td> Auto-validé
<td className="px-3 py-2 text-sm"> </Badge>
<div className="text-gray-900"> )}
{delivery.group?.name}
</div>
</td> </td>
<td className="px-3 py-2 text-sm"> <td className="px-3 py-2 text-sm">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{canModify ? ( {isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
<> <>
<button <Button
className="text-gray-600 hover:text-blue-600 transition-colors duration-200 p-1 hover:bg-blue-50" variant="ghost"
title="Voir les détails" size="sm"
className="text-gray-600 hover:text-blue-600 p-1"
title="Modifier"
> >
<Eye className="w-4 h-4" /> <Edit className="w-4 h-4" />
</button> </Button>
<button <Button
onClick={() => handleDevalidateAutoReconciliation(delivery.id)} variant="ghost"
className="text-gray-600 hover:text-orange-600 transition-colors duration-200 p-1 hover:bg-orange-50" size="sm"
title="Dévalider le rapprochement automatique" onClick={() => handleValidateManual(delivery.id)}
disabled={validateManualMutation.isPending}
className="text-green-600 hover:text-green-700 p-1"
title="Valider"
> >
<X className="w-4 h-4" /> <Check className="w-4 h-4" />
</button> </Button>
</> </>
) : ( )}
<button
className="text-gray-600 hover:text-blue-600 transition-colors duration-200 p-1 hover:bg-blue-50" {!isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
title="Voir les détails" <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"
> >
<Eye className="w-4 h-4" /> <Ban className="w-4 h-4" />
</button> </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> </div>
</td> </td>
@@ -453,6 +760,15 @@ export default function BLReconciliation() {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination du bas */}
<PaginationControls
currentPage={currentPage}
totalPages={totalAutomaticPages}
onPageChange={setCurrentPage}
totalItems={filteredAutomaticDeliveries.length}
itemsPerPage={itemsPerPage}
/>
</div> </div>
)} )}
</TabsContent> </TabsContent>
File diff suppressed because it is too large. Load diff
+742
View File
@@ -0,0 +1,742 @@
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 { 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
React.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">
<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">
{filteredManualDeliveries.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>
</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">
<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">
{filteredAutomaticDeliveries.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>
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
+3 -3
View File
@@ -48,10 +48,10 @@ const PERMISSIONS: Record<Module, Record<Role, Permission[]>> = {
employee: [] employee: []
}, },
// Rapprochement - Admin tout, Directeur tout sauf delete, Manager/Employé rien // Rapprochement - Admin tout y compris validate, Directeur tout sauf delete, Manager/Employé rien
reconciliation: { reconciliation: {
admin: ['view', 'create', 'edit', 'delete'], admin: ['view', 'create', 'edit', 'delete', 'validate'],
directeur: ['view', 'create', 'edit'], directeur: ['view', 'create', 'edit', 'validate'],
manager: [], manager: [],
employee: [] employee: []
}, },