feat(mails): send supplier document requests over each store's own SMTP

Replaces the mailto: link with a server-side send, so the message carries the
store's signature and logo instead of depending on each workstation's Outlook.

Store record (groups):
- address, phone and logo (data URI, 200 KB cap) feed the mail signature
- per-store SMTP settings: host, port, SSL/STARTTLS, credentials, sender
  address and display name, with an enable switch
- "test connection" button verifies the server without sending anything
- the empty-form literal, previously repeated five times, becomes one constant

Sending:
- nodemailer transport built per store from its own settings
- multipart mail: plain-text alternative plus HTML whose signature embeds the
  logo as an inline CID attachment, which Outlook renders without the remote
  image blocking that a data: URI would hit
- delivery details are HTML-escaped
- Reply-To set to the store address; the row shows a spinner and refuses a
  second click while a send is in flight

Credentials:
- the SMTP password is never returned to the client; a response-layer
  sanitizer strips it from every /api payload and replaces it with a
  smtpPasswordSet flag, covering the ten-plus queries that join full group
  rows into deliveries, orders and user relations
- an empty password field on save keeps the stored one rather than clearing it

Verified end-to-end against a local SMTP server: transport, auth, From/Reply-To,
multipart structure and the inline logo attachment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
This commit is contained in:
Claude committed 2026-08-14 15:25:00 +00:00
1 parent 02645c34a5
commit 012024a293
14 files changed
+994 -209

No files matched your search

