Files
LogiFlow/client/src/pages/BLReconciliation.tsx
T
michaelschal 314f56ed01 Improve reconciliation filtering and remove debugging logs
Remove temporary debug logs from the reconciliation filtering logic and refine the validation checks to correctly identify reconciled deliveries based on new criteria.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1e4433b5-47ab-464c-b663-fea2e53367dd
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1957c339-2757-4d1f-8e92-e9f71a1ce58e/1e4433b5-47ab-464c-b663-fea2e53367dd/zhCtuJP
2025-09-21 09:21:48 +00:00

1450 lines
62 KiB
TypeScript

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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { useStore } from "@/contexts/StoreContext";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { usePermissions } from "@shared/permissions";
import { Pagination, usePagination } from "@/components/ui/pagination";
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload, CheckCircle, XCircle, Clock, MessageSquare } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import ReconciliationComments from "@/components/ReconciliationComments";
import ReconciliationModal from "@/components/modals/ReconciliationModal";
export default function BLReconciliation() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
const { toast } = useToast();
const queryClient = useQueryClient();
const permissions = usePermissions(user?.role);
// Redirection pour les employés
if (user?.role === 'employee') {
return (
<div className="p-4 sm:p-6">
<div className="bg-orange-50 border-l-4 border-orange-400 p-4 rounded-r-lg">
<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("");
const [selectedDelivery, setSelectedDelivery] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [filterStatus, setFilterStatus] = useState<"all" | "validated" | "not_validated">("all");
// État pour le modal d'envoi de facture
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
const [selectedDeliveryForInvoice, setSelectedDeliveryForInvoice] = useState<any>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
// État pour le modal d'attente du webhook
const [showWaitingModal, setShowWaitingModal] = useState(false);
const [processingSeconds, setProcessingSeconds] = useState(0);
const [processingTimeout, setProcessingTimeout] = useState<NodeJS.Timeout | null>(null);
// État pour le système de vérification de facture
const [verificationResults, setVerificationResults] = useState<Record<number, any>>({});
const [verifyingDeliveries, setVerifyingDeliveries] = useState<Set<number>>(new Set());
// État pour le modal de commentaire
const [showCommentModal, setShowCommentModal] = useState(false);
const [selectedDeliveryForComment, setSelectedDeliveryForComment] = useState<any>(null);
// Récupérer les fournisseurs pour la logique automatique
const { data: suppliers = [] } = useQuery<any[]>({
queryKey: ['/api/suppliers'],
});
// Fonction de vérification de facture
const verifyInvoiceMutation = useMutation({
mutationFn: async ({ deliveryId, invoiceReference, blNumber, forceRefresh }: { deliveryId: number; invoiceReference?: string; blNumber?: string; forceRefresh?: boolean }) => {
try {
const result = await apiRequest(`/api/deliveries/${deliveryId}/verify-invoice`, 'POST', {
invoiceReference,
blNumber,
forceRefresh: forceRefresh || false
});
return result;
} catch (error: any) {
console.error('Erreur API vérification:', error);
throw new Error(error.message || 'Erreur de vérification');
}
},
onSuccess: (result, variables) => {
setVerificationResults(prev => ({
...prev,
[variables.deliveryId]: result
}));
// Auto-remplissage si facture trouvée via BL
if (result.exists && result.matchType === 'bl_number' && result.invoiceReference) {
// Auto-remplir les champs dans la livraison via API
apiRequest(`/api/deliveries/${variables.deliveryId}`, "PUT", {
invoiceReference: result.invoiceReference,
invoiceAmount: result.invoiceAmount
}).then(() => {
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
}).catch((error) => {
console.error('Erreur auto-remplissage:', error);
});
}
setVerifyingDeliveries(prev => {
const newSet = new Set(prev);
newSet.delete(variables.deliveryId);
return newSet;
});
},
onError: (error, variables) => {
console.error('Erreur vérification facture:', error);
setVerificationResults(prev => ({
...prev,
[variables.deliveryId]: {
exists: false,
matchType: 'none',
errorMessage: error instanceof Error ? error.message : 'Erreur inconnue'
}
}));
setVerifyingDeliveries(prev => {
const newSet = new Set(prev);
newSet.delete(variables.deliveryId);
return newSet;
});
toast({
title: "Erreur de vérification",
description: error instanceof Error ? error.message : 'Erreur inconnue',
variant: "destructive",
});
}
});
// Fonction pour déclencher la vérification
const handleVerifyInvoice = (delivery: any, forceRefresh: boolean = false) => {
// Accepter soit une référence de facture soit un numéro BL
const hasInvoiceRef = delivery.invoiceReference?.trim();
const hasBlNumber = delivery.blNumber?.trim();
if (!hasInvoiceRef && !hasBlNumber) {
toast({
title: "Référence manquante",
description: "Veuillez saisir une référence de facture ou un numéro BL avant la vérification",
variant: "destructive",
});
return;
}
if (!delivery.group?.nocodbTableName && !delivery.group?.nocodbConfigId && !delivery.group?.webhookUrl) {
toast({
title: "Vérification non disponible",
description: "Ce magasin n'a pas de configuration NocoDB",
variant: "destructive",
});
return;
}
console.log('🔍 Déclenchement vérification:', {
deliveryId: delivery.id,
hasInvoiceRef,
hasBlNumber,
invoiceReference: delivery.invoiceReference,
blNumber: delivery.blNumber,
supplier: delivery.supplier?.name
});
setVerifyingDeliveries(prev => new Set(prev).add(delivery.id));
verifyInvoiceMutation.mutate({
deliveryId: delivery.id,
invoiceReference: delivery.invoiceReference,
blNumber: delivery.blNumber,
forceRefresh
});
};
// Fonction pour vérifier toutes les factures avec un bouton
const handleVerifyAllInvoices = () => {
const deliveriesToVerify = manualReconciliationDeliveries.filter(delivery =>
(delivery.invoiceReference?.trim() || delivery.blNumber?.trim()) &&
(delivery.group?.nocodbTableName || delivery.group?.nocodbConfigId || delivery.group?.webhookUrl)
);
if (deliveriesToVerify.length === 0) {
toast({
title: "Aucune facture à vérifier",
description: "Aucune livraison avec référence de facture ou numéro BL trouvée",
});
return;
}
deliveriesToVerify.forEach((delivery, index) => {
// Délai échelonné pour éviter la surcharge
setTimeout(() => {
handleVerifyInvoice(delivery, true); // Force refresh pour toutes
}, index * 200); // 200ms entre chaque vérification
});
toast({
title: "Vérification lancée",
description: `Vérification de ${deliveriesToVerify.length} facture(s)/BL en cours...`,
});
};
// Récupérer les livraisons validées avec BL - CACHE INVALIDÉ après modifications
const { data: deliveriesWithBL = [], isLoading, refetch } = useQuery({
queryKey: ['/api/deliveries/bl', selectedStoreId],
queryFn: async () => {
const params = new URLSearchParams({});
if (selectedStoreId && (user?.role === 'admin' || user?.role === 'directeur')) {
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();
// Debug désactivé en production pour éviter latence
if (import.meta.env.DEV && deliveries[0]) {
console.log('🔍 DEBUG - Première livraison:', deliveries[0]);
}
const filtered = Array.isArray(deliveries) ? deliveries.filter((d: any) => d.status === 'delivered') : [];
return filtered.sort((a: any, b: any) => new Date(b.deliveredDate || b.updatedAt).getTime() - new Date(a.deliveredDate || a.updatedAt).getTime());
},
enabled: !!user,
staleTime: 0 // Éviter la mise en cache pour toujours avoir les dernières données BL
});
// VÉRIFICATION AUTOMATIQUE AU CHARGEMENT avec système de cache
useEffect(() => {
if (!deliveriesWithBL.length || !suppliers.length) return;
if (import.meta.env.DEV) {
console.log('🔄 Déclenchement vérifications automatiques...');
}
// Pré-populer les résultats pour les livraisons déjà réconciliées
const newVerificationResults = { ...verificationResults };
let hasNewReconciledResults = false;
deliveriesWithBL.forEach((delivery: any) => {
// Si la livraison est déjà réconciliée, marquer comme vérifiée avec succès
if (delivery.reconciled && !verificationResults[delivery.id]) {
newVerificationResults[delivery.id] = {
exists: true,
matchType: delivery.invoiceReference ? 'invoice_reference' : 'bl_number',
fromCache: true,
permanent: true,
reconciled: true
};
hasNewReconciledResults = true;
if (import.meta.env.DEV) {
console.log(`✅ Livraison ${delivery.id} déjà réconciliée, marquée comme vérifiée`);
}
}
});
// Mettre à jour les résultats si on a de nouvelles livraisons réconciliées
if (hasNewReconciledResults) {
setVerificationResults(newVerificationResults);
}
deliveriesWithBL.forEach((delivery: any) => {
// ⛔ EXCLURE COMPLÈTEMENT les livraisons déjà réconciliées
// Une fois validées avec coche verte, on ne contrôle plus JAMAIS
if (delivery.reconciled) {
return; // Livraison déjà validée = AUCUNE vérification nécessaire
}
// Vérifier seulement les livraisons NON validées
const hasVerifiableData = delivery.invoiceReference || delivery.blNumber;
const notAlreadyProcessed = !verificationResults[delivery.id] && !verifyingDeliveries.has(delivery.id);
if (hasVerifiableData && notAlreadyProcessed) {
if (import.meta.env.DEV) {
console.log(`🔍 Vérification auto pour livraison NON réconciliée ${delivery.id}:`, {
invoiceRef: delivery.invoiceReference,
blNumber: delivery.blNumber,
reconciled: delivery.reconciled
});
}
// Délai pour éviter de surcharger le serveur
setTimeout(() => {
handleVerifyInvoice(delivery, false);
}, Math.random() * 1000);
}
});
}, [deliveriesWithBL, suppliers, verificationResults, verifyingDeliveries]);
// Séparer les livraisons par mode de rapprochement
const manualReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
const supplier = suppliers.find(s => s.id === delivery.supplierId);
return supplier?.automaticReconciliation !== true;
});
const automaticReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
const supplier = suppliers.find(s => s.id === delivery.supplierId);
return supplier?.automaticReconciliation === true;
});
// Fonctions de gestion
const handleOpenModal = (delivery: any) => {
setSelectedDelivery(delivery);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setSelectedDelivery(null);
setIsModalOpen(false);
};
const handleSaveReconciliation = async () => {
try {
// Invalidation cache + refetch forcé pour mise à jour immédiate
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
refetch();
handleCloseModal();
} catch (error) {
toast({
title: "Erreur",
description: "Impossible de mettre à jour les données",
variant: "destructive",
});
}
};
// Nouvelles fonctions pour le modal d'envoi de facture
const handleOpenInvoiceModal = (delivery: any) => {
setSelectedDeliveryForInvoice(delivery);
setSelectedFile(null);
setShowInvoiceModal(true);
};
// Fonctions pour le modal de commentaire
const handleOpenCommentModal = (delivery: any) => {
setSelectedDeliveryForComment(delivery);
setShowCommentModal(true);
};
const handleCloseCommentModal = () => {
setShowCommentModal(false);
setSelectedDeliveryForComment(null);
};
const handleCloseInvoiceModal = () => {
setShowInvoiceModal(false);
setSelectedDeliveryForInvoice(null);
setSelectedFile(null);
setIsUploading(false);
};
const handleCloseWaitingModal = () => {
setShowWaitingModal(false);
setProcessingSeconds(0);
if (processingTimeout) {
clearTimeout(processingTimeout);
setProcessingTimeout(null);
}
};
const startProcessingTimer = () => {
setProcessingSeconds(0);
const interval = setInterval(() => {
setProcessingSeconds(prev => {
if (prev >= 60) {
clearInterval(interval);
return 60;
}
return prev + 1;
});
}, 1000);
setProcessingTimeout(interval);
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && file.type === 'application/pdf') {
setSelectedFile(file);
} else {
toast({
title: "Erreur",
description: "Veuillez sélectionner un fichier PDF",
variant: "destructive",
});
}
};
const handleSendInvoice = async () => {
if (!selectedDeliveryForInvoice || !selectedFile) {
toast({
title: "Erreur",
description: "Veuillez sélectionner un fichier PDF",
variant: "destructive",
});
return;
}
if (!selectedDeliveryForInvoice.group?.webhookUrl) {
toast({
title: "Erreur",
description: "Aucun webhook configuré pour ce magasin",
variant: "destructive",
});
return;
}
// Fermer le modal de sélection et ouvrir le modal d'attente
setShowInvoiceModal(false);
setShowWaitingModal(true);
setIsUploading(true);
startProcessingTimer();
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('supplier', selectedDeliveryForInvoice.supplier?.name || '');
formData.append('blNumber', selectedDeliveryForInvoice.blNumber || '');
formData.append('type', 'Facture');
// Créer un AbortController pour gérer le timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 secondes
const response = await fetch(selectedDeliveryForInvoice.group.webhookUrl, {
method: 'POST',
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`Erreur ${response.status}: ${response.statusText}`);
}
handleCloseWaitingModal();
toast({
title: "Succès",
description: "Facture traitée avec succès via le webhook",
});
// Reset des données
setSelectedDeliveryForInvoice(null);
setSelectedFile(null);
// Relancer la vérification de la facture qui vient d'être traitée
try {
// Invalidation des caches pour forcer le rechargement des données
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
// Recharger les données
await refetch();
// Attendre un peu que les données soient à jour, puis vérifier cette livraison spécifique
setTimeout(() => {
handleVerifyInvoice(selectedDeliveryForInvoice, true);
}, 1000);
console.log('🔄 Vérification automatique relancée pour la livraison traitée par webhook');
} catch (error) {
console.error('Erreur lors de la relance de la vérification:', error);
}
} catch (error: any) {
handleCloseWaitingModal();
let errorMessage = "Impossible d'envoyer la facture";
if (error.name === 'AbortError') {
errorMessage = "Le traitement a pris trop de temps (timeout de 60 secondes)";
} else if (error.message) {
errorMessage = `Impossible d'envoyer la facture: ${error.message}`;
}
toast({
title: "Erreur",
description: errorMessage,
variant: "destructive",
});
// Réouvrir le modal de sélection en cas d'erreur
setShowInvoiceModal(true);
} finally {
setIsUploading(false);
}
};
// Fonction pour déterminer si le bouton "Envoyer Facture" doit être affiché
const shouldShowInvoiceButton = (delivery: any) => {
// Conditions : ligne non validée OU pas de RefFacture renseignée
const isNotValidated = !delivery.reconciled;
const hasNoInvoiceReference = !delivery.invoiceReference;
// Et il faut qu'il y ait un magasin assigné avec un webhook
const hasValidGroup = delivery.group && delivery.group.webhookUrl;
// Debug uniquement en développement
if (import.meta.env.DEV && delivery && delivery.supplier?.name) {
console.log(`🔍 Debug bouton facture pour ${delivery.supplier.name}:`, {
isNotValidated,
hasNoInvoiceReference,
hasValidGroup,
shouldShow: (isNotValidated || hasNoInvoiceReference) && hasValidGroup
});
}
return (isNotValidated || hasNoInvoiceReference) && hasValidGroup;
};
const handleQuickValidate = async (delivery: any) => {
try {
await apiRequest(`/api/deliveries/${delivery.id}`, "PUT", {
reconciled: true,
validatedAt: new Date().toISOString()
});
toast({
title: "Succès",
description: "Rapprochement validé avec succès",
});
// Invalidation cache + refetch forcé pour mise à jour immédiate
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
refetch(); // Force recharge immédiate des données BL
} catch (error) {
toast({
title: "Erreur",
description: "Impossible de valider le rapprochement",
variant: "destructive",
});
}
};
const handleDevalidateReconciliation = async (deliveryId: number) => {
if (!permissions.canEdit('reconciliation') && !permissions.canValidate('reconciliation')) {
toast({
title: "Accès refusé",
description: "Vous n'avez pas les permissions nécessaires pour dévalider les rapprochements",
variant: "destructive",
});
return;
}
if (!window.confirm("Êtes-vous sûr de vouloir dévalider ce rapprochement ?")) {
return;
}
try {
await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
reconciled: false,
validatedAt: null
});
toast({
title: "Succès",
description: "Rapprochement dévalidé avec succès",
});
// Invalidation cache + refetch forcé pour mise à jour immédiate
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
refetch();
} catch (error) {
toast({
title: "Erreur",
description: "Impossible de dévalider le rapprochement",
variant: "destructive",
});
}
};
const handleDeleteDelivery = async (deliveryId: number) => {
if (!permissions.canDelete('reconciliation')) {
toast({
title: "Accès refusé",
description: "Seuls les administrateurs peuvent supprimer les livraisons",
variant: "destructive",
});
return;
}
if (!window.confirm("Êtes-vous sûr de vouloir supprimer cette livraison ? Cette action est irréversible.")) {
return;
}
try {
await apiRequest(`/api/deliveries/${deliveryId}`, "DELETE");
toast({
title: "Succès",
description: "Livraison supprimée avec succès",
});
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
} catch (error) {
toast({
title: "Erreur",
description: "Impossible de supprimer la livraison",
variant: "destructive",
});
}
};
// Filtrage des livraisons par recherche et statut de validation
const filterDeliveries = (deliveries: any[]) => {
return deliveries.filter((delivery: any) => {
// Conversion sûre de reconciled - gestion de tous les cas possibles
const isReconciled =
delivery.reconciled === true ||
delivery.reconciled === 1 ||
delivery.reconciled === "t" ||
delivery.reconciled === "true" ||
(typeof delivery.reconciled === 'string' && delivery.reconciled.toLowerCase() === 'true');
// Vérifier aussi si une facture a été trouvée ET validée automatiquement
// (certaines livraisons ont une coche verte mais reconciled = false)
const hasVerifiedInvoice = verificationResults[delivery.id]?.exists === true;
const isValidated = isReconciled || (hasVerifiedInvoice && verificationResults[delivery.id]?.reconciled === true);
// Filtre par statut validé
if (filterStatus === "validated") {
// Afficher seulement les livraisons vraiment validées (reconciled = true)
return isReconciled;
}
if (filterStatus === "not_validated") {
// Afficher seulement les livraisons non validées (reconciled = false)
return !isReconciled;
}
// Filtre par recherche
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 pour les rapprochements manuels
const {
currentPage: manualCurrentPage,
setCurrentPage: setManualCurrentPage,
itemsPerPage: manualItemsPerPage,
setItemsPerPage: setManualItemsPerPage,
totalPages: manualTotalPages,
paginatedData: paginatedManualDeliveries,
totalItems: manualTotalItems
} = usePagination(filteredManualDeliveries, 20);
// Pagination pour les rapprochements automatiques
const {
currentPage: autoCurrentPage,
setCurrentPage: setAutoCurrentPage,
itemsPerPage: autoItemsPerPage,
setItemsPerPage: setAutoItemsPerPage,
totalPages: autoTotalPages,
paginatedData: paginatedAutoDeliveries,
totalItems: autoTotalItems
} = usePagination(filteredAutomaticDeliveries, 20);
const canModify = user?.role === 'directeur' || user?.role === 'admin';
if (isLoading) {
return <div className="flex justify-center items-center h-64">Chargement...</div>;
}
return (
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* Header */}
<div className="bg-white border-b border-gray-200 p-4 sm:p-6 shadow-sm -m-4 sm:-m-6 mb-4 sm:mb-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
<div>
<h2 className="text-xl sm:text-2xl font-semibold text-gray-900 flex items-center">
<FileText className="w-5 h-5 sm:w-6 sm:h-6 mr-2 sm:mr-3 text-blue-600" />
Rapprochement BL/Factures
</h2>
<p className="text-gray-600 mt-1 text-sm sm:text-base">
Gestion des rapprochements manuels et automatiques
</p>
</div>
<div className="flex flex-wrap items-center gap-2 sm:gap-4">
<Badge variant="outline" className="text-xs sm:text-sm border border-gray-300">
{manualReconciliationDeliveries.length} manuels
</Badge>
<Badge variant="outline" className="text-xs sm:text-sm border border-gray-300 bg-blue-50">
{automaticReconciliationDeliveries.length} automatiques
</Badge>
<Button
onClick={handleVerifyAllInvoices}
variant="outline"
size="sm"
className="text-xs sm:text-sm bg-green-50 border-green-300 text-green-700 hover:bg-green-100"
>
<Search className="w-4 h-4 mr-1" />
Vérifier toutes les factures
</Button>
</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>
{/* Filters */}
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
<div className="flex-1 min-w-0">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Rechercher par fournisseur, BL ou facture..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 border border-gray-300 shadow-sm w-full"
/>
</div>
</div>
<div className="w-full sm:w-48">
<Select value={filterStatus} onValueChange={(value: "all" | "validated" | "not_validated") => setFilterStatus(value)}>
<SelectTrigger className="border border-gray-300 shadow-sm">
<Filter className="w-4 h-4 mr-2" />
<SelectValue placeholder="Filtrer par statut" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
Tous les rapprochements
</SelectItem>
<SelectItem value="validated">
<div className="flex items-center">
<Check className="w-4 h-4 mr-2 text-green-600" />
Validés seulement
</div>
</SelectItem>
<SelectItem value="not_validated">
<div className="flex items-center">
<X className="w-4 h-4 mr-2 text-orange-600" />
Non validés seulement
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</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="flex-1 overflow-y-auto">
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden">
{/* Pagination du haut */}
<div className="p-4 border-b border-gray-200">
<Pagination
currentPage={manualCurrentPage}
totalPages={manualTotalPages}
totalItems={manualTotalItems}
itemsPerPage={manualItemsPerPage}
onPageChange={setManualCurrentPage}
onItemsPerPageChange={setManualItemsPerPage}
/>
</div>
<div className="table-container">
<table className="w-full min-w-[900px]">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Fournisseur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N° BL
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date Livr.
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Montant BL
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Ref. Facture
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Montant Fact.
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Écart
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Magasin
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedManualDeliveries.map((delivery: any) => (
<tr
key={delivery.id}
className={`hover:bg-gray-50 ${
delivery.reconciled === true
? 'bg-gray-100 opacity-60 text-gray-500'
: 'bg-white'
}`}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className={`truncate max-w-32 ${
delivery.reconciled !== true
? 'font-bold text-gray-900'
: 'font-medium text-gray-900'
}`}>
{delivery.supplier?.name}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className={`${
delivery.reconciled !== true
? 'font-bold text-gray-900'
: 'text-gray-900'
}`}>
{delivery.blNumber || (
<span
className="text-gray-400 italic text-xs hover:text-blue-500 cursor-pointer hover:underline transition-colors"
onClick={() => handleOpenModal(delivery)}
title="Cliquer pour modifier"
>
Non renseigné
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className={`text-sm ${delivery.reconciled !== true ? 'font-medium text-gray-900' : 'text-gray-600'}`}>
{delivery.blAmount ?
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
<span
className="text-gray-400 italic hover:text-blue-500 cursor-pointer hover:underline transition-colors"
onClick={() => handleOpenModal(delivery)}
title="Cliquer pour modifier"
>
Non renseigné
</span>
}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center space-x-2">
{/* Icône de vérification de facture */}
{(delivery.group?.nocodbTableName || delivery.group?.nocodbConfigId || delivery.group?.webhookUrl) && (
<div className="flex items-center">
{verifyingDeliveries.has(delivery.id) ? (
<Clock className="h-4 w-4 text-blue-500 animate-spin" />
) : verificationResults[delivery.id] ? (
verificationResults[delivery.id].exists ? (
<CheckCircle className="h-4 w-4 text-green-500 cursor-help" />
) : (
<XCircle className="h-4 w-4 text-red-500 cursor-help" />
)
) : (
<button
onClick={() => handleVerifyInvoice(delivery)}
className="h-4 w-4 text-gray-400 hover:text-blue-500 transition-colors"
title="Cliquer pour vérifier la facture"
>
<Search className="h-4 w-4" />
</button>
)}
</div>
)}
<div className={`text-sm ${delivery.reconciled !== true ? 'font-medium text-gray-900' : 'text-gray-600'}`}>
{delivery.invoiceReference || (
<span
className="text-gray-400 italic hover:text-blue-500 cursor-pointer hover:underline transition-colors"
onClick={() => handleOpenModal(delivery)}
title="Cliquer pour modifier"
>
Non renseigné
</span>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className={`text-sm ${delivery.reconciled !== true ? 'font-medium text-gray-900' : 'text-gray-600'}`}>
{delivery.invoiceAmount ?
`${parseFloat(delivery.invoiceAmount).toFixed(2)}€` :
<span
className="text-gray-400 italic hover:text-blue-500 cursor-pointer hover:underline transition-colors"
onClick={() => handleOpenModal(delivery)}
title="Cliquer pour modifier"
>
Non renseigné
</span>
}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
{(() => {
const blAmount = delivery.blAmount ? parseFloat(delivery.blAmount) : 0;
const invoiceAmount = delivery.invoiceAmount ? parseFloat(delivery.invoiceAmount) : 0;
if (blAmount && invoiceAmount) {
const diff = invoiceAmount - blAmount;
const diffAbs = Math.abs(diff);
return (
<div className={`font-medium text-center ${
diff === 0 ? 'text-green-600' :
diffAbs > 10 ? 'text-red-600' : 'text-orange-600'
}`}>
{diff > 0 ? '+' : ''}{diff.toFixed(2)}€
</div>
);
}
return <span className="text-gray-400 italic text-xs">-</span>;
})()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{delivery.group?.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end space-x-2">
{shouldShowInvoiceButton(delivery) && (
<Button
variant="outline"
size="sm"
onClick={() => handleOpenInvoiceModal(delivery)}
className="h-8 w-8 p-0 text-green-600 hover:text-green-700"
title="Envoyer Facture/Avoir"
>
<Upload className="h-4 w-4" />
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleOpenCommentModal(delivery)}
className={`h-8 w-8 p-0 ${
delivery.reconciliationCommentsCount && delivery.reconciliationCommentsCount > 0
? 'text-blue-600 hover:text-blue-700 border-blue-300'
: 'text-gray-600 hover:text-gray-700'
} ${delivery.reconciled ? 'opacity-70' : ''}`}
title={`Voir/Gérer les commentaires ${
delivery.reconciliationCommentsCount ? `(${delivery.reconciliationCommentsCount})` : ''
}`}
>
<MessageSquare className={`h-4 w-4 ${
delivery.reconciliationCommentsCount && delivery.reconciliationCommentsCount > 0
? 'fill-blue-100'
: ''
}`} />
</Button>
{!delivery.reconciled ? (
<>
{permissions.canValidate('reconciliation') && (
<Button
variant="outline"
size="sm"
onClick={() => handleQuickValidate(delivery)}
className="h-8 w-8 p-0"
title="Valider le rapprochement"
>
<Check className="w-4 h-4" />
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleOpenModal(delivery)}
className="h-8 w-8 p-0"
title="Modifier les données"
>
<Edit className="w-4 h-4" />
</Button>
{permissions.canDelete('reconciliation') && (
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteDelivery(delivery.id)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
title="Supprimer la livraison"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</>
) : (
<>
{user?.role === 'admin' && (
<Button
variant="outline"
size="sm"
onClick={() => handleDevalidateReconciliation(delivery.id)}
className="h-8 w-8 p-0 text-orange-600 hover:text-orange-700"
title="Dévalider le rapprochement"
>
<Ban className="w-4 h-4" />
</Button>
)}
{permissions.canDelete('reconciliation') && (
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteDelivery(delivery.id)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
title="Supprimer la livraison"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination du bas */}
<div className="p-4 border-t border-gray-200">
<Pagination
currentPage={manualCurrentPage}
totalPages={manualTotalPages}
totalItems={manualTotalItems}
itemsPerPage={manualItemsPerPage}
onPageChange={setManualCurrentPage}
onItemsPerPageChange={setManualItemsPerPage}
/>
</div>
</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') || permissions.canValidate('reconciliation')) ? (
" Vous pouvez dévalider ces rapprochements si nécessaire."
) : (
" Seuls les utilisateurs autorisés peuvent dévalider 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="flex-1 overflow-y-auto">
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden">
{/* Pagination du haut */}
<div className="p-4 border-b border-gray-200">
<Pagination
currentPage={autoCurrentPage}
totalPages={autoTotalPages}
totalItems={autoTotalItems}
itemsPerPage={autoItemsPerPage}
onPageChange={setAutoCurrentPage}
onItemsPerPageChange={setAutoItemsPerPage}
/>
</div>
<div className="table-container">
<table className="w-full min-w-[700px]">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Fournisseur
<Badge variant="secondary" className="ml-2 text-xs">AUTO</Badge>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N° BL
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date Livr.
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date Valid.
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Magasin
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedAutoDeliveries.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="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 hover:text-blue-500 cursor-pointer hover:underline transition-colors"
onClick={() => handleOpenModal(delivery)}
title="Cliquer pour modifier"
>
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="text-gray-900">
{delivery.validatedAt ?
safeFormat(delivery.validatedAt, 'dd/MM/yy HH:mm') :
<span className="text-gray-400 italic text-xs">Non validé</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">
{shouldShowInvoiceButton(delivery) && (
<button
onClick={() => handleOpenInvoiceModal(delivery)}
className="text-green-600 hover:text-green-700 transition-colors duration-200 p-1 hover:bg-green-50 rounded"
title="Envoyer Facture/Avoir"
>
<Upload className="h-4 w-4" />
</button>
)}
<button
onClick={() => handleOpenCommentModal(delivery)}
className={`transition-colors duration-200 p-1 rounded text-gray-600 hover:text-gray-700 hover:bg-gray-50 ${delivery.reconciled ? 'opacity-70' : ''}`}
title="Voir/Gérer les commentaires"
>
<MessageSquare className="h-4 w-4" />
</button>
{(permissions.canEdit('reconciliation') || permissions.canValidate('reconciliation')) && (
<button
onClick={() => handleDevalidateReconciliation(delivery.id)}
className="text-gray-600 hover:text-orange-600 transition-colors duration-200 p-1 hover:bg-orange-50 rounded"
title="Dévalider le rapprochement automatique"
>
<Ban className="w-4 h-4" />
</button>
)}
{permissions.canDelete('reconciliation') && (
<button
onClick={() => handleDeleteDelivery(delivery.id)}
className="text-gray-600 hover:text-red-600 transition-colors duration-200 p-1 hover:bg-red-50 rounded"
title="Supprimer la livraison"
>
<Trash2 className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleOpenModal(delivery)}
className="text-gray-600 hover:text-blue-600 transition-colors duration-200 p-1 hover:bg-blue-50 rounded"
title="Voir les détails"
>
<Eye className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination du bas */}
<div className="p-4 border-t border-gray-200">
<Pagination
currentPage={autoCurrentPage}
totalPages={autoTotalPages}
totalItems={autoTotalItems}
itemsPerPage={autoItemsPerPage}
onPageChange={setAutoCurrentPage}
onItemsPerPageChange={setAutoItemsPerPage}
/>
</div>
</div>
</div>
)}
</TabsContent>
</Tabs>
{/* Modal de rapprochement */}
{selectedDelivery && (
<ReconciliationModal
isOpen={isModalOpen}
delivery={selectedDelivery}
onSave={handleSaveReconciliation}
onClose={handleCloseModal}
/>
)}
{/* Modal d'envoi de facture */}
<Dialog open={showInvoiceModal} onOpenChange={setShowInvoiceModal}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Envoyer Facture</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{selectedDeliveryForInvoice && (
<div className="bg-gray-50 p-3 rounded-md">
<div className="text-sm">
<div className="font-medium">{selectedDeliveryForInvoice.supplier?.name}</div>
<div className="text-gray-600">
BL: {selectedDeliveryForInvoice.blNumber || 'Non renseigné'}
</div>
<div className="text-gray-600">
Magasin: {selectedDeliveryForInvoice.group?.name}
</div>
</div>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="pdf-file">Fichier PDF</Label>
<input
id="pdf-file"
type="file"
accept=".pdf"
onChange={handleFileChange}
className="w-full p-2 border border-gray-300 rounded-md"
/>
{selectedFile && (
<div className="text-sm text-green-600">
Fichier sélectionné: {selectedFile.name}
</div>
)}
</div>
{selectedDeliveryForInvoice?.group?.webhookUrl && (
<div className="text-xs text-gray-500">
Envoi via: {selectedDeliveryForInvoice.group.webhookUrl}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCloseInvoiceModal}>
Annuler
</Button>
<Button
onClick={handleSendInvoice}
disabled={!selectedFile || isUploading}
>
{isUploading ? "Envoi..." : "Envoyer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal d'attente pour le traitement du webhook */}
<Dialog open={showWaitingModal} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Clock className="w-5 h-5 text-blue-600 animate-spin" />
<span>Traitement en cours</span>
</DialogTitle>
</DialogHeader>
<div className="grid gap-6 py-6">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 relative">
<div className="w-16 h-16 border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-bold text-blue-600">{processingSeconds}s</span>
</div>
</div>
<div className="space-y-2">
<h3 className="font-medium text-gray-900">
Traitement de votre facture en cours...
</h3>
<p className="text-sm text-gray-600">
Le workflow peut prendre jusqu'à 1 minute.
<br />
Veuillez patienter.
</p>
</div>
<div className="mt-4 bg-gray-100 rounded-full h-2 w-full">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-1000 ease-out"
style={{ width: `${Math.min((processingSeconds / 60) * 100, 100)}%` }}
></div>
</div>
<div className="mt-2 text-xs text-gray-500">
{processingSeconds < 60 ? `${60 - processingSeconds}s restantes (max)` : 'Finalisation...'}
</div>
</div>
{selectedDeliveryForInvoice && (
<div className="bg-blue-50 p-3 rounded-md">
<div className="text-sm">
<div className="font-medium text-blue-900">
{selectedDeliveryForInvoice.supplier?.name}
</div>
<div className="text-blue-700">
BL: {selectedDeliveryForInvoice.blNumber || 'Non renseigné'}
</div>
<div className="text-blue-700">
Fichier: {selectedFile?.name}
</div>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Modal de commentaires de rapprochement */}
<Dialog open={showCommentModal} onOpenChange={setShowCommentModal}>
<DialogContent className="sm:max-w-[800px]">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<MessageSquare className="w-5 h-5 text-blue-600" />
<span>Commentaires de rapprochement</span>
</DialogTitle>
</DialogHeader>
{selectedDeliveryForComment && (
<div className="grid gap-4 py-4">
<div className="bg-gray-50 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-900">
{selectedDeliveryForComment.supplier?.name}
</div>
<div className="text-sm text-gray-600">
BL: {selectedDeliveryForComment.blNumber || 'Non renseigné'} |
Facture: {selectedDeliveryForComment.invoiceReference || 'Non renseignée'}
</div>
<div className="text-sm text-gray-600">
Date prévue: {selectedDeliveryForComment.scheduledDate ? new Date(selectedDeliveryForComment.scheduledDate).toLocaleDateString('fr-FR') : 'Non renseignée'}
</div>
</div>
{/* Composant de commentaires de rapprochement */}
<ReconciliationComments
deliveryId={selectedDeliveryForComment.id}
className="max-h-[400px] overflow-y-auto"
/>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={handleCloseCommentModal}
>
Fermer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}