Files
LogiFlow/client/src/pages/mobile/CustomerOrdersPage.tsx
T
Claude 5d3e5357a0 perf(client): pages chargées à la demande et un seul appel /api/user
- Routes en React.lazy (sauf connexion et accueil) avec un Suspense dans
  la zone de contenu ; recharts n'est plus préchargé. JS initial :
  1,72 Mo -> 625 Ko (183 Ko gzip). Rechargement automatique unique si un
  ancien fichier JS a disparu après un déploiement, et ErrorBoundary par
  page pour garder le menu affiché en cas d'erreur.
- Authentification unifiée sur le cache React Query ['/api/user'] : un
  seul GET /api/user pour toute l'application (au lieu de 4 à 5 par
  page), plus de rechargement complet après la connexion.
- Pas de nouvel essai sur les erreurs 4xx ; fournisseurs et magasins en
  cache 5 minutes ; valeur du StoreContext mémoïsée et redimensionnement
  qui ne re-rend que lorsque le palier d'écran change.
- Pages : requêtes inutilisées ou en double supprimées, appels lancés
  seulement une fois l'utilisateur connu, invalidations ciblées au lieu
  de rechargements complets, filtres et tris mémoïsés, anciennes données
  gardées (et estompées) pendant un changement de filtre ou de mois,
  recherche DLC mobile temporisée, vérifications de factures à
  concurrence bornée, navigation interne sans rechargement de la page.
- index.html : script de bannière Replit retiré, lang="fr".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
2026-10-03 04:29:51 +00:00

699 lines
35 KiB
TypeScript

