diff --git a/src/app/(dashboard)/grid/page.tsx b/src/app/(dashboard)/grid/page.tsx index 407e0b1..427cd49 100644 --- a/src/app/(dashboard)/grid/page.tsx +++ b/src/app/(dashboard)/grid/page.tsx @@ -33,16 +33,16 @@ export default async function GridPage({ searchParams }: GridPageProps) { return ; } - // 3. Load product data — résultat mis en cache 5 min par fournisseur (unstable_cache). - // La première requête est lente mais les suivantes (refresh, navigation) sont instantanées. - console.log(`\n>>> [GridPage] Fetching data for: ${codeFournisseur}, shop: ${magasin}`); - const rows = await getGridData(codeFournisseur, magasin, filters); - console.log(`>>> [GridPage] Received ${rows.length} rows from getGridData`); + // 3. Load product data — pagination pour ne pas crasher le JS client + console.log(`\n>>> [GridPage] Fetching page 0 for: ${codeFournisseur}, shop: ${magasin}`); + const { rows: initialRows, total } = await getGridData(codeFournisseur, magasin, filters, 0, 200); + console.log(`>>> [GridPage] Received ${initialRows.length}/${total} rows from getGridData`); const selectedFournisseur = fournisseurs.find((f: { code: string; nom: string }) => f.code === codeFournisseur); return ( -): Promise { - return getProductRows({ codeFournisseur, magasin, filters }); + filters?: Partial, + page: number = 0, + pageSize: number = 200, +): Promise { + return getProductRows({ codeFournisseur, magasin, filters, page, pageSize }); } diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 876d14d..e664e2b 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -22,6 +22,19 @@ export interface GetProductRowsInput { codeFournisseur: string; magasin?: string; filters?: Partial; + page?: number; + pageSize?: number; + search?: string; + gamme?: string | null; + code3?: string | null; +} + +export interface GetProductRowsResult { + rows: ProductRow[]; + total: number; + page: number; + pageSize: number; + totalPages: number; } // ─── Fonction interne : fetch complet non-paginé (cachée) ─────────────────── @@ -312,11 +325,56 @@ async function _fetchAllProductRows(codeFournisseur: string): Promise { - const { codeFournisseur } = input; - - // Appel direct sans l'enveloppe dynamique de unstable_cache qui cause un freeze/timeout - return _fetchAllProductRows(codeFournisseur); +export async function getProductRows(input: GetProductRowsInput): Promise { + const { + codeFournisseur, + page, + pageSize = 200, + search, + gamme, + code3, + } = input; + + // Récupérer toutes les lignes (idéalement mis en cache ici via unstable_cache, mais on l'a désactivé pour l'instant) + const allRows = await _fetchAllProductRows(codeFournisseur); + + let filtered = allRows; + + if (search && search.trim() !== "") { + const q = search.trim().toLowerCase(); + filtered = filtered.filter(r => + (r.libelle1 ?? "").toLowerCase().includes(q) || + (r.codein ?? "").toLowerCase().includes(q) || + (r.reference ?? "").toLowerCase().includes(q) || + (r.gtin ?? "").toLowerCase().includes(q) + ); + } + + if (gamme !== undefined && gamme !== null && gamme !== "") { + filtered = filtered.filter(r => { + const g = r.codeGamme ?? ""; + const norm = g.trim() === "" ? "Aucune" : g; + return norm === gamme; + }); + } + + if (code3) { + filtered = filtered.filter(r => r.code3 === code3); + } + + const total = filtered.length; + + if (page === undefined) { + return { rows: filtered, total, page: 0, pageSize: total, totalPages: 1 }; + } + + const safePageSize = Math.max(1, Math.min(pageSize, 500)); + const totalPages = Math.ceil(total / safePageSize); + const safePage = Math.max(0, Math.min(page, totalPages - 1)); + const start = safePage * safePageSize; + const rows = filtered.slice(start, start + safePageSize); + + return { rows, total, page: safePage, pageSize: safePageSize, totalPages }; } diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx index 2d6931b..1364fde 100644 --- a/src/features/grid/components/grid-client.tsx +++ b/src/features/grid/components/grid-client.tsx @@ -8,6 +8,7 @@ import { GridFilterBar } from "@/features/grid/components/grid-filter-bar"; import { ExportDropdown } from "@/features/grid/components/export-dropdown"; import { useGridStore } from "@/features/grid/store/use-grid-store"; import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts"; +import { useInfiniteGrid } from "@/features/grid/hooks/use-infinite-grid"; import type { ProductRow } from "@/types/grid"; import { CheckCircle, AlertCircle, Loader2 } from "lucide-react"; import { useSession } from "next-auth/react"; @@ -15,6 +16,7 @@ import { useSearchParams } from "next/navigation"; interface GridClientProps { initialRows: ProductRow[]; + initialTotal: number; codeFournisseur: string; nomFournisseur: string; fournisseurs: { code: string; nom: string }[]; @@ -22,7 +24,7 @@ interface GridClientProps { magasin: string; } -export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) { +export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) { const { data: session } = useSession(); const isAdmin = (session?.user as { role?: string })?.role === "admin"; const setRows = useGridStore((s) => s.setRows); @@ -40,6 +42,14 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn const [isMounted, setIsMounted] = useState(false); + // ─── Chargement progressif des pages suivantes ───────────────────────────── + const { rows: allRows, total, isLoadingMore, hasMore, loadNextPage, progress } = useInfiniteGrid({ + codeFournisseur, + initialRows, + initialTotal, + pageSize: 200, + }); + useEffect(() => { setIsMounted(true); }, []); @@ -60,17 +70,25 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn setFilter("code3", null); prevFournisseurRef.current = codeFournisseur; } - // Les scores ont déjà été calculés côté serveur dans _fetchAllProductRows. - // On pousse directement les lignes dans le store sans recalcul côté client. - setRows(initialRows); - }, [codeFournisseur, initialRows, setRows, setFilter, isMounted]); + // Les scores ont déjà été calculés côté serveur. + // On pousse toutes les lignes accumulées par l'infinite scroll dans le store. + setRows(allRows); + }, [codeFournisseur, allRows, setRows, setFilter, isMounted]); - // Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement) + // Synchroniser le magasin actif useEffect(() => { if (!isMounted) return; setActiveMagasin(magasin || "TOTAL"); }, [magasin, setActiveMagasin, isMounted]); + // Chargement automatique des pages suivantes en arrière-plan + useEffect(() => { + if (!isMounted || !hasMore || isLoadingMore) return; + // Délai court pour ne pas bloquer le rendu + const timer = setTimeout(loadNextPage, 300); + return () => clearTimeout(timer); + }, [isMounted, hasMore, isLoadingMore, loadNextPage]); + const handleSave = () => { startTransition(async () => { const result = await save(); @@ -94,7 +112,12 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn

