import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useToast } from "@/hooks/use-toast"; import { apiRequest } from "@/lib/queryClient"; import { isUnauthorizedError } from "@/lib/authUtils"; import { Plus, Search, Users, Edit, Trash2, Package, Truck, Palette } from "lucide-react"; import type { EntityActivityStats, Group, NocodbConfig } from "@shared/schema"; const colorOptions = [ { value: '#1976D2', label: 'Bleu' }, { value: '#388E3C', label: 'Vert' }, { value: '#FF6F00', label: 'Orange' }, { value: '#D32F2F', label: 'Rouge' }, { value: '#7B1FA2', label: 'Violet' }, { value: '#F57C00', label: 'Ambre' }, { value: '#5D4037', label: 'Marron' }, { value: '#455A64', label: 'Bleu gris' }, { value: '#E91E63', label: 'Rose' }, { value: '#00796B', label: 'Sarcelle' }, ]; // Taille maximale du logo magasin : il est encodé en base64 dans la fiche // et joint à chaque mail, un fichier lourd alourdirait tous les envois. const MAX_LOGO_KB = 200; // Valeurs par défaut du formulaire magasin (création et réinitialisations) const EMPTY_GROUP_FORM = { name: "", color: "#1976D2", nocodbConfigId: "", nocodbTableName: "", invoiceColumnName: "", nocodbBlColumnName: "", nocodbAmountColumnName: "", nocodbInvoiceAmountTTCColumnName: "", nocodbSupplierColumnName: "", nocodbDueDateColumnName: "", webhookUrl: "", // Coordonnées reprises dans la signature des mails fournisseurs address: "", phone: "", logo: "", // Configuration SMTP propre au magasin smtpEnabled: false, smtpHost: "", smtpPort: "", smtpSecure: false, smtpUser: "", smtpPassword: "", smtpSenderEmail: "", smtpSenderName: "", }; export default function Groups() { const { user } = useAuthUnified(); const { toast } = useToast(); const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); const [showCreateModal, setShowCreateModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [selectedGroup, setSelectedGroup] = useState(null); const [formData, setFormData] = useState({ ...EMPTY_GROUP_FORM }); const { data: groups = [], isLoading } = useQuery({ queryKey: ['/api/groups'], }); const { data: rawNocodbConfigs = [] } = useQuery({ queryKey: ['/api/nocodb-config'], }); // Protection renforcée contre les erreurs TypeError const nocodbConfigs = Array.isArray(rawNocodbConfigs) ? rawNocodbConfigs : []; // Comptages agrégés en base : la page n'affiche que des totaux, inutile de // rapatrier l'historique complet des commandes et des livraisons. const { data: groupStats = [] } = useQuery({ queryKey: ['/api/stats/by-group'], }); const createMutation = useMutation({ mutationFn: async (data: any) => { console.log('🏪 Frontend: Creating group with data:', data); const result = await apiRequest("/api/groups", "POST", data); console.log('🏪 Frontend: Group creation result:', result); return result; }, onSuccess: () => { toast({ title: "Succès", description: "Groupe créé avec succès", }); queryClient.invalidateQueries({ queryKey: ['/api/groups'] }); setShowCreateModal(false); setFormData({ ...EMPTY_GROUP_FORM }); }, onError: (error) => { if (isUnauthorizedError(error)) { toast({ title: "Non autorisé", description: "Vous êtes déconnecté. Reconnexion...", variant: "destructive", }); setTimeout(() => { window.location.href = "/api/login"; }, 500); return; } toast({ title: "Erreur", description: "Impossible de créer le groupe", variant: "destructive", }); }, }); const updateMutation = useMutation({ mutationFn: async (data: any) => { await apiRequest(`/api/groups/${selectedGroup?.id}`, "PUT", data); }, onSuccess: () => { toast({ title: "Succès", description: "Groupe modifié avec succès", }); queryClient.invalidateQueries({ queryKey: ['/api/groups'] }); setShowEditModal(false); setSelectedGroup(null); setFormData({ ...EMPTY_GROUP_FORM }); }, onError: (error) => { if (isUnauthorizedError(error)) { toast({ title: "Non autorisé", description: "Vous êtes déconnecté. Reconnexion...", variant: "destructive", }); setTimeout(() => { window.location.href = "/api/login"; }, 500); return; } toast({ title: "Erreur", description: "Impossible de modifier le groupe", variant: "destructive", }); }, }); // Test de la connexion SMTP du magasin (aucun message envoyé) const testSmtpMutation = useMutation({ mutationFn: async (groupId: number) => { return await apiRequest(`/api/groups/${groupId}/test-smtp`, 'POST'); }, onSuccess: (result: any) => { toast({ title: "Connexion réussie", description: result?.message || "Le serveur SMTP répond correctement", }); }, onError: (error: any) => { toast({ title: "Échec de la connexion", description: error?.message || "Impossible de joindre le serveur SMTP", variant: "destructive", }); }, }); // Lecture du logo en data URI : stocké tel quel sur la fiche magasin const handleLogoChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (file.size > MAX_LOGO_KB * 1024) { toast({ title: "Logo trop volumineux", description: `Le fichier fait ${Math.round(file.size / 1024)} Ko. Maximum autorisé : ${MAX_LOGO_KB} Ko.`, variant: "destructive", }); e.target.value = ''; return; } const reader = new FileReader(); reader.onload = () => handleChange('logo', String(reader.result || '')); reader.onerror = () => { toast({ title: "Lecture impossible", description: "Le fichier n'a pas pu être lu", variant: "destructive", }); }; reader.readAsDataURL(file); }; const deleteMutation = useMutation({ mutationFn: async (id: number) => { await apiRequest(`/api/groups/${id}`, "DELETE"); }, onSuccess: () => { toast({ title: "Succès", description: "Groupe supprimé avec succès", }); queryClient.invalidateQueries({ queryKey: ['/api/groups'] }); }, onError: (error) => { if (isUnauthorizedError(error)) { toast({ title: "Non autorisé", description: "Vous êtes déconnecté. Reconnexion...", variant: "destructive", }); setTimeout(() => { window.location.href = "/api/login"; }, 500); return; } toast({ title: "Erreur", description: "Impossible de supprimer le groupe", variant: "destructive", }); }, }); const filteredGroups = Array.isArray(groups) ? groups.filter(group => group.name.toLowerCase().includes(searchTerm.toLowerCase()) ) : []; const getGroupStats = (groupId: number) => { const stats = Array.isArray(groupStats) ? groupStats.find(s => s.id === groupId) : undefined; return { orders: stats?.orders ?? 0, deliveries: stats?.deliveries ?? 0, delivered: stats?.delivered ?? 0, }; }; const handleCreate = () => { setFormData({ ...EMPTY_GROUP_FORM }); setShowCreateModal(true); }; const handleEdit = (group: Group) => { setSelectedGroup(group); setFormData({ name: group.name, color: group.color || "#1976D2", nocodbConfigId: group.nocodbConfigId?.toString() || "", nocodbTableName: group.nocodbTableName || "", invoiceColumnName: group.invoiceColumnName || "", nocodbBlColumnName: group.nocodbBlColumnName || "", nocodbAmountColumnName: group.nocodbAmountColumnName || "", nocodbInvoiceAmountTTCColumnName: group.nocodbInvoiceAmountTTCColumnName || "", nocodbSupplierColumnName: group.nocodbSupplierColumnName || "", nocodbDueDateColumnName: group.nocodbDueDateColumnName || "", webhookUrl: group.webhookUrl || "", address: (group as any).address || "", phone: (group as any).phone || "", logo: (group as any).logo || "", smtpEnabled: Boolean((group as any).smtpEnabled), smtpHost: (group as any).smtpHost || "", smtpPort: (group as any).smtpPort?.toString() || "", smtpSecure: Boolean((group as any).smtpSecure), smtpUser: (group as any).smtpUser || "", // Jamais renvoyé par l'API : vide = mot de passe inchangé smtpPassword: "", smtpSenderEmail: (group as any).smtpSenderEmail || "", smtpSenderName: (group as any).smtpSenderName || "", }); setShowEditModal(true); }; const handleDelete = (group: Group) => { const stats = getGroupStats(group.id); if (stats.orders > 0 || stats.deliveries > 0) { toast({ title: "Suppression impossible", description: "Ce groupe a des commandes ou livraisons associées", variant: "destructive", }); return; } if (window.confirm(`Êtes-vous sûr de vouloir supprimer le groupe "${group.name}" ?`)) { deleteMutation.mutate(group.id); } }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!formData.name.trim()) { toast({ title: "Erreur", description: "Le nom du groupe est requis", variant: "destructive", }); return; } // Prepare data with proper type conversion const submitData: any = { ...formData, nocodbConfigId: formData.nocodbConfigId ? parseInt(formData.nocodbConfigId) : null, smtpPort: formData.smtpPort ? parseInt(formData.smtpPort) : null, }; // Mot de passe laissé vide = on conserve celui déjà enregistré if (!formData.smtpPassword) { delete submitData.smtpPassword; } if (selectedGroup) { updateMutation.mutate(submitData); } else { createMutation.mutate(submitData); } }; const handleChange = (field: string, value: string | boolean) => { setFormData(prev => ({ ...prev, [field]: value })); }; const canManage = user?.role === 'admin' || user?.role === 'manager'; return (
{/* Header */}

