feat(contacts): add contacts page with per-store management and supplier email field

- New contacts table (group_id, name, role, phone, email, notes) per store
- Suppliers table: add email field with form and card display
- Page /contacts: two-column layout (suppliers read-only / free contacts CRUD)
- Permissions: read all roles, create/edit/delete admin + directeur + manager
- Admin can filter contacts by store; other roles see their own stores only
- Migration SQL: 20260515_add_contacts_and_supplier_email.sql

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-05-15 10:44:39 +02:00
1 parent 40cf456a64
commit 805664dc32
8 files changed
+736 -6

No files matched your search

@@ -28,6 +28,7 @@ import WeatherSettings from "@/pages/WeatherSettings";
import Analytics from "@/pages/Analytics";
import SalesAnalysisPage from "@/pages/SalesAnalysisPage";
import PaymentSchedulePage from "@/pages/PaymentSchedulePage";
import Contacts from "@/pages/Contacts";
import Layout from "@/components/Layout";
// Mobile pages
@@ -114,6 +115,7 @@ function RouterProduction() {
<Route path="/tasks" component={MobileTasksPage} />
{/* Pages sans version mobile - utiliser version desktop pour l'instant */}
<Route path="/contacts" component={Contacts} />
<Route path="/suppliers" component={Suppliers} />
<Route path="/groups" component={Groups} />
<Route path="/users" component={Users} />
@@ -153,6 +155,7 @@ function RouterProduction() {
<Route path="/dashboard" component={Dashboard} />
<Route path="/orders" component={Orders} />
<Route path="/deliveries" component={Deliveries} />
<Route path="/contacts" component={Contacts} />
<Route path="/suppliers" component={Suppliers} />
<Route path="/groups" component={Groups} />
<Route path="/users" component={Users} />
+8 -1
View File
@@ -36,7 +36,8 @@ import {
Send,
Receipt,
TrendingUp,
CreditCard
CreditCard,
BookUser,
} from "lucide-react";
export default function Sidebar() {
@@ -331,6 +332,12 @@ export default function Sidebar() {
icon: Wrench,
roles: ["admin", "directeur", "manager", "employee"]
},
{
path: "/contacts",
label: "Contacts",
icon: BookUser,
roles: ["admin", "directeur", "manager", "employee"]
},
]
},
];
+510
View File
@@ -0,0 +1,510 @@
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"];
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 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("");
const [showModal, setShowModal] = useState(false);
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
const [formData, setFormData] = useState({
groupId: effectiveGroupId ?? 0,
name: "",
role: "",
phone: "",
email: "",
notes: "",
});
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 openCreate = () => {
setSelectedContact(null);
setFormData({
groupId: effectiveGroupId ?? 0,
name: "",
role: "",
phone: "",
email: "",
notes: "",
});
setShowModal(true);
};
const openEdit = (contact: Contact) => {
setSelectedContact(contact);
setFormData({
groupId: contact.groupId,
name: contact.name,
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 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>
</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.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-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>
</div>
);
}
+26 -5
View File
@@ -16,11 +16,12 @@ import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { isUnauthorizedError } from "@/lib/authUtils";
import {
Plus,
Search,
Building,
Phone,
import {
Plus,
Search,
Building,
Phone,
Mail,
Edit,
Trash2,
Package,
@@ -43,6 +44,7 @@ export default function Suppliers() {
name: "",
contact: "",
phone: "",
email: "",
hasDlc: false,
automaticReconciliation: false,
requiresControl: false,
@@ -198,6 +200,7 @@ export default function Suppliers() {
name: "",
contact: "",
phone: "",
email: "",
hasDlc: false,
automaticReconciliation: false,
requiresControl: false,
@@ -236,6 +239,7 @@ export default function Suppliers() {
name: supplier.name || "",
contact: supplier.contact || "",
phone: supplier.phone || "",
email: (supplier as any).email || "",
hasDlc: supplier.hasDlc || false,
automaticReconciliation: supplier.automaticReconciliation || false,
requiresControl: supplier.requiresControl || false,
@@ -409,6 +413,12 @@ export default function Suppliers() {
{supplier.phone}
</div>
)}
{(supplier as any).email && (
<div className="flex items-center text-sm text-gray-600">
<Mail className="w-4 h-4 mr-2" />
{(supplier as any).email}
</div>
)}
{supplier.paymentMethod && (
<div className="flex items-center text-sm text-gray-600">
<Package className="w-4 h-4 mr-2" />
@@ -528,6 +538,17 @@ export default function Suppliers() {
/>
</div>
<div>
<Label htmlFor="email">Email de contact</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
placeholder="Email du contact fournisseur"
/>
</div>
<div>
<Label htmlFor="paymentMethod">Mode de paiement</Label>
<Select