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