Merge pull request #439 from R0m1k3/1.1

1.1
This commit is contained in:
LogiFlow authored and GitHub committed 2025-09-11 12:47:53 +02:00
commit c1d86754ee
6 files changed
+381 -3

No files matched your search

+8
View File
@@ -15,6 +15,10 @@ run = ["npm", "run", "start"]
localPort = 5000
externalPort = 80
[[ports]]
localPort = 38145
externalPort = 3003
[[ports]]
localPort = 38451
externalPort = 3002
@@ -23,6 +27,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: {