Gestion des Groupes/Magasins

{filteredGroups.length} groupe{filteredGroups.length !== 1 ? 's' : ''}

{canManage && ( )}
{/* Search */}
setSearchTerm(e.target.value)} className="pl-10 border border-gray-300 shadow-sm" />
{/* Groups List */}
{isLoading ? (
) : filteredGroups.length === 0 ? (

{searchTerm ? "Aucun groupe trouvé" : "Aucun groupe"}

{searchTerm ? "Aucun groupe ne correspond à votre recherche." : "Vous n'avez pas encore de groupes. Créez votre premier groupe pour commencer."}

{!searchTerm && canManage && ( )}
) : (
{filteredGroups.map((group) => { const stats = getGroupStats(group.id); return (

{group.name}

#{group.id}

{canManage && (
)}
{group.color}
{stats.orders}

Commandes

{stats.deliveries}

Livraisons

{stats.deliveries > 0 && (

{stats.delivered} livrées ({Math.round((stats.delivered / stats.deliveries) * 100)}%)

)}
); })}
)}
{/* Create/Edit Modal */} { setShowCreateModal(false); setShowEditModal(false); setSelectedGroup(null); setFormData({ ...EMPTY_GROUP_FORM, invoiceColumnName: "Ref Facture" }); }}> {selectedGroup ? 'Modifier' : 'Nouveau'} Groupe

