mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-21 14:08:15 +02:00
- Routes en React.lazy (sauf connexion et accueil) avec un Suspense dans la zone de contenu ; recharts n'est plus préchargé. JS initial : 1,72 Mo -> 625 Ko (183 Ko gzip). Rechargement automatique unique si un ancien fichier JS a disparu après un déploiement, et ErrorBoundary par page pour garder le menu affiché en cas d'erreur. - Authentification unifiée sur le cache React Query ['/api/user'] : un seul GET /api/user pour toute l'application (au lieu de 4 à 5 par page), plus de rechargement complet après la connexion. - Pas de nouvel essai sur les erreurs 4xx ; fournisseurs et magasins en cache 5 minutes ; valeur du StoreContext mémoïsée et redimensionnement qui ne re-rend que lorsque le palier d'écran change. - Pages : requêtes inutilisées ou en double supprimées, appels lancés seulement une fois l'utilisateur connu, invalidations ciblées au lieu de rechargements complets, filtres et tris mémoïsés, anciennes données gardées (et estompées) pendant un changement de filtre ou de mois, recherche DLC mobile temporisée, vérifications de factures à concurrence bornée, navigation interne sans rechargement de la page. - index.html : script de bannière Replit retiré, lang="fr". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
960 lines
37 KiB
TypeScript
960 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'],
|
|
});
|
|
|
|
// Configurations NocoDB (liste déroulante de la fenêtre magasin) : réservées à
|
|
// l'admin, le serveur répond 403 aux autres rôles. Les mutations de la page
|
|
// NocoDB invalident déjà cette clé.
|
|
const { data: rawNocodbConfigs = [] } = useQuery<NocodbConfig[]>({
|
|
queryKey: ['/api/nocodb-config'],
|
|
enabled: user?.role === 'admin',
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
|
|
// 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) => {
|
|
return await apiRequest("/api/groups", "POST", data);
|
|
},
|
|
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>
|
|
);
|
|
}
|