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
@@ -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);
+71
View File
@@ -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 {
+75
View File
@@ -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
+22
View File
@@ -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;