mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
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
958 lines
37 KiB
TypeScript
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>
|
|
);
|
|
}
|