-123
View File
@@ -1,123 +0,0 @@
import { safeFormat } from "@/lib/dateUtils";
/**
* Génération des emails de relance fournisseur (rapprochement BL/Factures).
* Le lien mailto ouvre le client de messagerie par défaut du poste (Outlook)
* avec le destinataire, l'objet et le corps du message déjà préremplis.
*/
export interface SupplierMailDelivery {
blNumber?: string | null;
blAmount?: string | number | null;
invoiceReference?: string | null;
scheduledDate?: string | Date | null;
deliveredDate?: string | Date | null;
supplier?: {
name?: string | null;
email?: string | null;
contact?: string | null;
} | null;
group?: { name?: string | null } | null;
}
/**
* Objet du mail : identifie la livraison concernée.
*/
export function buildSupplierMailSubject(delivery: SupplierMailDelivery): string {
const deliveryDate = delivery.deliveredDate || delivery.scheduledDate;
const formattedDate = deliveryDate
? safeFormat(deliveryDate, "dd/MM/yyyy", { defaultValue: "" })
: "";
const reference = delivery.blNumber
? `BL n° ${delivery.blNumber}`
: formattedDate
? `Livraison du ${formattedDate}`
: "Livraison";
const parts = [
"Demande de facture (PDF) ou BL (Excel)",
delivery.blNumber && formattedDate ? `${reference} du ${formattedDate}` : reference,
];
if (delivery.group?.name) {
parts.push(delivery.group.name);
}
return parts.join(" - ");
}
/**
* Corps du mail : rappel des documents attendus + récapitulatif de la livraison.
* Seules les informations réellement renseignées sont listées.
*/
export function buildSupplierMailBody(delivery: SupplierMailDelivery): string {
const deliveryDate = delivery.deliveredDate || delivery.scheduledDate;
const formattedDate = deliveryDate
? safeFormat(deliveryDate, "dd/MM/yyyy", { defaultValue: "" })
: "";
const blAmount =
delivery.blAmount !== null && delivery.blAmount !== undefined && delivery.blAmount !== ""
? parseFloat(String(delivery.blAmount))
: null;
const details: string[] = [];
if (delivery.supplier?.name) details.push(`- Fournisseur : ${delivery.supplier.name}`);
if (delivery.group?.name) details.push(`- Magasin : ${delivery.group.name}`);
if (formattedDate) details.push(`- Date de livraison : ${formattedDate}`);
if (delivery.blNumber) details.push(`- N° de BL : ${delivery.blNumber}`);
if (blAmount !== null && !isNaN(blAmount)) {
details.push(`- Montant du BL : ${blAmount.toFixed(2)} €`);
}
if (delivery.invoiceReference) {
details.push(`- Référence facture : ${delivery.invoiceReference}`);
}
const lines = [
"Bonjour,",
"",
"Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :",
"",
"- la facture au format PDF, ou",
"- le bon de livraison au format Excel",
"",
"Livraison concernée :",
...(details.length > 0 ? details : ["- (informations de livraison à préciser)"]),
"",
"Vous en remerciant par avance.",
"",
// Le corps s'arrête ici : l'enseigne, le magasin et le logo sont apportés
// par la signature configurée dans Outlook sur le poste.
"Cordialement,",
];
return lines.join("\n");
}
/**
* Construit le lien mailto complet (destinataire + objet + corps préremplis).
*/
export function buildSupplierMailtoUrl(
delivery: SupplierMailDelivery,
supplierEmail: string
): string {
const subject = buildSupplierMailSubject(delivery);
// RFC 6068 : les sauts de ligne du corps doivent être encodés en CRLF (%0D%0A),
// sinon Outlook peut coller les lignes les unes aux autres.
const body = buildSupplierMailBody(delivery).replace(/\n/g, "\r\n");
return `mailto:${supplierEmail.trim()}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
}
/**
* Ouvre le client de messagerie par défaut (Outlook sur les postes du magasin).
*/
export function openMailClient(mailtoUrl: string): void {
const link = document.createElement("a");
link.href = mailtoUrl;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
+60 -13
View File
@@ -19,7 +19,6 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import ReconciliationComments from "@/components/ReconciliationComments";
import ReconciliationModal from "@/components/modals/ReconciliationModal";
import { buildSupplierMailtoUrl, openMailClient } from "@/lib/supplierMail";
export default function BLReconciliation() {
const { user } = useAuthUnified();
@@ -68,6 +67,8 @@ export default function BLReconciliation() {
// É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());
// Livraisons dont le mail fournisseur est en cours d'envoi
const [sendingMailDeliveries, setSendingMailDeliveries] = useState<Set<number>>(new Set());
// État pour le modal de commentaire
const [showCommentModal, setShowCommentModal] = useState(false);
@@ -676,8 +677,34 @@ export default function BLReconciliation() {
return supplier?.email ? String(supplier.email).trim() : '';
};
// Ouvre Outlook (client mail par défaut) avec un message prérempli
// demandant la facture au format PDF ou le BL au format Excel
// Envoi de la demande de facture (PDF) / BL (Excel) au fournisseur.
// Le mail part du serveur SMTP configuré sur la fiche du magasin.
const sendSupplierMailMutation = useMutation({
mutationFn: async (delivery: any) => {
return await apiRequest(`/api/deliveries/${delivery.id}/send-supplier-mail`, 'POST');
},
onSuccess: (result: any) => {
toast({
title: "Mail envoyé",
description: `Demande envoyée à ${result?.supplierName || 'le fournisseur'} (${result?.sentTo})`,
});
},
onError: (error: any) => {
toast({
title: "Envoi impossible",
description: error?.message || "Le mail n'a pas pu être envoyé",
variant: "destructive",
});
},
onSettled: (_result, _error, delivery: any) => {
setSendingMailDeliveries(prev => {
const next = new Set(prev);
next.delete(delivery.id);
return next;
});
},
});
const handleRequestDocumentsByEmail = (delivery: any) => {
const supplierEmail = getSupplierEmail(delivery);
@@ -690,8 +717,12 @@ export default function BLReconciliation() {
return;
}
const mailtoUrl = buildSupplierMailtoUrl(delivery, supplierEmail);
openMailClient(mailtoUrl);
// Envoi direct : on bloque la ligne le temps de la requête pour éviter
// qu'un double clic ne déclenche deux mails au même fournisseur.
if (sendingMailDeliveries.has(delivery.id)) return;
setSendingMailDeliveries(prev => new Set(prev).add(delivery.id));
sendSupplierMailMutation.mutate(delivery);
};
const handleQuickValidate = async (delivery: any) => {
@@ -1132,10 +1163,12 @@ export default function BLReconciliation() {
<div className="flex items-center justify-end space-x-2">
{(() => {
const supplierEmail = getSupplierEmail(delivery);
const isSending = sendingMailDeliveries.has(delivery.id);
return (
<Button
variant="outline"
size="sm"
disabled={isSending}
onClick={() => handleRequestDocumentsByEmail(delivery)}
className={`h-8 w-8 p-0 ${
supplierEmail
@@ -1143,12 +1176,18 @@ export default function BLReconciliation() {
: 'text-gray-400 hover:text-gray-500'
}`}
title={
supplierEmail
? `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
isSending
? "Envoi en cours..."
: supplierEmail
? `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
}
>
<Mail className="h-4 w-4" />
{isSending ? (
<Clock className="h-4 w-4 animate-spin" />
) : (
<Mail className="h-4 w-4" />
)}
</Button>
);
})()}
@@ -1462,8 +1501,10 @@ export default function BLReconciliation() {
<div className="flex items-center justify-end space-x-2">
{(() => {
const supplierEmail = getSupplierEmail(delivery);
const isSending = sendingMailDeliveries.has(delivery.id);
return (
<button
disabled={isSending}
onClick={() => handleRequestDocumentsByEmail(delivery)}
className={`transition-colors duration-200 p-1 rounded opacity-70 ${
supplierEmail
@@ -1471,12 +1512,18 @@ export default function BLReconciliation() {
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
}`}
title={
supplierEmail
? `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
isSending
? "Envoi en cours..."
: supplierEmail
? `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
}
>
<Mail className="w-4 h-4" />
{isSending ? (
<Clock className="w-4 h-4 animate-spin" />
) : (
<Mail className="w-4 h-4" />
)}
</button>
);
})()}
+308 -70
View File
@@ -3,6 +3,7 @@ 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";
@@ -33,6 +34,38 @@ const colorOptions = [
{ 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();
@@ -42,19 +75,7 @@ export default function Groups() {
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
const [formData, setFormData] = useState({
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
const [formData, setFormData] = useState({ ...EMPTY_GROUP_FORM });
const { data: groups = [], isLoading } = useQuery<Group[]>({
queryKey: ['/api/groups'],
@@ -97,19 +118,7 @@ export default function Groups() {
});
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
setShowCreateModal(false);
setFormData({
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
setFormData({ ...EMPTY_GROUP_FORM });
},
onError: (error) => {
if (isUnauthorizedError(error)) {
@@ -143,19 +152,7 @@ export default function Groups() {
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
setShowEditModal(false);
setSelectedGroup(null);
setFormData({
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
setFormData({ ...EMPTY_GROUP_FORM });
},
onError: (error) => {
if (isUnauthorizedError(error)) {
@@ -177,6 +174,53 @@ export default function Groups() {
},
});
// 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");
@@ -225,19 +269,7 @@ export default function Groups() {
};
const handleCreate = () => {
setFormData({
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
setFormData({ ...EMPTY_GROUP_FORM });
setShowCreateModal(true);
};
@@ -255,6 +287,18 @@ export default function Groups() {
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);
};
@@ -289,11 +333,17 @@ export default function Groups() {
}
// Prepare data with proper type conversion
const submitData = {
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 {
@@ -301,7 +351,7 @@ export default function Groups() {
}
};
const handleChange = (field: string, value: string) => {
const handleChange = (field: string, value: string | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
@@ -683,27 +733,215 @@ export default function Groups() {
)}
</div>
{/* Section Coordonnées du magasin (signature des mails) */}
<div className="border-t pt-4 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 className="flex items-center space-x-3 pt-4">
<Button
type="button"
variant="outline"
<Button
type="button"
variant="outline"
onClick={() => {
setShowCreateModal(false);
setShowEditModal(false);
setSelectedGroup(null);
setFormData({
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
setFormData({ ...EMPTY_GROUP_FORM });
}}
>
Annuler