From 88a3121c924e200acda4b79f250602b8d12aa607 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 2 Apr 2026 17:22:41 +0200 Subject: [PATCH] feat: implement grid state management, data fetching, and infinite scroll infrastructure --- src/features/grid/api/get-product-rows.ts | 19 +++++++---- src/features/grid/components/grid-client.tsx | 34 ++++++++++---------- src/features/grid/hooks/use-infinite-grid.ts | 32 ++++++++++-------- src/features/grid/store/use-grid-store.ts | 13 ++++++++ 4 files changed, 61 insertions(+), 37 deletions(-) diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index e664e2b..6ba0f56 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -16,6 +16,7 @@ import { import { db } from "@/db"; import { sessionSnapshots } from "@/db/schema"; import { eq, desc } from "drizzle-orm"; +import { unstable_cache } from "next/cache"; // ─── Interfaces ─────────────────────────────────────────────────────────────── export interface GetProductRowsInput { @@ -37,13 +38,19 @@ export interface GetProductRowsResult { totalPages: number; } +// Fonction utilitaire cachée pour mémoiser le chargement par fournisseur +const getCachedSupplierRows = (codeFournisseur: string) => { + return unstable_cache( + async () => _fetchAllProductRows(codeFournisseur), + [`grid-rows-${codeFournisseur}`], + { revalidate: 300, tags: [`grid-rows-${codeFournisseur}`] } + )(); +}; + // ─── Fonction interne : fetch complet non-paginé (cachée) ─────────────────── -// Cette fonction est le « dataloader » central. Elle est enveloppée dans -// unstable_cache pour ne jamais refaire les 6 requêtes SQL tant que le cache -// est valide (revalidate : 300s). Le tag permet une invalidation ciblée. async function _fetchAllProductRows(codeFournisseur: string): Promise { const magasin = "TOTAL"; // Le cache est toujours TOTAL ; le switch magasin est client-side - console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`); + console.log(`\n>>> [_fetchAllProductRows] (NO CACHE) supplier: ${codeFournisseur}, magasin: ${magasin}`); try { const { dateDebut, dateFin } = buildLast12MonthsRange(); @@ -337,8 +344,8 @@ export async function getProductRows(input: GetProductRowsInput): Promise s.setRows); + const appendRows = useGridStore((s) => s.appendRows); + const rows = useGridStore((s) => s.rows); const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery); const setFilter = useGridStore((s) => s.setFilter); const setActiveMagasin = useGridStore((s) => s.setActiveMagasin); @@ -43,11 +45,16 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour const [isMounted, setIsMounted] = useState(false); // ─── Chargement progressif des pages suivantes ───────────────────────────── - const { rows: allRows, total, isLoadingMore, hasMore, loadNextPage, progress } = useInfiniteGrid({ + const handlePageLoaded = useCallback((newRows: ProductRow[]) => { + // Ajout O(n_nouveaux_items) au lieu de O(N_total) + appendRows(newRows); + }, [appendRows]); + + const { total, isLoadingMore, hasMore, loadNextPage, progress } = useInfiniteGrid({ codeFournisseur, - initialRows, initialTotal, - pageSize: 200, + pageSize: 10000, + onPageLoaded: handlePageLoaded }); useEffect(() => { @@ -70,10 +77,9 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour setFilter("code3", null); prevFournisseurRef.current = codeFournisseur; } - // 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]); + // Reset du store pour ce fournisseur + setRows(initialRows); + }, [codeFournisseur, initialRows, setRows, setFilter, isMounted]); // Synchroniser le magasin actif useEffect(() => { @@ -112,12 +118,7 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour

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

@@ -155,15 +156,14 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour > - Chargement : {allRows.length.toLocaleString("fr-FR")} / {total.toLocaleString("fr-FR")} références + Téléchargement : {rows.length.toLocaleString("fr-FR")} / {total.toLocaleString("fr-FR")} références -
+
- {progress}%
)} diff --git a/src/features/grid/hooks/use-infinite-grid.ts b/src/features/grid/hooks/use-infinite-grid.ts index b95e29a..0c61db3 100644 --- a/src/features/grid/hooks/use-infinite-grid.ts +++ b/src/features/grid/hooks/use-infinite-grid.ts @@ -5,37 +5,37 @@ 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; + onPageLoaded: (newRows: ProductRow[]) => void; } export interface InfiniteGridState { - rows: ProductRow[]; total: number; isLoadingMore: boolean; hasMore: boolean; loadNextPage: () => void; reset: () => void; progress: number; + loadedPagesCount: number; } export function useInfiniteGrid({ codeFournisseur, - initialRows, initialTotal, - pageSize = 200, + pageSize = 10000, search, gamme, code3, + onPageLoaded, }: InfiniteGridOptions): InfiniteGridState { - const [rows, setRows] = useState(initialRows); const [total, setTotal] = useState(initialTotal); const [currentPage, setCurrentPage] = useState(0); const [isLoadingMore, setIsLoadingMore] = useState(false); + const [loadedPagesCount, setLoadedPagesCount] = useState(1); // Page 0 est initialRows const fetchIdRef = useRef(0); const loadedPagesRef = useRef(new Set([0])); @@ -57,12 +57,12 @@ export function useInfiniteGrid({ ); useEffect(() => { - setRows(initialRows); setTotal(initialTotal); setCurrentPage(0); + setLoadedPagesCount(1); loadedPagesRef.current = new Set([0]); fetchIdRef.current += 1; - }, [codeFournisseur, initialRows, initialTotal, search, gamme, code3]); + }, [codeFournisseur, initialTotal, search, gamme, code3]); const loadNextPage = useCallback(async () => { if (!codeFournisseur || isLoadingMore) return; @@ -95,9 +95,12 @@ export function useInfiniteGrid({ }; loadedPagesRef.current.add(nextPage); - setRows(prev => [...prev, ...data.rows]); + setLoadedPagesCount(prev => prev + 1); setTotal(data.total); setCurrentPage(nextPage); + + // Envoyer directement au Store Zustand pour bypasser le cycle React DOM + onPageLoaded(data.rows); } catch (error) { console.error("[useInfiniteGrid] loadNextPage error:", error); } finally { @@ -105,27 +108,28 @@ export function useInfiniteGrid({ setIsLoadingMore(false); } } - }, [codeFournisseur, currentPage, total, pageSize, isLoadingMore, buildUrl]); + }, [codeFournisseur, currentPage, total, pageSize, isLoadingMore, buildUrl, onPageLoaded]); const reset = useCallback(() => { fetchIdRef.current += 1; loadedPagesRef.current = new Set([0]); - setRows(initialRows); setTotal(initialTotal); setCurrentPage(0); + setLoadedPagesCount(1); setIsLoadingMore(false); - }, [initialRows, initialTotal]); + }, [initialTotal]); - const hasMore = rows.length < total; - const progress = total > 0 ? Math.round((rows.length / total) * 100) : 100; + const loadedItemsEstimation = loadedPagesCount * pageSize; + const hasMore = loadedItemsEstimation < total && loadedPagesCount < Math.ceil(total / pageSize); + const progress = total > 0 ? Math.round(Math.min((loadedItemsEstimation / total) * 100, 100)) : 100; return { - rows, total, isLoadingMore, hasMore, loadNextPage, reset, progress, + loadedPagesCount, }; } diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index 4127cf8..1e3d9b2 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -27,6 +27,7 @@ interface GridState { // Actions setRows: (rows: ProductRow[]) => void; + appendRows: (newRows: ProductRow[]) => void; setDraftGamme: (codein: string, gamme: GammeCode) => void; resetDrafts: () => void; /** Clear specific draft changes by codein */ @@ -102,6 +103,18 @@ export const useGridStore = create()( set({ rows, rowsIndex, summary: computeSummary(rows, get().draftChanges, get().activeMagasin) }); }, + appendRows: (newRows) => { + const { rows, rowsIndex, draftChanges, activeMagasin } = get(); + // O(n) uniquement sur les nouveaux éléments, au lieu de re-calculer les 130k existants + const mergedRows = [...rows, ...newRows]; + newRows.forEach(r => rowsIndex.set(r.codein, r)); + set({ + rows: mergedRows, + rowsIndex, + summary: computeSummary(mergedRows, draftChanges, activeMagasin) + }); + }, + setDraftGamme: (codein, gamme) => { const { rows, rowsIndex, draftChanges: oldDrafts } = get(); // Lookup O(1) via l'index au lieu de O(n) via .find()