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
+719 -299

No files matched your search

+102 -72
View File
@@ -3,6 +3,8 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useQuery } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useState, useRef } from "react";
import { Loader2, Search } from "lucide-react";
import {
Form,
FormControl,
@@ -68,15 +70,6 @@ export function CustomerOrderForm({
const { user } = useAuthUnified();
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
const { data: groups = [] } = useQuery<Group[]>({
@@ -128,51 +121,18 @@ export function CustomerOrderForm({
});
const handleSubmit = (data: CustomerOrderFormData) => {
console.log("🚀 FORM SUBMIT STARTED");
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
if (!data.customerName || data.customerName.trim() === '') return;
const groupId = getUserAssignedGroupId();
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.");
return;
}
console.log("✅ Customer Order: Using assigned group:", groupId, "for user:", user?.role);
// Prepare final data with user's assigned group
const submitData = {
orderTaker: data.orderTaker || user?.firstName + ' ' + user?.lastName || user?.username || "Inconnu",
customerName: data.customerName.trim(),
customerPhone: data.contactNumber || '',
customerPhone: data.contactNumber || '',
customerEmail: data.customerEmail || '',
productDesignation: data.productName || '',
productReference: data.productReference || '',
@@ -184,29 +144,75 @@ export function CustomerOrderForm({
isPromotionalPrice: data.isPromotionalPrice || false,
customerNotified: data.customerNotified || false,
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);
console.log("🔥 onSubmit CALLED");
};
// Auto-ensure groupId is always set to user's assigned group
const currentGroupId = form.getValues('groupId');
const userGroupId = getUserAssignedGroupId();
if (!currentGroupId && 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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
@@ -310,7 +316,19 @@ export function CustomerOrderForm({
<FormItem>
<FormLabel>Référence (optionnel)</FormLabel>
<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>
<FormMessage />
</FormItem>
@@ -324,7 +342,19 @@ export function CustomerOrderForm({
<FormItem>
<FormLabel>Gencode (obligatoire)</FormLabel>
<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>
<FormMessage />
</FormItem>
@@ -403,26 +433,26 @@ export function CustomerOrderForm({
{/* Case "Client appelé" cachée pour nouvelles commandes */}
</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 */}
<div className="flex justify-end space-x-3 pt-4">
<Button
type="button"
variant="outline"
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isLoading}
>
Annuler
</Button>
<Button
<Button
type="submit"
disabled={isLoading}
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);
}}
disabled={isLoading || (articleNotFound && !order) || articleLookupLoading}
>
{isLoading ? "Enregistrement..." : order ? "Modifier" : "Créer"}
</Button>
+2 -50
View File
@@ -28,9 +28,7 @@ export default function Layout({ children }: LayoutProps) {
const [selectedStoreId, setSelectedStoreId] = useState<number | null>(() => {
// Restaurer le selectedStoreId depuis localStorage si disponible
const saved = localStorage.getItem('selectedStoreId');
const restoredId = saved ? parseInt(saved) : null;
console.log('🏪 Layout - Restoring selectedStoreId from localStorage:', { saved, restoredId });
return restoredId;
return saved ? parseInt(saved) : null;
});
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
try {
const saved = localStorage.getItem('sidebarCollapsed');
console.log('🔧 Layout - Restoring sidebar state:', { saved, isMobile });
return isMobile ? true : (saved ? JSON.parse(saved) : false);
} catch (error) {
console.error('Error reading sidebar state from localStorage:', error);
} catch {
return isMobile ? true : false;
}
});
@@ -63,55 +59,21 @@ export default function Layout({ children }: LayoutProps) {
if (selectedStoreId) {
const storeExists = stores.find(store => store.id === selectedStoreId);
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);
localStorage.removeItem('selectedStoreId');
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
// 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) {
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;
setSelectedStoreId(singleStoreId);
localStorage.setItem('selectedStoreId', singleStoreId.toString());
}
// 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);
}
}, [user, stores, selectedStoreId]);
@@ -172,15 +134,7 @@ export default function Layout({ children }: LayoutProps) {
<Select
value={selectedStoreId?.toString() || (user.role === 'admin' ? "all" : "")}
onValueChange={(value) => {
console.log('🏪 Store selector changed:', {
value,
userRole: user.role,
parsed: 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({
predicate: (query) => {
const key = query.queryKey;
@@ -194,10 +148,8 @@ export default function Layout({ children }: LayoutProps) {
// Sauvegarder dans localStorage et mettre à jour l'état
if (newStoreId) {
localStorage.setItem('selectedStoreId', newStoreId.toString());
console.log('💾 Store saved to localStorage:', newStoreId);
} else {
localStorage.removeItem('selectedStoreId');
console.log('🗑️ Store removed from localStorage');
}
setSelectedStoreId(newStoreId);
}}
@@ -26,7 +26,6 @@ import SavTickets from "@/pages/SavTickets";
import Avoirs from "@/pages/Avoirs";
import WeatherSettings from "@/pages/WeatherSettings";
import Analytics from "@/pages/Analytics";
import SalesAnalysisPage from "@/pages/SalesAnalysisPage";
import PaymentSchedulePage from "@/pages/PaymentSchedulePage";
import Contacts from "@/pages/Contacts";
import Layout from "@/components/Layout";
@@ -127,7 +126,6 @@ function RouterProduction() {
<Route path="/sav" component={MobileSavPage} />
<Route path="/avoirs" component={MobileAvoirsPage} />
<Route path="/analytics" component={Analytics} />
<Route path="/sales-analysis" component={SalesAnalysisPage} />
<Route path="/payment-schedule" component={PaymentSchedulePage} />
{/* Redirection /auth vers dashboard */}
@@ -169,7 +167,6 @@ function RouterProduction() {
<Route path="/sav" component={SavTickets} />
<Route path="/avoirs" component={Avoirs} />
<Route path="/analytics" component={Analytics} />
<Route path="/sales-analysis" component={SalesAnalysisPage} />
<Route path="/payment-schedule" component={PaymentSchedulePage} />
{/* 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 () => {
try {
// Force logout via fetch to ensure session is destroyed
@@ -208,13 +203,11 @@ export default function Sidebar() {
const toggleSidebar = () => {
const newCollapsed = !sidebarCollapsed;
console.log('🔧 Sidebar - Toggling sidebar:', { from: sidebarCollapsed, to: newCollapsed });
setSidebarCollapsed(newCollapsed);
try {
localStorage.setItem('sidebarCollapsed', JSON.stringify(newCollapsed));
console.log('💾 Sidebar state saved to localStorage:', newCollapsed);
} catch (error) {
console.error('Error saving sidebar state:', error);
} catch {
// ignore
}
};
@@ -291,12 +284,6 @@ export default function Sidebar() {
icon: BarChart3,
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 hasRole = 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;
return user?.role && roles.includes(user.role);
};
@@ -405,14 +387,6 @@ export default function Sidebar() {
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
if (isLoading) {
@@ -584,7 +558,7 @@ export default function Sidebar() {
</div>
{/* 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">
{menuSections.map((section, sectionIndex) => {
// 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" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Prissela" className="py-3">
<SelectItem value="Laurie" className="py-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">
<span className="text-sm font-medium text-purple-600">P</span>
<span className="text-sm font-medium text-purple-600">L</span>
</div>
<span className="font-medium">Prissela</span>
<span className="font-medium">Laurie</span>
</div>
</SelectItem>
<SelectItem value="Jeremy" className="py-3">
+115 -34
View File
@@ -29,7 +29,7 @@ import {
SelectValue,
} from "@/components/ui/select";
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 { safeFormat, safeDate } from "@/lib/dateUtils";
import { format } from "date-fns";
@@ -56,6 +56,9 @@ export default function CustomerOrders() {
const [showStatusModal, setShowStatusModal] = useState(false);
const [showClientCallsModal, setShowClientCallsModal] = useState(false);
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 [sortBy, setSortBy] = useState<"date" | "status" | "supplier">("date");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
@@ -177,16 +180,28 @@ export default function CustomerOrders() {
},
});
// Notification mutation
const notificationMutation = useMutation({
mutationFn: ({ id, customerNotified }: { id: number; customerNotified: boolean }) =>
apiRequest(`/api/customer-orders/${id}`, 'PUT', { customerNotified }),
// Mutation marquer client contacté (avec date + commentaire)
const markCalledMutation = useMutation({
mutationFn: ({ id, comment }: { id: number; comment?: string }) =>
apiRequest(`/api/customer-orders/${id}/mark-called`, 'PATCH', { comment }),
onSuccess: () => {
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString()?.includes('/api/customer-orders') ?? false });
toast({
title: "Succès",
description: "Statut de notification mis à jour",
});
toast({ title: "Client marqué comme contacté" });
setShowContactModal(false);
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) => {
notificationMutation.mutate({
id: order.id,
customerNotified: !order.customerNotified
});
setContactOrder(order);
setContactComment("");
setShowContactModal(true);
};
// Fonction pour calculer le checksum EAN13
@@ -892,28 +906,30 @@ export default function CustomerOrders() {
<Edit className="h-4 w-4" />
</Button>
{canShowButtons(order.status) && (
<>
<Button
variant="outline"
size="sm"
onClick={() => handlePrintLabel(order)}
>
<Printer className="h-4 w-4" />
</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={() => handlePrintLabel(order)}
>
<Printer 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') && (
<Button
variant="outline"
@@ -1053,6 +1069,71 @@ export default function CustomerOrders() {
onClose={() => setShowClientCallsModal(false)}
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>
);
}
+4 -22
View File
@@ -198,7 +198,8 @@ export default function Dashboard() {
queryKey: ['/api/ad-campaigns', 'upcoming'],
queryFn: async () => {
// 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[] = [];
for (const year of years) {
@@ -307,15 +308,9 @@ export default function Dashboard() {
}) : []; // Afficher toutes les commandes en attente
const upcomingDeliveries = Array.isArray(allDeliveries) ? allDeliveries
.filter((d: any) => {
const isPlanned = d.status === 'planned';
console.log('🚚 Dashboard - Delivery filter:', { id: d.id, status: d.status, isPlanned, scheduledDate: d.scheduledDate });
return isPlanned;
})
.filter((d: any) => d.status === 'planned')
.sort((a: any, b: any) => new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime())
.slice(0, 4) : []; // Afficher les 4 prochaines livraisons
console.log('🚚 Dashboard - Upcoming deliveries result:', upcomingDeliveries.length, upcomingDeliveries);
.slice(0, 4) : [];
// Calculs pour les statistiques
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
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
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 { useForm } from "react-hook-form";
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
const createMutation = useMutation({
mutationFn: (data: InsertDlcProduct) => apiRequest("/api/dlc-products", "POST", data),
@@ -670,11 +730,17 @@ export default function DlcPage() {
<FormItem>
<FormLabel>Code EAN13 (optionnel)</FormLabel>
<FormControl>
<Input
{...field}
value={field.value || ""}
placeholder="1234567890123"
/>
<div className="relative">
<Input
{...field}
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>
<FormMessage />
</FormItem>
+13
View File
@@ -44,6 +44,7 @@ export default function Suppliers() {
name: "",
contact: "",
phone: "",
codefou: "",
email: "",
hasDlc: false,
automaticReconciliation: false,
@@ -200,6 +201,7 @@ export default function Suppliers() {
name: "",
contact: "",
phone: "",
codefou: "",
email: "",
hasDlc: false,
automaticReconciliation: false,
@@ -239,6 +241,7 @@ export default function Suppliers() {
name: supplier.name || "",
contact: supplier.contact || "",
phone: supplier.phone || "",
codefou: (supplier as any).codefou || "",
email: (supplier as any).email || "",
hasDlc: supplier.hasDlc || false,
automaticReconciliation: supplier.automaticReconciliation || false,
@@ -538,6 +541,16 @@ export default function Suppliers() {
/>
</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>
<Label htmlFor="email">Email de contact</Label>
<Input
+2 -19
View File
@@ -9,7 +9,6 @@ import {
Cloud,
Send,
Code,
TrendingUp
} from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -22,7 +21,6 @@ import DatabaseDebug from "./DatabaseDebug";
import WeatherSettings from "./WeatherSettings";
import WebhookBAPConfig from "./WebhookBAPConfig";
import SQLExecutor from "./SQLExecutor";
import SalesAnalysisConfig from "./SalesAnalysisConfig";
export default function Utilities() {
const { user } = useAuthUnified();
@@ -43,10 +41,8 @@ export default function Utilities() {
const canManageWeather = user?.role === 'admin';
const canManageWebhookBAP = 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é
if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL && !canManageSalesAnalysis) {
if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL) {
return (
<div className="flex-1 flex items-center justify-center">
<Card className="w-96">
@@ -82,7 +78,7 @@ export default function Utilities() {
{/* Content avec onglets */}
<div className="flex-1 p-6 overflow-hidden">
<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 && (
<TabsTrigger value="backups" className="flex items-center gap-2">
<Database className="w-4 h-4" />
@@ -119,12 +115,6 @@ export default function Utilities() {
Exécution SQL
</TabsTrigger>
)}
{canManageSalesAnalysis && (
<TabsTrigger value="salesanalysis" className="flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Analyse Vente
</TabsTrigger>
)}
</TabsList>
{/* Contenu des onglets */}
@@ -176,13 +166,6 @@ export default function Utilities() {
</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>
</div>
</div>
+1 -1
View File
@@ -163,7 +163,7 @@ export default function WebhookBAPConfig() {
Configuration Webhook BAP
</CardTitle>
<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>
</CardHeader>
<CardContent className="space-y-6">
+173 -6
View File
@@ -2,7 +2,7 @@
* MobileCustomerOrdersPage.tsx
* 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 { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext";
@@ -15,12 +15,16 @@ import {
Package,
Search,
Phone,
PhoneCall,
User,
ClipboardList,
Plus,
MoreVertical,
X,
Filter
Filter,
Loader2,
MessageSquare,
Search
} from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
@@ -79,6 +83,8 @@ export default function MobileCustomerOrdersPage() {
const { selectedStoreId } = useStore();
const [searchTerm, setSearchTerm] = useState("");
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [contactOrder, setContactOrder] = useState<any | null>(null);
const [contactComment, setContactComment] = useState("");
const { toast } = useToast();
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({
mutationFn: (data: any) => apiRequest('/api/customer-orders', 'POST', data),
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) => {
// Validation basique groupe
let groupId = selectedStoreId;
@@ -274,7 +357,7 @@ export default function MobileCustomerOrdersPage() {
</DropdownMenu>
</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 && (
<a
href={`tel:${order.customerPhone}`}
@@ -284,6 +367,13 @@ export default function MobileCustomerOrdersPage() {
<span className="font-medium">Appeler</span>
</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="bg-gray-100 px-2 py-1 rounded text-xs">
Qty: {order.quantity || 1}
@@ -380,7 +470,12 @@ export default function MobileCustomerOrdersPage() {
<FormItem>
<FormLabel>Référence (Optionnel)</FormLabel>
<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>
</FormItem>
)}
@@ -444,7 +539,12 @@ export default function MobileCustomerOrdersPage() {
<FormItem>
<FormLabel>Gencode (Scanner si dispo)</FormLabel>
<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>
</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"}
</Button>
</form>
@@ -519,6 +624,68 @@ export default function MobileCustomerOrdersPage() {
>
<Plus className="h-6 w-6 text-white" />
</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>
);
}
+73 -3
View File
@@ -2,7 +2,7 @@
* MobileDlcPage.tsx
* 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 { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext";
@@ -20,7 +20,8 @@ import {
CheckCircle,
X,
Calendar,
MoreVertical
MoreVertical,
Loader2
} from "lucide-react";
import { format, parseISO } from "date-fns";
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) => {
let groupId = selectedStoreId;
if (!groupId && user?.userGroups?.[0]?.groupId) groupId = user.userGroups[0].groupId;
@@ -386,7 +447,16 @@ export default function MobileDlcPage() {
<FormItem>
<FormLabel>Gencode (Scanner si dispo)</FormLabel>
<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>
</FormItem>
)}
+2 -2
View File
@@ -5,8 +5,8 @@
"license": "MIT",
"scripts": {
"dev": "NODE_ENV=development tsx server/index.ts",
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
"start": "NODE_ENV=production node dist/index.js",
"build": "vite build && tsc --project tsconfig.server.json",
"start": "NODE_ENV=production node dist/server/index.js",
"check": "tsc",
"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
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',
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 { createRequire } from "module";
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');
@@ -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' });
}
if (!['Prissela', 'Jeremy'].includes(recipient)) {
if (!['Laurie', 'Jeremy'].includes(recipient)) {
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
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) {
console.error('❌ INVOICE PROXY: Boundary manquant dans Content-Type', contentType);
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
});
// Import dynamique standard pour ESM (sans eval)
console.log('🔍 INVOICE PROXY: FormData loaded', {
type: typeof FormData,
isConstructor: typeof FormData === 'function'
});
// Utilisation du FormData natif de Node.js (global)
// On utilise Blob pour transformer le Buffer en objet compatible
const formData = new FormData();
formData.append('file', parts.file.buffer, {
filename: parts.file.filename,
contentType: parts.file.contentType
});
const fileBlob = new Blob([parts.file.buffer], { type: parts.file.contentType });
formData.append('file', fileBlob, parts.file.filename);
formData.append('supplier', parts.supplier || '');
formData.append('blNumber', parts.blNumber || '');
formData.append('type', parts.type || 'Facture');
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, {
method: 'POST',
body: formData as any,
headers: formData.getHeaders()
body: formData as any
});
if (!response.ok) {
console.error('❌ INVOICE PROXY: Webhook error', response.status);
return res.status(500).json({ error: `Webhook error: ${response.status}` });
const errorBody = await response.text().catch(() => 'No body');
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');
@@ -3423,9 +3429,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
}
const updatedOrder = await storage.markClientCalled(id, user.id);
console.log(`📞 Client marked as called: order ${id} by user ${user.id} (${user.role})`);
const { comment } = req.body;
const updatedOrder = await storage.markClientCalled(id, user.id, comment);
res.json(updatedOrder);
} catch (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
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
.update(customerOrders)
.set({
customerNotified: true,
notifiedAt: new Date(),
notifiedComment: comment || null,
updatedAt: new Date()
})
.where(eq(customerOrders.id, customerOrderId))
+3
View File
@@ -80,6 +80,7 @@ export const suppliers = pgTable("suppliers", {
name: varchar("name").notNull(),
contact: varchar("contact"),
phone: varchar("phone"),
codefou: varchar("codefou"), // Code fournisseur dans l'API ffnancy
email: varchar("email"),
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
@@ -244,6 +245,8 @@ export const customerOrders = pgTable("customer_orders", {
// Communication client
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: 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
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
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
- [x] **Remplacement des API calls (Client-side)**
- [x] `client/src/pages/Calendar.tsx`
- [x] `client/src/pages/Dashboard.tsx`
- [x] `client/src/components/PublicityForm.tsx`
- [x] `client/src/pages/Publicities.tsx`
- [x] `client/src/pages/mobile/PublicitiesPage.tsx`
- [x] **Revue du sélecteur d'année Mobile**
- [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] Aligner avec la correction desktop si nécessaire.
- [x] **Correction des erreurs Lint TypeScript**
- [x] Installer les paquets `@types` manquants (`node`, `express`, `react`, etc.).
- [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.
- [x] **Analyse du code source**
- [x] Examiner `server/routes.ts` pour trouver l'endpoint `/api/reconciliation/send-invoice`.
- [x] Analyser la construction de la requête vers N8N (utilisation de `FormData` natif vs bibliothèque `form-data`).
- [x] **Investigation technique**
- [x] Vérifier la version de Node.js (v24.11.1 détectée).
- [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] **Correction et Test**
- [x] Remplacer l'implémentation personnalisée par une utilisation plus propre des APIs natives Node.js.
- [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.
- [/] Demander à l'utilisateur de tester après avoir reconstruit son image Docker (`docker compose build`).
- [ ] Analyser la réponse détaillée du webhook si l'erreur persiste.
## 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.