import React, { useState, useEffect, useRef, useMemo, useDeferredValue } 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, Mail } 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"; // Tableau vide stable tant que la liste n'est pas chargée (évite de relancer // effets et calculs mémoïsés à chaque rendu) const NO_DELIVERIES: any[] = []; // Clé de la liste du rapprochement : elle commence par '/api/deliveries', donc // toute invalidation des livraisons (ici ou depuis une autre page) l'atteint const RECONCILIATION_QUERY_KEY = ['/api/deliveries', 'reconciliation'] as const; // Filtrage des livraisons par recherche uniquement function filterDeliveriesBySearch(deliveries: any[], searchTerm: string) { if (!searchTerm) return deliveries; const searchLower = searchTerm.toLowerCase(); return deliveries.filter((delivery: any) => { return ( delivery.supplier?.name?.toLowerCase().includes(searchLower) || delivery.blNumber?.toLowerCase().includes(searchLower) || delivery.invoiceReference?.toLowerCase().includes(searchLower) || delivery.group?.name?.toLowerCase().includes(searchLower) ); }); } // Progression affichée dans le modal d'attente (60 s max). Le compteur vit dans // ce composant pour ne pas re-rendre toute la page chaque seconde ; il repart de // zéro à chaque ouverture du modal et s'arrête à sa fermeture. function WaitingProgress() { const [processingSeconds, setProcessingSeconds] = useState(0); useEffect(() => { const interval = setInterval(() => { setProcessingSeconds(prev => { if (prev >= 60) { clearInterval(interval); return 60; } return prev + 1; }); }, 1000); return () => clearInterval(interval); }, []); return (
{processingSeconds}s

Traitement de votre facture en cours...

Le workflow peut prendre jusqu'à 1 minute.
Veuillez patienter.

