mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-23 06:58:19 +02:00
Merge pull request #562 from R0m1k3/claude/focused-volta-u3pwmj
Claude/focused volta u3pwmj
This commit is contained in:
2 files changed
+356
-104
No files matched your search
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { safeFormat } from "@/lib/dateUtils";
|
import { safeFormat } from "@/lib/dateUtils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -12,13 +12,14 @@ import { useStore } from "@/contexts/StoreContext";
|
|||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { usePermissions } from "@shared/permissions";
|
import { usePermissions } from "@shared/permissions";
|
||||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import ReconciliationComments from "@/components/ReconciliationComments";
|
import ReconciliationComments from "@/components/ReconciliationComments";
|
||||||
import ReconciliationModal from "@/components/modals/ReconciliationModal";
|
import ReconciliationModal from "@/components/modals/ReconciliationModal";
|
||||||
|
import { buildSupplierMailtoUrl, openMailClient } from "@/lib/supplierMail";
|
||||||
|
|
||||||
export default function BLReconciliation() {
|
export default function BLReconciliation() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
@@ -67,7 +68,6 @@ export default function BLReconciliation() {
|
|||||||
// État pour le système de vérification de facture
|
// État pour le système de vérification de facture
|
||||||
const [verificationResults, setVerificationResults] = useState<Record<number, any>>({});
|
const [verificationResults, setVerificationResults] = useState<Record<number, any>>({});
|
||||||
const [verifyingDeliveries, setVerifyingDeliveries] = useState<Set<number>>(new Set());
|
const [verifyingDeliveries, setVerifyingDeliveries] = useState<Set<number>>(new Set());
|
||||||
const [autoVerifiedDeliveries, setAutoVerifiedDeliveries] = useState<Set<number>>(new Set());
|
|
||||||
|
|
||||||
// État pour le modal de commentaire
|
// État pour le modal de commentaire
|
||||||
const [showCommentModal, setShowCommentModal] = useState(false);
|
const [showCommentModal, setShowCommentModal] = useState(false);
|
||||||
@@ -78,9 +78,27 @@ export default function BLReconciliation() {
|
|||||||
queryKey: ['/api/suppliers'],
|
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<Array<{ delivery: any; forceRefresh: boolean; silent: boolean }>>([]);
|
||||||
|
const activeVerificationsRef = useRef(0);
|
||||||
|
// Livraisons en file d'attente ou en cours (dédoublonnage synchrone)
|
||||||
|
const pendingVerificationIdsRef = useRef<Set<number>>(new Set());
|
||||||
|
// Livraisons déjà vérifiées automatiquement : jamais relancées par l'effet
|
||||||
|
const autoRequestedIdsRef = useRef<Set<number>>(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
|
// Fonction de vérification de facture
|
||||||
const verifyInvoiceMutation = useMutation({
|
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 {
|
try {
|
||||||
const result = await apiRequest(`/api/deliveries/${deliveryId}/verify-invoice`, 'POST', {
|
const result = await apiRequest(`/api/deliveries/${deliveryId}/verify-invoice`, 'POST', {
|
||||||
invoiceReference,
|
invoiceReference,
|
||||||
@@ -149,9 +167,10 @@ export default function BLReconciliation() {
|
|||||||
apiRequest(`/api/deliveries/${variables.deliveryId}`, "PUT", updateData)
|
apiRequest(`/api/deliveries/${variables.deliveryId}`, "PUT", updateData)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('✅ Données sauvegardées avec succès');
|
console.log('✅ Données sauvegardées avec succès');
|
||||||
// Invalider les caches (pas de refetch pour éviter boucles)
|
// Invalidation différée : sinon chaque auto-remplissage relance un
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
|
// refetch complet de la liste, qui relance l'effet, qui relance des
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
// vérifications... jusqu'à saturer le navigateur.
|
||||||
|
needsCacheInvalidationRef.current = true;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('❌ Erreur auto-remplissage:', error);
|
console.error('❌ Erreur auto-remplissage:', error);
|
||||||
@@ -184,56 +203,113 @@ export default function BLReconciliation() {
|
|||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
// Les vérifications automatiques restent silencieuses (coche rouge) :
|
||||||
title: "Erreur de vérification",
|
// sinon une panne réseau génère autant de toasts que de lignes affichées.
|
||||||
description: error instanceof Error ? error.message : 'Erreur inconnue',
|
if (!variables.silent) {
|
||||||
variant: "destructive",
|
toast({
|
||||||
});
|
title: "Erreur de vérification",
|
||||||
|
description: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Fonction pour déclencher la vérification
|
// Dépile la file d'attente en respectant la limite de requêtes simultanées
|
||||||
const handleVerifyInvoice = (delivery: any, forceRefresh: boolean = false) => {
|
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
|
// Accepter soit une référence de facture soit un numéro BL
|
||||||
const hasInvoiceRef = delivery.invoiceReference?.trim();
|
const hasInvoiceRef = delivery.invoiceReference?.trim();
|
||||||
const hasBlNumber = delivery.blNumber?.trim();
|
const hasBlNumber = delivery.blNumber?.trim();
|
||||||
|
|
||||||
if (!hasInvoiceRef && !hasBlNumber) {
|
if (!hasInvoiceRef && !hasBlNumber) {
|
||||||
toast({
|
if (!silent) {
|
||||||
title: "Référence manquante",
|
toast({
|
||||||
description: "Veuillez saisir une référence de facture ou un numéro BL avant la vérification",
|
title: "Référence manquante",
|
||||||
variant: "destructive",
|
description: "Veuillez saisir une référence de facture ou un numéro BL avant la vérification",
|
||||||
});
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!delivery.group?.nocodbTableName && !delivery.group?.nocodbConfigId && !delivery.group?.webhookUrl) {
|
if (!delivery.group?.nocodbTableName && !delivery.group?.nocodbConfigId && !delivery.group?.webhookUrl) {
|
||||||
toast({
|
if (!silent) {
|
||||||
title: "Vérification non disponible",
|
toast({
|
||||||
description: "Ce magasin n'a pas de configuration NocoDB",
|
title: "Vérification non disponible",
|
||||||
variant: "destructive",
|
description: "Ce magasin n'a pas de configuration NocoDB",
|
||||||
});
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔍 Déclenchement vérification:', {
|
// Déjà en file d'attente ou en cours : on ne l'ajoute pas une seconde fois
|
||||||
deliveryId: delivery.id,
|
if (pendingVerificationIdsRef.current.has(delivery.id)) {
|
||||||
hasInvoiceRef,
|
return;
|
||||||
hasBlNumber,
|
}
|
||||||
invoiceReference: delivery.invoiceReference,
|
|
||||||
blNumber: delivery.blNumber,
|
|
||||||
supplier: delivery.supplier?.name
|
|
||||||
});
|
|
||||||
|
|
||||||
|
pendingVerificationIdsRef.current.add(delivery.id);
|
||||||
|
verificationQueueRef.current.push({ delivery, forceRefresh, silent });
|
||||||
setVerifyingDeliveries(prev => new Set(prev).add(delivery.id));
|
setVerifyingDeliveries(prev => new Set(prev).add(delivery.id));
|
||||||
|
|
||||||
verifyInvoiceMutation.mutate({
|
drainVerificationQueue();
|
||||||
deliveryId: delivery.id,
|
|
||||||
invoiceReference: delivery.invoiceReference,
|
|
||||||
blNumber: delivery.blNumber,
|
|
||||||
forceRefresh
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fonction pour vérifier toutes les factures avec un bouton
|
// Fonction pour vérifier toutes les factures avec un bouton
|
||||||
@@ -251,11 +327,10 @@ export default function BLReconciliation() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
deliveriesToVerify.forEach((delivery: any, index: number) => {
|
// La file d'attente limite déjà le nombre de requêtes simultanées :
|
||||||
// Délai échelonné pour éviter la surcharge
|
// on peut tout empiler d'un coup sans surcharger le navigateur.
|
||||||
setTimeout(() => {
|
deliveriesToVerify.forEach((delivery: any) => {
|
||||||
handleVerifyInvoice(delivery, true); // Force refresh pour toutes
|
handleVerifyInvoice(delivery, true); // Force refresh pour toutes
|
||||||
}, index * 200); // 200ms entre chaque vérification
|
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -296,6 +371,12 @@ export default function BLReconciliation() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// VÉRIFICATION AUTOMATIQUE AU CHARGEMENT avec système de cache
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!deliveriesWithBL.length || !suppliers.length) return;
|
if (!deliveriesWithBL.length || !suppliers.length) return;
|
||||||
|
|
||||||
@@ -303,90 +384,67 @@ export default function BLReconciliation() {
|
|||||||
console.log('🔄 Déclenchement vérifications automatiques...');
|
console.log('🔄 Déclenchement vérifications automatiques...');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pré-populer les résultats pour les livraisons déjà réconciliées
|
// Résultats déduits localement (sans appel réseau) pour afficher les coches
|
||||||
const newVerificationResults = { ...verificationResults };
|
const cachedResults: Record<number, any> = {};
|
||||||
let hasNewReconciledResults = false;
|
|
||||||
|
|
||||||
deliveriesWithBL.forEach((delivery: any) => {
|
deliveriesWithBL.forEach((delivery: any) => {
|
||||||
// Si la livraison est déjà réconciliée, marquer comme vérifiée avec succès
|
const hasVerifiableData = delivery.invoiceReference || delivery.blNumber;
|
||||||
if (delivery.reconciled && !verificationResults[delivery.id]) {
|
|
||||||
newVerificationResults[delivery.id] = {
|
if (delivery.reconciled) {
|
||||||
|
// Livraison déjà réconciliée → coche verte sans vérification
|
||||||
|
cachedResults[delivery.id] = {
|
||||||
exists: true,
|
exists: true,
|
||||||
matchType: delivery.invoiceReference ? 'invoice_reference' : 'bl_number',
|
matchType: delivery.invoiceReference ? 'invoice_reference' : 'bl_number',
|
||||||
fromCache: true,
|
fromCache: true,
|
||||||
permanent: true,
|
permanent: true,
|
||||||
reconciled: true
|
reconciled: true
|
||||||
};
|
};
|
||||||
hasNewReconciledResults = true;
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
// CAS SPÉCIAL : réconciliée mais cellules vides → auto-remplissage (une seule fois)
|
||||||
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) {
|
|
||||||
const hasEmptyCells = !delivery.invoiceReference || !delivery.invoiceAmount || !delivery.dueDate;
|
const hasEmptyCells = !delivery.invoiceReference || !delivery.invoiceAmount || !delivery.dueDate;
|
||||||
const hasBLNumber = delivery.blNumber?.trim();
|
const hasBLNumber = delivery.blNumber?.trim();
|
||||||
const notAlreadyAutoVerified = !autoVerifiedDeliveries.has(delivery.id);
|
|
||||||
const notCurrentlyVerifying = !verifyingDeliveries.has(delivery.id);
|
|
||||||
|
|
||||||
if (hasEmptyCells && hasBLNumber && notAlreadyAutoVerified && notCurrentlyVerifying) {
|
if (hasEmptyCells && hasBLNumber && !autoRequestedIdsRef.current.has(delivery.id)) {
|
||||||
// Livraison réconciliée avec cellules vides → vérifier pour auto-remplir (UNE SEULE FOIS)
|
autoRequestedIdsRef.current.add(delivery.id);
|
||||||
if (import.meta.env.DEV) {
|
handleVerifyInvoice(delivery, false, true);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
return; // Autres livraisons réconciliées = AUCUNE vérification nécessaire
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// VÉRIFICATION AUTOMATIQUE pour afficher les coches
|
// Facture déjà trouvée (montant renseigné) → coche verte sans appel réseau
|
||||||
// NE vérifier QUE les factures qui n'ont PAS encore de montant renseigné (pas encore trouvées)
|
if (hasVerifiableData && delivery.invoiceAmount) {
|
||||||
const hasVerifiableData = delivery.invoiceReference || delivery.blNumber;
|
cachedResults[delivery.id] = {
|
||||||
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] = {
|
|
||||||
exists: true,
|
exists: true,
|
||||||
matchType: delivery.invoiceReference ? 'invoice_reference' : 'bl_number',
|
matchType: delivery.invoiceReference ? 'invoice_reference' : 'bl_number',
|
||||||
fromCache: true,
|
fromCache: true,
|
||||||
permanent: true,
|
permanent: true,
|
||||||
invoiceAmount: delivery.invoiceAmount
|
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
|
// Séparer les livraisons : non validées manuelles et toutes les validées
|
||||||
const manualNotValidatedDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
const manualNotValidatedDeliveries = deliveriesWithBL.filter((delivery: any) => {
|
||||||
@@ -607,6 +665,35 @@ export default function BLReconciliation() {
|
|||||||
return (isNotValidated || hasNoInvoiceReference) && hasValidGroup;
|
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) => {
|
const handleQuickValidate = async (delivery: any) => {
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/api/deliveries/${delivery.id}`, "PUT", {
|
await apiRequest(`/api/deliveries/${delivery.id}`, "PUT", {
|
||||||
@@ -1043,6 +1130,28 @@ export default function BLReconciliation() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
<div className="flex items-center justify-end space-x-2">
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
{(() => {
|
||||||
|
const supplierEmail = getSupplierEmail(delivery);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRequestDocumentsByEmail(delivery)}
|
||||||
|
className={`h-8 w-8 p-0 ${
|
||||||
|
supplierEmail
|
||||||
|
? 'text-blue-600 hover:text-blue-700 border-blue-300'
|
||||||
|
: 'text-gray-400 hover:text-gray-500'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
supplierEmail
|
||||||
|
? `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
|
||||||
|
: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{shouldShowInvoiceButton(delivery) && (
|
{shouldShowInvoiceButton(delivery) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -1351,6 +1460,26 @@ export default function BLReconciliation() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
<div className="flex items-center justify-end space-x-2">
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
{(() => {
|
||||||
|
const supplierEmail = getSupplierEmail(delivery);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRequestDocumentsByEmail(delivery)}
|
||||||
|
className={`transition-colors duration-200 p-1 rounded opacity-70 ${
|
||||||
|
supplierEmail
|
||||||
|
? 'text-blue-600 hover:text-blue-700 hover:bg-blue-50'
|
||||||
|
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
supplierEmail
|
||||||
|
? `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
|
||||||
|
: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleOpenCommentModal(delivery)}
|
onClick={() => handleOpenCommentModal(delivery)}
|
||||||
className={`transition-colors duration-200 p-1 rounded ${
|
className={`transition-colors duration-200 p-1 rounded ${
|
||||||
|
|||||||
Reference in new issue
Block a user