diff --git a/.replit b/.replit index f9b73f0..4206a87 100644 --- a/.replit +++ b/.replit @@ -23,6 +23,10 @@ externalPort = 3002 localPort = 41343 externalPort = 3001 +[[ports]] +localPort = 43467 +externalPort = 3000 + [workflows] runButton = "Project" diff --git a/client/src/components/modals/ClientCallsModal.tsx b/client/src/components/modals/ClientCallsModal.tsx new file mode 100644 index 0000000..ef931ec --- /dev/null +++ b/client/src/components/modals/ClientCallsModal.tsx @@ -0,0 +1,156 @@ +import { useState } from "react"; +import { useQueryClient, useMutation } from "@tanstack/react-query"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest } from "@/lib/queryClient"; +import { Phone, User, Package, Building } from "lucide-react"; +import type { CustomerOrderWithRelations } from "@shared/schema"; + +interface ClientCallsModalProps { + isOpen: boolean; + onClose: () => void; + pendingCalls: CustomerOrderWithRelations[]; +} + +export default function ClientCallsModal({ isOpen, onClose, pendingCalls }: ClientCallsModalProps) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [callingIds, setCallingIds] = useState>(new Set()); + + const markCalledMutation = useMutation({ + mutationFn: async (customerOrderId: number) => { + return apiRequest(`/api/customer-orders/${customerOrderId}/mark-called`, 'PATCH'); + }, + onMutate: (customerOrderId) => { + setCallingIds(prev => new Set([...prev, customerOrderId])); + }, + onSuccess: (_, customerOrderId) => { + toast({ + title: "Client marqué comme appelé", + description: "L'appel a été enregistré avec succès", + }); + + // Invalider les caches + queryClient.invalidateQueries({ queryKey: ['/api/customer-orders/pending-calls'] }); + queryClient.invalidateQueries({ queryKey: ['/api/customer-orders'] }); + }, + onError: (error, customerOrderId) => { + console.error("Error marking client as called:", error); + toast({ + title: "Erreur", + description: "Impossible de marquer l'appel client", + variant: "destructive", + }); + }, + onSettled: (_, __, customerOrderId) => { + setCallingIds(prev => { + const newSet = new Set(prev); + newSet.delete(customerOrderId); + return newSet; + }); + } + }); + + const handleMarkCalled = (customerOrderId: number) => { + markCalledMutation.mutate(customerOrderId); + }; + + return ( + + + + + + Clients à Appeler + {pendingCalls.length} + + + +
+ Produits disponibles - Les clients suivants n'ont pas encore été contactés : +
+ + {pendingCalls.length === 0 ? ( +
+ +

Aucun client à appeler actuellement

+

Tous les clients ont été contactés ou aucun produit n'est disponible

+
+ ) : ( + +
+ {pendingCalls.map((order) => ( +
+
+
+ {/* Client Info */} +
+ +
+
+ {order.customerName} +
+
+ + {order.customerPhone} +
+
+
+ + {/* Product Info */} +
+ +
+
+ {order.productDesignation} +
+
+ Quantité: {order.quantity} • {order.supplier?.name} +
+
+
+ + {/* Store Info */} +
+ +
+ {order.group?.name} +
+
+
+ + {/* Call Button */} +
+ +
+
+
+ ))} +
+
+ )} + +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/client/src/pages/Orders.tsx b/client/src/pages/Orders.tsx index 015d1ff..734423a 100644 --- a/client/src/pages/Orders.tsx +++ b/client/src/pages/Orders.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Pagination, usePagination } from "@/components/ui/pagination"; import { useAuthUnified } from "@/hooks/useAuthUnified"; @@ -21,14 +22,16 @@ import { Edit, Trash2, Building, - User + User, + Phone } from "lucide-react"; import { safeFormat } from "@/lib/dateUtils"; import CreateOrderModal from "@/components/modals/CreateOrderModal"; import EditOrderModal from "@/components/modals/EditOrderModal"; import OrderDetailModal from "@/components/modals/OrderDetailModal"; import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal"; -import type { OrderWithRelations } from "@shared/schema"; +import ClientCallsModal from "@/components/modals/ClientCallsModal"; +import type { OrderWithRelations, CustomerOrderWithRelations } from "@shared/schema"; export default function Orders() { const { user } = useAuthUnified(); @@ -65,6 +68,7 @@ export default function Orders() { const [showEditModal, setShowEditModal] = useState(false); const [showDetailModal, setShowDetailModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showClientCallsModal, setShowClientCallsModal] = useState(false); const [selectedOrder, setSelectedOrder] = useState(null); const [orderToDelete, setOrderToDelete] = useState(null); @@ -72,6 +76,22 @@ export default function Orders() { // CRITICAL FIX: Appliquer le filtrage par storeId pour TOUS les rôles, pas seulement admin const ordersUrl = `/api/orders${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`; + // Query pour les appels clients en attente + const pendingCallsUrl = `/api/customer-orders/pending-calls${selectedStoreId ? `?storeId=${selectedStoreId}` : ''}`; + const { data: pendingCalls = [], isLoading: isPendingCallsLoading } = useQuery({ + queryKey: [pendingCallsUrl, selectedStoreId], + queryFn: async () => { + const response = await fetch(pendingCallsUrl, { credentials: 'include' }); + if (!response.ok) { + throw new Error('Failed to fetch pending calls'); + } + const data = await response.json(); + console.log('📞 Pending calls received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items'); + return data; + }, + refetchInterval: 30000, // Refetch every 30 seconds + }); + const { data: ordersData = [], isLoading } = useQuery({ queryKey: [ordersUrl, selectedStoreId], queryFn: async () => { @@ -252,6 +272,23 @@ export default function Orders() { + {/* Alerte pour les appels clients en attente */} + {!isPendingCallsLoading && pendingCalls.length > 0 && ( + setShowClientCallsModal(true)} + data-testid="alert-client-calls" + > + + + 📞 {pendingCalls.length} client{pendingCalls.length > 1 ? 's' : ''} à appeler + + Produits disponibles - Cliquez pour voir la liste des clients à contacter + + + + )} + {/* Filters */}
@@ -465,6 +502,13 @@ export default function Orders() { itemName={orderToDelete ? `${orderToDelete.supplier?.name} - ${safeFormat(orderToDelete.plannedDate, 'dd/MM/yyyy')}` : undefined} isLoading={deleteMutation.isPending} /> + + {/* Modal pour les appels clients */} + setShowClientCallsModal(false)} + pendingCalls={pendingCalls} + />
); } diff --git a/server/routes.ts b/server/routes.ts index d53eb51..ea59632 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -2498,6 +2498,80 @@ export async function registerRoutes(app: Express): Promise { } }); + // Client call tracking routes + app.get('/api/customer-orders/pending-calls', 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; + + // Only admin, directeur, and manager can view pending calls + if (user.role === 'employee') { + return res.status(403).json({ message: "Insufficient permissions" }); + } + + if (user.role === 'admin') { + // Admin can optionally filter by store + const { storeId } = req.query; + if (storeId) { + groupIds = [parseInt(storeId as string)]; + } + } else { + // For directeur and manager, filter by their assigned groups + const userGroupIds = (user as any).userGroups?.map((ug: any) => ug.groupId) || []; + groupIds = userGroupIds; + } + + const pendingCalls = await storage.getPendingClientCalls(groupIds); + + console.log(`📞 Pending client calls fetched: ${pendingCalls.length} calls for user ${user.role}`); + res.json(pendingCalls); + } catch (error) { + console.error("Error fetching pending client calls:", error); + res.status(500).json({ message: "Failed to fetch pending client calls" }); + } + }); + + app.patch('/api/customer-orders/:id/mark-called', 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" }); + } + + const id = parseInt(req.params.id); + + // Only admin, directeur, and manager can mark calls + if (user.role === 'employee') { + return res.status(403).json({ message: "Insufficient permissions" }); + } + + // Check if user has access to this customer order + const customerOrder = await storage.getCustomerOrder(id); + if (!customerOrder) { + return res.status(404).json({ message: "Customer order not found" }); + } + + if (user.role !== 'admin') { + const userGroupIds = (user as any).userGroups?.map((ug: any) => ug.groupId) || []; + if (!userGroupIds.includes(customerOrder.groupId)) { + return res.status(403).json({ message: "Access denied to this customer order" }); + } + } + + const updatedOrder = await storage.markClientCalled(id, user.id); + + console.log(`📞 Client marked as called: order ${id} by user ${user.id} (${user.role})`); + res.json(updatedOrder); + } catch (error) { + console.error("Error marking client as called:", error); + res.status(500).json({ message: "Failed to mark client as called" }); + } + }); + // Avoir routes app.get('/api/avoirs', isAuthenticated, async (req: any, res) => { try { diff --git a/server/storage.ts b/server/storage.ts index d910797..651d9c7 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -179,6 +179,10 @@ export interface IStorage { createCustomerOrder(customerOrder: InsertCustomerOrder): Promise; updateCustomerOrder(id: number, customerOrder: Partial): Promise; deleteCustomerOrder(id: number): Promise; + + // Client call tracking + getPendingClientCalls(groupIds?: number[]): Promise; + markClientCalled(customerOrderId: number, calledBy: string): Promise; // DLC Product operations getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise; @@ -1676,6 +1680,50 @@ export class DatabaseStorage implements IStorage { await db.delete(customerOrders).where(eq(customerOrders.id, id)); } + async getPendingClientCalls(groupIds?: number[]): Promise { + const conditions = [ + eq(dlcProducts.status, 'valides'), + eq(customerOrders.customerNotified, false) + ]; + + if (groupIds && groupIds.length > 0) { + conditions.push(inArray(customerOrders.groupId, groupIds)); + } + + const results = await db + .select({ + customerOrder: customerOrders, + supplier: suppliers, + group: groups + }) + .from(customerOrders) + .innerJoin(dlcProducts, eq(customerOrders.gencode, dlcProducts.gencode)) + .leftJoin(suppliers, eq(customerOrders.supplierId, suppliers.id)) + .leftJoin(groups, eq(customerOrders.groupId, groups.id)) + .where(and(...conditions)) + .orderBy(desc(customerOrders.createdAt)); + + return results.map(result => ({ + ...result.customerOrder, + supplier: result.supplier!, + group: result.group!, + creator: null // Will be populated if needed + })); + } + + async markClientCalled(customerOrderId: number, calledBy: string): Promise { + const [updated] = await db + .update(customerOrders) + .set({ + customerNotified: true, + updatedAt: new Date() + }) + .where(eq(customerOrders.id, customerOrderId)) + .returning(); + + return updated; + } + async getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise { let query = db .select({ @@ -3421,6 +3469,49 @@ export class MemStorage implements IStorage { this.customerOrders.delete(id); } + async getPendingClientCalls(groupIds?: number[]): Promise { + let orders = Array.from(this.customerOrders.values()); + + // Filter by groupIds if provided + if (groupIds && groupIds.length > 0) { + orders = orders.filter(order => groupIds.includes(order.groupId)); + } + + // Find orders where client not notified and product is available + const pendingOrders = orders.filter(order => { + if (order.customerNotified) return false; + + // Check if there's a DLC product with status 'valides' matching this gencode + const matchingDlcProduct = Array.from(this.dlcProducts.values()) + .find(dlc => dlc.gencode === order.gencode && dlc.status === 'valides'); + + return !!matchingDlcProduct; + }); + + return pendingOrders.map(order => ({ + ...order, + supplier: this.suppliers.get(order.supplierId)!, + group: this.groups.get(order.groupId)!, + creator: this.users.get(order.createdBy)! + })); + } + + async markClientCalled(customerOrderId: number, calledBy: string): Promise { + const order = this.customerOrders.get(customerOrderId); + if (!order) { + throw new Error(`CustomerOrder with id ${customerOrderId} not found`); + } + + const updatedOrder = { + ...order, + customerNotified: true, + updatedAt: new Date() + }; + + this.customerOrders.set(customerOrderId, updatedOrder); + return updatedOrder; + } + async getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise { let products = Array.from(this.dlcProducts.values()); diff --git a/tailwind.config.ts b/tailwind.config.ts index 307d868..19d297c 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -14,7 +14,12 @@ export default { 'p-3', 'p-4', 'px-3', 'px-4', 'py-2', 'py-3', 'gap-2', 'gap-3', 'gap-4', 'sm:gap-4', 'order-1', 'order-2', 'sm:order-1', 'sm:order-2', - 'flex-col', 'sm:flex-row', 'justify-stretch', 'sm:justify-end' + 'flex-col', 'sm:flex-row', 'justify-stretch', 'sm:justify-end', + // Classes pour alertes appels clients (production-safe) + 'bg-orange-50', 'border-orange-200', 'text-orange-700', 'bg-orange-600', 'hover:bg-orange-700', + 'bg-green-600', 'hover:bg-green-700', 'bg-blue-600', 'hover:bg-blue-700', + 'sticky', 'top-0', 'z-50', 'cursor-pointer', + 'max-w-2xl', 'w-full', 'mx-4', 'max-h-[80vh]', 'overflow-y-auto' ], theme: { extend: {