mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-22 22:48:15 +02:00
merge(main): resolve conflicts keeping codefou + email on suppliers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
b8ecacee3b
20 files changed
+712
-292
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in new issue
Block a user