mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-15 11:19:43 +02:00
617 lines
23 KiB
TypeScript
617 lines
23 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
|
import { useStore } from "@/contexts/StoreContext";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
import {
|
|
Plus,
|
|
Search,
|
|
Building,
|
|
Phone,
|
|
Mail,
|
|
User,
|
|
Edit,
|
|
Trash2,
|
|
BookUser,
|
|
StickyNote,
|
|
} from "lucide-react";
|
|
import type { Supplier, Contact, Group } from "@shared/schema";
|
|
|
|
const CAN_EDIT_ROLES = ["admin", "directeur", "manager"];
|
|
const CAN_EDIT_SUPPLIER = ["admin", "directeur"];
|
|
|
|
export default function Contacts() {
|
|
const { user } = useAuthUnified();
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const { stores, selectedStoreId } = useStore();
|
|
|
|
const canEdit = user?.role && CAN_EDIT_ROLES.includes(user.role);
|
|
const canEditSupplier = user?.role && CAN_EDIT_SUPPLIER.includes(user.role);
|
|
const isAdmin = user?.role === "admin";
|
|
|
|
// Pour les admins, permettre de choisir un magasin
|
|
const [adminGroupId, setAdminGroupId] = useState<number | null>(null);
|
|
const effectiveGroupId = isAdmin ? adminGroupId : selectedStoreId;
|
|
|
|
const [supplierSearch, setSupplierSearch] = useState("");
|
|
const [contactSearch, setContactSearch] = useState("");
|
|
|
|
// Modal contacts libres
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
|
const [formData, setFormData] = useState({
|
|
groupId: effectiveGroupId ?? 0,
|
|
name: "",
|
|
company: "",
|
|
role: "",
|
|
phone: "",
|
|
email: "",
|
|
notes: "",
|
|
});
|
|
|
|
// Modal coordonnées fournisseur
|
|
const [showSupplierModal, setShowSupplierModal] = useState(false);
|
|
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
|
|
const [supplierFormData, setSupplierFormData] = useState({
|
|
contact: "",
|
|
phone: "",
|
|
email: "",
|
|
});
|
|
|
|
const { data: suppliers = [] } = useQuery<Supplier[]>({
|
|
queryKey: ["/api/suppliers"],
|
|
});
|
|
|
|
const contactsQueryKey = effectiveGroupId
|
|
? [`/api/contacts?groupId=${effectiveGroupId}`]
|
|
: ["/api/contacts"];
|
|
|
|
const { data: contacts = [] } = useQuery<Contact[]>({
|
|
queryKey: contactsQueryKey,
|
|
queryFn: async () => {
|
|
const url = effectiveGroupId
|
|
? `/api/contacts?groupId=${effectiveGroupId}`
|
|
: "/api/contacts";
|
|
return await apiRequest(url, "GET");
|
|
},
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: any) => apiRequest("/api/contacts", "POST", data),
|
|
onSuccess: () => {
|
|
toast({ title: "Succès", description: "Contact créé avec succès" });
|
|
queryClient.invalidateQueries({ queryKey: ["/api/contacts"] });
|
|
closeModal();
|
|
},
|
|
onError: () => {
|
|
toast({ title: "Erreur", description: "Impossible de créer le contact", variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: (data: any) => apiRequest(`/api/contacts/${selectedContact?.id}`, "PUT", data),
|
|
onSuccess: () => {
|
|
toast({ title: "Succès", description: "Contact modifié avec succès" });
|
|
queryClient.invalidateQueries({ queryKey: ["/api/contacts"] });
|
|
closeModal();
|
|
},
|
|
onError: () => {
|
|
toast({ title: "Erreur", description: "Impossible de modifier le contact", variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: number) => apiRequest(`/api/contacts/${id}`, "DELETE"),
|
|
onSuccess: () => {
|
|
toast({ title: "Succès", description: "Contact supprimé" });
|
|
queryClient.invalidateQueries({ queryKey: ["/api/contacts"] });
|
|
},
|
|
onError: () => {
|
|
toast({ title: "Erreur", description: "Impossible de supprimer le contact", variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const updateSupplierMutation = useMutation({
|
|
mutationFn: (data: any) => apiRequest(`/api/suppliers/${selectedSupplier?.id}`, "PUT", data),
|
|
onSuccess: () => {
|
|
toast({ title: "Succès", description: "Coordonnées fournisseur mises à jour" });
|
|
queryClient.invalidateQueries({ queryKey: ["/api/suppliers"] });
|
|
setShowSupplierModal(false);
|
|
setSelectedSupplier(null);
|
|
},
|
|
onError: () => {
|
|
toast({ title: "Erreur", description: "Impossible de modifier le fournisseur", variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const openCreate = () => {
|
|
setSelectedContact(null);
|
|
setFormData({
|
|
groupId: effectiveGroupId ?? 0,
|
|
name: "",
|
|
company: "",
|
|
role: "",
|
|
phone: "",
|
|
email: "",
|
|
notes: "",
|
|
});
|
|
setShowModal(true);
|
|
};
|
|
|
|
const openEdit = (contact: Contact) => {
|
|
setSelectedContact(contact);
|
|
setFormData({
|
|
groupId: contact.groupId,
|
|
name: contact.name,
|
|
company: (contact as any).company || "",
|
|
role: contact.role || "",
|
|
phone: contact.phone || "",
|
|
email: contact.email || "",
|
|
notes: contact.notes || "",
|
|
});
|
|
setShowModal(true);
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setShowModal(false);
|
|
setSelectedContact(null);
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const payload = { ...formData, groupId: effectiveGroupId ?? formData.groupId };
|
|
if (selectedContact) {
|
|
updateMutation.mutate(payload);
|
|
} else {
|
|
createMutation.mutate(payload);
|
|
}
|
|
};
|
|
|
|
const handleDelete = (contact: Contact) => {
|
|
if (confirm(`Supprimer le contact "${contact.name}" ?`)) {
|
|
deleteMutation.mutate(contact.id);
|
|
}
|
|
};
|
|
|
|
const openEditSupplier = (supplier: Supplier) => {
|
|
setSelectedSupplier(supplier);
|
|
setSupplierFormData({
|
|
contact: supplier.contact || "",
|
|
phone: supplier.phone || "",
|
|
email: (supplier as any).email || "",
|
|
});
|
|
setShowSupplierModal(true);
|
|
};
|
|
|
|
const filteredSuppliers = suppliers.filter((s) =>
|
|
s.name.toLowerCase().includes(supplierSearch.toLowerCase()) ||
|
|
(s.contact || "").toLowerCase().includes(supplierSearch.toLowerCase())
|
|
);
|
|
|
|
const filteredContacts = contacts.filter((c) =>
|
|
c.name.toLowerCase().includes(contactSearch.toLowerCase()) ||
|
|
(c.role || "").toLowerCase().includes(contactSearch.toLowerCase())
|
|
);
|
|
|
|
const groupName = (groupId: number) =>
|
|
stores.find((s) => s.id === groupId)?.name ?? `Magasin #${groupId}`;
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
|
<BookUser className="w-6 h-6 mr-3 text-blue-600" />
|
|
Contacts
|
|
</h2>
|
|
<p className="text-gray-600 mt-1">
|
|
Fournisseurs et contacts par magasin
|
|
</p>
|
|
</div>
|
|
|
|
{canEdit && effectiveGroupId && (
|
|
<Button onClick={openCreate} className="bg-primary hover:bg-blue-700 text-white">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Nouveau contact
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sélecteur de magasin (admin uniquement) */}
|
|
{isAdmin && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
<Label className="text-sm font-medium text-blue-800 mb-2 block">
|
|
Magasin affiché (admin)
|
|
</Label>
|
|
<Select
|
|
value={adminGroupId ? String(adminGroupId) : "all"}
|
|
onValueChange={(v) => setAdminGroupId(v === "all" ? null : parseInt(v))}
|
|
>
|
|
<SelectTrigger className="w-64">
|
|
<SelectValue placeholder="Tous les magasins" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Tous les magasins</SelectItem>
|
|
{stores.map((g) => (
|
|
<SelectItem key={g.id} value={String(g.id)}>
|
|
{g.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Deux colonnes */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Colonne Fournisseurs */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center space-x-2">
|
|
<Building className="w-5 h-5 text-blue-600" />
|
|
<h3 className="text-lg font-semibold text-gray-900">
|
|
Fournisseurs ({filteredSuppliers.length})
|
|
</h3>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
<Input
|
|
placeholder="Rechercher un fournisseur..."
|
|
value={supplierSearch}
|
|
onChange={(e) => setSupplierSearch(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{filteredSuppliers.length === 0 ? (
|
|
<div className="text-center py-10 text-gray-500 border border-dashed rounded-lg">
|
|
<Building className="w-10 h-10 mx-auto mb-2 text-gray-300" />
|
|
Aucun fournisseur trouvé
|
|
</div>
|
|
) : (
|
|
filteredSuppliers.map((supplier) => (
|
|
<div
|
|
key={supplier.id}
|
|
className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="w-9 h-9 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0">
|
|
<Building className="w-4 h-4 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="font-semibold text-gray-900">{supplier.name}</p>
|
|
{supplier.contact && (
|
|
<p className="text-sm text-gray-500 flex items-center mt-0.5">
|
|
<User className="w-3 h-3 mr-1" />
|
|
{supplier.contact}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{canEditSupplier && (
|
|
<Button variant="ghost" size="sm" onClick={() => openEditSupplier(supplier)}>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="mt-3 space-y-1 pl-12">
|
|
{supplier.phone && (
|
|
<a
|
|
href={`tel:${supplier.phone}`}
|
|
className="flex items-center text-sm text-blue-700 hover:underline"
|
|
>
|
|
<Phone className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
|
{supplier.phone}
|
|
</a>
|
|
)}
|
|
{(supplier as any).email && (
|
|
<a
|
|
href={`mailto:${(supplier as any).email}`}
|
|
className="flex items-center text-sm text-blue-700 hover:underline"
|
|
>
|
|
<Mail className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
|
{(supplier as any).email}
|
|
</a>
|
|
)}
|
|
{!supplier.phone && !(supplier as any).email && (
|
|
<p className="text-xs text-gray-400 italic">Aucune coordonnée renseignée</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Colonne Autres contacts */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center space-x-2">
|
|
<User className="w-5 h-5 text-green-600" />
|
|
<h3 className="text-lg font-semibold text-gray-900">
|
|
Autres contacts ({filteredContacts.length})
|
|
</h3>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
<Input
|
|
placeholder="Rechercher un contact..."
|
|
value={contactSearch}
|
|
onChange={(e) => setContactSearch(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
|
|
{!effectiveGroupId && !isAdmin && (
|
|
<div className="text-center py-10 text-gray-500 border border-dashed rounded-lg">
|
|
<BookUser className="w-10 h-10 mx-auto mb-2 text-gray-300" />
|
|
Sélectionnez un magasin pour voir les contacts
|
|
</div>
|
|
)}
|
|
|
|
{(effectiveGroupId || isAdmin) && (
|
|
<div className="space-y-3">
|
|
{filteredContacts.length === 0 ? (
|
|
<div className="text-center py-10 text-gray-500 border border-dashed rounded-lg">
|
|
<User className="w-10 h-10 mx-auto mb-2 text-gray-300" />
|
|
Aucun contact
|
|
{canEdit && effectiveGroupId && (
|
|
<div className="mt-3">
|
|
<Button onClick={openCreate} size="sm" variant="outline">
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
Ajouter un contact
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
filteredContacts.map((contact) => (
|
|
<div
|
|
key={contact.id}
|
|
className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="w-9 h-9 bg-green-600 rounded-full flex items-center justify-center flex-shrink-0">
|
|
<User className="w-4 h-4 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="font-semibold text-gray-900">{contact.name}</p>
|
|
{(contact as any).company && (
|
|
<p className="text-sm font-medium text-gray-700">{(contact as any).company}</p>
|
|
)}
|
|
{contact.role && (
|
|
<p className="text-sm text-gray-500">{contact.role}</p>
|
|
)}
|
|
{isAdmin && (
|
|
<p className="text-xs text-blue-500">{groupName(contact.groupId)}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{canEdit && (
|
|
<div className="flex items-center space-x-1">
|
|
<Button variant="ghost" size="sm" onClick={() => openEdit(contact)}>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDelete(contact)}
|
|
className="text-red-600 hover:text-red-700"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="mt-3 space-y-1 pl-12">
|
|
{contact.phone && (
|
|
<a
|
|
href={`tel:${contact.phone}`}
|
|
className="flex items-center text-sm text-green-700 hover:underline"
|
|
>
|
|
<Phone className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
|
{contact.phone}
|
|
</a>
|
|
)}
|
|
{contact.email && (
|
|
<a
|
|
href={`mailto:${contact.email}`}
|
|
className="flex items-center text-sm text-green-700 hover:underline"
|
|
>
|
|
<Mail className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
|
{contact.email}
|
|
</a>
|
|
)}
|
|
{contact.notes && (
|
|
<p className="flex items-start text-sm text-gray-500">
|
|
<StickyNote className="w-3.5 h-3.5 mr-2 flex-shrink-0 mt-0.5" />
|
|
{contact.notes}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal créer / modifier contact */}
|
|
<Dialog open={showModal} onOpenChange={closeModal}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{selectedContact ? "Modifier le contact" : "Nouveau contact"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{isAdmin && !selectedContact && (
|
|
<div>
|
|
<Label>Magasin *</Label>
|
|
<Select
|
|
value={String(formData.groupId || "")}
|
|
onValueChange={(v) => setFormData((p) => ({ ...p, groupId: parseInt(v) }))}
|
|
required
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Choisir un magasin" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{stores.map((g) => (
|
|
<SelectItem key={g.id} value={String(g.id)}>
|
|
{g.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<Label htmlFor="c-name">Nom *</Label>
|
|
<Input
|
|
id="c-name"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
|
placeholder="Nom du contact"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="c-company">Entreprise</Label>
|
|
<Input
|
|
id="c-company"
|
|
value={formData.company}
|
|
onChange={(e) => setFormData((p) => ({ ...p, company: e.target.value }))}
|
|
placeholder="Nom de l'entreprise"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="c-role">Fonction / Rôle</Label>
|
|
<Input
|
|
id="c-role"
|
|
value={formData.role}
|
|
onChange={(e) => setFormData((p) => ({ ...p, role: e.target.value }))}
|
|
placeholder="Ex: Directeur régional, Commercial..."
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="c-phone">Téléphone</Label>
|
|
<Input
|
|
id="c-phone"
|
|
value={formData.phone}
|
|
onChange={(e) => setFormData((p) => ({ ...p, phone: e.target.value }))}
|
|
placeholder="Numéro de téléphone"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="c-email">Email</Label>
|
|
<Input
|
|
id="c-email"
|
|
type="email"
|
|
value={formData.email}
|
|
onChange={(e) => setFormData((p) => ({ ...p, email: e.target.value }))}
|
|
placeholder="Adresse email"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="c-notes">Notes</Label>
|
|
<Input
|
|
id="c-notes"
|
|
value={formData.notes}
|
|
onChange={(e) => setFormData((p) => ({ ...p, notes: e.target.value }))}
|
|
placeholder="Notes libres..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-3 pt-2">
|
|
<Button type="button" variant="outline" onClick={closeModal}>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={createMutation.isPending || updateMutation.isPending}
|
|
className="bg-primary hover:bg-blue-700"
|
|
>
|
|
{createMutation.isPending || updateMutation.isPending
|
|
? "Enregistrement..."
|
|
: selectedContact
|
|
? "Modifier"
|
|
: "Créer"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Modal coordonnées fournisseur */}
|
|
<Dialog open={showSupplierModal} onOpenChange={() => { setShowSupplierModal(false); setSelectedSupplier(null); }}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Coordonnées — {selectedSupplier?.name}</DialogTitle>
|
|
</DialogHeader>
|
|
<form
|
|
onSubmit={(e) => { e.preventDefault(); updateSupplierMutation.mutate(supplierFormData); }}
|
|
className="space-y-4"
|
|
>
|
|
<div>
|
|
<Label htmlFor="s-contact">Nom du contact</Label>
|
|
<Input
|
|
id="s-contact"
|
|
value={supplierFormData.contact}
|
|
onChange={(e) => setSupplierFormData((p) => ({ ...p, contact: e.target.value }))}
|
|
placeholder="Nom du contact chez le fournisseur"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="s-phone">Téléphone</Label>
|
|
<Input
|
|
id="s-phone"
|
|
value={supplierFormData.phone}
|
|
onChange={(e) => setSupplierFormData((p) => ({ ...p, phone: e.target.value }))}
|
|
placeholder="Numéro de téléphone"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="s-email">Email</Label>
|
|
<Input
|
|
id="s-email"
|
|
type="email"
|
|
value={supplierFormData.email}
|
|
onChange={(e) => setSupplierFormData((p) => ({ ...p, email: e.target.value }))}
|
|
placeholder="Adresse email"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center space-x-3 pt-2">
|
|
<Button type="button" variant="outline" onClick={() => { setShowSupplierModal(false); setSelectedSupplier(null); }}>
|
|
Annuler
|
|
</Button>
|
|
<Button type="submit" disabled={updateSupplierMutation.isPending} className="bg-primary hover:bg-blue-700">
|
|
{updateSupplierMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|