mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-23 06:58:19 +02:00
feat: implement BAP webhook configuration management and customer order tracking support
This commit is contained in:
1 parent
576db2d7f4
commit
55191c511b
6 files changed
+208
-21
No files matched your search
@@ -29,7 +29,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||||
import { Plus, Edit, Trash2, Phone, PhoneCall, Printer, Eye, Package, AlertCircle } from "lucide-react";
|
import { Plus, Edit, Trash2, Phone, PhoneCall, Printer, Eye, Package, AlertCircle, MessageSquare } from "lucide-react";
|
||||||
import JsBarcode from 'jsbarcode';
|
import JsBarcode from 'jsbarcode';
|
||||||
import { safeFormat, safeDate } from "@/lib/dateUtils";
|
import { safeFormat, safeDate } from "@/lib/dateUtils";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
@@ -56,6 +56,9 @@ export default function CustomerOrders() {
|
|||||||
const [showStatusModal, setShowStatusModal] = useState(false);
|
const [showStatusModal, setShowStatusModal] = useState(false);
|
||||||
const [showClientCallsModal, setShowClientCallsModal] = useState(false);
|
const [showClientCallsModal, setShowClientCallsModal] = useState(false);
|
||||||
const [selectedOrder, setSelectedOrder] = useState<CustomerOrderWithRelations | null>(null);
|
const [selectedOrder, setSelectedOrder] = useState<CustomerOrderWithRelations | null>(null);
|
||||||
|
const [showContactModal, setShowContactModal] = useState(false);
|
||||||
|
const [contactOrder, setContactOrder] = useState<CustomerOrderWithRelations | null>(null);
|
||||||
|
const [contactComment, setContactComment] = useState("");
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [sortBy, setSortBy] = useState<"date" | "status" | "supplier">("date");
|
const [sortBy, setSortBy] = useState<"date" | "status" | "supplier">("date");
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||||
@@ -177,16 +180,28 @@ export default function CustomerOrders() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notification mutation
|
// Mutation marquer client contacté (avec date + commentaire)
|
||||||
const notificationMutation = useMutation({
|
const markCalledMutation = useMutation({
|
||||||
mutationFn: ({ id, customerNotified }: { id: number; customerNotified: boolean }) =>
|
mutationFn: ({ id, comment }: { id: number; comment?: string }) =>
|
||||||
apiRequest(`/api/customer-orders/${id}`, 'PUT', { customerNotified }),
|
apiRequest(`/api/customer-orders/${id}/mark-called`, 'PATCH', { comment }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString()?.includes('/api/customer-orders') ?? false });
|
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString()?.includes('/api/customer-orders') ?? false });
|
||||||
toast({
|
toast({ title: "Client marqué comme contacté" });
|
||||||
title: "Succès",
|
setShowContactModal(false);
|
||||||
description: "Statut de notification mis à jour",
|
setContactComment("");
|
||||||
});
|
setContactOrder(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutation annuler contact
|
||||||
|
const unmarkCalledMutation = useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
apiRequest(`/api/customer-orders/${id}`, 'PUT', { customerNotified: false, notifiedAt: null, notifiedComment: null }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString()?.includes('/api/customer-orders') ?? false });
|
||||||
|
toast({ title: "Contact annulé" });
|
||||||
|
setShowContactModal(false);
|
||||||
|
setContactOrder(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -266,10 +281,9 @@ export default function CustomerOrders() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNotificationToggle = (order: CustomerOrderWithRelations) => {
|
const handleNotificationToggle = (order: CustomerOrderWithRelations) => {
|
||||||
notificationMutation.mutate({
|
setContactOrder(order);
|
||||||
id: order.id,
|
setContactComment("");
|
||||||
customerNotified: !order.customerNotified
|
setShowContactModal(true);
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fonction pour calculer le checksum EAN13
|
// Fonction pour calculer le checksum EAN13
|
||||||
@@ -904,10 +918,14 @@ export default function CustomerOrders() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleNotificationToggle(order)}
|
onClick={() => handleNotificationToggle(order)}
|
||||||
className={order.customerNotified ? "bg-green-100" : ""}
|
className={order.customerNotified ? "bg-green-100 border-green-300" : ""}
|
||||||
|
title={order.customerNotified && (order as any).notifiedAt
|
||||||
|
? `Contacté le ${format(new Date((order as any).notifiedAt), "dd/MM/yyyy 'à' HH:mm", { locale: fr })}${(order as any).notifiedComment ? ` — ${(order as any).notifiedComment}` : ''}`
|
||||||
|
: "Marquer client comme contacté"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{order.customerNotified ? (
|
{order.customerNotified ? (
|
||||||
<PhoneCall className="h-4 w-4" />
|
<PhoneCall className="h-4 w-4 text-green-700" />
|
||||||
) : (
|
) : (
|
||||||
<Phone className="h-4 w-4" />
|
<Phone className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
@@ -1053,6 +1071,71 @@ export default function CustomerOrders() {
|
|||||||
onClose={() => setShowClientCallsModal(false)}
|
onClose={() => setShowClientCallsModal(false)}
|
||||||
pendingCalls={pendingCalls}
|
pendingCalls={pendingCalls}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Modal contact client */}
|
||||||
|
<Dialog open={showContactModal} onOpenChange={(open) => { setShowContactModal(open); if (!open) { setContactOrder(null); setContactComment(""); } }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{contactOrder?.customerNotified ? (
|
||||||
|
<><PhoneCall className="h-5 w-5 text-green-600" /> Client déjà contacté</>
|
||||||
|
) : (
|
||||||
|
<><Phone className="h-5 w-5 text-blue-600" /> Marquer client comme contacté</>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
{contactOrder && (
|
||||||
|
<DialogDescription>
|
||||||
|
{contactOrder.customerName} — {contactOrder.productDesignation}
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{contactOrder?.customerNotified ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md bg-green-50 border border-green-200 p-3 space-y-1 text-sm">
|
||||||
|
<p className="font-medium text-green-800">
|
||||||
|
Contacté le {contactOrder.notifiedAt ? format(new Date((contactOrder as any).notifiedAt), "dd/MM/yyyy 'à' HH:mm", { locale: fr }) : "—"}
|
||||||
|
</p>
|
||||||
|
{(contactOrder as any).notifiedComment && (
|
||||||
|
<p className="text-green-700 flex items-start gap-1.5">
|
||||||
|
<MessageSquare className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
{(contactOrder as any).notifiedComment}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => { setShowContactModal(false); setContactOrder(null); }}>Fermer</Button>
|
||||||
|
<Button variant="destructive" onClick={() => unmarkCalledMutation.mutate(contactOrder.id)} disabled={unmarkCalledMutation.isPending}>
|
||||||
|
Annuler le contact
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-700">Commentaire (optionnel)</label>
|
||||||
|
<textarea
|
||||||
|
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Ex : Laissé un message, Rappeler demain matin..."
|
||||||
|
value={contactComment}
|
||||||
|
onChange={(e) => setContactComment(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => { setShowContactModal(false); setContactOrder(null); setContactComment(""); }}>Annuler</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => markCalledMutation.mutate({ id: contactOrder!.id, comment: contactComment || undefined })}
|
||||||
|
disabled={markCalledMutation.isPending}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<PhoneCall className="h-4 w-4 mr-2" /> Confirmer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,13 +15,15 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Search,
|
Search,
|
||||||
Phone,
|
Phone,
|
||||||
|
PhoneCall,
|
||||||
User,
|
User,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Plus,
|
Plus,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
X,
|
X,
|
||||||
Filter,
|
Filter,
|
||||||
Loader2
|
Loader2,
|
||||||
|
MessageSquare
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
@@ -80,6 +82,8 @@ export default function MobileCustomerOrdersPage() {
|
|||||||
const { selectedStoreId } = useStore();
|
const { selectedStoreId } = useStore();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [contactOrder, setContactOrder] = useState<any | null>(null);
|
||||||
|
const [contactComment, setContactComment] = useState("");
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -124,6 +128,27 @@ export default function MobileCustomerOrdersPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const markCalledMutation = useMutation({
|
||||||
|
mutationFn: ({ id, comment }: { id: number; comment?: string }) =>
|
||||||
|
apiRequest(`/api/customer-orders/${id}/mark-called`, 'PATCH', { comment }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/customer-orders"] });
|
||||||
|
toast({ title: "Client marqué comme contacté" });
|
||||||
|
setContactOrder(null);
|
||||||
|
setContactComment("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const unmarkCalledMutation = useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
apiRequest(`/api/customer-orders/${id}`, 'PUT', { customerNotified: false, notifiedAt: null, notifiedComment: null }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/customer-orders"] });
|
||||||
|
toast({ title: "Contact annulé" });
|
||||||
|
setContactOrder(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => apiRequest('/api/customer-orders', 'POST', data),
|
mutationFn: (data: any) => apiRequest('/api/customer-orders', 'POST', data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -342,7 +367,7 @@ export default function MobileCustomerOrdersPage() {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mt-3 pt-3 border-t border-gray-100 text-sm text-gray-600">
|
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-gray-100 text-sm text-gray-600 flex-wrap">
|
||||||
{order.customerPhone && (
|
{order.customerPhone && (
|
||||||
<a
|
<a
|
||||||
href={`tel:${order.customerPhone}`}
|
href={`tel:${order.customerPhone}`}
|
||||||
@@ -352,6 +377,13 @@ export default function MobileCustomerOrdersPage() {
|
|||||||
<span className="font-medium">Appeler</span>
|
<span className="font-medium">Appeler</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { setContactOrder(order); setContactComment(""); }}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full transition-colors text-xs font-medium ${order.customerNotified ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||||
|
>
|
||||||
|
<PhoneCall className="h-3.5 w-3.5" />
|
||||||
|
{order.customerNotified ? 'Contacté' : 'Marquer contacté'}
|
||||||
|
</button>
|
||||||
<div className="flex items-center gap-1.5 ml-auto">
|
<div className="flex items-center gap-1.5 ml-auto">
|
||||||
<div className="bg-gray-100 px-2 py-1 rounded text-xs">
|
<div className="bg-gray-100 px-2 py-1 rounded text-xs">
|
||||||
Qty: {order.quantity || 1}
|
Qty: {order.quantity || 1}
|
||||||
@@ -598,6 +630,68 @@ export default function MobileCustomerOrdersPage() {
|
|||||||
>
|
>
|
||||||
<Plus className="h-6 w-6 text-white" />
|
<Plus className="h-6 w-6 text-white" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Modal contact client */}
|
||||||
|
{contactOrder && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50" onClick={() => { setContactOrder(null); setContactComment(""); }}>
|
||||||
|
<div className="w-full max-w-lg bg-white rounded-t-2xl p-5 space-y-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{contactOrder.customerNotified
|
||||||
|
? <PhoneCall className="h-5 w-5 text-green-600" />
|
||||||
|
: <Phone className="h-5 w-5 text-blue-600" />}
|
||||||
|
<h3 className="font-semibold text-gray-900">
|
||||||
|
{contactOrder.customerNotified ? "Client déjà contacté" : "Marquer comme contacté"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">{contactOrder.customerName} — {contactOrder.productDesignation}</p>
|
||||||
|
|
||||||
|
{contactOrder.customerNotified ? (
|
||||||
|
<div className="rounded-md bg-green-50 border border-green-200 p-3 space-y-1 text-sm">
|
||||||
|
<p className="font-medium text-green-800">
|
||||||
|
Contacté le {contactOrder.notifiedAt ? format(new Date(contactOrder.notifiedAt), "dd/MM/yyyy 'à' HH:mm") : "—"}
|
||||||
|
</p>
|
||||||
|
{contactOrder.notifiedComment && (
|
||||||
|
<p className="text-green-700 flex items-start gap-1.5">
|
||||||
|
<MessageSquare className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
{contactOrder.notifiedComment}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Commentaire optionnel (ex: laissé un message...)"
|
||||||
|
value={contactComment}
|
||||||
|
onChange={(e) => setContactComment(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<button onClick={() => { setContactOrder(null); setContactComment(""); }} className="flex-1 py-2.5 rounded-lg border border-gray-300 text-sm font-medium text-gray-700">
|
||||||
|
{contactOrder.customerNotified ? "Fermer" : "Annuler"}
|
||||||
|
</button>
|
||||||
|
{contactOrder.customerNotified ? (
|
||||||
|
<button
|
||||||
|
onClick={() => unmarkCalledMutation.mutate(contactOrder.id)}
|
||||||
|
disabled={unmarkCalledMutation.isPending}
|
||||||
|
className="flex-1 py-2.5 rounded-lg bg-red-600 text-white text-sm font-medium"
|
||||||
|
>
|
||||||
|
Annuler le contact
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => markCalledMutation.mutate({ id: contactOrder.id, comment: contactComment || undefined })}
|
||||||
|
disabled={markCalledMutation.isPending}
|
||||||
|
className="flex-1 py-2.5 rounded-lg bg-green-600 text-white text-sm font-medium flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<PhoneCall className="h-4 w-4" /> Confirmer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</MobileLayout>
|
</MobileLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,13 @@ export async function runMigrations() {
|
|||||||
|
|
||||||
// Ajouter les migrations hardcodées directement dans le code pour éviter les problèmes de chemins
|
// Ajouter les migrations hardcodées directement dans le code pour éviter les problèmes de chemins
|
||||||
const hardcodedMigrations = [
|
const hardcodedMigrations = [
|
||||||
|
{
|
||||||
|
filename: '20260402000002_add_notified_fields_to_customer_orders.sql',
|
||||||
|
content: `
|
||||||
|
ALTER TABLE customer_orders ADD COLUMN IF NOT EXISTS notified_at TIMESTAMP;
|
||||||
|
ALTER TABLE customer_orders ADD COLUMN IF NOT EXISTS notified_comment TEXT;
|
||||||
|
`
|
||||||
|
},
|
||||||
{
|
{
|
||||||
filename: '20260402000000_add_codefou_to_suppliers.sql',
|
filename: '20260402000000_add_codefou_to_suppliers.sql',
|
||||||
content: `ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS codefou VARCHAR;`
|
content: `ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS codefou VARCHAR;`
|
||||||
|
|||||||
+2
-3
@@ -3352,9 +3352,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedOrder = await storage.markClientCalled(id, user.id);
|
const { comment } = req.body;
|
||||||
|
const updatedOrder = await storage.markClientCalled(id, user.id, comment);
|
||||||
console.log(`📞 Client marked as called: order ${id} by user ${user.id} (${user.role})`);
|
|
||||||
res.json(updatedOrder);
|
res.json(updatedOrder);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error marking client as called:", error);
|
console.error("Error marking client as called:", error);
|
||||||
|
|||||||
+3
-1
@@ -1848,11 +1848,13 @@ export class DatabaseStorage implements IStorage {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async markClientCalled(customerOrderId: number, calledBy: string): Promise<CustomerOrder> {
|
async markClientCalled(customerOrderId: number, calledBy: string, comment?: string): Promise<CustomerOrder> {
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(customerOrders)
|
.update(customerOrders)
|
||||||
.set({
|
.set({
|
||||||
customerNotified: true,
|
customerNotified: true,
|
||||||
|
notifiedAt: new Date(),
|
||||||
|
notifiedComment: comment || null,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
})
|
})
|
||||||
.where(eq(customerOrders.id, customerOrderId))
|
.where(eq(customerOrders.id, customerOrderId))
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ export const customerOrders = pgTable("customer_orders", {
|
|||||||
|
|
||||||
// Communication client
|
// Communication client
|
||||||
customerNotified: boolean("customer_notified").default(false), // Client appelé
|
customerNotified: boolean("customer_notified").default(false), // Client appelé
|
||||||
|
notifiedAt: timestamp("notified_at"), // Date/heure du contact
|
||||||
|
notifiedComment: text("notified_comment"), // Commentaire lors de l'appel
|
||||||
|
|
||||||
// Notes additionnelles
|
// Notes additionnelles
|
||||||
notes: text("notes"), // Notes sur la commande
|
notes: text("notes"), // Notes sur la commande
|
||||||
|
|||||||
Reference in new issue
Block a user