{nomFournisseur} {" "}• {activeStoreNom} - {" "}• {initialRows.length} références + {" "}• {allRows.length.toLocaleString("fr-FR")} références + {hasMore && ( + + {" "}/ {total.toLocaleString("fr-FR")} + + )}

@@ -125,6 +148,25 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn
+ {/* Barre de progression chargement en arrière-plan */} + {(hasMore || isLoadingMore) && ( +
+ + + Chargement : {allRows.length.toLocaleString("fr-FR")} / {total.toLocaleString("fr-FR")} références + +
+
+
+ {progress}% +
+ )} + {/* Filters */}
diff --git a/src/features/grid/hooks/use-infinite-grid.ts b/src/features/grid/hooks/use-infinite-grid.ts new file mode 100644 index 0000000..b95e29a --- /dev/null +++ b/src/features/grid/hooks/use-infinite-grid.ts @@ -0,0 +1,131 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ProductRow } from "@/types/grid"; + +export interface InfiniteGridOptions { + codeFournisseur: string | null; + initialRows: ProductRow[]; + initialTotal: number; + pageSize?: number; + search?: string; + gamme?: string | null; + code3?: string | null; +} + +export interface InfiniteGridState { + rows: ProductRow[]; + total: number; + isLoadingMore: boolean; + hasMore: boolean; + loadNextPage: () => void; + reset: () => void; + progress: number; +} + +export function useInfiniteGrid({ + codeFournisseur, + initialRows, + initialTotal, + pageSize = 200, + search, + gamme, + code3, +}: InfiniteGridOptions): InfiniteGridState { + const [rows, setRows] = useState(initialRows); + const [total, setTotal] = useState(initialTotal); + const [currentPage, setCurrentPage] = useState(0); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + const fetchIdRef = useRef(0); + const loadedPagesRef = useRef(new Set([0])); + + const buildUrl = useCallback( + (page: number): string => { + if (!codeFournisseur) return ""; + const params = new URLSearchParams({ + fournisseur: codeFournisseur, + page: String(page), + pageSize: String(pageSize), + }); + if (search) params.set("search", search); + if (gamme) params.set("gamme", gamme); + if (code3) params.set("code3", code3); + return `/api/grid/rows?${params.toString()}`; + }, + [codeFournisseur, pageSize, search, gamme, code3] + ); + + useEffect(() => { + setRows(initialRows); + setTotal(initialTotal); + setCurrentPage(0); + loadedPagesRef.current = new Set([0]); + fetchIdRef.current += 1; + }, [codeFournisseur, initialRows, initialTotal, search, gamme, code3]); + + const loadNextPage = useCallback(async () => { + if (!codeFournisseur || isLoadingMore) return; + + const nextPage = currentPage + 1; + const alreadyLoaded = loadedPagesRef.current.has(nextPage); + const totalPages = Math.ceil(total / pageSize); + + if (alreadyLoaded || nextPage >= totalPages) return; + + const currentFetchId = ++fetchIdRef.current; + setIsLoadingMore(true); + + try { + const url = buildUrl(nextPage); + const response = await fetch(url, { credentials: "same-origin" }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + if (currentFetchId !== fetchIdRef.current) return; + + const data = await response.json() as { + rows: ProductRow[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + }; + + loadedPagesRef.current.add(nextPage); + setRows(prev => [...prev, ...data.rows]); + setTotal(data.total); + setCurrentPage(nextPage); + } catch (error) { + console.error("[useInfiniteGrid] loadNextPage error:", error); + } finally { + if (currentFetchId === fetchIdRef.current) { + setIsLoadingMore(false); + } + } + }, [codeFournisseur, currentPage, total, pageSize, isLoadingMore, buildUrl]); + + const reset = useCallback(() => { + fetchIdRef.current += 1; + loadedPagesRef.current = new Set([0]); + setRows(initialRows); + setTotal(initialTotal); + setCurrentPage(0); + setIsLoadingMore(false); + }, [initialRows, initialTotal]); + + const hasMore = rows.length < total; + const progress = total > 0 ? Math.round((rows.length / total) * 100) : 100; + + return { + rows, + total, + isLoadingMore, + hasMore, + loadNextPage, + reset, + progress, + }; +}