Files
LogiFlow/client/src/pages/Groups.tsx
T
Claude d09821ddc0 fix(magasins): two-column store form so the modal fits the screen
The stacked form outgrew the viewport even with internal scrolling. The
dialog widens to max-w-4xl and the form becomes a two-column grid on
desktop: identity and NocoDB configuration on the left, store contact
details and SMTP settings on the right — the SMTP section is now visible
without scrolling. Single column is preserved on small screens, and the
90vh cap with internal scroll stays as a safety net.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
2026-08-15 20:57:32 +00:00

958 lines
37 KiB
TypeScript

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<Group | null>(null);
const [formData, setFormData] = useState({ ...EMPTY_GROUP_FORM });
const { data: groups = [], isLoading } = useQuery<Group[]>({
queryKey: ['/api/groups'],
});
const { data: rawNocodbConfigs = [] } = useQuery<NocodbConfig[]>({
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<EntityActivityStats[]>({
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<HTMLInputElement>) => {
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 (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="bg-white border-b border-gray-200 p-6 shadow-sm">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
<Users className="w-6 h-6 mr-3 text-blue-600" />
Gestion des Groupes/Magasins
</h2>
<p className="text-gray-600 mt-1">
{filteredGroups.length} groupe{filteredGroups.length !== 1 ? 's' : ''}
</p>
</div>
{canManage && (
<Button
onClick={handleCreate}
className="bg-blue-600 hover:bg-blue-700 text-white shadow-md"
>
<Plus className="w-4 h-4 mr-2" />
Nouveau Groupe
</Button>
)}
</div>
</div>
{/* Search */}
<div className="bg-gray-50 border-b border-gray-200 p-4">
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Rechercher un groupe..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 border border-gray-300 shadow-sm"
/>
</div>
</div>
{/* Groups List */}
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
) : filteredGroups.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
<Users className="w-16 h-16 mb-4 text-gray-300" />
<h3 className="text-lg font-medium mb-2">
{searchTerm ? "Aucun groupe trouvé" : "Aucun groupe"}
</h3>
<p className="text-center max-w-md">
{searchTerm
? "Aucun groupe ne correspond à votre recherche."
: "Vous n'avez pas encore de groupes. Créez votre premier groupe pour commencer."}
</p>
{!searchTerm && canManage && (
<Button
onClick={handleCreate}
className="mt-4 bg-primary hover:bg-blue-700 text-white"
>
<Plus className="w-4 h-4 mr-2" />
Créer un groupe
</Button>
)}
</div>
) : (
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredGroups.map((group) => {
const stats = getGroupStats(group.id);
return (
<div key={group.id} className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<div
className="w-10 h-10 flex items-center justify-center mr-3"
style={{ backgroundColor: group.color || undefined }}
>
<Users className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="font-medium text-gray-900">{group.name}</h3>
<p className="text-sm text-gray-500">#{group.id}</p>
</div>
</div>
{canManage && (
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(group)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(group)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
<div className="flex items-center mb-4">
<Palette className="w-4 h-4 mr-2 text-gray-500" />
<div className="flex items-center">
<div
className="w-4 h-4 rounded mr-2"
style={{ backgroundColor: group.color || undefined }}
/>
<span className="text-sm text-gray-600">{group.color}</span>
</div>
</div>
<div className="border-t pt-4">
<div className="grid grid-cols-2 gap-4">
<div className="text-center">
<div className="flex items-center justify-center text-primary mb-1">
<Package className="w-4 h-4 mr-1" />
<span className="font-semibold">{stats.orders}</span>
</div>
<p className="text-xs text-gray-500">Commandes</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center text-secondary mb-1">
<Truck className="w-4 h-4 mr-1" />
<span className="font-semibold">{stats.deliveries}</span>
</div>
<p className="text-xs text-gray-500">Livraisons</p>
</div>
</div>
{stats.deliveries > 0 && (
<div className="mt-2 text-center">
<p className="text-xs text-gray-500">
{stats.delivered} livrées ({Math.round((stats.delivered / stats.deliveries) * 100)}%)
</p>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Create/Edit Modal */}
<Dialog open={showCreateModal || showEditModal} onOpenChange={() => {
setShowCreateModal(false);
setShowEditModal(false);
setSelectedGroup(null);
setFormData({ ...EMPTY_GROUP_FORM, invoiceColumnName: "Ref Facture" });
}}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto" aria-describedby="group-modal-description">
<DialogHeader>
<DialogTitle>
{selectedGroup ? 'Modifier' : 'Nouveau'} Groupe
</DialogTitle>
<p id="group-modal-description" className="text-sm text-gray-600 mt-1">
{selectedGroup ? 'Modifier les informations du groupe' : 'Créer un nouveau groupe/magasin'}
</p>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 items-start">
{/* Colonne gauche : identité + configuration NocoDB */}
<div className="space-y-4">
<div>
<Label htmlFor="name">Nom du groupe *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Nom du groupe/magasin"
required
/>
</div>
<div>
<Label htmlFor="color">Couleur</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<div
className="w-6 h-6 rounded border"
style={{ backgroundColor: formData.color }}
/>
<Input
id="color"
type="color"
value={formData.color}
onChange={(e) => handleChange('color', e.target.value)}
className="w-20 h-8"
/>
<span className="text-sm text-gray-500">{formData.color}</span>
</div>
<div className="grid grid-cols-5 gap-2">
{colorOptions.map(option => (
<button
key={option.value}
type="button"
className={`w-8 h-8 rounded border-2 ${
formData.color === option.value ? 'border-gray-400' : 'border-gray-200'
}`}
style={{ backgroundColor: option.value }}
onClick={() => handleChange('color', option.value)}
title={option.label}
/>
))}
</div>
</div>
</div>
{/* Section Configuration NocoDB */}
<div className="border-t pt-4 space-y-4">
<div className="flex items-center space-x-2 mb-3">
<div className="w-4 h-4 bg-blue-500 rounded"></div>
<h3 className="font-medium text-gray-900">Configuration NocoDB (Optionnel)</h3>
</div>
<p className="text-sm text-gray-600 mb-4">
Configurez la vérification automatique des références de facture dans une table NocoDB.
</p>
<div>
<Label htmlFor="nocodbConfig">Configuration NocoDB</Label>
<select
id="nocodbConfig"
value={formData.nocodbConfigId}
onChange={(e) => handleChange('nocodbConfigId', e.target.value)}
className="w-full mt-1 p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Aucune configuration</option>
{(nocodbConfigs || []).map(config => (
<option key={config.id} value={config.id.toString()}>
{config.name} - {config.baseUrl}
</option>
))}
</select>
</div>
{formData.nocodbConfigId && (
<>
<div>
<Label htmlFor="nocodbTableName">Nom de la table NocoDB *</Label>
<Input
id="nocodbTableName"
value={formData.nocodbTableName}
onChange={(e) => handleChange('nocodbTableName', e.target.value)}
placeholder="ex: Factures_Paris_2025"
required
/>
<p className="text-xs text-gray-500 mt-1">
Nom exact de la table dans votre base NocoDB
</p>
</div>
<div className="border-t pt-4">
<h4 className="font-medium text-gray-900 mb-3">Mapping des colonnes</h4>
<p className="text-sm text-gray-600 mb-4">
Configurez les noms exacts des colonnes dans votre table NocoDB
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="invoiceColumnName">Colonne Facture *</Label>
<Input
id="invoiceColumnName"
value={formData.invoiceColumnName}
onChange={(e) => handleChange('invoiceColumnName', e.target.value)}
placeholder="ex: Ref_Facture"
required
/>
</div>
<div>
<Label htmlFor="nocodbBlColumnName">Colonne BL</Label>
<Input
id="nocodbBlColumnName"
value={formData.nocodbBlColumnName}
onChange={(e) => handleChange('nocodbBlColumnName', e.target.value)}
placeholder="ex: Num_BL"
/>
</div>
<div>
<Label htmlFor="nocodbAmountColumnName">Colonne Montant HT</Label>
<Input
id="nocodbAmountColumnName"
value={formData.nocodbAmountColumnName}
onChange={(e) => handleChange('nocodbAmountColumnName', e.target.value)}
placeholder="ex: Montant_HT"
/>
</div>
<div>
<Label htmlFor="nocodbInvoiceAmountTTCColumnName">Colonne Montant TTC</Label>
<Input
id="nocodbInvoiceAmountTTCColumnName"
value={formData.nocodbInvoiceAmountTTCColumnName}
onChange={(e) => handleChange('nocodbInvoiceAmountTTCColumnName', e.target.value)}
placeholder="ex: Montant_TTC"
/>
</div>
<div>
<Label htmlFor="nocodbSupplierColumnName">Colonne Fournisseur</Label>
<Input
id="nocodbSupplierColumnName"
value={formData.nocodbSupplierColumnName}
onChange={(e) => handleChange('nocodbSupplierColumnName', e.target.value)}
placeholder="ex: Fournisseur"
/>
</div>
<div>
<Label htmlFor="nocodbDueDateColumnName">Colonne Date d'Échéance</Label>
<Input
id="nocodbDueDateColumnName"
value={formData.nocodbDueDateColumnName}
onChange={(e) => handleChange('nocodbDueDateColumnName', e.target.value)}
placeholder="ex: Date_Echeance"
/>
</div>
</div>
</div>
<div className="border-t pt-4">
<h4 className="font-medium text-gray-900 mb-3">Notifications</h4>
<div>
<Label htmlFor="webhookUrl">URL Webhook (optionnel)</Label>
<Input
id="webhookUrl"
value={formData.webhookUrl}
onChange={(e) => handleChange('webhookUrl', e.target.value)}
placeholder="https://votre-site.com/webhook"
type="url"
/>
<p className="text-xs text-gray-500 mt-1">
URL pour recevoir les notifications d'événements
</p>
</div>
</div>
{(formData.nocodbTableName && formData.invoiceColumnName) && (
<div className="border-t pt-4">
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
// TODO: Implémenter le test de configuration
toast({
title: "Test de configuration",
description: "Fonctionnalité à implémenter",
});
}}
>
🔍 Tester la configuration NocoDB
</Button>
<p className="text-xs text-gray-500 mt-1 text-center">
Vérifie que la table et les colonnes existent
</p>
</div>
)}
</>
)}
</div>
</div>
{/* Colonne droite : coordonnées + envoi de mails */}
<div className="space-y-4">
{/* Section Coordonnées du magasin (signature des mails) */}
<div className="border-t md:border-t-0 pt-4 md:pt-0 space-y-4">
<div className="flex items-center space-x-2 mb-1">
<div className="w-4 h-4 bg-green-500 rounded"></div>
<h3 className="font-medium text-gray-900">Coordonnées du magasin</h3>
</div>
<p className="text-sm text-gray-600">
Reprises dans la signature des mails envoyés aux fournisseurs.
</p>
<div>
<Label htmlFor="address">Adresse</Label>
<Textarea
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
placeholder={"12 rue des Fleurs\n54000 Nancy"}
rows={2}
/>
</div>
<div>
<Label htmlFor="phone">Téléphone</Label>
<Input
id="phone"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
placeholder="03 83 00 00 00"
/>
</div>
<div>
<Label htmlFor="logo">Logo</Label>
<div className="flex items-center space-x-3 mt-1">
{formData.logo ? (
<img
src={formData.logo}
alt="Logo du magasin"
className="h-12 w-auto max-w-[120px] object-contain border rounded bg-white p-1"
/>
) : (
<div className="h-12 w-[120px] border border-dashed rounded flex items-center justify-center text-xs text-gray-400">
Aucun logo
</div>
)}
<div className="space-y-1">
<Input
id="logo"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleLogoChange}
className="text-xs"
/>
{formData.logo && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs text-red-600 hover:text-red-700"
onClick={() => handleChange('logo', '')}
>
Retirer le logo
</Button>
)}
</div>
</div>
<p className="text-xs text-gray-500 mt-1">
PNG ou JPEG, {MAX_LOGO_KB} Ko maximum. Affiché dans la signature des mails.
</p>
</div>
</div>
{/* Section Configuration SMTP du magasin */}
<div className="border-t pt-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-4 h-4 bg-purple-500 rounded"></div>
<h3 className="font-medium text-gray-900">Envoi de mails (SMTP)</h3>
</div>
<label className="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={formData.smtpEnabled}
onChange={(e) => handleChange('smtpEnabled', e.target.checked)}
className="rounded border-gray-300"
/>
<span>Activé</span>
</label>
</div>
<p className="text-sm text-gray-600">
Serveur utilisé pour envoyer les demandes de facture aux fournisseurs
depuis le rapprochement. Chaque magasin utilise sa propre boîte mail.
</p>
{formData.smtpEnabled && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Label htmlFor="smtpHost">Serveur SMTP *</Label>
<Input
id="smtpHost"
value={formData.smtpHost}
onChange={(e) => handleChange('smtpHost', e.target.value)}
placeholder="smtp.office365.com"
/>
</div>
<div>
<Label htmlFor="smtpPort">Port *</Label>
<Input
id="smtpPort"
type="number"
value={formData.smtpPort}
onChange={(e) => handleChange('smtpPort', e.target.value)}
placeholder="587"
/>
</div>
</div>
<label className="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={formData.smtpSecure}
onChange={(e) => handleChange('smtpSecure', e.target.checked)}
className="rounded border-gray-300"
/>
<span>Connexion SSL/TLS directe (port 465). Décoché = STARTTLS (port 587).</span>
</label>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="smtpUser">Identifiant</Label>
<Input
id="smtpUser"
value={formData.smtpUser}
onChange={(e) => handleChange('smtpUser', e.target.value)}
placeholder="magasin@lafoirfouille.fr"
autoComplete="off"
/>
</div>
<div>
<Label htmlFor="smtpPassword">Mot de passe</Label>
<Input
id="smtpPassword"
type="password"
value={formData.smtpPassword}
onChange={(e) => handleChange('smtpPassword', e.target.value)}
placeholder={
selectedGroup && (selectedGroup as any).smtpPasswordSet
? "Inchangé"
: "Mot de passe"
}
autoComplete="new-password"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="smtpSenderEmail">Adresse expéditeur *</Label>
<Input
id="smtpSenderEmail"
type="email"
value={formData.smtpSenderEmail}
onChange={(e) => handleChange('smtpSenderEmail', e.target.value)}
placeholder="magasin@lafoirfouille.fr"
/>
</div>
<div>
<Label htmlFor="smtpSenderName">Nom affiché</Label>
<Input
id="smtpSenderName"
value={formData.smtpSenderName}
onChange={(e) => handleChange('smtpSenderName', e.target.value)}
placeholder={formData.name || "LaFoir'Fouille Nancy"}
/>
</div>
</div>
{selectedGroup && (
<div>
<Button
type="button"
variant="outline"
className="w-full"
disabled={testSmtpMutation.isPending}
onClick={() => testSmtpMutation.mutate(selectedGroup.id)}
>
{testSmtpMutation.isPending
? "Test en cours..."
: "✉️ Tester la connexion SMTP"}
</Button>
<p className="text-xs text-gray-500 mt-1 text-center">
Vérifie le serveur et les identifiants enregistrés, sans envoyer de message
</p>
</div>
)}
</>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-3 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => {
setShowCreateModal(false);
setShowEditModal(false);
setSelectedGroup(null);
setFormData({ ...EMPTY_GROUP_FORM });
}}
>
Annuler
</Button>
<Button
type="submit"
disabled={createMutation.isPending || updateMutation.isPending}
className="bg-primary hover:bg-blue-700"
>
{createMutation.isPending || updateMutation.isPending
? "Enregistrement..."
: (selectedGroup ? "Modifier" : "Créer")}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
);
}