merge(main): resolve conflicts keeping codefou + email on suppliers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-05-15 11:14:35 +02:00
commit b8ecacee3b
20 files changed
+712 -292

No files matched your search

+95 -65
View File
@@ -3,6 +3,8 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useState, useRef } from "react";
import { Loader2, Search } from "lucide-react";
import { import {
Form, Form,
FormControl, FormControl,
@@ -68,15 +70,6 @@ export function CustomerOrderForm({
const { user } = useAuthUnified(); const { user } = useAuthUnified();
const { selectedStoreId } = useStore(); const { selectedStoreId } = useStore();
// DEBUG: Log user data to see what we actually receive
console.log("🔍 FRONTEND USER DEBUG:", {
user: user,
userRole: user?.role,
userGroups: user?.userGroups,
userGroupsType: typeof user?.userGroups,
userGroupsLength: user?.userGroups?.length,
fullUserObject: JSON.stringify(user, null, 2)
});
// Fetch groups for store selection // Fetch groups for store selection
const { data: groups = [] } = useQuery<Group[]>({ const { data: groups = [] } = useQuery<Group[]>({
@@ -128,47 +121,14 @@ export function CustomerOrderForm({
}); });
const handleSubmit = (data: CustomerOrderFormData) => { const handleSubmit = (data: CustomerOrderFormData) => {
console.log("🚀 FORM SUBMIT STARTED"); if (!data.customerName || data.customerName.trim() === '') return;
console.log("📝 Form submission data:", data);
console.log("🔍 Form errors:", form.formState.errors);
console.log("✅ Form is valid:", form.formState.isValid);
console.log("👤 User context DETAILED:", {
role: user?.role,
userGroups: user?.userGroups,
userGroupsLength: user?.userGroups?.length,
userGroupsData: user?.userGroups?.map(ug => ({
groupId: ug.groupId,
group: ug.group,
fullObject: ug
})),
selectedStoreId,
fullUser: user
});
// Validate required fields
if (!data.customerName || data.customerName.trim() === '') {
console.error("❌ Customer name is required but empty");
return;
}
// Always use user's assigned group - no override needed
const groupId = getUserAssignedGroupId(); const groupId = getUserAssignedGroupId();
if (!groupId) { if (!groupId) {
console.error("❌❌❌ CRITICAL: No group available for user:", {
role: user?.role,
userGroups: user?.userGroups,
userGroupsCount: user?.userGroups?.length,
selectedStoreId,
getUserAssignedGroupIdResult: groupId
});
alert("ERREUR: Votre utilisateur n'a pas de magasin assigné. Contactez l'administrateur."); alert("ERREUR: Votre utilisateur n'a pas de magasin assigné. Contactez l'administrateur.");
return; return;
} }
console.log("✅ Customer Order: Using assigned group:", groupId, "for user:", user?.role);
// Prepare final data with user's assigned group
const submitData = { const submitData = {
orderTaker: data.orderTaker || user?.firstName + ' ' + user?.lastName || user?.username || "Inconnu", orderTaker: data.orderTaker || user?.firstName + ' ' + user?.lastName || user?.username || "Inconnu",
customerName: data.customerName.trim(), customerName: data.customerName.trim(),
@@ -184,29 +144,75 @@ export function CustomerOrderForm({
isPromotionalPrice: data.isPromotionalPrice || false, isPromotionalPrice: data.isPromotionalPrice || false,
customerNotified: data.customerNotified || false, customerNotified: data.customerNotified || false,
notes: data.notes || '', notes: data.notes || '',
groupId: groupId, // Always use the assigned group groupId: groupId,
}; };
console.log("✅ Customer Order Submit FINAL:", {
userRole: user?.role,
assignedGroupId: groupId,
submitData: submitData
});
console.log("🔥 CALLING onSubmit with data:", submitData);
onSubmit(submitData); onSubmit(submitData);
console.log("🔥 onSubmit CALLED");
}; };
// Auto-ensure groupId is always set to user's assigned group // Auto-ensure groupId is always set to user's assigned group
const currentGroupId = form.getValues('groupId'); const currentGroupId = form.getValues('groupId');
const userGroupId = getUserAssignedGroupId(); const userGroupId = getUserAssignedGroupId();
if (!currentGroupId && userGroupId) { if (!currentGroupId && userGroupId) {
form.setValue('groupId', userGroupId); form.setValue('groupId', userGroupId);
console.log("🏪 Auto-setting user's assigned group:", userGroupId);
} }
// Lookup API ffnancy par gencode ou référence (déclenché manuellement)
const [articleLookupLoading, setArticleLookupLoading] = useState(false);
const [articleNotFound, setArticleNotFound] = useState(false);
const fetchAndFillArticle = async (params: URLSearchParams) => {
setArticleLookupLoading(true);
setArticleNotFound(false);
try {
const res = await fetch(`/api/ffnancy/articles?${params}&limit=1`, { credentials: 'include' });
if (!res.ok) { setArticleNotFound(true); return; }
const data = await res.json();
const article = data.articles?.[0];
if (!article) { setArticleNotFound(true); return; }
setArticleNotFound(false);
form.setValue("productName", article.libelle1, { shouldValidate: true });
if (article.gtin) form.setValue("gencode", article.gtin, { shouldValidate: true });
if (article.ref_fou_principale) form.setValue("productReference", article.ref_fou_principale, { shouldValidate: true });
// Fournisseur : dernière entrée en stock en priorité
let codefouToMatch = article.codefou_principal;
try {
const mvtRes = await fetch(
`/api/ffnancy/mouvements/entrees?artNoId=${article.no_id}&limit=1&dateDebut=2000-01-01`,
{ credentials: 'include' }
);
if (mvtRes.ok) {
const mvtData = await mvtRes.json();
const lastEntree = mvtData.entrees?.[0];
if (lastEntree?.codefou) codefouToMatch = lastEntree.codefou;
}
} catch { /* fallback codefou_principal */ }
if (codefouToMatch) {
const matched = (suppliers as any[]).find((s: any) =>
s.codefou && s.codefou.trim().toLowerCase() === codefouToMatch.trim().toLowerCase()
) || (suppliers as any[]).find((s: any) =>
s.name.toLowerCase().trim().includes(article.nom_fou_principal?.toLowerCase().trim() || '') ||
(article.nom_fou_principal?.toLowerCase().trim() || '').includes(s.name.toLowerCase().trim())
);
if (matched) form.setValue("supplierId", matched.id, { shouldValidate: true });
}
} catch { setArticleNotFound(true); } finally {
setArticleLookupLoading(false);
}
};
const handleSearchByGencode = () => {
const val = form.getValues("gencode");
if (val) fetchAndFillArticle(new URLSearchParams({ ean: val }));
};
const handleSearchByReference = () => {
const val = form.getValues("productReference");
if (val) fetchAndFillArticle(new URLSearchParams({ codein: val }));
};
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
@@ -310,7 +316,19 @@ export function CustomerOrderForm({
<FormItem> <FormItem>
<FormLabel>Référence (optionnel)</FormLabel> <FormLabel>Référence (optionnel)</FormLabel>
<FormControl> <FormControl>
<Input placeholder="REF-123456" {...field} /> <div className="flex gap-2">
<Input placeholder="REF-123456" {...field} />
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSearchByReference}
disabled={articleLookupLoading}
className="shrink-0"
>
{articleLookupLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
</Button>
</div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -324,7 +342,19 @@ export function CustomerOrderForm({
<FormItem> <FormItem>
<FormLabel>Gencode (obligatoire)</FormLabel> <FormLabel>Gencode (obligatoire)</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Code à barres" {...field} /> <div className="flex gap-2">
<Input placeholder="Code à barres" {...field} />
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSearchByGencode}
disabled={articleLookupLoading}
className="shrink-0"
>
{articleLookupLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
</Button>
</div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -403,6 +433,13 @@ export function CustomerOrderForm({
{/* Case "Client appelé" cachée pour nouvelles commandes */} {/* Case "Client appelé" cachée pour nouvelles commandes */}
</div> </div>
{/* Produit non référencé — uniquement en création */}
{articleNotFound && !order && (
<div className="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
<span className="font-medium">Produit non référencé</span> — ce produit n'existe pas dans la base. La commande ne peut pas être créée.
</div>
)}
{/* Actions */} {/* Actions */}
<div className="flex justify-end space-x-3 pt-4"> <div className="flex justify-end space-x-3 pt-4">
<Button <Button
@@ -415,14 +452,7 @@ export function CustomerOrderForm({
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading || (articleNotFound && !order) || articleLookupLoading}
onClick={(e) => {
console.log("Submit button clicked");
console.log("Form state:", form.formState);
console.log("Form values:", form.getValues());
console.log("Form validation errors:", form.formState.errors);
console.log("Current user context:", user?.userGroups, "available groups:", groups);
}}
> >
{isLoading ? "Enregistrement..." : order ? "Modifier" : "Créer"} {isLoading ? "Enregistrement..." : order ? "Modifier" : "Créer"}
</Button> </Button>
+2 -50
View File
@@ -28,9 +28,7 @@ export default function Layout({ children }: LayoutProps) {
const [selectedStoreId, setSelectedStoreId] = useState<number | null>(() => { const [selectedStoreId, setSelectedStoreId] = useState<number | null>(() => {
// Restaurer le selectedStoreId depuis localStorage si disponible // Restaurer le selectedStoreId depuis localStorage si disponible
const saved = localStorage.getItem('selectedStoreId'); const saved = localStorage.getItem('selectedStoreId');
const restoredId = saved ? parseInt(saved) : null; return saved ? parseInt(saved) : null;
console.log('🏪 Layout - Restoring selectedStoreId from localStorage:', { saved, restoredId });
return restoredId;
}); });
const [storeInitialized, setStoreInitialized] = useState(false); const [storeInitialized, setStoreInitialized] = useState(false);
@@ -39,10 +37,8 @@ export default function Layout({ children }: LayoutProps) {
// Restaurer l'état de la sidebar depuis localStorage, mais forcer collapsed sur mobile // Restaurer l'état de la sidebar depuis localStorage, mais forcer collapsed sur mobile
try { try {
const saved = localStorage.getItem('sidebarCollapsed'); const saved = localStorage.getItem('sidebarCollapsed');
console.log('🔧 Layout - Restoring sidebar state:', { saved, isMobile });
return isMobile ? true : (saved ? JSON.parse(saved) : false); return isMobile ? true : (saved ? JSON.parse(saved) : false);
} catch (error) { } catch {
console.error('Error reading sidebar state from localStorage:', error);
return isMobile ? true : false; return isMobile ? true : false;
} }
}); });
@@ -63,55 +59,21 @@ export default function Layout({ children }: LayoutProps) {
if (selectedStoreId) { if (selectedStoreId) {
const storeExists = stores.find(store => store.id === selectedStoreId); const storeExists = stores.find(store => store.id === selectedStoreId);
if (!storeExists) { if (!storeExists) {
console.log('🏪 Selected store not found in available stores, clearing selection:', {
selectedStoreId,
userRole: user.role,
availableStores: stores.map(s => s.id)
});
setSelectedStoreId(null); setSelectedStoreId(null);
localStorage.removeItem('selectedStoreId'); localStorage.removeItem('selectedStoreId');
needsUpdate = true; needsUpdate = true;
} else {
console.log('🏪 Selected store validated successfully:', {
selectedStoreId,
userRole: user.role,
storeName: storeExists.name
});
} }
} }
// IMPORTANT FIX: Pour les rôles directeur/manager, forcer la sélection d'un magasin spécifique // IMPORTANT FIX: Pour les rôles directeur/manager, forcer la sélection d'un magasin spécifique
// si aucun n'est sélectionné et qu'ils n'ont accès qu'à un seul magasin // si aucun n'est sélectionné et qu'ils n'ont accès qu'à un seul magasin
if ((user.role === 'directeur' || user.role === 'manager') && !selectedStoreId && stores.length === 1 && !needsUpdate) { if ((user.role === 'directeur' || user.role === 'manager') && !selectedStoreId && stores.length === 1 && !needsUpdate) {
console.log('🏪 Auto-selecting single available store for directeur/manager:', {
storeId: stores[0].id,
storeName: stores[0].name,
userRole: user.role,
userId: user.id,
previousSelectedStoreId: selectedStoreId,
timestamp: new Date().toISOString()
});
const singleStoreId = stores[0].id; const singleStoreId = stores[0].id;
setSelectedStoreId(singleStoreId); setSelectedStoreId(singleStoreId);
localStorage.setItem('selectedStoreId', singleStoreId.toString()); localStorage.setItem('selectedStoreId', singleStoreId.toString());
} }
// VALIDATION CRITIQUE: Pour les directeurs/managers, s'assurer qu'un magasin est toujours sélectionné // VALIDATION CRITIQUE: Pour les directeurs/managers, s'assurer qu'un magasin est toujours sélectionné
if ((user.role === 'directeur' || user.role === 'manager') && !selectedStoreId && !needsUpdate) {
console.log('🚨 CRITICAL: Directeur/Manager has no store selected, this will cause empty data display:', {
userId: user.id,
userRole: user.role,
availableStores: stores.map(s => ({ id: s.id, name: s.name }))
});
}
console.log('🏪 Store initialization complete:', {
user: user.role,
selectedStoreId,
storesCount: stores.length,
validatedStore: selectedStoreId ? stores.find(s => s.id === selectedStoreId)?.name : 'None',
autoSelected: (user.role === 'directeur' || user.role === 'manager') && !selectedStoreId && stores.length === 1
});
setStoreInitialized(true); setStoreInitialized(true);
} }
}, [user, stores, selectedStoreId]); }, [user, stores, selectedStoreId]);
@@ -172,15 +134,7 @@ export default function Layout({ children }: LayoutProps) {
<Select <Select
value={selectedStoreId?.toString() || (user.role === 'admin' ? "all" : "")} value={selectedStoreId?.toString() || (user.role === 'admin' ? "all" : "")}
onValueChange={(value) => { onValueChange={(value) => {
console.log('🏪 Store selector changed:', {
value,
userRole: user.role,
parsed: value === "all" ? null : parseInt(value)
});
const newStoreId = value === "all" ? null : parseInt(value); const newStoreId = value === "all" ? null : parseInt(value);
// Invalider toutes les variantes de queryKey pour changement magasin
console.log('🧹 Invalidating data caches for store change');
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: (query) => { predicate: (query) => {
const key = query.queryKey; const key = query.queryKey;
@@ -194,10 +148,8 @@ export default function Layout({ children }: LayoutProps) {
// Sauvegarder dans localStorage et mettre à jour l'état // Sauvegarder dans localStorage et mettre à jour l'état
if (newStoreId) { if (newStoreId) {
localStorage.setItem('selectedStoreId', newStoreId.toString()); localStorage.setItem('selectedStoreId', newStoreId.toString());
console.log('💾 Store saved to localStorage:', newStoreId);
} else { } else {
localStorage.removeItem('selectedStoreId'); localStorage.removeItem('selectedStoreId');
console.log('🗑️ Store removed from localStorage');
} }
setSelectedStoreId(newStoreId); setSelectedStoreId(newStoreId);
}} }}
@@ -26,7 +26,6 @@ import SavTickets from "@/pages/SavTickets";
import Avoirs from "@/pages/Avoirs"; import Avoirs from "@/pages/Avoirs";
import WeatherSettings from "@/pages/WeatherSettings"; import WeatherSettings from "@/pages/WeatherSettings";
import Analytics from "@/pages/Analytics"; import Analytics from "@/pages/Analytics";
import SalesAnalysisPage from "@/pages/SalesAnalysisPage";
import PaymentSchedulePage from "@/pages/PaymentSchedulePage"; import PaymentSchedulePage from "@/pages/PaymentSchedulePage";
import Contacts from "@/pages/Contacts"; import Contacts from "@/pages/Contacts";
import Layout from "@/components/Layout"; import Layout from "@/components/Layout";
@@ -127,7 +126,6 @@ function RouterProduction() {
<Route path="/sav" component={MobileSavPage} /> <Route path="/sav" component={MobileSavPage} />
<Route path="/avoirs" component={MobileAvoirsPage} /> <Route path="/avoirs" component={MobileAvoirsPage} />
<Route path="/analytics" component={Analytics} /> <Route path="/analytics" component={Analytics} />
<Route path="/sales-analysis" component={SalesAnalysisPage} />
<Route path="/payment-schedule" component={PaymentSchedulePage} /> <Route path="/payment-schedule" component={PaymentSchedulePage} />
{/* Redirection /auth vers dashboard */} {/* Redirection /auth vers dashboard */}
@@ -169,7 +167,6 @@ function RouterProduction() {
<Route path="/sav" component={SavTickets} /> <Route path="/sav" component={SavTickets} />
<Route path="/avoirs" component={Avoirs} /> <Route path="/avoirs" component={Avoirs} />
<Route path="/analytics" component={Analytics} /> <Route path="/analytics" component={Analytics} />
<Route path="/sales-analysis" component={SalesAnalysisPage} />
<Route path="/payment-schedule" component={PaymentSchedulePage} /> <Route path="/payment-schedule" component={PaymentSchedulePage} />
{/* Routes de compatibilité - redirection vers utilities */} {/* Routes de compatibilité - redirection vers utilities */}
+7 -33
View File
@@ -186,11 +186,6 @@ export default function Sidebar() {
} }
}; };
// Debug logging pour production
console.log('Sidebar - User:', user);
console.log('Sidebar - isLoading:', isLoading);
console.log('Sidebar - error:', error);
const handleLogout = async () => { const handleLogout = async () => {
try { try {
// Force logout via fetch to ensure session is destroyed // Force logout via fetch to ensure session is destroyed
@@ -208,13 +203,11 @@ export default function Sidebar() {
const toggleSidebar = () => { const toggleSidebar = () => {
const newCollapsed = !sidebarCollapsed; const newCollapsed = !sidebarCollapsed;
console.log('🔧 Sidebar - Toggling sidebar:', { from: sidebarCollapsed, to: newCollapsed });
setSidebarCollapsed(newCollapsed); setSidebarCollapsed(newCollapsed);
try { try {
localStorage.setItem('sidebarCollapsed', JSON.stringify(newCollapsed)); localStorage.setItem('sidebarCollapsed', JSON.stringify(newCollapsed));
console.log('💾 Sidebar state saved to localStorage:', newCollapsed); } catch {
} catch (error) { // ignore
console.error('Error saving sidebar state:', error);
} }
}; };
@@ -291,12 +284,6 @@ export default function Sidebar() {
icon: BarChart3, icon: BarChart3,
roles: ["admin", "directeur", "manager"] roles: ["admin", "directeur", "manager"]
}, },
{
path: "/sales-analysis",
label: "Analyse Vente",
icon: TrendingUp,
roles: ["admin", "directeur"]
},
] ]
}, },
{ {
@@ -377,12 +364,7 @@ export default function Sidebar() {
]; ];
const hasPermission = (roles: string[]) => { const hasPermission = (roles: string[]) => {
const hasRole = user?.role && roles.includes(user.role); return user?.role && roles.includes(user.role);
// Debug uniquement en développement pour éviter spam console
if (import.meta.env.MODE === 'development') {
console.log('hasPermission check:', { userRole: user?.role, roles, hasRole });
}
return hasRole;
}; };
@@ -405,14 +387,6 @@ export default function Sidebar() {
const sidebarClasses = getSidebarClasses(); const sidebarClasses = getSidebarClasses();
// Debug log pour vérifier l'état de la sidebar
console.log('🔧 Sidebar Debug:', {
isMobile,
sidebarCollapsed,
mobileMenuOpen,
width: sidebarCollapsed ? 'collapsed (64px)' : 'expanded (256px)',
classes: sidebarClasses
});
// Si l'utilisateur n'est pas encore chargé, afficher un état de chargement // Si l'utilisateur n'est pas encore chargé, afficher un état de chargement
if (isLoading) { if (isLoading) {
@@ -584,7 +558,7 @@ export default function Sidebar() {
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 py-2 px-3 overflow-y-hidden"> <nav className="flex-1 py-2 px-3 overflow-y-auto">
<div className="space-y-3"> <div className="space-y-3">
{menuSections.map((section, sectionIndex) => { {menuSections.map((section, sectionIndex) => {
// Vérifier si au moins un item de la section est accessible // Vérifier si au moins un item de la section est accessible
@@ -817,12 +791,12 @@ export default function Sidebar() {
<SelectValue placeholder="🎯 Choisir le destinataire" /> <SelectValue placeholder="🎯 Choisir le destinataire" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="Prissela" className="py-3"> <SelectItem value="Laurie" className="py-3">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center"> <div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-purple-600">P</span> <span className="text-sm font-medium text-purple-600">L</span>
</div> </div>
<span className="font-medium">Prissela</span> <span className="font-medium">Laurie</span>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value="Jeremy" className="py-3"> <SelectItem value="Jeremy" className="py-3">
+115 -34
View File
@@ -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
@@ -892,28 +906,30 @@ export default function CustomerOrders() {
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
{canShowButtons(order.status) && ( {canShowButtons(order.status) && (
<> <Button
<Button variant="outline"
variant="outline" size="sm"
size="sm" onClick={() => handlePrintLabel(order)}
onClick={() => handlePrintLabel(order)} >
> <Printer className="h-4 w-4" />
<Printer className="h-4 w-4" /> </Button>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleNotificationToggle(order)}
className={order.customerNotified ? "bg-green-100" : ""}
>
{order.customerNotified ? (
<PhoneCall className="h-4 w-4" />
) : (
<Phone className="h-4 w-4" />
)}
</Button>
</>
)} )}
<Button
variant="outline"
size="sm"
onClick={() => handleNotificationToggle(order)}
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 ? (
<PhoneCall className="h-4 w-4 text-green-700" />
) : (
<Phone className="h-4 w-4" />
)}
</Button>
{permissions.canDelete('customer-orders') && ( {permissions.canDelete('customer-orders') && (
<Button <Button
variant="outline" variant="outline"
@@ -1053,6 +1069,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>
); );
} }
+4 -22
View File
@@ -198,7 +198,8 @@ export default function Dashboard() {
queryKey: ['/api/ad-campaigns', 'upcoming'], queryKey: ['/api/ad-campaigns', 'upcoming'],
queryFn: async () => { queryFn: async () => {
// Essayer d'abord 2024, puis 2025 pour avoir toutes les publicités // Essayer d'abord 2024, puis 2025 pour avoir toutes les publicités
const years = [2024, 2025]; const currentYear = new Date().getFullYear();
const years = [currentYear - 1, currentYear, currentYear + 1];
let allPublicities: PublicityWithRelations[] = []; let allPublicities: PublicityWithRelations[] = [];
for (const year of years) { for (const year of years) {
@@ -307,15 +308,9 @@ export default function Dashboard() {
}) : []; // Afficher toutes les commandes en attente }) : []; // Afficher toutes les commandes en attente
const upcomingDeliveries = Array.isArray(allDeliveries) ? allDeliveries const upcomingDeliveries = Array.isArray(allDeliveries) ? allDeliveries
.filter((d: any) => { .filter((d: any) => d.status === 'planned')
const isPlanned = d.status === 'planned';
console.log('🚚 Dashboard - Delivery filter:', { id: d.id, status: d.status, isPlanned, scheduledDate: d.scheduledDate });
return isPlanned;
})
.sort((a: any, b: any) => new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime()) .sort((a: any, b: any) => new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime())
.slice(0, 4) : []; // Afficher les 4 prochaines livraisons .slice(0, 4) : [];
console.log('🚚 Dashboard - Upcoming deliveries result:', upcomingDeliveries.length, upcomingDeliveries);
// Calculs pour les statistiques // Calculs pour les statistiques
const pendingOrdersCount = Array.isArray(allOrders) ? allOrders.filter((order: any) => order.status === 'pending').length : 0; const pendingOrdersCount = Array.isArray(allOrders) ? allOrders.filter((order: any) => order.status === 'pending').length : 0;
@@ -372,19 +367,6 @@ export default function Dashboard() {
} }
}; };
console.log('📊 Dashboard Debug - Raw Data:', {
allOrders: Array.isArray(allOrders) ? allOrders.length : 'NOT_ARRAY',
allDeliveries: Array.isArray(allDeliveries) ? allDeliveries.length : 'NOT_ARRAY',
customerOrders: Array.isArray(customerOrders) ? customerOrders.length : 'NOT_ARRAY',
upcomingPublicities: Array.isArray(upcomingPublicities) ? upcomingPublicities.length : 'NOT_ARRAY',
stats: stats,
samples: {
order: Array.isArray(allOrders) && allOrders.length > 0 ? allOrders[0] : null,
delivery: Array.isArray(allDeliveries) && allDeliveries.length > 0 ? allDeliveries[0] : null,
customerOrder: Array.isArray(customerOrders) && customerOrders.length > 0 ? customerOrders[0] : null,
publicity: Array.isArray(upcomingPublicities) && upcomingPublicities.length > 0 ? upcomingPublicities[0] : null
}
});
// Statistiques pour les commandes clients // Statistiques pour les commandes clients
const ordersByStatus = { const ordersByStatus = {
+73 -7
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { AlertTriangle, Plus, Eye, Edit, Trash2, CheckCircle, CheckCircle2, Package, Clock, AlertCircle, Filter, Download, FileText, PackageX, RotateCcw, X } from "lucide-react"; import { AlertTriangle, Plus, Eye, Edit, Trash2, CheckCircle, CheckCircle2, Package, Clock, AlertCircle, Filter, Download, FileText, PackageX, RotateCcw, X, Loader2 } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -115,6 +115,66 @@ export default function DlcPage() {
}, },
}); });
// EAN lookup from external API
const [eanLookupLoading, setEanLookupLoading] = useState(false);
const eanDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const gencodeValue = form.watch("gencode");
useEffect(() => {
if (!gencodeValue || gencodeValue.length < 8) return;
if (eanDebounceRef.current) clearTimeout(eanDebounceRef.current);
eanDebounceRef.current = setTimeout(async () => {
setEanLookupLoading(true);
try {
// Étape 1 : récupérer l'article par EAN
const res = await fetch(`/api/ffnancy/articles?ean=${encodeURIComponent(gencodeValue)}&limit=1`, { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
const article = data.articles?.[0];
if (!article) return;
form.setValue("productName", article.libelle1, { shouldValidate: true });
// Étape 2 : chercher le fournisseur de la dernière entrée en stock
let codefouToMatch = article.codefou_principal;
try {
const mvtRes = await fetch(
`/api/ffnancy/mouvements/entrees?artNoId=${article.no_id}&limit=1&dateDebut=2000-01-01`,
{ credentials: 'include' }
);
if (mvtRes.ok) {
const mvtData = await mvtRes.json();
const lastEntree = mvtData.entrees?.[0];
if (lastEntree?.codefou) codefouToMatch = lastEntree.codefou;
}
} catch { /* fallback sur codefou_principal */ }
// Matching : codefou exact en priorité, fallback par nom
if (codefouToMatch) {
const matched = (suppliers as any[]).find((s: any) =>
s.codefou && s.codefou.trim().toLowerCase() === codefouToMatch.trim().toLowerCase()
) || (suppliers as any[]).find((s: any) =>
s.name.toLowerCase().trim().includes(article.nom_fou_principal?.toLowerCase().trim() || '') ||
(article.nom_fou_principal?.toLowerCase().trim() || '').includes(s.name.toLowerCase().trim())
);
if (matched) {
form.setValue("supplierId", matched.id, { shouldValidate: true });
}
}
} catch {
// Lookup best-effort, on ignore les erreurs
} finally {
setEanLookupLoading(false);
}
}, 600);
return () => {
if (eanDebounceRef.current) clearTimeout(eanDebounceRef.current);
};
}, [gencodeValue, suppliers]);
// Create mutation - optimized cache invalidation // Create mutation - optimized cache invalidation
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: InsertDlcProduct) => apiRequest("/api/dlc-products", "POST", data), mutationFn: (data: InsertDlcProduct) => apiRequest("/api/dlc-products", "POST", data),
@@ -670,11 +730,17 @@ export default function DlcPage() {
<FormItem> <FormItem>
<FormLabel>Code EAN13 (optionnel)</FormLabel> <FormLabel>Code EAN13 (optionnel)</FormLabel>
<FormControl> <FormControl>
<Input <div className="relative">
{...field} <Input
value={field.value || ""} {...field}
placeholder="1234567890123" value={field.value || ""}
/> placeholder="1234567890123"
className={eanLookupLoading ? "pr-8" : ""}
/>
{eanLookupLoading && (
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-blue-500" />
)}
</div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
+13
View File
@@ -44,6 +44,7 @@ export default function Suppliers() {
name: "", name: "",
contact: "", contact: "",
phone: "", phone: "",
codefou: "",
email: "", email: "",
hasDlc: false, hasDlc: false,
automaticReconciliation: false, automaticReconciliation: false,
@@ -200,6 +201,7 @@ export default function Suppliers() {
name: "", name: "",
contact: "", contact: "",
phone: "", phone: "",
codefou: "",
email: "", email: "",
hasDlc: false, hasDlc: false,
automaticReconciliation: false, automaticReconciliation: false,
@@ -239,6 +241,7 @@ export default function Suppliers() {
name: supplier.name || "", name: supplier.name || "",
contact: supplier.contact || "", contact: supplier.contact || "",
phone: supplier.phone || "", phone: supplier.phone || "",
codefou: (supplier as any).codefou || "",
email: (supplier as any).email || "", email: (supplier as any).email || "",
hasDlc: supplier.hasDlc || false, hasDlc: supplier.hasDlc || false,
automaticReconciliation: supplier.automaticReconciliation || false, automaticReconciliation: supplier.automaticReconciliation || false,
@@ -538,6 +541,16 @@ export default function Suppliers() {
/> />
</div> </div>
<div>
<Label htmlFor="codefou">Code fournisseur API <span className="text-xs text-gray-400 font-normal">(liaison ffnancy)</span></Label>
<Input
id="codefou"
value={formData.codefou}
onChange={(e) => handleChange('codefou', e.target.value)}
placeholder="ex: FOU01"
/>
</div>
<div> <div>
<Label htmlFor="email">Email de contact</Label> <Label htmlFor="email">Email de contact</Label>
<Input <Input
+2 -19
View File
@@ -9,7 +9,6 @@ import {
Cloud, Cloud,
Send, Send,
Code, Code,
TrendingUp
} from "lucide-react"; } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -22,7 +21,6 @@ import DatabaseDebug from "./DatabaseDebug";
import WeatherSettings from "./WeatherSettings"; import WeatherSettings from "./WeatherSettings";
import WebhookBAPConfig from "./WebhookBAPConfig"; import WebhookBAPConfig from "./WebhookBAPConfig";
import SQLExecutor from "./SQLExecutor"; import SQLExecutor from "./SQLExecutor";
import SalesAnalysisConfig from "./SalesAnalysisConfig";
export default function Utilities() { export default function Utilities() {
const { user } = useAuthUnified(); const { user } = useAuthUnified();
@@ -43,10 +41,8 @@ export default function Utilities() {
const canManageWeather = user?.role === 'admin'; const canManageWeather = user?.role === 'admin';
const canManageWebhookBAP = user?.role === 'admin'; const canManageWebhookBAP = user?.role === 'admin';
const canExecuteSQL = user?.role === 'admin'; const canExecuteSQL = user?.role === 'admin';
const canManageSalesAnalysis = user?.role === 'admin' || user?.role === 'directeur';
// Si l'utilisateur n'a aucune permission, afficher le message d'accès refusé // Si l'utilisateur n'a aucune permission, afficher le message d'accès refusé
if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL && !canManageSalesAnalysis) { if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL) {
return ( return (
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<Card className="w-96"> <Card className="w-96">
@@ -82,7 +78,7 @@ export default function Utilities() {
{/* Content avec onglets */} {/* Content avec onglets */}
<div className="flex-1 p-6 overflow-hidden"> <div className="flex-1 p-6 overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col"> <Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
<TabsList className="grid w-full grid-cols-7 mb-6"> <TabsList className="grid w-full grid-cols-6 mb-6">
{canManageBackups && ( {canManageBackups && (
<TabsTrigger value="backups" className="flex items-center gap-2"> <TabsTrigger value="backups" className="flex items-center gap-2">
<Database className="w-4 h-4" /> <Database className="w-4 h-4" />
@@ -119,12 +115,6 @@ export default function Utilities() {
Exécution SQL Exécution SQL
</TabsTrigger> </TabsTrigger>
)} )}
{canManageSalesAnalysis && (
<TabsTrigger value="salesanalysis" className="flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Analyse Vente
</TabsTrigger>
)}
</TabsList> </TabsList>
{/* Contenu des onglets */} {/* Contenu des onglets */}
@@ -176,13 +166,6 @@ export default function Utilities() {
</TabsContent> </TabsContent>
)} )}
{canManageSalesAnalysis && (
<TabsContent value="salesanalysis" className="flex-1 overflow-hidden">
<div className="h-full bg-gray-50 -m-6 p-6">
<SalesAnalysisConfig />
</div>
</TabsContent>
)}
</Tabs> </Tabs>
</div> </div>
</div> </div>
+1 -1
View File
@@ -163,7 +163,7 @@ export default function WebhookBAPConfig() {
Configuration Webhook BAP Configuration Webhook BAP
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Configurez l'URL du webhook n8n pour l'envoi des fichiers BAP vers Prissela et Jeremy Configurez l'URL du webhook n8n pour l'envoi des fichiers BAP vers Laurie et Jeremy
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
+173 -6
View File
@@ -2,7 +2,7 @@
* MobileCustomerOrdersPage.tsx * MobileCustomerOrdersPage.tsx
* Version mobile de la page Commandes Clients avec création * Version mobile de la page Commandes Clients avec création
*/ */
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
@@ -15,12 +15,16 @@ import {
Package, Package,
Search, Search,
Phone, Phone,
PhoneCall,
User, User,
ClipboardList, ClipboardList,
Plus, Plus,
MoreVertical, MoreVertical,
X, X,
Filter Filter,
Loader2,
MessageSquare,
Search
} 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";
@@ -79,6 +83,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();
@@ -123,6 +129,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: () => {
@@ -157,6 +184,62 @@ export default function MobileCustomerOrdersPage() {
} }
}); });
// Lookup API ffnancy par gencode ou référence (déclenché manuellement)
const [articleLookupLoading, setArticleLookupLoading] = useState(false);
const [articleNotFound, setArticleNotFound] = useState(false);
const fetchAndFillArticle = async (params: URLSearchParams) => {
setArticleLookupLoading(true);
setArticleNotFound(false);
try {
const res = await fetch(`/api/ffnancy/articles?${params}&limit=1`, { credentials: 'include' });
if (!res.ok) { setArticleNotFound(true); return; }
const data = await res.json();
const article = data.articles?.[0];
if (!article) { setArticleNotFound(true); return; }
setArticleNotFound(false);
form.setValue("productName", article.libelle1, { shouldValidate: true });
if (article.gtin) form.setValue("gencode", article.gtin, { shouldValidate: true });
if (article.ref_fou_principale) form.setValue("productReference", article.ref_fou_principale, { shouldValidate: true });
let codefouToMatch = article.codefou_principal;
try {
const mvtRes = await fetch(
`/api/ffnancy/mouvements/entrees?artNoId=${article.no_id}&limit=1&dateDebut=2000-01-01`,
{ credentials: 'include' }
);
if (mvtRes.ok) {
const mvtData = await mvtRes.json();
const lastEntree = mvtData.entrees?.[0];
if (lastEntree?.codefou) codefouToMatch = lastEntree.codefou;
}
} catch { /* fallback codefou_principal */ }
if (codefouToMatch) {
const matched = (suppliers as any[]).find((s: any) =>
s.codefou && s.codefou.trim().toLowerCase() === codefouToMatch.trim().toLowerCase()
) || (suppliers as any[]).find((s: any) =>
s.name.toLowerCase().trim().includes(article.nom_fou_principal?.toLowerCase().trim() || '') ||
(article.nom_fou_principal?.toLowerCase().trim() || '').includes(s.name.toLowerCase().trim())
);
if (matched) form.setValue("supplierId", matched.id, { shouldValidate: true });
}
} catch { setArticleNotFound(true); } finally {
setArticleLookupLoading(false);
}
};
const handleSearchByGencode = () => {
const val = form.getValues("gencode");
if (val) fetchAndFillArticle(new URLSearchParams({ ean: val }));
};
const handleSearchByReference = () => {
const val = form.getValues("productReference");
if (val) fetchAndFillArticle(new URLSearchParams({ codein: val }));
};
const onSubmit = (data: any) => { const onSubmit = (data: any) => {
// Validation basique groupe // Validation basique groupe
let groupId = selectedStoreId; let groupId = selectedStoreId;
@@ -274,7 +357,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}`}
@@ -284,6 +367,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}
@@ -380,7 +470,12 @@ export default function MobileCustomerOrdersPage() {
<FormItem> <FormItem>
<FormLabel>Référence (Optionnel)</FormLabel> <FormLabel>Référence (Optionnel)</FormLabel>
<FormControl> <FormControl>
<Input placeholder="REF-123..." {...field} /> <div className="flex gap-2">
<Input placeholder="REF-123..." {...field} />
<Button type="button" variant="outline" size="sm" onClick={handleSearchByReference} disabled={articleLookupLoading} className="shrink-0">
{articleLookupLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
</Button>
</div>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@@ -444,7 +539,12 @@ export default function MobileCustomerOrdersPage() {
<FormItem> <FormItem>
<FormLabel>Gencode (Scanner si dispo)</FormLabel> <FormLabel>Gencode (Scanner si dispo)</FormLabel>
<FormControl> <FormControl>
<Input placeholder="EAN13" {...field} /> <div className="flex gap-2">
<Input placeholder="EAN13" {...field} />
<Button type="button" variant="outline" size="sm" onClick={handleSearchByGencode} disabled={articleLookupLoading} className="shrink-0">
{articleLookupLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
</Button>
</div>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@@ -504,7 +604,12 @@ export default function MobileCustomerOrdersPage() {
)} )}
/> />
<Button type="submit" className="w-full bg-blue-600 h-12 text-lg font-bold shadow-lg mt-4" disabled={createMutation.isPending}> {articleNotFound && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
<span className="font-medium">Produit non référencé</span> — ce produit n'existe pas dans la base. La commande ne peut pas être créée.
</div>
)}
<Button type="submit" className="w-full bg-blue-600 h-12 text-lg font-bold shadow-lg mt-4" disabled={createMutation.isPending || articleNotFound || articleLookupLoading}>
{createMutation.isPending ? "Création..." : "Valider la commande"} {createMutation.isPending ? "Création..." : "Valider la commande"}
</Button> </Button>
</form> </form>
@@ -519,6 +624,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>
); );
} }
+73 -3
View File
@@ -2,7 +2,7 @@
* MobileDlcPage.tsx * MobileDlcPage.tsx
* Version mobile de la page Gestion des DLC * Version mobile de la page Gestion des DLC
*/ */
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext"; import { useStore } from "@/contexts/StoreContext";
@@ -20,7 +20,8 @@ import {
CheckCircle, CheckCircle,
X, X,
Calendar, Calendar,
MoreVertical MoreVertical,
Loader2
} from "lucide-react"; } from "lucide-react";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
@@ -139,6 +140,66 @@ export default function MobileDlcPage() {
} }
}); });
// EAN lookup from external API
const [eanLookupLoading, setEanLookupLoading] = useState(false);
const eanDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const gencodeValue = form.watch("gencode");
useEffect(() => {
if (!gencodeValue || gencodeValue.length < 8) return;
if (eanDebounceRef.current) clearTimeout(eanDebounceRef.current);
eanDebounceRef.current = setTimeout(async () => {
setEanLookupLoading(true);
try {
// Étape 1 : récupérer l'article par EAN
const res = await fetch(`/api/ffnancy/articles?ean=${encodeURIComponent(gencodeValue)}&limit=1`, { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
const article = data.articles?.[0];
if (!article) return;
form.setValue("productName", article.libelle1, { shouldValidate: true });
// Étape 2 : chercher le fournisseur de la dernière entrée en stock
let codefouToMatch = article.codefou_principal;
try {
const mvtRes = await fetch(
`/api/ffnancy/mouvements/entrees?artNoId=${article.no_id}&limit=1&dateDebut=2000-01-01`,
{ credentials: 'include' }
);
if (mvtRes.ok) {
const mvtData = await mvtRes.json();
const lastEntree = mvtData.entrees?.[0];
if (lastEntree?.codefou) codefouToMatch = lastEntree.codefou;
}
} catch { /* fallback sur codefou_principal */ }
// Matching : codefou exact en priorité, fallback par nom
if (codefouToMatch) {
const matched = (suppliers as any[]).find((s: any) =>
s.codefou && s.codefou.trim().toLowerCase() === codefouToMatch.trim().toLowerCase()
) || (suppliers as any[]).find((s: any) =>
s.name.toLowerCase().trim().includes(article.nom_fou_principal?.toLowerCase().trim() || '') ||
(article.nom_fou_principal?.toLowerCase().trim() || '').includes(s.name.toLowerCase().trim())
);
if (matched) {
form.setValue("supplierId", matched.id, { shouldValidate: true });
}
}
} catch {
// Lookup best-effort, on ignore les erreurs
} finally {
setEanLookupLoading(false);
}
}, 600);
return () => {
if (eanDebounceRef.current) clearTimeout(eanDebounceRef.current);
};
}, [gencodeValue, suppliers]);
const onSubmit = (data: any) => { const onSubmit = (data: any) => {
let groupId = selectedStoreId; let groupId = selectedStoreId;
if (!groupId && user?.userGroups?.[0]?.groupId) groupId = user.userGroups[0].groupId; if (!groupId && user?.userGroups?.[0]?.groupId) groupId = user.userGroups[0].groupId;
@@ -386,7 +447,16 @@ export default function MobileDlcPage() {
<FormItem> <FormItem>
<FormLabel>Gencode (Scanner si dispo)</FormLabel> <FormLabel>Gencode (Scanner si dispo)</FormLabel>
<FormControl> <FormControl>
<Input placeholder="EAN13" {...field} /> <div className="relative">
<Input
placeholder="EAN13"
{...field}
className={eanLookupLoading ? "pr-8" : ""}
/>
{eanLookupLoading && (
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-blue-500" />
)}
</div>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
+2 -2
View File
@@ -5,8 +5,8 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "NODE_ENV=development tsx server/index.ts", "dev": "NODE_ENV=development tsx server/index.ts",
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist", "build": "vite build && tsc --project tsconfig.server.json",
"start": "NODE_ENV=production node dist/index.js", "start": "NODE_ENV=production node dist/server/index.js",
"check": "tsc", "check": "tsc",
"db:push": "drizzle-kit push" "db:push": "drizzle-kit push"
}, },
+33
View File
@@ -0,0 +1,33 @@
import fetch from 'node-fetch';
import FormData from 'form-data';
const webhookUrl = 'https://workflow.ffnancy.fr/webhook-test/acf9cbf7-040a-4cf5-a43d-80210420d30a';
async function testWebhook() {
console.log('🚀 Testing webhook:', webhookUrl);
const formData = new FormData();
formData.append('file', Buffer.from('test content'), {
filename: 'test.txt',
contentType: 'text/plain'
});
formData.append('supplier', 'Test Supplier');
formData.append('blNumber', '12345');
formData.append('type', 'Facture');
try {
const response = await fetch(webhookUrl, {
method: 'POST',
body: formData,
headers: formData.getHeaders()
});
console.log('📡 Response status:', response.status);
const text = await response.text();
console.log('📄 Response body:', text);
} catch (error) {
console.error('❌ Error calling webhook:', error);
}
}
testWebhook();
+27
View File
@@ -0,0 +1,27 @@
const webhookUrl = 'https://workflow.ffnancy.fr/webhook-test/acf9cbf7-040a-4cf5-a43d-80210420d30a';
async function testWebhook() {
console.log('🚀 Testing webhook (Native Fetch/FormData):', webhookUrl);
const formData = new FormData();
const fileBlob = new Blob(['test content'], { type: 'text/plain' });
formData.append('file', fileBlob, 'test.txt');
formData.append('supplier', 'Test Supplier');
formData.append('blNumber', '12345');
formData.append('type', 'Facture');
try {
const response = await fetch(webhookUrl, {
method: 'POST',
body: formData
});
console.log('📡 Response status:', response.status);
const text = await response.text();
console.log('📄 Response body:', text);
} catch (error) {
console.error('❌ Error calling webhook:', error);
}
}
testWebhook();
+11
View File
@@ -36,6 +36,17 @@ 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',
content: `ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS codefou VARCHAR;`
},
{ {
filename: '20250903141000_create_webhook_bap_config.sql', filename: '20250903141000_create_webhook_bap_config.sql',
content: `CREATE TABLE IF NOT EXISTS webhook_bap_config ( content: `CREATE TABLE IF NOT EXISTS webhook_bap_config (
+56 -20
View File
@@ -6,7 +6,8 @@ import { requireModulePermission, requireAdmin, requirePermission } from "./perm
import { db, pool } from "./db"; import { db, pool } from "./db";
import { createRequire } from "module"; import { createRequire } from "module";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const FormData = require('form-data'); // Suppression de l'import form-data car nous utilisons le FormData natif de Node.js 18+
// const FormData = require('form-data');
console.log('🔍 Using development storage and authentication'); console.log('🔍 Using development storage and authentication');
@@ -699,7 +700,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
return res.status(400).json({ error: 'Données manquantes: pdfBase64, fileName ou recipient' }); return res.status(400).json({ error: 'Données manquantes: pdfBase64, fileName ou recipient' });
} }
if (!['Prissela', 'Jeremy'].includes(recipient)) { if (!['Laurie', 'Jeremy'].includes(recipient)) {
return res.status(400).json({ error: 'Destinataire invalide' }); return res.status(400).json({ error: 'Destinataire invalide' });
} }
@@ -862,8 +863,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Parser le multipart/form-data manuellement // Parser le multipart/form-data manuellement
const contentType = req.headers['content-type'] || ''; const contentType = req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/boundary=(.+)$/); // Gestion plus robuste du boundary (avec ou sans guillemets)
const boundaryMatch = contentType.match(/boundary="?([^";]+)"?/i);
if (!boundaryMatch) { if (!boundaryMatch) {
console.error('❌ INVOICE PROXY: Boundary manquant dans Content-Type', contentType);
return res.status(400).json({ error: 'Format multipart invalide' }); return res.status(400).json({ error: 'Format multipart invalide' });
} }
@@ -890,32 +893,35 @@ export async function registerRoutes(app: Express): Promise<Server> {
size: parts.file.buffer.length size: parts.file.buffer.length
}); });
// Import dynamique standard pour ESM (sans eval) // Utilisation du FormData natif de Node.js (global)
console.log('🔍 INVOICE PROXY: FormData loaded', { // On utilise Blob pour transformer le Buffer en objet compatible
type: typeof FormData,
isConstructor: typeof FormData === 'function'
});
const formData = new FormData(); const formData = new FormData();
formData.append('file', parts.file.buffer, { const fileBlob = new Blob([parts.file.buffer], { type: parts.file.contentType });
filename: parts.file.filename,
contentType: parts.file.contentType formData.append('file', fileBlob, parts.file.filename);
});
formData.append('supplier', parts.supplier || ''); formData.append('supplier', parts.supplier || '');
formData.append('blNumber', parts.blNumber || ''); formData.append('blNumber', parts.blNumber || '');
formData.append('type', parts.type || 'Facture'); formData.append('type', parts.type || 'Facture');
console.log('🔗 INVOICE PROXY: Calling webhook URL:', parts.webhookUrl); console.log('🔗 INVOICE PROXY: Calling webhook URL:', parts.webhookUrl);
// En utilisant le FormData natif avec fetch, pas besoin de headers manuels (boundary géré automatiquement)
const response = await fetch(parts.webhookUrl, { const response = await fetch(parts.webhookUrl, {
method: 'POST', method: 'POST',
body: formData as any, body: formData as any
headers: formData.getHeaders()
}); });
if (!response.ok) { if (!response.ok) {
console.error('❌ INVOICE PROXY: Webhook error', response.status); const errorBody = await response.text().catch(() => 'No body');
return res.status(500).json({ error: `Webhook error: ${response.status}` }); console.error('❌ INVOICE PROXY: Webhook error', {
status: response.status,
statusText: response.statusText,
body: errorBody
});
return res.status(500).json({
error: `Webhook error: ${response.status}`,
details: errorBody
});
} }
console.log('✅ INVOICE PROXY: Success'); console.log('✅ INVOICE PROXY: Success');
@@ -3423,9 +3429,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);
@@ -6102,6 +6107,37 @@ RÉSUMÉ DU SCAN
} }
}); });
// Proxy API ffnancy - évite les problèmes CORS côté navigateur
app.get('/api/ffnancy/articles', requireAuth, async (req: Request, res: Response) => {
try {
const params = new URLSearchParams();
const allowed = ['search', 'codein', 'ean', 'codefou', 'actif', 'page', 'limit'];
for (const key of allowed) {
if (req.query[key]) params.append(key, req.query[key] as string);
}
const response = await fetch(`https://api.ffnancy.fr/api/articles?${params}`);
if (!response.ok) return res.status(response.status).json({ error: 'API ffnancy error' });
res.json(await response.json());
} catch {
res.status(500).json({ error: 'Failed to reach API ffnancy' });
}
});
app.get('/api/ffnancy/mouvements/entrees', requireAuth, async (req: Request, res: Response) => {
try {
const params = new URLSearchParams();
const allowed = ['artNoId', 'dateDebut', 'dateFin', 'site', 'page', 'limit'];
for (const key of allowed) {
if (req.query[key]) params.append(key, req.query[key] as string);
}
const response = await fetch(`https://api.ffnancy.fr/api/mouvements/entrees?${params}`);
if (!response.ok) return res.status(response.status).json({ error: 'API ffnancy error' });
res.json(await response.json());
} catch {
res.status(500).json({ error: 'Failed to reach API ffnancy' });
}
});
// Create server instance // Create server instance
const httpServer = createServer(app); const httpServer = createServer(app);
+3 -1
View File
@@ -1883,11 +1883,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))
+3
View File
@@ -80,6 +80,7 @@ export const suppliers = pgTable("suppliers", {
name: varchar("name").notNull(), name: varchar("name").notNull(),
contact: varchar("contact"), contact: varchar("contact"),
phone: varchar("phone"), phone: varchar("phone"),
codefou: varchar("codefou"), // Code fournisseur dans l'API ffnancy
email: varchar("email"), email: varchar("email"),
hasDlc: boolean("has_dlc").default(false), // Coche DLC pour la gestion DLC hasDlc: boolean("has_dlc").default(false), // Coche DLC pour la gestion DLC
paymentMethod: varchar("payment_method"), // Mode de paiement: Virement, Traite, Traite Magnétique, Chèque paymentMethod: varchar("payment_method"), // Mode de paiement: Virement, Traite, Traite Magnétique, Chèque
@@ -244,6 +245,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
+19 -26
View File
@@ -1,38 +1,31 @@
# Task: Correction blocage API Publicités # Task: Debugging Webhook Error 500 on Invoice Sending
## Context ## Context
L'utilisateur rencontre une erreur `net::ERR_BLOCKED_BY_CLIENT` lors de l'appel à l'API `/api/publicities`. Cette erreur est typique des bloqueurs de publicité (AdBlock, uBlock) qui filtrent les requêtes contenant des mots-clés comme "publicity" ou "ads". L'utilisateur a redémarré son serveur et rencontre une erreur 500 lors de l'envoi d'une facture via le proxy `/api/reconciliation/send-invoice`. L'appel vers le webhook N8N (`https://workflow.ffnancy.fr/webhook/...`) échoue avec un code 500.
## Current Focus ## Current Focus
Renommer les endpoints API pour éviter le blocage par les bloqueurs de publicité. Identifier la cause de l'erreur 500 lors de l'appel au webhook N8N et corriger le comportement du proxy de facture.
## Master Plan ## Master Plan
- [x] **Remplacement des API calls (Client-side)** - [x] **Analyse du code source**
- [x] `client/src/pages/Calendar.tsx` - [x] Examiner `server/routes.ts` pour trouver l'endpoint `/api/reconciliation/send-invoice`.
- [x] `client/src/pages/Dashboard.tsx` - [x] Analyser la construction de la requête vers N8N (utilisation de `FormData` natif vs bibliothèque `form-data`).
- [x] `client/src/components/PublicityForm.tsx` - [x] **Investigation technique**
- [x] `client/src/pages/Publicities.tsx` - [x] Vérifier la version de Node.js (v24.11.1 détectée).
- [x] `client/src/pages/mobile/PublicitiesPage.tsx` - [x] Identifier pourquoi N8N renvoie 500 (problème de format multipart et de headers avec l'ancienne bibliothèque).
- [x] Prendre en compte l'URL de test fournie : `https://workflow.ffnancy.fr/webhook-test/...`.
- [x] **Revue du sélecteur d'année Mobile** - [x] **Correction et Test**
- [x] Vérifier la logique de génération des années dans `client/src/pages/mobile/PublicitiesPage.tsx` pour inclure les années passées (ex: 2024 en 2026). - [x] Remplacer l'implémentation personnalisée par une utilisation plus propre des APIs natives Node.js.
- [x] Aligner avec la correction desktop si nécessaire. - [x] Améliorer le parsing du boundary pour gérer les guillemets.
- [x] Utiliser les APIs natives `fetch` et `FormData` de Node.js pour plus de robustesse.
- [x] **Correction des erreurs Lint TypeScript** - [/] Demander à l'utilisateur de tester après avoir reconstruit son image Docker (`docker compose build`).
- [x] Installer les paquets `@types` manquants (`node`, `express`, `react`, etc.). - [ ] Analyser la réponse détaillée du webhook si l'erreur persiste.
- [x] Fixer les paramètres `req` et `res` (implicitement `any`) dans `server/routes.ts`.
- [x] Corriger l'erreur de propriété `invoiceAmountTTC` dans `server/routes.ts`.
- [x] Résoudre `Property 'env' does not exist on type 'ImportMeta'`.
- [x] Résoudre `JSX element implicitly has type 'any'`.
- [x] **Tests et Validation**
- [x] Vérifier que les appels API utilisent bien `/api/ad-campaigns`.
- [x] Tester le sélecteur d'année sur mobile.
- [x] Compiler le projet (`npm run check`) pour s'assurer que les erreurs critiques sont résolues.
## Progress Log ## Progress Log
- Création de la tâche suite aux logs fournis. - Initialisation de la tâche pour déboguer l'erreur 500 sur l'envoi de facture.
- Analyse du code : le proxy utilisait une ancienne bibliothèque `form-data` manuellement importée via `require`, ce qui peut causer des problèmes de compatibilité en Node 24.
- Correction appliquée : passage aux APIs natives `fetch` et `FormData` de Node.js et amélioration du parsing du boundary.