{processingSeconds < 60 ? `${60 - processingSeconds}s restantes (max)` : 'Finalisation...'}
); } 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 [selectedFile, setSelectedFile] = useState(null); const [isUploading, setIsUploading] = useState(false); // État pour le modal d'attente du webhook (compteur dans WaitingProgress) const [showWaitingModal, setShowWaitingModal] = useState(false); // État pour le système de vérification de facture const [verificationResults, setVerificationResults] = useState>({}); const [verifyingDeliveries, setVerifyingDeliveries] = useState>(new Set()); // Livraisons dont le mail fournisseur est en cours d'envoi const [sendingMailDeliveries, setSendingMailDeliveries] = useState>(new Set()); // État pour le modal de commentaire const [showCommentModal, setShowCommentModal] = useState(false); const [selectedDeliveryForComment, setSelectedDeliveryForComment] = useState(null); // Historique des relances fournisseurs : dernier envoi affiché sur l'icône mail const { data: supplierMailLogs = [] } = useQuery({ queryKey: ['/api/supplier-mail-logs', 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/supplier-mail-logs?${params.toString()}`, { credentials: 'include' }); if (!response.ok) throw new Error('Failed to fetch supplier mail logs'); return response.json(); }, enabled: !!user, }); // Dernier envoi réussi par livraison (les logs arrivent triés du plus récent au plus ancien) const lastMailByDelivery = React.useMemo(() => { const map = new Map(); for (const log of supplierMailLogs) { if (log.status === 'sent' && !map.has(log.deliveryId)) { map.set(log.deliveryId, log); } } return map; }, [supplierMailLogs]); // --------------------------------------------------------------------------- // File d'attente des vérifications de facture // // Sans limite, une page de 200 livraisons déclenchait 200 requêtes simultanées // (+ autant de PUT d'auto-remplissage), ce qui saturait le navigateur // (net::ERR_INSUFFICIENT_RESOURCES). On limite donc le nombre de requêtes en // vol et on dédoublonne les livraisons déjà en attente. // --------------------------------------------------------------------------- const MAX_CONCURRENT_VERIFICATIONS = 3; const verificationQueueRef = useRef>([]); const activeVerificationsRef = useRef(0); // Livraisons en file d'attente ou en cours (dédoublonnage synchrone) const pendingVerificationIdsRef = useRef>(new Set()); // Livraisons déjà vérifiées automatiquement : jamais relancées par l'effet const autoRequestedIdsRef = useRef>(new Set()); // Invalidation de cache différée : une seule fois quand la file est vidée const needsCacheInvalidationRef = useRef(false); // Après une modification de livraison : la liste du rapprochement et les // autres listes de livraisons sont invalidées par préfixe (seules les requêtes // affichées sont rechargées) ; l'échéancier, calculé à partir des mêmes // livraisons, est seulement marqué périmé. const invalidateDeliveries = () => Promise.all([ queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }), queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] }), ]); // Fonction de vérification de facture const verifyInvoiceMutation = useMutation({ mutationFn: async ({ deliveryId, invoiceReference, blNumber, forceRefresh }: { deliveryId: number; invoiceReference?: string; blNumber?: string; forceRefresh?: boolean; silent?: 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 })); // Pas de toast - affichage silencieux des coches vertes/rouges // Auto-remplissage : le serveur (verify-invoice) enregistre déjà sur la // livraison la référence, les montants et l'échéance trouvés. Il suffit de // recharger la liste, une seule fois quand la file est vidée : sinon // chaque résultat relancerait un refetch complet, qui relancerait l'effet // de vérification automatique. if (result.exists && ( (result.invoiceReference && result.matchType === 'bl_number') || (result.invoiceAmount !== undefined && result.invoiceAmount !== null) || result.dueDate )) { needsCacheInvalidationRef.current = true; } 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; }); // Les vérifications automatiques restent silencieuses (coche rouge) : // sinon une panne réseau génère autant de toasts que de lignes affichées. if (!variables.silent) { toast({ title: "Erreur de vérification", description: error instanceof Error ? error.message : 'Erreur inconnue', variant: "destructive", }); } } }); // Dépile la file d'attente en respectant la limite de requêtes simultanées const drainVerificationQueue = () => { while ( activeVerificationsRef.current < MAX_CONCURRENT_VERIFICATIONS && verificationQueueRef.current.length > 0 ) { const next = verificationQueueRef.current.shift(); if (!next) break; const { delivery, forceRefresh, silent } = next; activeVerificationsRef.current += 1; verifyInvoiceMutation .mutateAsync({ deliveryId: delivery.id, invoiceReference: delivery.invoiceReference, blNumber: delivery.blNumber, forceRefresh, silent }) // Les erreurs sont déjà tracées et affichées par onError .catch(() => undefined) .finally(() => { activeVerificationsRef.current -= 1; pendingVerificationIdsRef.current.delete(delivery.id); drainVerificationQueue(); // File vidée : on rafraîchit la liste une seule fois if ( activeVerificationsRef.current === 0 && verificationQueueRef.current.length === 0 && needsCacheInvalidationRef.current ) { needsCacheInvalidationRef.current = false; invalidateDeliveries(); } }); } }; // Fonction pour déclencher la vérification (mise en file d'attente) // silent = déclenchement automatique : aucun toast, seules les coches parlent const handleVerifyInvoice = ( delivery: any, forceRefresh: boolean = false, silent: 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) { if (!silent) { 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) { if (!silent) { toast({ title: "Vérification non disponible", description: "Ce magasin n'a pas de configuration NocoDB", variant: "destructive", }); } return; } // Déjà en file d'attente ou en cours : on ne l'ajoute pas une seconde fois if (pendingVerificationIdsRef.current.has(delivery.id)) { return; } pendingVerificationIdsRef.current.add(delivery.id); verificationQueueRef.current.push({ delivery, forceRefresh, silent }); setVerifyingDeliveries(prev => new Set(prev).add(delivery.id)); drainVerificationQueue(); }; // Fonction pour vérifier toutes les factures avec un bouton const handleVerifyAllInvoices = () => { const deliveriesToVerify = manualNotValidatedDeliveries.filter((delivery: any) => (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; } // La file d'attente limite déjà le nombre de requêtes simultanées : // on peut tout empiler d'un coup sans surcharger le navigateur. deliveriesToVerify.forEach((delivery: any) => { handleVerifyInvoice(delivery, true); // Force refresh pour toutes }); toast({ title: "Vérification lancée", description: `Vérification de ${deliveriesToVerify.length} facture(s)/BL en cours...`, }); }; // Récupérer les livraisons livrées (onglets manuel et validées). Toute // modification de livraison invalide ['/api/deliveries'], donc cette liste : // le cache par défaut (30 s) évite de la retélécharger à chaque visite. const { data: deliveriesWithBL = NO_DELIVERIES, isLoading } = useQuery({ queryKey: [...RECONCILIATION_QUERY_KEY, selectedStoreId], queryFn: async () => { // Seules les livraisons livrées sont demandées (filtre appliqué en base) const params = new URLSearchParams({ status: 'delivered' }); 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(); // Filtre conservé par sécurité si le serveur ignore le paramètre status 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, }); // VÉRIFICATION AUTOMATIQUE AU CHARGEMENT avec système de cache // // Cet effet ne dépend QUE des livraisons : y ajouter // verificationResults/verifyingDeliveries le relançait à chaque résultat reçu, // ce qui reprogrammait en boucle les mêmes vérifications. // Le dédoublonnage s'appuie sur autoRequestedIdsRef (ref, pas state) pour être // effectif immédiatement, sans attendre un re-rendu. useEffect(() => { if (!deliveriesWithBL.length) return; // Résultats déduits localement (sans appel réseau) pour afficher les coches const cachedResults: Record = {}; deliveriesWithBL.forEach((delivery: any) => { const hasVerifiableData = delivery.invoiceReference || delivery.blNumber; if (delivery.reconciled) { // Livraison déjà réconciliée → coche verte sans vérification cachedResults[delivery.id] = { exists: true, matchType: delivery.invoiceReference ? 'invoice_reference' : 'bl_number', fromCache: true, permanent: true, reconciled: true }; // CAS SPÉCIAL : réconciliée mais cellules vides → auto-remplissage (une seule fois) const hasEmptyCells = !delivery.invoiceReference || !delivery.invoiceAmount || !delivery.dueDate; const hasBLNumber = delivery.blNumber?.trim(); if (hasEmptyCells && hasBLNumber && !autoRequestedIdsRef.current.has(delivery.id)) { autoRequestedIdsRef.current.add(delivery.id); handleVerifyInvoice(delivery, false, true); } return; } // Facture déjà trouvée (montant renseigné) → coche verte sans appel réseau if (hasVerifiableData && delivery.invoiceAmount) { cachedResults[delivery.id] = { exists: true, matchType: delivery.invoiceReference ? 'invoice_reference' : 'bl_number', fromCache: true, permanent: true, invoiceAmount: delivery.invoiceAmount }; return; } // Sinon : vérification réseau, une seule fois par livraison if (hasVerifiableData && !autoRequestedIdsRef.current.has(delivery.id)) { autoRequestedIdsRef.current.add(delivery.id); handleVerifyInvoice(delivery, false, true); } }); // Application des résultats déduits, sans écraser ceux déjà obtenus setVerificationResults(prev => { const merged = { ...prev }; let changed = false; Object.entries(cachedResults).forEach(([id, result]) => { if (!merged[Number(id)]) { merged[Number(id)] = result; changed = true; } }); return changed ? merged : prev; }); }, [deliveriesWithBL]); // Séparer les livraisons : non validées manuelles et toutes les validées. // Le fournisseur (dont automaticReconciliation) est joint à chaque livraison. const manualNotValidatedDeliveries = useMemo(() => deliveriesWithBL.filter((delivery: any) => { const isManual = delivery.supplier?.automaticReconciliation !== true; const isNotValidated = delivery.reconciled !== true && delivery.reconciled !== 1; return isManual && isNotValidated; }), [deliveriesWithBL]); // Fournisseurs en rapprochement automatique : toujours considérés validés, // même si reconciled n'a pas été posé (livraisons antérieures au passage en mode auto) const allValidatedDeliveries = useMemo(() => deliveriesWithBL.filter((delivery: any) => { const isAutomatic = delivery.supplier?.automaticReconciliation === true; return isAutomatic || delivery.reconciled === true || delivery.reconciled === 1; }), [deliveriesWithBL]); // Fonctions de gestion const handleOpenModal = (delivery: any) => { setSelectedDelivery(delivery); setIsModalOpen(true); }; const handleCloseModal = () => { setSelectedDelivery(null); setIsModalOpen(false); }; const handleSaveReconciliation = async () => { try { // Mise à jour de l'affichage : seules les listes affichées sont rechargées. // cancelRefetch: false réutilise le rechargement déjà lancé par la modale // (après l'enregistrement) au lieu d'en relancer un second. await Promise.all([ queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }, { cancelRefetch: false }), queryClient.invalidateQueries({ queryKey: ['/api/payment-schedule'] }), ]); 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); }; 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 || !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); try { const formData = new FormData(); formData.append('file', selectedFile); formData.append('webhookUrl', selectedDeliveryForInvoice.group.webhookUrl); formData.append('supplier', selectedDeliveryForInvoice.supplier?.name || ''); formData.append('blNumber', selectedDeliveryForInvoice.blNumber || ''); formData.append('type', 'Facture'); // Envoyer via route backend proxy (résout problèmes CORS) const response = await fetch('/api/reconciliation/send-invoice', { method: 'POST', body: formData, credentials: 'include' }); if (!response.ok) { const errorData = await response.json().catch(() => ({ error: 'Erreur inconnue' })); throw new Error(errorData.error || `Erreur ${response.status}`); } 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 { // Recharger les livraisons (une seule requête pour la liste affichée) await invalidateDeliveries(); // Attendre un peu que les données soient à jour, puis vérifier cette livraison spécifique setTimeout(() => { handleVerifyInvoice(selectedDeliveryForInvoice, true); }, 1000); } 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; return (isNotValidated || hasNoInvoiceReference) && hasValidGroup; }; // Email du fournisseur : la fiche fournisseur complète est jointe à la livraison const getSupplierEmail = (delivery: any): string => { const email = delivery?.supplier?.email; return email && String(email).trim() ? String(email).trim() : ''; }; // Envoi de la demande de facture (PDF) / BL (Excel) au fournisseur. // Le mail part du serveur SMTP configuré sur la fiche du magasin. const sendSupplierMailMutation = useMutation({ mutationFn: async (delivery: any) => { return await apiRequest(`/api/deliveries/${delivery.id}/send-supplier-mail`, 'POST'); }, onSuccess: (result: any) => { toast({ title: "Mail envoyé", description: `Demande envoyée à ${result?.supplierName || 'le fournisseur'} (${result?.sentTo})`, }); // Rafraîchir l'historique pour marquer la ligne comme relancée queryClient.invalidateQueries({ queryKey: ['/api/supplier-mail-logs'] }); }, onError: (error: any) => { toast({ title: "Envoi impossible", description: error?.message || "Le mail n'a pas pu être envoyé", variant: "destructive", }); }, onSettled: (_result, _error, delivery: any) => { setSendingMailDeliveries(prev => { const next = new Set(prev); next.delete(delivery.id); return next; }); }, }); const handleRequestDocumentsByEmail = (delivery: any) => { const supplierEmail = getSupplierEmail(delivery); if (!supplierEmail) { toast({ title: "Email fournisseur manquant", description: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}. Ajoutez-la depuis la page Contacts ou Fournisseurs.`, variant: "destructive", }); return; } // Envoi direct : on bloque la ligne le temps de la requête pour éviter // qu'un double clic ne déclenche deux mails au même fournisseur. if (sendingMailDeliveries.has(delivery.id)) return; setSendingMailDeliveries(prev => new Set(prev).add(delivery.id)); sendSupplierMailMutation.mutate(delivery); }; 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", }); // Mise à jour optimiste du cache local pour disparition immédiate queryClient.setQueryData([...RECONCILIATION_QUERY_KEY, selectedStoreId], (oldData: any) => { if (!oldData) return oldData; return oldData.map((d: any) => d.id === delivery.id ? { ...d, reconciled: true, validatedAt: new Date().toISOString() } : d ); }); // Synchronisation avec le serveur (listes affichées uniquement) invalidateDeliveries(); } catch (error) { // En cas d'erreur, rechargement pour resynchroniser la liste queryClient.invalidateQueries({ queryKey: RECONCILIATION_QUERY_KEY }); 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", }); // Rechargement immédiat pour déplacer la facture dans l'onglet manuel await invalidateDeliveries(); } 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", }); // Mise à jour optimiste du cache local pour disparition immédiate queryClient.setQueryData([...RECONCILIATION_QUERY_KEY, selectedStoreId], (oldData: any) => { if (!oldData) return oldData; return oldData.filter((d: any) => d.id !== deliveryId); }); // Synchronisation avec le serveur (listes affichées uniquement) invalidateDeliveries(); } catch (error) { // En cas d'erreur, rechargement pour resynchroniser la liste queryClient.invalidateQueries({ queryKey: RECONCILIATION_QUERY_KEY }); toast({ title: "Erreur", description: "Impossible de supprimer la livraison", variant: "destructive", }); } }; // Filtrage par recherche, recalculé seulement quand la liste ou la recherche // change ; la recherche différée garde la saisie fluide sur une longue liste const deferredSearchTerm = useDeferredValue(searchTerm); const filteredManualDeliveries = useMemo( () => filterDeliveriesBySearch(manualNotValidatedDeliveries, deferredSearchTerm), [manualNotValidatedDeliveries, deferredSearchTerm] ); const filteredValidatedDeliveries = useMemo( () => filterDeliveriesBySearch(allValidatedDeliveries, deferredSearchTerm), [allValidatedDeliveries, deferredSearchTerm] ); // 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 livraisons validées const { currentPage: validatedCurrentPage, setCurrentPage: setValidatedCurrentPage, itemsPerPage: validatedItemsPerPage, setItemsPerPage: setValidatedItemsPerPage, totalPages: validatedTotalPages, paginatedData: paginatedValidatedDeliveries, totalItems: validatedTotalItems } = usePagination(filteredValidatedDeliveries, 20); const canModify = user?.role === 'directeur' || user?.role === 'admin'; if (isLoading) { return
Chargement...
; } return (
{/* Header */}

Rapprochement BL/Factures

Gestion des rapprochements manuels et automatiques

{manualNotValidatedDeliveries.length} à traiter {allValidatedDeliveries.length} validées
{/* Onglets */} Rapprochement Manuel {manualNotValidatedDeliveries.length} Livraisons Validées {allValidatedDeliveries.length}
{/* Barre de recherche */}
setSearchTerm(e.target.value)} className="pl-10 border border-gray-300 shadow-sm w-full" />
{/* Contenu des onglets */} {filteredManualDeliveries.length === 0 ? (

Aucun rapprochement manuel trouvé

Les livraisons de fournisseurs en mode manuel apparaîtront ici.

) : (
{/* Pagination du haut */}
{paginatedManualDeliveries.map((delivery: any) => ( ))}
Fournisseur N° BL Date Livr. Montant BL Ref. Facture Montant Fact. Échéance Écart Magasin Actions
{delivery.supplier?.name}
{delivery.blNumber || ( handleOpenModal(delivery)} title="Cliquer pour modifier" > Non renseigné )}
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
{delivery.blAmount ? `${parseFloat(delivery.blAmount).toFixed(2)}€` : handleOpenModal(delivery)} title="Cliquer pour modifier" > 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 ? ( ) : ( ) ) : ( )}
)}
{delivery.invoiceReference || ( handleOpenModal(delivery)} title="Cliquer pour modifier" > Non renseigné )}
{delivery.invoiceAmount ? `${parseFloat(delivery.invoiceAmount).toFixed(2)}€` : handleOpenModal(delivery)} title="Cliquer pour modifier" > Non renseigné }
{delivery.dueDate ? ( {safeFormat(delivery.dueDate, 'dd/MM/yyyy')} ) : ( - )}
{(() => { 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 (
10 ? 'text-red-600' : 'text-orange-600' }`}> {diff > 0 ? '+' : ''}{diff.toFixed(2)}€
); } return -; })()}
{delivery.group?.name}
{(() => { const supplierEmail = getSupplierEmail(delivery); const isSending = sendingMailDeliveries.has(delivery.id); const lastMail = lastMailByDelivery.get(delivery.id); return ( ); })()} {shouldShowInvoiceButton(delivery) && ( )} {!delivery.reconciled ? ( <> {permissions.canValidate('reconciliation') && verificationResults[delivery.id]?.exists === true ? ( ) : permissions.canValidate('reconciliation') && verificationResults[delivery.id]?.exists === false ? ( ) : permissions.canValidate('reconciliation') ? ( ) : null} {permissions.canDelete('reconciliation') && ( )} ) : ( <> {user?.role === 'admin' && ( )} {permissions.canDelete('reconciliation') && ( )} )}
{/* Pagination du bas */}
)}
{/* Message d'information */}

Livraisons validées

Cette section regroupe toutes les livraisons validées (manuellement ou automatiquement). {(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." )}

{filteredValidatedDeliveries.length === 0 ? (

Aucune livraison validée trouvée

Les livraisons validées apparaîtront ici.

) : (
{/* Pagination du haut */}
{paginatedValidatedDeliveries.map((delivery: any) => { const isAutomatic = delivery.supplier?.automaticReconciliation === true; const ecart = delivery.blAmount && delivery.invoiceAmount ? ((parseFloat(delivery.invoiceAmount) - parseFloat(delivery.blAmount)) / parseFloat(delivery.blAmount) * 100).toFixed(1) : null; return ( ); })}
Fournisseur N° BL Date Livr. Ref. Facture Montant BL Montant Fact. Échéance Écart Magasin Actions
{delivery.supplier?.name}
{isAutomatic && ( AUTO )}
{delivery.blNumber || ( Non renseigné )}
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
{delivery.invoiceReference || ( Non renseignée )}
{delivery.blAmount ? `${parseFloat(delivery.blAmount).toFixed(2)}€` : Non renseigné }
{delivery.invoiceAmount ? `${parseFloat(delivery.invoiceAmount).toFixed(2)}€` : Non renseigné }
{delivery.dueDate ? ( {safeFormat(delivery.dueDate, 'dd/MM/yyyy')} ) : ( - )}
{ecart !== null ? ( 5 ? "destructive" : "secondary"} className="text-xs" > {parseFloat(ecart) > 0 ? '+' : ''}{ecart}% ) : ( - )}
{delivery.group?.name}
{(() => { const supplierEmail = getSupplierEmail(delivery); const isSending = sendingMailDeliveries.has(delivery.id); const lastMail = lastMailByDelivery.get(delivery.id); return ( ); })()} {(permissions.canEdit('reconciliation') || permissions.canValidate('reconciliation')) && ( )} {permissions.canDelete('reconciliation') && ( )}
{/* Pagination du bas */}
)}
{/* Modal de rapprochement */} {selectedDelivery && ( )} {/* Modal d'envoi de facture */} Envoyer Facture
{selectedDeliveryForInvoice && (
{selectedDeliveryForInvoice.supplier?.name}
BL: {selectedDeliveryForInvoice.blNumber || 'Non renseigné'}
Magasin: {selectedDeliveryForInvoice.group?.name}
)}
{selectedFile && (
Fichier sélectionné: {selectedFile.name}
)}
{selectedDeliveryForInvoice?.group?.webhookUrl && (
Envoi via: {selectedDeliveryForInvoice.group.webhookUrl}
)}
{/* Modal d'attente pour le traitement du webhook */} {}}> Traitement en cours
{selectedDeliveryForInvoice && (
{selectedDeliveryForInvoice.supplier?.name}
BL: {selectedDeliveryForInvoice.blNumber || 'Non renseigné'}
Fichier: {selectedFile?.name}
)}
{/* Modal de commentaires de rapprochement */} Commentaires de rapprochement {selectedDeliveryForComment && (
{selectedDeliveryForComment.supplier?.name}
BL: {selectedDeliveryForComment.blNumber || 'Non renseigné'} | Facture: {selectedDeliveryForComment.invoiceReference || 'Non renseignée'}
Date prévue: {selectedDeliveryForComment.scheduledDate ? new Date(selectedDeliveryForComment.scheduledDate).toLocaleDateString('fr-FR') : 'Non renseignée'}
{/* Composant de commentaires de rapprochement */}
)}
); }