/**
* MobileCustomerOrdersPage.tsx
* Version mobile de la page Commandes Clients avec création
*/
import { useState, useEffect, useRef, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext";
import MobileLayout from "./MobileLayout";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Package,
Search,
Phone,
PhoneCall,
User,
ClipboardList,
Plus,
MoreVertical,
X,
Filter,
Loader2,
MessageSquare
} from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
// Schema for form validation
const customerOrderFormSchema = z.object({
customerName: z.string().min(1, "Nom du client est requis"),
contactNumber: z.string().optional(),
productName: z.string().min(1, "Désignation est requise"),
productReference: z.string().optional(),
gencode: z.string().optional(),
quantity: z.coerce.number().int().positive().default(1),
supplierId: z.coerce.number().int().positive().default(1),
orderTaker: z.string().min(1, "Prise de commande requise"),
notes: z.string().optional(),
deposit: z.coerce.number().optional().default(0),
});
// Référence stable tant que la liste n'est pas chargée
const NO_ORDERS: any[] = [];
export default function MobileCustomerOrdersPage() {
const { user } = useAuthUnified();
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();
// Queries
const { data: orders = NO_ORDERS, isLoading } = useQuery({
queryKey: ["/api/customer-orders", selectedStoreId],
queryFn: async () => {
if (!selectedStoreId) return [];
const response = await fetch(
`/api/customer-orders?storeId=${selectedStoreId}`,
{ credentials: 'include' }
);
if (!response.ok) throw new Error("Failed to fetch customer orders");
return response.json();
},
enabled: !!selectedStoreId && !!user,
});
const { data: suppliers = [] } = useQuery({
queryKey: ['/api/suppliers'],
queryFn: async () => {
const res = await fetch('/api/suppliers', { credentials: 'include' });
return res.json();
}
});
const { data: groups = [] } = useQuery({
queryKey: ['/api/groups'],
queryFn: async () => {
const res = await fetch('/api/groups', { credentials: 'include' });
return res.json();
}
});
// Mutations
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: number; status: string }) =>
apiRequest(`/api/customer-orders/${id}`, 'PUT', { status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/customer-orders"] });
toast({ title: "Statut mis à jour" });
},
});
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: () => {
queryClient.invalidateQueries({ queryKey: ["/api/customer-orders"] });
toast({ title: "Commande créée avec succès" });
setIsCreateOpen(false);
form.reset();
},
onError: (err: any) => {
toast({
title: "Erreur lors de la création",
description: err.message,
variant: "destructive"
});
}
});
// Form setup
const form = useForm({
resolver: zodResolver(customerOrderFormSchema),
defaultValues: {
customerName: "",
contactNumber: "",
productName: "",
productReference: "",
gencode: "",
quantity: 1,
supplierId: 1,
orderTaker: user?.username || "",
notes: "",
deposit: 0
}
});
// 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;
if (!groupId && user?.userGroups?.[0]?.groupId) {
groupId = user.userGroups[0].groupId;
}
if (!groupId && groups.length > 0 && user?.role === 'admin') {
groupId = groups[0].id;
}
if (!groupId) {
toast({ title: "Erreur", description: "Impossible de déterminer le magasin", variant: "destructive" });
return;
}
const submitData = {
...data,
customerPhone: data.contactNumber,
productDesignation: data.productName,
status: "En attente de Commande",
groupId,
isPromotionalPrice: data.isPromotionalPrice,
customerNotified: data.customerNotified
};
createMutation.mutate(submitData);
};
// Recalculé seulement quand la liste ou la recherche change (et non à
// chaque frappe dans le commentaire de contact)
const filteredOrders = useMemo(() => {
const search = searchTerm.toLowerCase();
return orders.filter((order: any) =>
order.customerName.toLowerCase().includes(search) ||
order.productDesignation.toLowerCase().includes(search) ||
(order.productReference && order.productReference.toLowerCase().includes(search))
);
}, [orders, searchTerm]);
const getStatusColor = (status: string) => {
switch (status) {
case "Disponible": return "bg-green-100 text-green-800 border-green-200";
case "Commande en Cours": return "bg-blue-100 text-blue-800 border-blue-200";
case "Retiré": return "bg-gray-100 text-gray-800 border-gray-200";
case "Annulé": return "bg-red-100 text-red-800 border-red-200";
default: return "bg-yellow-100 text-yellow-800 border-yellow-200";
}
};
return (
<MobileLayout>
<div className="p-4 space-y-4 pb-20">
{/* Header */}
<div className="flex items-center gap-2">
<Package className="h-6 w-6 text-blue-600" />
<h1 className="text-xl font-bold text-gray-900">Commandes Clients</h1>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder="Client, produit, rév..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-10"
/>
</div>
{/* Orders List */}
<div className="space-y-3">
{isLoading ? (
<div className="flex justify-center p-8">
<div className="animate-spin h-8 w-8 border-4 border-blue-600 border-t-transparent rounded-full" />
</div>
) : filteredOrders.length === 0 ? (
<div className="text-center py-10 text-gray-500 bg-white rounded-lg border border-dashed border-gray-300">
<Package className="h-10 w-10 mx-auto text-gray-300 mb-2" />
<p>Aucune commande trouvée</p>
</div>
) : (
filteredOrders.map((order: any) => (
<Card key={order.id} className="overflow-hidden">
<CardContent className="p-4">
<div className="flex justify-between items-start mb-2">
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<Badge className={`${getStatusColor(order.status)} border px-2 py-0.5 text-xs`}>
{order.status}
</Badge>
<span className="text-xs text-gray-500">
{format(new Date(order.createdAt), "dd/MM", { locale: fr })}
</span>
</div>
<h3 className="font-bold text-gray-900 leading-tight">
{order.customerName}
</h3>
<p className="text-sm text-gray-600 line-clamp-1 mt-1">
{order.productDesignation}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 -mr-2">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => statusMutation.mutate({ id: order.id, status: 'Disponible' })}>
Marquer Disponible
</DropdownMenuItem>
<DropdownMenuItem onClick={() => statusMutation.mutate({ id: order.id, status: 'Retiré' })}>
Marquer Retiré
</DropdownMenuItem>
<DropdownMenuItem className="text-red-600" onClick={() => statusMutation.mutate({ id: order.id, status: 'Annulé' })}>
Annuler
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<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}`}
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-50 text-green-700 rounded-full hover:bg-green-100 transition-colors"
>
<Phone className="h-3.5 w-3.5" />
<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}
</div>
{order.deposit && parseFloat(order.deposit) > 0 && (
<div className="bg-orange-100 text-orange-800 px-2 py-1 rounded text-xs font-medium">
Acompte: {parseFloat(order.deposit)}€
</div>
)}
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
{/* Create Order Sheet */}
<Sheet open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<SheetContent side="bottom" className="h-[90vh] overflow-y-auto rounded-t-xl px-4 py-6">
<SheetHeader className="mb-6">
<SheetTitle className="text-left flex items-center gap-2">
<Plus className="h-5 w-5 bg-blue-100 text-blue-600 rounded p-0.5" />
Nouvelle Commande
</SheetTitle>
</SheetHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 pb-8">
<div className="bg-gray-50 p-3 rounded-lg space-y-3">
<h3 className="font-medium text-sm text-gray-500 uppercase">Client</h3>
<FormField
control={form.control}
name="customerName"
render={({ field }) => (
<FormItem>
<FormLabel>Nom</FormLabel>
<FormControl>
<Input placeholder="Nom du client" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="contactNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Téléphone</FormLabel>
<FormControl>
<Input placeholder="06..." type="tel" {...field} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="bg-gray-50 p-3 rounded-lg space-y-3">
<h3 className="font-medium text-sm text-gray-500 uppercase">Vendeur</h3>
<FormField
control={form.control}
name="orderTaker"
render={({ field }) => (
<FormItem>
<FormLabel>Prise par</FormLabel>
<FormControl>
<Input placeholder="Nom du vendeur" {...field} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="bg-gray-50 p-3 rounded-lg space-y-3">
<h3 className="font-medium text-sm text-gray-500 uppercase">Produit</h3>
<FormField
control={form.control}
name="productName"
render={({ field }) => (
<FormItem>
<FormLabel>Désignation</FormLabel>
<FormControl>
<Textarea placeholder="Nom du produit" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="productReference"
render={({ field }) => (
<FormItem>
<FormLabel>Référence (Optionnel)</FormLabel>
<FormControl>
<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>
)}
/>
<div className="flex gap-3">
<FormField
control={form.control}
name="quantity"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Qté</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="deposit"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Acompte (€)</FormLabel>
<FormControl>
<Input type="number" step="0.01" {...field} />
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="supplierId"
render={({ field }) => (
<FormItem>
<FormLabel>Fournisseur</FormLabel>
<Select
onValueChange={(val) => field.onChange(parseInt(val))}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Choisir..." />
</SelectTrigger>
</FormControl>
<SelectContent>
{suppliers.map((s: any) => (
<SelectItem key={s.id} value={s.id.toString()}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
/>
<FormField
control={form.control}
name="gencode"
render={({ field }) => (
<FormItem>
<FormLabel>Gencode (Scanner si dispo)</FormLabel>
<FormControl>
<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>
)}
/>
</div>
<FormField
control={form.control}
name={"isPromotionalPrice" as any}
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Prix promotionnel / Pub
</FormLabel>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name={"customerNotified" as any}
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Client déjà notifié
</FormLabel>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes internes</FormLabel>
<FormControl>
<Textarea placeholder="..." className="h-20" {...field} />
</FormControl>
</FormItem>
)}
/>
{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>
</Form>
</SheetContent>
</Sheet>
{/* FAB */}
<Button
className="fixed bottom-20 right-4 h-14 w-14 rounded-full shadow-lg bg-blue-600 hover:bg-blue-700 p-0 flex items-center justify-center z-50 transition-transform active:scale-95"
onClick={() => setIsCreateOpen(true)}
>
<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>
);
}