Files
LogiFlow/client/src/pages/Contacts.tsx
T
Claude 5d3e5357a0 perf(client): pages chargées à la demande et un seul appel /api/user
- 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
2026-10-03 04:29:51 +00:00

613 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"],
});
// Clé structurée : l'invalidation par préfixe ["/api/contacts"] des mutations
// ci-dessous rafraîchit aussi la liste filtrée par magasin
const { data: contacts = [] } = useQuery<Contact[]>({
queryKey: ["/api/contacts", effectiveGroupId ?? null],
queryFn: () =>
apiRequest(effectiveGroupId ? `/api/contacts?groupId=${effectiveGroupId}` : "/api/contacts", "GET"),
// Sans magasin, un non-admin ne voit pas la liste : pas de requête
enabled: !!user && (isAdmin || !!effectiveGroupId),
});
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>
);
}