mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-23 15:08:22 +02:00
- 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
506 lines
23 KiB
TypeScript
506 lines
23 KiB
TypeScript
/**
|
|
* MobileDlcPage.tsx
|
|
* Version mobile de la page Gestion des DLC
|
|
*/
|
|
import { useState, useMemo, useEffect, useRef } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
|
import { useStore } from "@/contexts/StoreContext";
|
|
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 {
|
|
Clock,
|
|
Search,
|
|
Plus,
|
|
Filter,
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
X,
|
|
Calendar,
|
|
MoreVertical,
|
|
Loader2
|
|
} from "lucide-react";
|
|
import { format, parseISO } 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,
|
|
} 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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
const dlcFormSchema = z.object({
|
|
productName: z.string().min(1, "Nom du produit requis"),
|
|
gencode: z.string().optional(),
|
|
dlcDate: z.string().min(1, "Date requise"),
|
|
dateType: z.enum(["dlc", "ddm", "dluo"]).default("dlc"),
|
|
supplierId: z.coerce.number().min(1, "Fournisseur requis"),
|
|
status: z.string().default("en_cours"),
|
|
notes: z.string().optional(),
|
|
});
|
|
|
|
// Référence stable tant que la liste n'est pas chargée
|
|
const NO_PRODUCTS: any[] = [];
|
|
|
|
export default function MobileDlcPage() {
|
|
const { user } = useAuthUnified();
|
|
const { selectedStoreId } = useStore();
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState("en_cours"); // en_cours, expires_soon, expires, valides
|
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
|
|
// Recherche appliquée 300 ms après la dernière frappe (filtrage local)
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
setDebouncedSearchTerm(searchTerm);
|
|
}, 300);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [searchTerm]);
|
|
|
|
// Data fetching
|
|
// Toute la liste du magasin est chargée une seule fois : l'onglet et la
|
|
// recherche filtrent localement, sans nouvelle requête (clé propre au mobile,
|
|
// toujours invalidée par le préfixe "/api/dlc-products" des mutations)
|
|
const { data: dlcProducts = NO_PRODUCTS, isLoading } = useQuery({
|
|
queryKey: ["/api/dlc-products", "mobile", selectedStoreId],
|
|
queryFn: async () => {
|
|
if (!selectedStoreId) return [];
|
|
return apiRequest(`/api/dlc-products?storeId=${selectedStoreId}`);
|
|
},
|
|
enabled: !!selectedStoreId && !!user,
|
|
staleTime: 2 * 60 * 1000, // 2 minutes, comme sur ordinateur
|
|
});
|
|
|
|
const { data: suppliers = [] } = useQuery({
|
|
queryKey: ["/api/suppliers", "dlc"],
|
|
queryFn: () => apiRequest("/api/suppliers?dlc=true"),
|
|
});
|
|
|
|
const { data: groups = [] } = useQuery({
|
|
queryKey: ["/api/groups"],
|
|
queryFn: () => apiRequest("/api/groups"),
|
|
});
|
|
|
|
// Mutations
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: any) => apiRequest("/api/dlc-products", "POST", data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
|
toast({ title: "Produit ajouté" });
|
|
setIsCreateOpen(false);
|
|
form.reset();
|
|
},
|
|
onError: (err: any) => toast({ title: "Erreur", description: err.message, variant: "destructive" })
|
|
});
|
|
|
|
const validateMutation = useMutation({
|
|
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/validate`, "POST"),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
|
toast({ title: "Produit validé (retiré)" });
|
|
}
|
|
});
|
|
|
|
// Form
|
|
const form = useForm({
|
|
resolver: zodResolver(dlcFormSchema),
|
|
defaultValues: {
|
|
productName: "",
|
|
gencode: "",
|
|
dlcDate: format(new Date(), 'yyyy-MM-dd'),
|
|
dateType: "dlc",
|
|
supplierId: 1,
|
|
status: "en_cours",
|
|
notes: ""
|
|
}
|
|
});
|
|
|
|
// EAN lookup from external API
|
|
const [eanLookupLoading, setEanLookupLoading] = useState(false);
|
|
const eanDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const gencodeValue = form.watch("gencode");
|
|
|
|
useEffect(() => {
|
|
if (!gencodeValue || gencodeValue.length < 8) return;
|
|
|
|
if (eanDebounceRef.current) clearTimeout(eanDebounceRef.current);
|
|
|
|
eanDebounceRef.current = setTimeout(async () => {
|
|
setEanLookupLoading(true);
|
|
try {
|
|
// Étape 1 : récupérer l'article par EAN
|
|
const res = await fetch(`/api/ffnancy/articles?ean=${encodeURIComponent(gencodeValue)}&limit=1`, { credentials: 'include' });
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
const article = data.articles?.[0];
|
|
if (!article) return;
|
|
|
|
form.setValue("productName", article.libelle1, { shouldValidate: true });
|
|
|
|
// Étape 2 : chercher le fournisseur de la dernière entrée en stock
|
|
let codefouToMatch = article.codefou_principal;
|
|
try {
|
|
const mvtRes = await fetch(
|
|
`/api/ffnancy/mouvements/entrees?artNoId=${article.no_id}&limit=1&dateDebut=2000-01-01`,
|
|
{ credentials: 'include' }
|
|
);
|
|
if (mvtRes.ok) {
|
|
const mvtData = await mvtRes.json();
|
|
const lastEntree = mvtData.entrees?.[0];
|
|
if (lastEntree?.codefou) codefouToMatch = lastEntree.codefou;
|
|
}
|
|
} catch { /* fallback sur codefou_principal */ }
|
|
|
|
// Matching : codefou exact en priorité, fallback par nom
|
|
if (codefouToMatch) {
|
|
const matched = (suppliers as any[]).find((s: any) =>
|
|
s.codefou && s.codefou.trim().toLowerCase() === codefouToMatch.trim().toLowerCase()
|
|
) || (suppliers as any[]).find((s: any) =>
|
|
s.name.toLowerCase().trim().includes(article.nom_fou_principal?.toLowerCase().trim() || '') ||
|
|
(article.nom_fou_principal?.toLowerCase().trim() || '').includes(s.name.toLowerCase().trim())
|
|
);
|
|
if (matched) {
|
|
form.setValue("supplierId", matched.id, { shouldValidate: true });
|
|
}
|
|
}
|
|
} catch {
|
|
// Lookup best-effort, on ignore les erreurs
|
|
} finally {
|
|
setEanLookupLoading(false);
|
|
}
|
|
}, 600);
|
|
|
|
return () => {
|
|
if (eanDebounceRef.current) clearTimeout(eanDebounceRef.current);
|
|
};
|
|
}, [gencodeValue, suppliers]);
|
|
|
|
const onSubmit = (data: any) => {
|
|
let groupId = selectedStoreId;
|
|
if (!groupId && user?.userGroups?.[0]?.groupId) groupId = user.userGroups[0].groupId;
|
|
if (!groupId && groups.length > 0 && user?.role === 'admin') groupId = groups[0].id;
|
|
|
|
if (!groupId) {
|
|
toast({ title: "Erreur", description: "Magasin introuvable", variant: "destructive" });
|
|
return;
|
|
}
|
|
|
|
const submitData = {
|
|
...data,
|
|
name: data.productName,
|
|
expiryDate: new Date(data.dlcDate),
|
|
quantity: 1,
|
|
unit: "unité",
|
|
location: "Magasin",
|
|
alertThreshold: 15,
|
|
groupId
|
|
};
|
|
createMutation.mutate(submitData);
|
|
};
|
|
|
|
// Filtering logic
|
|
const filteredProducts = useMemo(() => {
|
|
if (!Array.isArray(dlcProducts)) return [];
|
|
|
|
let filtered = dlcProducts;
|
|
|
|
// Search
|
|
if (debouncedSearchTerm) {
|
|
const lower = debouncedSearchTerm.toLowerCase();
|
|
filtered = filtered.filter((p: any) =>
|
|
p.productName.toLowerCase().includes(lower) ||
|
|
(p.gencode && p.gencode.includes(lower))
|
|
);
|
|
}
|
|
|
|
// Status logic
|
|
const today = new Date();
|
|
filtered = filtered.filter((p: any) => {
|
|
const expiry = new Date(p.expiryDate);
|
|
const daysUntil = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
if (p.status === 'valides') return statusFilter === 'valides';
|
|
|
|
if (statusFilter === 'expires') return daysUntil < 0;
|
|
if (statusFilter === 'expires_soon') return daysUntil >= 0 && daysUntil <= 15;
|
|
if (statusFilter === 'en_cours') return daysUntil > 15;
|
|
|
|
return true; // 'all'
|
|
});
|
|
|
|
return filtered.sort((a: any, b: any) => new Date(a.expiryDate).getTime() - new Date(b.expiryDate).getTime());
|
|
}, [dlcProducts, debouncedSearchTerm, statusFilter]);
|
|
|
|
const getDaysBadge = (dateStr: string) => {
|
|
const today = new Date();
|
|
const expiry = new Date(dateStr);
|
|
const days = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
if (days < 0) return <Badge variant="destructive">Expiré J{days}</Badge>;
|
|
if (days <= 3) return <Badge className="bg-red-500 hover:bg-red-600">J-{days}</Badge>;
|
|
if (days <= 15) return <Badge className="bg-orange-500 hover:bg-orange-600">J-{days}</Badge>;
|
|
return <Badge variant="outline" className="text-green-600 border-green-200">J-{days}</Badge>;
|
|
};
|
|
|
|
return (
|
|
<MobileLayout>
|
|
<div className="p-4 space-y-4 pb-20">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-2">
|
|
<Clock className="h-6 w-6 text-orange-600" />
|
|
<h1 className="text-xl font-bold text-gray-900">Gestion DLC</h1>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
|
|
<Input
|
|
placeholder="Rechercher..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-9 h-10"
|
|
/>
|
|
</div>
|
|
|
|
{/* Tabs for quick filtering */}
|
|
<Tabs defaultValue="en_cours" value={statusFilter} onValueChange={setStatusFilter} className="w-full">
|
|
<TabsList className="grid w-full grid-cols-4 h-9">
|
|
<TabsTrigger value="en_cours" className="text-xs px-0">OK</TabsTrigger>
|
|
<TabsTrigger value="expires_soon" className="text-xs px-0">Bientôt</TabsTrigger>
|
|
<TabsTrigger value="expires" className="text-xs px-0">Expirés</TabsTrigger>
|
|
<TabsTrigger value="valides" className="text-xs px-0">Validés</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
{/* 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-orange-500 border-t-transparent rounded-full" />
|
|
</div>
|
|
) : filteredProducts.length === 0 ? (
|
|
<div className="text-center py-10 text-gray-500 bg-white rounded-lg border border-dashed border-gray-300">
|
|
<Clock className="h-10 w-10 mx-auto text-gray-300 mb-2" />
|
|
<p>Aucun produit trouvé</p>
|
|
</div>
|
|
) : (
|
|
filteredProducts.map((p: any) => (
|
|
<Card key={p.id} className="overflow-hidden">
|
|
<CardContent className="p-3">
|
|
<div className="flex justify-between items-start">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
{getDaysBadge(p.expiryDate)}
|
|
<span className="text-xs text-gray-500 font-mono">
|
|
{format(new Date(p.expiryDate), 'dd/MM/yy')}
|
|
</span>
|
|
</div>
|
|
<h3 className="font-bold text-gray-900">{p.productName}</h3>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
{p.supplier?.name} {p.gencode ? `• ${p.gencode}` : ''}
|
|
</p>
|
|
</div>
|
|
|
|
{statusFilter !== 'valides' && (
|
|
<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={() => validateMutation.mutate(p.id)}>
|
|
<CheckCircle className="mr-2 h-4 w-4 text-green-600" />
|
|
Valider (Sortir)
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Create 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-orange-100 text-orange-600 rounded p-0.5" />
|
|
Nouveau Produit DLC
|
|
</SheetTitle>
|
|
</SheetHeader>
|
|
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 pb-8">
|
|
<FormField
|
|
control={form.control}
|
|
name="productName"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Nom du produit</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="Beurre, Yaourt..." {...field} />
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<FormField
|
|
control={form.control}
|
|
name="dlcDate"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Date Expiration</FormLabel>
|
|
<FormControl>
|
|
<Input type="date" {...field} />
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="dateType"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Type</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
defaultValue={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="dlc">DLC</SelectItem>
|
|
<SelectItem value="ddm">DDM</SelectItem>
|
|
<SelectItem value="dluo">DLUO</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</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="relative">
|
|
<Input
|
|
placeholder="EAN13"
|
|
{...field}
|
|
className={eanLookupLoading ? "pr-8" : ""}
|
|
/>
|
|
{eanLookupLoading && (
|
|
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-blue-500" />
|
|
)}
|
|
</div>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="notes"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Notes internes</FormLabel>
|
|
<FormControl>
|
|
<Textarea placeholder="Remarques..." className="h-20" {...field} />
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<Button type="submit" className="w-full bg-orange-600 hover:bg-orange-700 h-12 text-lg font-bold shadow-lg mt-4" disabled={createMutation.isPending}>
|
|
{createMutation.isPending ? "Ajout..." : "Ajouter le produit"}
|
|
</Button>
|
|
</form>
|
|
</Form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{/* FAB */}
|
|
<Button
|
|
className="fixed bottom-20 right-4 h-14 w-14 rounded-full shadow-lg bg-orange-600 hover:bg-orange-700 p-0 flex items-center justify-center z-50"
|
|
onClick={() => setIsCreateOpen(true)}
|
|
>
|
|
<Plus className="h-6 w-6 text-white" />
|
|
</Button>
|
|
</MobileLayout>
|
|
);
|
|
}
|