mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
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:
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} />
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Migration: Ajout email fournisseurs + table contacts par magasin
|
||||
-- Created: 2026-05-15
|
||||
|
||||
-- Ajout du champ email sur les fournisseurs
|
||||
ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS email VARCHAR(255);
|
||||
|
||||
-- Table des contacts libres, rattachés à un magasin
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(255),
|
||||
phone VARCHAR(50),
|
||||
email VARCHAR(255),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index pour recherche rapide par magasin
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_group_id ON contacts(group_id);
|
||||
@@ -119,6 +119,7 @@ import {
|
||||
insertUtilitiesSchema,
|
||||
insertAvoirSchema,
|
||||
insertReconciliationCommentSchema,
|
||||
insertContactSchema,
|
||||
users, groups, userGroups, suppliers, orders, deliveries, publicities, publicityParticipations,
|
||||
customerOrders, nocodbConfig, dlcProducts, tasks, invoiceVerificationCache, dashboardMessages, webhookBapConfig,
|
||||
utilities,
|
||||
@@ -1191,6 +1192,76 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Contacts routes
|
||||
app.get('/api/contacts', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
if (!user) return res.status(404).json({ message: "User not found" });
|
||||
|
||||
let groupIds: number[] | undefined;
|
||||
if (user.role !== 'admin') {
|
||||
groupIds = (user.userGroups || []).map((ug: any) => ug.groupId);
|
||||
} else if (req.query.groupId) {
|
||||
groupIds = [parseInt(req.query.groupId as string)];
|
||||
}
|
||||
|
||||
const result = await storage.getContacts(groupIds);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error("Error fetching contacts:", error);
|
||||
res.status(500).json({ message: "Failed to fetch contacts" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/contacts', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
if (!user || !['admin', 'directeur', 'manager'].includes(user.role)) {
|
||||
return res.status(403).json({ message: "Insufficient permissions" });
|
||||
}
|
||||
const data = insertContactSchema.parse(req.body);
|
||||
const contact = await storage.createContact(data);
|
||||
res.json(contact);
|
||||
} catch (error: any) {
|
||||
if (error.name === 'ZodError') {
|
||||
return res.status(400).json({ message: "Validation failed", errors: error.errors });
|
||||
}
|
||||
console.error("Error creating contact:", error);
|
||||
res.status(500).json({ message: "Failed to create contact" });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/contacts/:id', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
if (!user || !['admin', 'directeur', 'manager'].includes(user.role)) {
|
||||
return res.status(403).json({ message: "Insufficient permissions" });
|
||||
}
|
||||
const id = parseInt(req.params.id);
|
||||
const data = insertContactSchema.partial().parse(req.body);
|
||||
const contact = await storage.updateContact(id, data);
|
||||
res.json(contact);
|
||||
} catch (error) {
|
||||
console.error("Error updating contact:", error);
|
||||
res.status(500).json({ message: "Failed to update contact" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/contacts/:id', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id);
|
||||
if (!user || !['admin', 'directeur', 'manager'].includes(user.role)) {
|
||||
return res.status(403).json({ message: "Insufficient permissions" });
|
||||
}
|
||||
const id = parseInt(req.params.id);
|
||||
await storage.deleteContact(id);
|
||||
res.json({ message: "Contact deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting contact:", error);
|
||||
res.status(500).json({ message: "Failed to delete contact" });
|
||||
}
|
||||
});
|
||||
|
||||
// Orders routes
|
||||
app.get('/api/orders', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
|
||||
@@ -78,6 +78,9 @@ import {
|
||||
type Avoir,
|
||||
type InsertAvoir,
|
||||
type AvoirWithRelations,
|
||||
contacts,
|
||||
type Contact,
|
||||
type InsertContact,
|
||||
} from "@shared/schema";
|
||||
import { db } from "./db";
|
||||
import { eq, and, inArray, desc, sql, gte, lte, lt, gt, or, isNull, isNotNull, asc, ne } from "drizzle-orm";
|
||||
@@ -109,6 +112,12 @@ export interface IStorage {
|
||||
updateSupplier(id: number, supplier: Partial<InsertSupplier>): Promise<Supplier>;
|
||||
deleteSupplier(id: number): Promise<void>;
|
||||
|
||||
// Contact operations
|
||||
getContacts(groupIds?: number[]): Promise<Contact[]>;
|
||||
createContact(contact: InsertContact): Promise<Contact>;
|
||||
updateContact(id: number, contact: Partial<InsertContact>): Promise<Contact>;
|
||||
deleteContact(id: number): Promise<void>;
|
||||
|
||||
// Order operations
|
||||
getOrders(groupIds?: number[]): Promise<OrderWithRelations[]>;
|
||||
getOrdersByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise<OrderWithRelations[]>;
|
||||
@@ -454,6 +463,32 @@ export class DatabaseStorage implements IStorage {
|
||||
await db.delete(suppliers).where(eq(suppliers.id, id));
|
||||
}
|
||||
|
||||
// Contact operations
|
||||
async getContacts(groupIds?: number[]): Promise<Contact[]> {
|
||||
if (groupIds && groupIds.length > 0) {
|
||||
return await db.select().from(contacts).where(inArray(contacts.groupId, groupIds)).orderBy(contacts.name);
|
||||
}
|
||||
return await db.select().from(contacts).orderBy(contacts.name);
|
||||
}
|
||||
|
||||
async createContact(contactData: InsertContact): Promise<Contact> {
|
||||
const [contact] = await db.insert(contacts).values(contactData).returning();
|
||||
return contact;
|
||||
}
|
||||
|
||||
async updateContact(id: number, contactData: Partial<InsertContact>): Promise<Contact> {
|
||||
const [contact] = await db
|
||||
.update(contacts)
|
||||
.set({ ...contactData, updatedAt: new Date() })
|
||||
.where(eq(contacts.id, id))
|
||||
.returning();
|
||||
return contact;
|
||||
}
|
||||
|
||||
async deleteContact(id: number): Promise<void> {
|
||||
await db.delete(contacts).where(eq(contacts.id, id));
|
||||
}
|
||||
|
||||
// Order operations
|
||||
async getOrders(groupIds?: number[]): Promise<OrderWithRelations[]> {
|
||||
let query = db
|
||||
@@ -3330,6 +3365,7 @@ export class MemStorage implements IStorage {
|
||||
private avoirs = new Map<number, Avoir>();
|
||||
private savTickets = new Map<number, SavTicket>();
|
||||
private invoiceVerificationCache = new Map<string, InvoiceVerificationCache>();
|
||||
private contactsMap = new Map<number, Contact>();
|
||||
|
||||
private idCounters = {
|
||||
group: 1,
|
||||
@@ -3344,6 +3380,7 @@ export class MemStorage implements IStorage {
|
||||
avoir: 1,
|
||||
savTicket: 1,
|
||||
reconciliationComment: 1,
|
||||
contact: 1,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@@ -3562,6 +3599,44 @@ export class MemStorage implements IStorage {
|
||||
this.suppliers.delete(id);
|
||||
}
|
||||
|
||||
// Contact operations
|
||||
async getContacts(groupIds?: number[]): Promise<Contact[]> {
|
||||
const all = Array.from(this.contactsMap.values());
|
||||
if (groupIds && groupIds.length > 0) {
|
||||
return all.filter(c => groupIds.includes(c.groupId));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async createContact(contactData: InsertContact): Promise<Contact> {
|
||||
const id = this.idCounters.contact++;
|
||||
const contact: Contact = {
|
||||
id,
|
||||
groupId: contactData.groupId,
|
||||
name: contactData.name,
|
||||
role: contactData.role || null,
|
||||
phone: contactData.phone || null,
|
||||
email: contactData.email || null,
|
||||
notes: contactData.notes || null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.contactsMap.set(id, contact);
|
||||
return contact;
|
||||
}
|
||||
|
||||
async updateContact(id: number, contactData: Partial<InsertContact>): Promise<Contact> {
|
||||
const existing = this.contactsMap.get(id);
|
||||
if (!existing) throw new Error('Contact not found');
|
||||
const updated = { ...existing, ...contactData, updatedAt: new Date() };
|
||||
this.contactsMap.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteContact(id: number): Promise<void> {
|
||||
this.contactsMap.delete(id);
|
||||
}
|
||||
|
||||
// NocoDB Configuration methods
|
||||
async getNocodbConfigs(): Promise<NocodbConfig[]> {
|
||||
return []; // Empty array for development
|
||||
|
||||
@@ -80,6 +80,7 @@ export const suppliers = pgTable("suppliers", {
|
||||
name: varchar("name").notNull(),
|
||||
contact: varchar("contact"),
|
||||
phone: varchar("phone"),
|
||||
email: varchar("email"),
|
||||
hasDlc: boolean("has_dlc").default(false), // Coche DLC pour la gestion DLC
|
||||
paymentMethod: varchar("payment_method"), // Mode de paiement: Virement, Traite, Traite Magnétique, Chèque
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
@@ -88,6 +89,19 @@ export const suppliers = pgTable("suppliers", {
|
||||
requiresControl: boolean("requires_control").default(false), // Fournisseur nécessite un contrôle à la livraison
|
||||
});
|
||||
|
||||
// Contacts libres (hors fournisseurs), rattachés à un magasin
|
||||
export const contacts = pgTable("contacts", {
|
||||
id: serial("id").primaryKey(),
|
||||
groupId: integer("group_id").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
role: varchar("role"),
|
||||
phone: varchar("phone"),
|
||||
email: varchar("email"),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
});
|
||||
|
||||
// Orders
|
||||
export const orders = pgTable("orders", {
|
||||
id: serial("id").primaryKey(),
|
||||
@@ -544,6 +558,12 @@ export const insertSupplierSchema = createInsertSchema(suppliers).omit({
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
export const insertContactSchema = createInsertSchema(contacts).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
export const insertOrderSchema = createInsertSchema(orders).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
@@ -706,6 +726,8 @@ export type Group = typeof groups.$inferSelect;
|
||||
export type InsertGroup = z.infer<typeof insertGroupSchema>;
|
||||
export type Supplier = typeof suppliers.$inferSelect;
|
||||
export type InsertSupplier = z.infer<typeof insertSupplierSchema>;
|
||||
export type Contact = typeof contacts.$inferSelect;
|
||||
export type InsertContact = z.infer<typeof insertContactSchema>;
|
||||
export type Order = typeof orders.$inferSelect;
|
||||
export type InsertOrder = z.infer<typeof insertOrderSchema>;
|
||||
export type Delivery = typeof deliveries.$inferSelect;
|
||||
|
||||
Reference in new issue
Block a user