diff --git a/client/src/lib/supplierMail.ts b/client/src/lib/supplierMail.ts new file mode 100644 index 0000000..7df5d97 --- /dev/null +++ b/client/src/lib/supplierMail.ts @@ -0,0 +1,123 @@ +import { safeFormat } from "@/lib/dateUtils"; + +/** + * Génération des emails de relance fournisseur (rapprochement BL/Factures). + * Le lien mailto ouvre le client de messagerie par défaut du poste (Outlook) + * avec le destinataire, l'objet et le corps du message déjà préremplis. + */ + +export interface SupplierMailDelivery { + blNumber?: string | null; + blAmount?: string | number | null; + invoiceReference?: string | null; + scheduledDate?: string | Date | null; + deliveredDate?: string | Date | null; + supplier?: { + name?: string | null; + email?: string | null; + contact?: string | null; + } | null; + group?: { name?: string | null } | null; +} + +/** + * Objet du mail : identifie la livraison concernée. + */ +export function buildSupplierMailSubject(delivery: SupplierMailDelivery): string { + const deliveryDate = delivery.deliveredDate || delivery.scheduledDate; + const formattedDate = deliveryDate + ? safeFormat(deliveryDate, "dd/MM/yyyy", { defaultValue: "" }) + : ""; + + const reference = delivery.blNumber + ? `BL n° ${delivery.blNumber}` + : formattedDate + ? `Livraison du ${formattedDate}` + : "Livraison"; + + const parts = [ + "Demande de facture (PDF) ou BL (Excel)", + delivery.blNumber && formattedDate ? `${reference} du ${formattedDate}` : reference, + ]; + + if (delivery.group?.name) { + parts.push(delivery.group.name); + } + + return parts.join(" - "); +} + +/** + * Corps du mail : rappel des documents attendus + récapitulatif de la livraison. + * Seules les informations réellement renseignées sont listées. + */ +export function buildSupplierMailBody(delivery: SupplierMailDelivery): string { + const deliveryDate = delivery.deliveredDate || delivery.scheduledDate; + const formattedDate = deliveryDate + ? safeFormat(deliveryDate, "dd/MM/yyyy", { defaultValue: "" }) + : ""; + + const blAmount = + delivery.blAmount !== null && delivery.blAmount !== undefined && delivery.blAmount !== "" + ? parseFloat(String(delivery.blAmount)) + : null; + + const details: string[] = []; + if (delivery.supplier?.name) details.push(`- Fournisseur : ${delivery.supplier.name}`); + if (delivery.group?.name) details.push(`- Magasin : ${delivery.group.name}`); + if (formattedDate) details.push(`- Date de livraison : ${formattedDate}`); + if (delivery.blNumber) details.push(`- N° de BL : ${delivery.blNumber}`); + if (blAmount !== null && !isNaN(blAmount)) { + details.push(`- Montant du BL : ${blAmount.toFixed(2)} €`); + } + if (delivery.invoiceReference) { + details.push(`- Référence facture : ${delivery.invoiceReference}`); + } + + const lines = [ + "Bonjour,", + "", + "Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :", + "", + "- la facture au format PDF, ou", + "- le bon de livraison au format Excel", + "", + "Livraison concernée :", + ...(details.length > 0 ? details : ["- (informations de livraison à préciser)"]), + "", + "Vous en remerciant par avance.", + "", + // Le corps s'arrête ici : l'enseigne, le magasin et le logo sont apportés + // par la signature configurée dans Outlook sur le poste. + "Cordialement,", + ]; + + return lines.join("\n"); +} + +/** + * Construit le lien mailto complet (destinataire + objet + corps préremplis). + */ +export function buildSupplierMailtoUrl( + delivery: SupplierMailDelivery, + supplierEmail: string +): string { + const subject = buildSupplierMailSubject(delivery); + // RFC 6068 : les sauts de ligne du corps doivent être encodés en CRLF (%0D%0A), + // sinon Outlook peut coller les lignes les unes aux autres. + const body = buildSupplierMailBody(delivery).replace(/\n/g, "\r\n"); + + return `mailto:${supplierEmail.trim()}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; +} + +/** + * Ouvre le client de messagerie par défaut (Outlook sur les postes du magasin). + */ +export function openMailClient(mailtoUrl: string): void { + const link = document.createElement("a"); + link.href = mailtoUrl; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} diff --git a/client/src/pages/BLReconciliation.tsx b/client/src/pages/BLReconciliation.tsx index 4926446..995da70 100644 --- a/client/src/pages/BLReconciliation.tsx +++ b/client/src/pages/BLReconciliation.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { safeFormat } from "@/lib/dateUtils"; import { Button } from "@/components/ui/button"; @@ -12,13 +12,14 @@ 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 { 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"; +import { buildSupplierMailtoUrl, openMailClient } from "@/lib/supplierMail"; export default function BLReconciliation() { const { user } = useAuthUnified(); @@ -67,7 +68,6 @@ export default function BLReconciliation() { // État pour le système de vérification de facture const [verificationResults, setVerificationResults] = useState>({}); const [verifyingDeliveries, setVerifyingDeliveries] = useState>(new Set()); - const [autoVerifiedDeliveries, setAutoVerifiedDeliveries] = useState>(new Set()); // État pour le modal de commentaire const [showCommentModal, setShowCommentModal] = useState(false); @@ -78,9 +78,27 @@ export default function BLReconciliation() { queryKey: ['/api/suppliers'], }); + // --------------------------------------------------------------------------- + // 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); + // Fonction de vérification de facture const verifyInvoiceMutation = useMutation({ - mutationFn: async ({ deliveryId, invoiceReference, blNumber, forceRefresh }: { deliveryId: number; invoiceReference?: string; blNumber?: string; forceRefresh?: boolean }) => { + 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, @@ -149,9 +167,10 @@ export default function BLReconciliation() { apiRequest(`/api/deliveries/${variables.deliveryId}`, "PUT", updateData) .then(() => { console.log('✅ Données sauvegardées avec succès'); - // Invalider les caches (pas de refetch pour éviter boucles) - queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] }); - queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }); + // Invalidation différée : sinon chaque auto-remplissage relance un + // refetch complet de la liste, qui relance l'effet, qui relance des + // vérifications... jusqu'à saturer le navigateur. + needsCacheInvalidationRef.current = true; }) .catch((error) => { console.error('❌ Erreur auto-remplissage:', error); @@ -184,56 +203,113 @@ export default function BLReconciliation() { return newSet; }); - toast({ - title: "Erreur de vérification", - description: error instanceof Error ? error.message : 'Erreur inconnue', - variant: "destructive", - }); + // 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", + }); + } } }); - // Fonction pour déclencher la vérification - const handleVerifyInvoice = (delivery: any, forceRefresh: boolean = false) => { + // 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; + + if (import.meta.env.DEV) { + console.log('🔍 Déclenchement vérification:', { + deliveryId: delivery.id, + invoiceReference: delivery.invoiceReference, + blNumber: delivery.blNumber, + supplier: delivery.supplier?.name, + enAttente: verificationQueueRef.current.length + }); + } + + 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; + queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] }); + queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] }); + } + }); + } + }; + + // 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) { - 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", - }); + 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) { - toast({ - title: "Vérification non disponible", - description: "Ce magasin n'a pas de configuration NocoDB", - variant: "destructive", - }); + if (!silent) { + 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 - }); - + // 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)); - - verifyInvoiceMutation.mutate({ - deliveryId: delivery.id, - invoiceReference: delivery.invoiceReference, - blNumber: delivery.blNumber, - forceRefresh - }); + + drainVerificationQueue(); }; // Fonction pour vérifier toutes les factures avec un bouton @@ -251,11 +327,10 @@ export default function BLReconciliation() { return; } - deliveriesToVerify.forEach((delivery: any, index: number) => { - // Délai échelonné pour éviter la surcharge - setTimeout(() => { - handleVerifyInvoice(delivery, true); // Force refresh pour toutes - }, index * 200); // 200ms entre chaque vérification + // 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({ @@ -296,97 +371,80 @@ export default function BLReconciliation() { }); // VÉRIFICATION AUTOMATIQUE AU CHARGEMENT avec système de cache + // + // Cet effet ne dépend QUE des données (livraisons + fournisseurs) : 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 || !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; - + + // Résultats déduits localement (sans appel réseau) pour afficher les coches + const cachedResults: Record = {}; + 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] = { + 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 }; - 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); - } - - // CAS SPÉCIAL : Livraisons réconciliées (✅) avec cellules vides - // Si reconciled=true ET (cellules vides) ET blNumber existe → auto-remplir - deliveriesWithBL.forEach((delivery: any) => { - if (delivery.reconciled) { + + // 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(); - const notAlreadyAutoVerified = !autoVerifiedDeliveries.has(delivery.id); - const notCurrentlyVerifying = !verifyingDeliveries.has(delivery.id); - - if (hasEmptyCells && hasBLNumber && notAlreadyAutoVerified && notCurrentlyVerifying) { - // Livraison réconciliée avec cellules vides → vérifier pour auto-remplir (UNE SEULE FOIS) - if (import.meta.env.DEV) { - console.log(`🔄 Livraison réconciliée #${delivery.id} avec cellules vides, auto-vérification (première tentative)...`); - } - // Marquer comme auto-vérifiée AVANT de lancer pour éviter les doublons - setAutoVerifiedDeliveries(prev => new Set(prev).add(delivery.id)); - // Lancer la vérification sans délai - handleVerifyInvoice(delivery, false); + + if (hasEmptyCells && hasBLNumber && !autoRequestedIdsRef.current.has(delivery.id)) { + autoRequestedIdsRef.current.add(delivery.id); + handleVerifyInvoice(delivery, false, true); } - return; // Autres livraisons réconciliées = AUCUNE vérification nécessaire + return; } - - // VÉRIFICATION AUTOMATIQUE pour afficher les coches - // NE vérifier QUE les factures qui n'ont PAS encore de montant renseigné (pas encore trouvées) - const hasVerifiableData = delivery.invoiceReference || delivery.blNumber; - const hasNoInvoiceAmount = !delivery.invoiceAmount; // Pas encore trouvée dans NocoDB - const notAlreadyVerified = !verificationResults[delivery.id]; - const notCurrentlyVerifying = !verifyingDeliveries.has(delivery.id); - - // Ne vérifier que si : a des données ET pas de montant (pas encore trouvée) ET pas déjà vérifiée - if (hasVerifiableData && hasNoInvoiceAmount && notAlreadyVerified && notCurrentlyVerifying) { - if (import.meta.env.DEV) { - console.log(`🔍 Vérification initiale ${delivery.id} (pas encore trouvée):`, { - invoiceRef: delivery.invoiceReference, - blNumber: delivery.blNumber - }); - } - - // Délai pour éviter de surcharger le serveur - setTimeout(() => { - handleVerifyInvoice(delivery, false); - }, Math.random() * 1000); - } - - // Si la facture a déjà un montant → marquer comme trouvée (coche verte) sans vérifier - if (hasVerifiableData && delivery.invoiceAmount && !verificationResults[delivery.id]) { - newVerificationResults[delivery.id] = { + + // 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 }; - hasNewReconciledResults = true; + 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); } }); - }, [deliveriesWithBL, suppliers, verificationResults, verifyingDeliveries]); + + // 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, suppliers]); // Séparer les livraisons : non validées manuelles et toutes les validées const manualNotValidatedDeliveries = deliveriesWithBL.filter((delivery: any) => { @@ -607,6 +665,35 @@ export default function BLReconciliation() { return (isNotValidated || hasNoInvoiceReference) && hasValidGroup; }; + // Email du fournisseur : donnée jointe à la livraison, avec repli sur la liste des fournisseurs + const getSupplierEmail = (delivery: any): string => { + const fromDelivery = delivery?.supplier?.email; + if (fromDelivery && String(fromDelivery).trim()) { + return String(fromDelivery).trim(); + } + + const supplier = suppliers.find((s: any) => s.id === delivery?.supplierId); + return supplier?.email ? String(supplier.email).trim() : ''; + }; + + // Ouvre Outlook (client mail par défaut) avec un message prérempli + // demandant la facture au format PDF ou le BL au format Excel + 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; + } + + const mailtoUrl = buildSupplierMailtoUrl(delivery, supplierEmail); + openMailClient(mailtoUrl); + }; + const handleQuickValidate = async (delivery: any) => { try { await apiRequest(`/api/deliveries/${delivery.id}`, "PUT", { @@ -1043,6 +1130,28 @@ export default function BLReconciliation() {
+ {(() => { + const supplierEmail = getSupplierEmail(delivery); + return ( + + ); + })()} {shouldShowInvoiceButton(delivery) && ( + ); + })()}