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 "@/components/Layout";
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 } 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 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 (
Accès restreint
Seuls les managers et administrateurs peuvent accéder au module de rapprochement BL/Factures.
);
}
const [activeTab, setActiveTab] = useState("manual");
const [searchTerm, setSearchTerm] = useState("");
const [selectedDelivery, setSelectedDelivery] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
// État pour le modal d'envoi de facture
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
const [selectedDeliveryForInvoice, setSelectedDeliveryForInvoice] = useState(null);
const [invoiceType, setInvoiceType] = useState("");
const [selectedFile, setSelectedFile] = useState(null);
const [isUploading, setIsUploading] = useState(false);
// État pour le système de vérification de facture
const [verificationResults, setVerificationResults] = useState>({});
const [verifyingDeliveries, setVerifyingDeliveries] = useState>(new Set());
// Récupérer les fournisseurs pour la logique automatique
const { data: suppliers = [] } = useQuery({
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
updateDeliveryMutation.mutate({
id: variables.deliveryId,
invoiceReference: result.invoiceReference,
invoiceAmount: result.invoiceAmount
});
}
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) => {
if (!delivery.invoiceReference?.trim()) {
toast({
title: "Référence manquante",
description: "Veuillez saisir une référence de facture 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;
}
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.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 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) 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') {
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...');
}
deliveriesWithBL.forEach((delivery: any) => {
// Vérifier seulement si on a une référence de facture et pas déjà de résultat
if (delivery.invoiceReference && !verificationResults[delivery.id] && !verifyingDeliveries.has(delivery.id)) {
if (import.meta.env.DEV) {
console.log(`🔍 Vérification auto pour livraison ${delivery.id}:`, delivery.invoiceReference);
}
// Délai pour éviter de surcharger le serveur - le cache évitera les appels inutiles
setTimeout(() => {
handleVerifyInvoice(delivery, false); // Pas de force refresh, utilise le cache
}, Math.random() * 1000); // Délai aléatoire entre 0 et 1 seconde
}
});
}, [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 (data: any) => {
try {
await apiRequest(`/api/deliveries/${selectedDelivery.id}`, "PUT", data);
toast({
title: "Succès",
description: "Données de rapprochement mises à jour",
});
// Invalidation cache + refetch forcé pour mise à jour immédiate
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
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);
setInvoiceType("");
setSelectedFile(null);
setShowInvoiceModal(true);
};
const handleCloseInvoiceModal = () => {
setShowInvoiceModal(false);
setSelectedDeliveryForInvoice(null);
setInvoiceType("");
setSelectedFile(null);
setIsUploading(false);
};
const handleFileChange = (event: React.ChangeEvent) => {
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 || !invoiceType || !selectedFile) {
toast({
title: "Erreur",
description: "Veuillez remplir tous les champs",
variant: "destructive",
});
return;
}
if (!selectedDeliveryForInvoice.group?.webhookUrl) {
toast({
title: "Erreur",
description: "Aucun webhook configuré pour ce magasin",
variant: "destructive",
});
return;
}
setIsUploading(true);
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('supplier', selectedDeliveryForInvoice.supplier?.name || '');
formData.append('blNumber', selectedDeliveryForInvoice.blNumber || '');
formData.append('type', invoiceType);
const response = await fetch(selectedDeliveryForInvoice.group.webhookUrl, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Erreur ${response.status}: ${response.statusText}`);
}
toast({
title: "Succès",
description: `${invoiceType} envoyée avec succès via le webhook`,
});
handleCloseInvoiceModal();
} catch (error: any) {
toast({
title: "Erreur",
description: `Impossible d'envoyer la ${invoiceType.toLowerCase()}: ${error.message}`,
variant: "destructive",
});
} 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 (user?.role !== 'admin') {
toast({
title: "Accès refusé",
description: "Seuls les administrateurs peuvent 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
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 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 Chargement...
;
}
return (
{/* Header */}
Rapprochement BL/Factures
Gestion des rapprochements manuels et automatiques
{manualReconciliationDeliveries.length} manuels
{automaticReconciliationDeliveries.length} automatiques
Vérifier toutes les factures
{/* Onglets */}
Rapprochement Manuel
{manualReconciliationDeliveries.length}
Rapprochement Automatique
{automaticReconciliationDeliveries.length}
{/* Filters */}
{/* Contenu des onglets */}
{filteredManualDeliveries.length === 0 ? (
Aucun rapprochement manuel trouvé
Les livraisons de fournisseurs en mode manuel apparaîtront ici.
) : (
{/* Pagination du haut */}
Fournisseur
N° BL
Date Livr.
Montant BL
Ref. Facture
Montant Fact.
Écart
Magasin
Actions
{paginatedManualDeliveries.map((delivery: any) => (
{delivery.supplier?.name}
{delivery.blNumber || (
Non renseigné
)}
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
{delivery.blAmount ?
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
Non renseigné
}
{/* Icône de vérification de facture */}
{(delivery.group?.nocodbTableName || delivery.group?.nocodbConfigId || delivery.group?.webhookUrl) && (
{verifyingDeliveries.has(delivery.id) ? (
) : verificationResults[delivery.id] ? (
verificationResults[delivery.id].exists ? (
) : (
)
) : (
handleVerifyInvoice(delivery)}
className="h-4 w-4 text-gray-400 hover:text-blue-500 transition-colors"
title="Cliquer pour vérifier la facture"
>
)}
)}
{delivery.invoiceReference || (
Non renseigné
)}
{delivery.invoiceAmount ?
`${parseFloat(delivery.invoiceAmount).toFixed(2)}€` :
Non renseigné
}
{(() => {
const blAmount = delivery.blAmount ? parseFloat(delivery.blAmount) : 0;
const invoiceAmount = delivery.invoiceAmount ? parseFloat(delivery.invoiceAmount) : 0;
if (blAmount && invoiceAmount) {
const diff = blAmount - invoiceAmount;
const diffAbs = Math.abs(diff);
return (
10 ? 'text-red-600' : 'text-orange-600'
}`}>
{diff > 0 ? '+' : ''}{diff.toFixed(2)}€
);
}
return - ;
})()}
{delivery.group?.name}
{shouldShowInvoiceButton(delivery) && (
handleOpenInvoiceModal(delivery)}
className="h-8 w-8 p-0 text-green-600 hover:text-green-700"
title="Envoyer Facture/Avoir"
>
)}
{!delivery.reconciled ? (
<>
{permissions.canValidate('reconciliation') && (
handleQuickValidate(delivery)}
className="h-8 w-8 p-0"
title="Valider le rapprochement"
>
)}
handleOpenModal(delivery)}
className="h-8 w-8 p-0"
title="Modifier les données"
>
{permissions.canDelete('reconciliation') && (
handleDeleteDelivery(delivery.id)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
title="Supprimer la livraison"
>
)}
>
) : (
<>
{user?.role === 'admin' && (
handleDevalidateReconciliation(delivery.id)}
className="h-8 w-8 p-0 text-orange-600 hover:text-orange-700"
title="Dévalider le rapprochement"
>
)}
{permissions.canDelete('reconciliation') && (
handleDeleteDelivery(delivery.id)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
title="Supprimer la livraison"
>
)}
>
)}
))}
{/* Pagination du bas */}
)}
{/* Message d'information */}
Mode rapprochement automatique
Les livraisons de fournisseurs en mode automatique sont validées automatiquement lorsqu'elles ont le statut "delivered" et un numéro de BL.
{user?.role === 'admin' ? (
" Vous pouvez dévalider ces rapprochements si nécessaire."
) : (
" Seuls les administrateurs peuvent dévalider ces rapprochements."
)}
{filteredAutomaticDeliveries.length === 0 ? (
Aucun rapprochement automatique trouvé
Les livraisons de fournisseurs en mode automatique apparaîtront ici.
) : (
{/* Pagination du haut */}
Fournisseur
AUTO
N° BL
Date Livr.
Date Valid.
Magasin
Actions
{paginatedAutoDeliveries.map((delivery: any) => (
{delivery.supplier?.name}
{delivery.blNumber || (
Non renseigné
)}
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
{delivery.validatedAt ?
safeFormat(delivery.validatedAt, 'dd/MM/yy HH:mm') :
Non validé
}
{delivery.group?.name}
{shouldShowInvoiceButton(delivery) && (
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"
>
)}
{user?.role === 'admin' && (
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"
>
)}
{permissions.canDelete('reconciliation') && (
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"
>
)}
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"
>
))}
{/* Pagination du bas */}
)}
{/* Modal de rapprochement */}
{selectedDelivery && (
)}
{/* Modal d'envoi de facture */}
Envoyer Facture/Avoir
{selectedDeliveryForInvoice && (
{selectedDeliveryForInvoice.supplier?.name}
BL: {selectedDeliveryForInvoice.blNumber || 'Non renseigné'}
Magasin: {selectedDeliveryForInvoice.group?.name}
)}
Type de document
Facture
Avoir
Fichier PDF
{selectedFile && (
Fichier sélectionné: {selectedFile.name}
)}
{selectedDeliveryForInvoice?.group?.webhookUrl && (
Envoi via: {selectedDeliveryForInvoice.group.webhookUrl}
)}
Annuler
{isUploading ? "Envoi..." : "Envoyer"}
);
}