Add a feature to notify users about pending client calls

Introduce functionality for tracking and displaying pending client calls. This includes new API endpoints for fetching and marking clients as called, along with frontend components to alert users and manage these calls.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 95e8aab4-f39f-4b48-aa57-a759ec15ba3d
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1957c339-2757-4d1f-8e92-e9f71a1ce58e/95e8aab4-f39f-4b48-aa57-a759ec15ba3d/U4JJoTO
This commit is contained in:
michaelschal committed 2025-09-11 10:46:27 +00:00
1 parent c0a7b06c34
commit 6c8f8a47ad
6 files changed
+377 -3

No files matched your search

+4
View File
@@ -23,6 +23,10 @@ externalPort = 3002
localPort = 41343
externalPort = 3001
[[ports]]
localPort = 43467
externalPort = 3000
[workflows]
runButton = "Project"
@@ -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<Set<number>>(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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Phone className="h-5 w-5 text-orange-600" />
Clients à Appeler
<Badge variant="secondary">{pendingCalls.length}</Badge>
</DialogTitle>
</DialogHeader>
<div className="text-sm text-gray-600 mb-4">
Produits disponibles - Les clients suivants n'ont pas encore été contactés :
</div>
{pendingCalls.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Phone className="h-12 w-12 mx-auto mb-3 text-gray-300" />
<p>Aucun client à appeler actuellement</p>
<p className="text-xs">Tous les clients ont été contactés ou aucun produit n'est disponible</p>
</div>
) : (
<ScrollArea className="max-h-[50vh]">
<div className="space-y-3">
{pendingCalls.map((order) => (
<div
key={order.id}
className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors"
data-testid={`client-call-item-${order.id}`}
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex-1 min-w-0">
{/* Client Info */}
<div className="flex items-center gap-2 mb-2">
<User className="h-4 w-4 text-blue-600 flex-shrink-0" />
<div className="min-w-0">
<div className="font-medium text-gray-900 truncate">
{order.customerName}
</div>
<div className="text-sm text-gray-600 flex items-center gap-1">
<Phone className="h-3 w-3" />
{order.customerPhone}
</div>
</div>
</div>
{/* Product Info */}
<div className="flex items-center gap-2 mb-2">
<Package className="h-4 w-4 text-green-600 flex-shrink-0" />
<div className="min-w-0">
<div className="text-sm text-gray-900 truncate">
{order.productDesignation}
</div>
<div className="text-xs text-gray-500">
Quantité: {order.quantity} • {order.supplier?.name}
</div>
</div>
</div>
{/* Store Info */}
<div className="flex items-center gap-2">
<Building className="h-4 w-4 text-purple-600 flex-shrink-0" />
<div className="text-sm text-gray-600">
{order.group?.name}
</div>
</div>
</div>
{/* Call Button */}
<div className="flex-shrink-0">
<Button
onClick={() => handleMarkCalled(order.id)}
disabled={callingIds.has(order.id)}
className="bg-green-600 hover:bg-green-700 text-white w-full sm:w-auto"
data-testid={`button-mark-called-${order.id}`}
>
<Phone className="h-4 w-4 mr-2" />
{callingIds.has(order.id) ? "Marquage..." : "Marquer Appelé"}
</Button>
</div>
</div>
</div>
))}
</div>
</ScrollArea>
)}
<div className="flex justify-end pt-4 border-t">
<Button variant="outline" onClick={onClose}>
Fermer
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+46 -2
View File
@@ -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<OrderWithRelations | null>(null);
const [orderToDelete, setOrderToDelete] = useState<OrderWithRelations | null>(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<CustomerOrderWithRelations[]>({
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<OrderWithRelations[]>({
queryKey: [ordersUrl, selectedStoreId],
queryFn: async () => {
@@ -252,6 +272,23 @@ export default function Orders() {
</div>
</div>
{/* Alerte pour les appels clients en attente */}
{!isPendingCallsLoading && pendingCalls.length > 0 && (
<Alert
className="sticky top-0 z-50 bg-orange-50 border-orange-200 cursor-pointer hover:bg-orange-100 transition-colors"
onClick={() => setShowClientCallsModal(true)}
data-testid="alert-client-calls"
>
<Phone className="h-4 w-4 text-orange-600" />
<AlertDescription className="text-orange-700">
<strong>📞 {pendingCalls.length} client{pendingCalls.length > 1 ? 's' : ''} à appeler</strong>
<span className="block text-sm mt-1">
Produits disponibles - Cliquez pour voir la liste des clients à contacter
</span>
</AlertDescription>
</Alert>
)}
{/* Filters */}
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
@@ -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 */}
<ClientCallsModal
isOpen={showClientCallsModal}
onClose={() => setShowClientCallsModal(false)}
pendingCalls={pendingCalls}
/>
</div>
);
}
+74
View File
@@ -2498,6 +2498,80 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// 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 {
+91
View File
@@ -179,6 +179,10 @@ export interface IStorage {
createCustomerOrder(customerOrder: InsertCustomerOrder): Promise<CustomerOrder>;
updateCustomerOrder(id: number, customerOrder: Partial<InsertCustomerOrder>): Promise<CustomerOrder>;
deleteCustomerOrder(id: number): Promise<void>;
// Client call tracking
getPendingClientCalls(groupIds?: number[]): Promise<CustomerOrderWithRelations[]>;
markClientCalled(customerOrderId: number, calledBy: string): Promise<CustomerOrder>;
// DLC Product operations
getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise<DlcProductWithRelations[]>;
@@ -1676,6 +1680,50 @@ export class DatabaseStorage implements IStorage {
await db.delete(customerOrders).where(eq(customerOrders.id, id));
}
async getPendingClientCalls(groupIds?: number[]): Promise<CustomerOrderWithRelations[]> {
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<CustomerOrder> {
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<DlcProductWithRelations[]> {
let query = db
.select({
@@ -3421,6 +3469,49 @@ export class MemStorage implements IStorage {
this.customerOrders.delete(id);
}
async getPendingClientCalls(groupIds?: number[]): Promise<CustomerOrderWithRelations[]> {
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<CustomerOrder> {
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<DlcProductWithRelations[]> {
let products = Array.from(this.dlcProducts.values());
+6 -1
View File
@@ -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: {