import { useState, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Plus, Search, FileText, CheckCircle, AlertCircle, Clock, Edit, Trash2, UserCheck, Send, Upload, XCircle } from "lucide-react"; import { useStore } from "@/components/Layout"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { format } from "date-fns"; import { fr } from "date-fns/locale"; import { useToast } from "@/hooks/use-toast"; import { apiRequest } from "@/lib/queryClient"; // Types interface Avoir { id: number; supplierId: number; groupId: number; invoiceReference?: string; amount?: number; comment?: string; commercialProcessed: boolean; status: 'En attente de demande' | 'Demandé' | 'Reçu'; webhookSent: boolean; nocodbVerified: boolean; nocodbVerifiedAt?: Date; processedAt?: Date; createdBy: string; createdAt: Date; updatedAt: Date; supplier: { id: number; name: string; contact?: string; }; group: { id: number; name: string; color: string; nocodbTableName?: string; nocodbConfigId?: number; }; creator: { id: string; firstName?: string; lastName?: string; username: string; }; } interface Supplier { id: number; name: string; } interface Group { id: number; name: string; color: string; nocodbTableName?: string; nocodbConfigId?: number; } // Schema for form validation const avoirSchema = z.object({ supplierId: z.coerce.number().min(1, "Veuillez sélectionner un fournisseur"), groupId: z.coerce.number().min(1, "Veuillez sélectionner un magasin"), invoiceReference: z.string().optional(), amount: z.union([ z.null().transform(() => undefined), z.undefined().transform(() => undefined), z.literal("").transform(() => undefined), z.string().transform((val) => { const num = parseFloat(val); if (isNaN(num)) { throw new Error("Montant invalide"); } if (num <= 0) { throw new Error("Le montant doit être supérieur à 0"); } return num; }) ]).optional(), comment: z.string().optional(), commercialProcessed: z.boolean().default(false), status: z.enum(["En attente de demande", "Demandé", "Reçu"]).optional(), }); type AvoirFormData = z.infer; // Type étendu pour les mises à jour incluant les champs de validation type AvoirUpdateData = AvoirFormData & { nocodbVerified?: boolean; nocodbVerifiedAt?: Date | null; }; export default function Avoirs() { const [searchTerm, setSearchTerm] = useState(""); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [selectedAvoir, setSelectedAvoir] = useState(null); // État pour le modal d'envoi d'avoir const [showAvoirModal, setShowAvoirModal] = useState(false); const [selectedAvoirForUpload, setSelectedAvoirForUpload] = useState(null); const [selectedFile, setSelectedFile] = useState(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(null); // États pour le système de vérification de facture const [avoirVerificationResults, setAvoirVerificationResults] = useState>({}); const [verifyingAvoirs, setVerifyingAvoirs] = useState>(new Set()); // Utiliser le contexte global du magasin const { selectedStoreId } = useStore(); const { toast } = useToast(); const queryClient = useQueryClient(); // Fetch user profile const { data: user } = useQuery({ queryKey: ['/api/user'], }); // Fetch groups for store filter const { data: groups = [] } = useQuery({ queryKey: ['/api/groups'], enabled: !!user, }); // Fetch suppliers const { data: suppliers = [] } = useQuery({ queryKey: ['/api/suppliers'], enabled: !!user, }); // Fetch avoirs avec filtrage par groupe (comme Orders/Deliveries) const avoirsUrl = `/api/avoirs${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`; const { data: avoirs = [], isLoading } = useQuery({ queryKey: [avoirsUrl, selectedStoreId, (user as any)?.role], queryFn: async () => { console.log('💰 Fetching avoirs from:', avoirsUrl); const response = await fetch(avoirsUrl, { credentials: 'include' }); if (!response.ok) { throw new Error('Failed to fetch avoirs'); } const data = await response.json(); console.log('💰 Avoirs received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items'); return Array.isArray(data) ? data : []; }, enabled: !!user, }); // Create avoir mutation const createAvoirMutation = useMutation({ mutationFn: async (data: AvoirFormData) => { const response = await fetch('/api/avoirs', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data), credentials: 'include', }); if (!response.ok) { throw new Error('Failed to create avoir'); } return response.json(); }, onSuccess: () => { // ✅ FIX: Invalider toutes les variations de queryKey avoirs queryClient.invalidateQueries({ predicate: (query) => { return query.queryKey[0]?.toString().includes('/api/avoirs') || false; } }); setIsCreateDialogOpen(false); toast({ title: "Avoir créé", description: "L'avoir a été créé avec succès.", }); }, onError: (error: any) => { toast({ title: "Erreur", description: error.message || "Erreur lors de la création de l'avoir", variant: "destructive", }); } }); // Edit avoir mutation const editAvoirMutation = useMutation({ mutationFn: async ({ id, data }: { id: number, data: AvoirUpdateData }) => { const response = await fetch(`/api/avoirs/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data), credentials: 'include', }); if (!response.ok) { throw new Error('Failed to edit avoir'); } return response.json(); }, onSuccess: () => { // ✅ FIX: Invalider toutes les variations de queryKey avoirs queryClient.invalidateQueries({ predicate: (query) => { return query.queryKey[0]?.toString().includes('/api/avoirs') || false; } }); setIsEditDialogOpen(false); setSelectedAvoir(null); toast({ title: "Avoir modifié", description: "L'avoir a été modifié avec succès.", }); }, onError: (error: any) => { toast({ title: "Erreur", description: error.message || "Erreur lors de la modification", variant: "destructive", }); } }); // Delete avoir mutation const deleteAvoirMutation = useMutation({ mutationFn: async (id: number) => { const response = await fetch(`/api/avoirs/${id}`, { method: 'DELETE', credentials: 'include', }); if (!response.ok) { throw new Error('Failed to delete avoir'); } return response.json(); }, onSuccess: () => { // ✅ FIX: Invalider toutes les variations de queryKey avoirs queryClient.invalidateQueries({ predicate: (query) => { return query.queryKey[0]?.toString().includes('/api/avoirs') || false; } }); setIsDeleteDialogOpen(false); setSelectedAvoir(null); toast({ title: "Avoir supprimé", description: "L'avoir a été supprimé avec succès.", }); }, onError: (error: any) => { toast({ title: "Erreur", description: error.message || "Erreur lors de la suppression", variant: "destructive", }); } }); // Initialize create form const form = useForm({ resolver: zodResolver(avoirSchema), defaultValues: { supplierId: 0, groupId: 0, invoiceReference: "", amount: undefined, comment: "", commercialProcessed: false, status: "En attente de demande", }, }); // Initialize edit form const editForm = useForm({ resolver: zodResolver(avoirSchema), defaultValues: { supplierId: 0, groupId: 0, invoiceReference: "", amount: undefined, comment: "", commercialProcessed: false, status: "En attente de demande", }, }); // Handle form submission const onSubmit = (data: AvoirFormData) => { createAvoirMutation.mutate(data); }; // Handle edit form submission const onEditSubmit = (data: AvoirFormData) => { if (selectedAvoir) { console.log('💰 Editing avoir - Form data sent:', { originalCommercial: selectedAvoir.commercialProcessed, formCommercial: data.commercialProcessed, fullData: data }); editAvoirMutation.mutate({ id: selectedAvoir.id, data }); } }; // Handle edit action const handleEdit = (avoir: Avoir) => { if (!avoir || !avoir.id) return; setSelectedAvoir(avoir); editForm.reset({ supplierId: avoir.supplierId || 0, groupId: avoir.groupId || 0, invoiceReference: avoir.invoiceReference || "", amount: avoir.amount, comment: avoir.comment || "", commercialProcessed: avoir.commercialProcessed || false, status: avoir.status || "En attente de demande", }); setIsEditDialogOpen(true); }; // Handle delete action const handleDelete = (avoir: Avoir) => { if (!avoir || !avoir.id) return; setSelectedAvoir(avoir); setIsDeleteDialogOpen(true); }; // Handle status change const handleStatusChange = (avoirId: number, newStatus: string) => { const avoir = avoirs?.find(a => a?.id === avoirId); if (avoir && avoir.supplierId && avoir.groupId) { editAvoirMutation.mutate({ id: avoirId, data: { supplierId: avoir.supplierId, groupId: avoir.groupId, invoiceReference: avoir.invoiceReference || "", amount: avoir.amount, comment: avoir.comment || "", commercialProcessed: avoir.commercialProcessed, status: newStatus as "En attente de demande" | "Demandé" | "Reçu", } }); } }; // Handle validation/devalidation const handleValidateAvoir = async (avoirId: number) => { try { // Utiliser la route spécifique pour la vérification NocoDB await apiRequest(`/api/avoirs/${avoirId}/nocodb-verification`, 'PUT', { verified: true }); // Marquer le cache comme réconcilié (permanent) const avoir = avoirs.find(a => a.id === avoirId); if (avoir?.invoiceReference) { try { await apiRequest('/api/cache/mark-reconciled', 'POST', { invoiceReference: avoir.invoiceReference, groupId: avoir.groupId }); } catch (cacheError) { console.warn('Cache marking failed but verification succeeded:', cacheError); } } // Recharger les avoirs pour voir les changements queryClient.invalidateQueries({ queryKey: ['/api/avoirs'] }); toast({ title: "Avoir validé", description: "L'avoir a été marqué comme validé avec succès", }); } catch (error) { toast({ title: "Erreur de validation", description: error instanceof Error ? error.message : "Erreur lors de la validation", variant: "destructive", }); } }; const handleDevalidateAvoir = async (avoirId: number) => { try { // Utiliser la route spécifique pour la vérification NocoDB await apiRequest(`/api/avoirs/${avoirId}/nocodb-verification`, 'PUT', { verified: false }); // Recharger les avoirs pour voir les changements queryClient.invalidateQueries({ queryKey: ['/api/avoirs'] }); toast({ title: "Avoir dévalidé", description: "L'avoir a été marqué comme non-validé avec succès", }); } catch (error) { toast({ title: "Erreur de dévalidation", description: error instanceof Error ? error.message : "Erreur lors de la dévalidation", variant: "destructive", }); } }; // 🔍 FONCTION DE VÉRIFICATION DE FACTURE (comme rapprochement) const verifyAvoirInvoiceMutation = useMutation({ mutationFn: async ({ avoirId, invoiceReference, forceRefresh }: { avoirId: number; invoiceReference?: string; forceRefresh?: boolean }) => { try { const result = await apiRequest(`/api/avoirs/${avoirId}/verify-invoice`, 'POST', { invoiceReference, forceRefresh: forceRefresh || false }); return result; } catch (error: any) { console.error('Erreur API vérification avoir:', error); throw new Error(error.message || 'Erreur de vérification'); } }, onSuccess: (result, variables) => { setAvoirVerificationResults(prev => ({ ...prev, [variables.avoirId]: result })); // Auto-remplissage sécurisé si facture trouvée et montant disponible if (result?.exists === true && result?.invoiceAmount !== undefined && result?.invoiceAmount !== null) { const avoir = avoirs?.find(a => a?.id === variables.avoirId); if (avoir && avoir.supplierId && avoir.groupId) { try { console.log('🔄 Auto-remplissage montant:', { avoirId: variables.avoirId, amount: result.invoiceAmount }); editAvoirMutation.mutate({ id: variables.avoirId, data: { supplierId: avoir.supplierId, groupId: avoir.groupId, invoiceReference: avoir.invoiceReference || "", amount: result.invoiceAmount, comment: avoir.comment || "", commercialProcessed: avoir.commercialProcessed || false, status: avoir.status as "En attente de demande" | "Demandé" | "Reçu", } }); } catch (autoFillError) { console.error('❌ Erreur auto-remplissage:', autoFillError); } } } setVerifyingAvoirs(prev => { const newSet = new Set(prev); newSet.delete(variables.avoirId); return newSet; }); }, onError: (error, variables) => { console.error('Erreur vérification facture avoir:', error); setAvoirVerificationResults(prev => ({ ...prev, [variables.avoirId]: { exists: false, matchType: 'none', errorMessage: error instanceof Error ? error.message : 'Erreur inconnue' } })); setVerifyingAvoirs(prev => { const newSet = new Set(prev); newSet.delete(variables.avoirId); 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 d'un avoir const handleVerifyAvoirInvoice = (avoir: Avoir, forceRefresh: boolean = false) => { const hasInvoiceRef = avoir.invoiceReference?.trim(); if (!hasInvoiceRef) { toast({ title: "Référence manquante", description: "Veuillez saisir une référence de facture avant la vérification", variant: "destructive", }); return; } if (!avoir.group?.nocodbTableName && !avoir.group?.nocodbConfigId) { toast({ title: "Vérification non disponible", description: "Ce magasin n'a pas de configuration NocoDB", variant: "destructive", }); return; } console.log('🔍 Déclenchement vérification avoir:', { avoirId: avoir.id, invoiceReference: avoir.invoiceReference, supplier: avoir.supplier?.name, group: avoir.group?.name }); setVerifyingAvoirs(prev => new Set(prev).add(avoir.id)); verifyAvoirInvoiceMutation.mutate({ avoirId: avoir.id, invoiceReference: avoir.invoiceReference, forceRefresh }); }; // Fonction pour vérifier tous les avoirs avec une référence facture const handleVerifyAllAvoirInvoices = () => { const avoirsToVerify = avoirs.filter(avoir => avoir.invoiceReference?.trim() && (avoir.group?.nocodbTableName || avoir.group?.nocodbConfigId) ); if (avoirsToVerify.length === 0) { toast({ title: "Aucun avoir à vérifier", description: "Aucun avoir avec référence de facture trouvé", }); return; } avoirsToVerify.forEach((avoir, index) => { // Délai échelonné pour éviter la surcharge setTimeout(() => { handleVerifyAvoirInvoice(avoir, true); // Force refresh pour tous }, index * 200); // 200ms entre chaque vérification }); toast({ title: "Vérification lancée", description: `Vérification de ${avoirsToVerify.length} avoir(s) en cours...`, }); }; // ✅ SUPPRIMÉ : Le chargement automatique causait des crashes JavaScript // Les vérifications se font maintenant uniquement à la demande utilisateur // 🔥 FONCTIONS WEBHOOK MODAL (comme rapprochement) const handleFileChange = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file && file.type === 'application/pdf') { setSelectedFile(file); } else { toast({ title: "Erreur", description: "Veuillez sélectionner un fichier PDF", variant: "destructive", }); } }; const handleOpenAvoirModal = (avoir: Avoir) => { setSelectedAvoirForUpload(avoir); setSelectedFile(null); setShowAvoirModal(true); }; const handleCloseAvoirModal = () => { setShowAvoirModal(false); setSelectedAvoirForUpload(null); setSelectedFile(null); setIsUploading(false); }; const startProcessingTimer = () => { setProcessingSeconds(0); const interval = setInterval(() => { setProcessingSeconds(prev => prev + 1); }, 1000); setProcessingTimeout(interval); }; const handleCloseWaitingModal = () => { setShowWaitingModal(false); setIsUploading(false); setProcessingSeconds(0); if (processingTimeout) { clearInterval(processingTimeout); setProcessingTimeout(null); } }; const handleSendAvoir = async () => { if (!selectedFile || !selectedAvoirForUpload) { toast({ title: "Erreur", description: "Veuillez sélectionner un fichier PDF", variant: "destructive", }); return; } // Utiliser groupe par défaut (1) pour admin si pas de groupe sélectionné const groupId = selectedAvoirForUpload.groupId || ((user as any)?.role === 'admin' ? 1 : selectedAvoirForUpload.groupId); const groups = await queryClient.fetchQuery({ queryKey: ['/api/groups'] }); const group = (groups as any[]).find(g => g.id === groupId); if (!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 setShowAvoirModal(false); setShowWaitingModal(true); setIsUploading(true); startProcessingTimer(); try { const formData = new FormData(); formData.append('file', selectedFile); formData.append('supplier', selectedAvoirForUpload.supplier?.name || ''); formData.append('invoiceReference', selectedAvoirForUpload.invoiceReference || ''); formData.append('type', 'Avoir'); // 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(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: "Avoir traité avec succès via le webhook", }); // Vérification automatique de la facture après envoi réussi if (selectedAvoirForUpload.invoiceReference?.trim()) { setTimeout(() => { handleVerifyAvoirInvoice(selectedAvoirForUpload, true); }, 1000); // Délai de 1 seconde pour laisser le temps aux données de se synchroniser } // Reset des données setSelectedAvoirForUpload(null); setSelectedFile(null); // Invalider les caches pour recharger queryClient.invalidateQueries({ predicate: (query) => { return query.queryKey[0]?.toString().includes('/api/avoirs') || false; } }); } catch (error: any) { handleCloseWaitingModal(); let errorMessage = "Impossible d'envoyer l'avoir"; if (error.name === 'AbortError') { errorMessage = "Le traitement a pris trop de temps (timeout de 60 secondes)"; } else if (error.message) { errorMessage = error.message; } toast({ title: "Erreur d'envoi", description: errorMessage, variant: "destructive", }); // Rouvrir le modal de sélection en cas d'erreur setShowAvoirModal(true); } }; // Confirm delete const confirmDelete = () => { if (selectedAvoir) { deleteAvoirMutation.mutate(selectedAvoir.id); } }; // Filter avoirs based on search term const filteredAvoirs = avoirs.filter(avoir => avoir.invoiceReference?.toLowerCase().includes(searchTerm.toLowerCase()) || avoir.supplier?.name?.toLowerCase().includes(searchTerm.toLowerCase()) || avoir.comment?.toLowerCase().includes(searchTerm.toLowerCase()) || (avoir.creator.firstName && avoir.creator.lastName ? `${avoir.creator.firstName} ${avoir.creator.lastName}`.toLowerCase().includes(searchTerm.toLowerCase()) : avoir.creator.username.toLowerCase().includes(searchTerm.toLowerCase()) ) ); // Status badge variant const getStatusVariant = (status: string) => { switch (status) { case 'Reçu': return 'default'; case 'Demandé': return 'secondary'; case 'En attente de demande': return 'outline'; default: return 'outline'; } }; // Status icon const getStatusIcon = (status: string) => { switch (status) { case 'Reçu': return ; case 'Demandé': return ; case 'En attente de demande': return ; default: return ; } }; if (!user) { return
Chargement...
; } return (

Avoirs

Gestion des demandes d'avoirs et de remboursements

{/* Bouton Vérifier tous si des avoirs avec référence existent */} {avoirs.some(avoir => avoir.invoiceReference?.trim()) && ( )}
Créer un nouvel avoir Remplissez les informations pour créer une nouvelle demande d'avoir.
( Fournisseur )} /> ( Magasin )} /> ( Référence facture (optionnel) )} /> ( Montant (€) (optionnel) field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)} /> )} /> ( Commentaire