{selectedGroup ? 'Modifier les informations du groupe' : 'Créer un nouveau groupe/magasin'}

{/* Colonne gauche : identité + configuration NocoDB */}
handleChange('name', e.target.value)} placeholder="Nom du groupe/magasin" required />
handleChange('color', e.target.value)} className="w-20 h-8" /> {formData.color}
{colorOptions.map(option => (
{/* Section Configuration NocoDB */}

Configuration NocoDB (Optionnel)

Configurez la vérification automatique des références de facture dans une table NocoDB.

{formData.nocodbConfigId && ( <>
handleChange('nocodbTableName', e.target.value)} placeholder="ex: Factures_Paris_2025" required />

Nom exact de la table dans votre base NocoDB

Mapping des colonnes

Configurez les noms exacts des colonnes dans votre table NocoDB

handleChange('invoiceColumnName', e.target.value)} placeholder="ex: Ref_Facture" required />
handleChange('nocodbBlColumnName', e.target.value)} placeholder="ex: Num_BL" />
handleChange('nocodbAmountColumnName', e.target.value)} placeholder="ex: Montant_HT" />
handleChange('nocodbInvoiceAmountTTCColumnName', e.target.value)} placeholder="ex: Montant_TTC" />
handleChange('nocodbSupplierColumnName', e.target.value)} placeholder="ex: Fournisseur" />
handleChange('nocodbDueDateColumnName', e.target.value)} placeholder="ex: Date_Echeance" />

Notifications

handleChange('webhookUrl', e.target.value)} placeholder="https://votre-site.com/webhook" type="url" />

URL pour recevoir les notifications d'événements

{(formData.nocodbTableName && formData.invoiceColumnName) && (

Vérifie que la table et les colonnes existent

)} )}
{/* Colonne droite : coordonnées + envoi de mails */}
{/* Section Coordonnées du magasin (signature des mails) */}

Coordonnées du magasin

Reprises dans la signature des mails envoyés aux fournisseurs.