mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
6 files changed
+381
-3
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user