feat: implement grid state management, data fetching, and infinite scroll infrastructure

This commit is contained in:
Michael committed 2026-04-02 17:22:41 +02:00
1 parent 2893114fca
commit 88a3121c92
4 files changed
+61 -37

No files matched your search

+13 -6
View File
@@ -16,6 +16,7 @@ import {
import { db } from "@/db"; import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema"; import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm"; import { eq, desc } from "drizzle-orm";
import { unstable_cache } from "next/cache";
// ─── Interfaces ─────────────────────────────────────────────────────────────── // ─── Interfaces ───────────────────────────────────────────────────────────────
export interface GetProductRowsInput { export interface GetProductRowsInput {
@@ -37,13 +38,19 @@ export interface GetProductRowsResult {
totalPages: number; 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) ─────────────────── // ─── 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<ProductRow[]> { async function _fetchAllProductRows(codeFournisseur: string): Promise<ProductRow[]> {
const magasin = "TOTAL"; // Le cache est toujours TOTAL ; le switch magasin est client-side 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 { try {
const { dateDebut, dateFin } = buildLast12MonthsRange(); const { dateDebut, dateFin } = buildLast12MonthsRange();
@@ -337,8 +344,8 @@ export async function getProductRows(input: GetProductRowsInput): Promise<GetPro
code3, code3,
} = input; } = 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) // Récupérer toutes les lignes mises en cache pour éviter les 650 requêtes SQL
const allRows = await _fetchAllProductRows(codeFournisseur); const allRows = await getCachedSupplierRows(codeFournisseur);
let filtered = allRows; let filtered = allRows;
+17 -17
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState, useTransition } from "react"; import { useEffect, useRef, useState, useTransition, useCallback } from "react";
import { HeatmapGrid } from "@/features/grid/components/heatmap-grid"; import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar"; import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar"; import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
@@ -28,6 +28,8 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
const { data: session } = useSession(); const { data: session } = useSession();
const isAdmin = (session?.user as { role?: string })?.role === "admin"; const isAdmin = (session?.user as { role?: string })?.role === "admin";
const setRows = useGridStore((s) => s.setRows); const setRows = useGridStore((s) => s.setRows);
const appendRows = useGridStore((s) => s.appendRows);
const rows = useGridStore((s) => s.rows);
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery); const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
const setFilter = useGridStore((s) => s.setFilter); const setFilter = useGridStore((s) => s.setFilter);
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin); const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
@@ -43,11 +45,16 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
// ─── Chargement progressif des pages suivantes ───────────────────────────── // ─── 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, codeFournisseur,
initialRows,
initialTotal, initialTotal,
pageSize: 200, pageSize: 10000,
onPageLoaded: handlePageLoaded
}); });
useEffect(() => { useEffect(() => {
@@ -70,10 +77,9 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
setFilter("code3", null); setFilter("code3", null);
prevFournisseurRef.current = codeFournisseur; prevFournisseurRef.current = codeFournisseur;
} }
// Les scores ont déjà été calculés côté serveur. // Reset du store pour ce fournisseur
// On pousse toutes les lignes accumulées par l'infinite scroll dans le store. setRows(initialRows);
setRows(allRows); }, [codeFournisseur, initialRows, setRows, setFilter, isMounted]);
}, [codeFournisseur, allRows, setRows, setFilter, isMounted]);
// Synchroniser le magasin actif // Synchroniser le magasin actif
useEffect(() => { useEffect(() => {
@@ -112,12 +118,7 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
<p className="text-[13px] mt-0.5" style={{ color: "var(--text-secondary)" }}> <p className="text-[13px] mt-0.5" style={{ color: "var(--text-secondary)" }}>
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>{nomFournisseur}</span> <span className="font-semibold" style={{ color: "var(--text-primary)" }}>{nomFournisseur}</span>
{" "}• <span className="font-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span> {" "}• <span className="font-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span>
{" "}• {allRows.length.toLocaleString("fr-FR")} références {" "}• {total.toLocaleString("fr-FR")} références
{hasMore && (
<span className="text-[11px] opacity-60">
{" "}/ {total.toLocaleString("fr-FR")}
</span>
)}
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -155,15 +156,14 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
> >
<Loader2 className="w-3 h-3 animate-spin shrink-0" style={{ color: "var(--accent)" }} /> <Loader2 className="w-3 h-3 animate-spin shrink-0" style={{ color: "var(--accent)" }} />
<span> <span>
Chargement&nbsp;: {allRows.length.toLocaleString("fr-FR")} / {total.toLocaleString("fr-FR")} références Téléchargement&nbsp;: {rows.length.toLocaleString("fr-FR")} / {total.toLocaleString("fr-FR")} références
</span> </span>
<div className="flex-1 h-1 rounded-full overflow-hidden" style={{ background: "var(--border)" }}> <div className="flex-1 h-1 w-[100px] rounded-full overflow-hidden" style={{ background: "var(--border)" }}>
<div <div
className="h-full rounded-full transition-all duration-500" className="h-full rounded-full transition-all duration-500"
style={{ width: `${progress}%`, background: "var(--accent)" }} style={{ width: `${progress}%`, background: "var(--accent)" }}
/> />
</div> </div>
<span>{progress}%</span>
</div> </div>
)} )}
+18 -14
View File
@@ -5,37 +5,37 @@ import type { ProductRow } from "@/types/grid";
export interface InfiniteGridOptions { export interface InfiniteGridOptions {
codeFournisseur: string | null; codeFournisseur: string | null;
initialRows: ProductRow[];
initialTotal: number; initialTotal: number;
pageSize?: number; pageSize?: number;
search?: string; search?: string;
gamme?: string | null; gamme?: string | null;
code3?: string | null; code3?: string | null;
onPageLoaded: (newRows: ProductRow[]) => void;
} }
export interface InfiniteGridState { export interface InfiniteGridState {
rows: ProductRow[];
total: number; total: number;
isLoadingMore: boolean; isLoadingMore: boolean;
hasMore: boolean; hasMore: boolean;
loadNextPage: () => void; loadNextPage: () => void;
reset: () => void; reset: () => void;
progress: number; progress: number;
loadedPagesCount: number;
} }
export function useInfiniteGrid({ export function useInfiniteGrid({
codeFournisseur, codeFournisseur,
initialRows,
initialTotal, initialTotal,
pageSize = 200, pageSize = 10000,
search, search,
gamme, gamme,
code3, code3,
onPageLoaded,
}: InfiniteGridOptions): InfiniteGridState { }: InfiniteGridOptions): InfiniteGridState {
const [rows, setRows] = useState<ProductRow[]>(initialRows);
const [total, setTotal] = useState(initialTotal); const [total, setTotal] = useState(initialTotal);
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const [loadedPagesCount, setLoadedPagesCount] = useState(1); // Page 0 est initialRows
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const loadedPagesRef = useRef(new Set<number>([0])); const loadedPagesRef = useRef(new Set<number>([0]));
@@ -57,12 +57,12 @@ export function useInfiniteGrid({
); );
useEffect(() => { useEffect(() => {
setRows(initialRows);
setTotal(initialTotal); setTotal(initialTotal);
setCurrentPage(0); setCurrentPage(0);
setLoadedPagesCount(1);
loadedPagesRef.current = new Set([0]); loadedPagesRef.current = new Set([0]);
fetchIdRef.current += 1; fetchIdRef.current += 1;
}, [codeFournisseur, initialRows, initialTotal, search, gamme, code3]); }, [codeFournisseur, initialTotal, search, gamme, code3]);
const loadNextPage = useCallback(async () => { const loadNextPage = useCallback(async () => {
if (!codeFournisseur || isLoadingMore) return; if (!codeFournisseur || isLoadingMore) return;
@@ -95,9 +95,12 @@ export function useInfiniteGrid({
}; };
loadedPagesRef.current.add(nextPage); loadedPagesRef.current.add(nextPage);
setRows(prev => [...prev, ...data.rows]); setLoadedPagesCount(prev => prev + 1);
setTotal(data.total); setTotal(data.total);
setCurrentPage(nextPage); setCurrentPage(nextPage);
// Envoyer directement au Store Zustand pour bypasser le cycle React DOM
onPageLoaded(data.rows);
} catch (error) { } catch (error) {
console.error("[useInfiniteGrid] loadNextPage error:", error); console.error("[useInfiniteGrid] loadNextPage error:", error);
} finally { } finally {
@@ -105,27 +108,28 @@ export function useInfiniteGrid({
setIsLoadingMore(false); setIsLoadingMore(false);
} }
} }
}, [codeFournisseur, currentPage, total, pageSize, isLoadingMore, buildUrl]); }, [codeFournisseur, currentPage, total, pageSize, isLoadingMore, buildUrl, onPageLoaded]);
const reset = useCallback(() => { const reset = useCallback(() => {
fetchIdRef.current += 1; fetchIdRef.current += 1;
loadedPagesRef.current = new Set([0]); loadedPagesRef.current = new Set([0]);
setRows(initialRows);
setTotal(initialTotal); setTotal(initialTotal);
setCurrentPage(0); setCurrentPage(0);
setLoadedPagesCount(1);
setIsLoadingMore(false); setIsLoadingMore(false);
}, [initialRows, initialTotal]); }, [initialTotal]);
const hasMore = rows.length < total; const loadedItemsEstimation = loadedPagesCount * pageSize;
const progress = total > 0 ? Math.round((rows.length / total) * 100) : 100; const hasMore = loadedItemsEstimation < total && loadedPagesCount < Math.ceil(total / pageSize);
const progress = total > 0 ? Math.round(Math.min((loadedItemsEstimation / total) * 100, 100)) : 100;
return { return {
rows,
total, total,
isLoadingMore, isLoadingMore,
hasMore, hasMore,
loadNextPage, loadNextPage,
reset, reset,
progress, progress,
loadedPagesCount,
}; };
} }
+13
View File
@@ -27,6 +27,7 @@ interface GridState {
// Actions // Actions
setRows: (rows: ProductRow[]) => void; setRows: (rows: ProductRow[]) => void;
appendRows: (newRows: ProductRow[]) => void;
setDraftGamme: (codein: string, gamme: GammeCode) => void; setDraftGamme: (codein: string, gamme: GammeCode) => void;
resetDrafts: () => void; resetDrafts: () => void;
/** Clear specific draft changes by codein */ /** Clear specific draft changes by codein */
@@ -102,6 +103,18 @@ export const useGridStore = create<GridState>()(
set({ rows, rowsIndex, summary: computeSummary(rows, get().draftChanges, get().activeMagasin) }); 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) => { setDraftGamme: (codein, gamme) => {
const { rows, rowsIndex, draftChanges: oldDrafts } = get(); const { rows, rowsIndex, draftChanges: oldDrafts } = get();
// Lookup O(1) via l'index au lieu de O(n) via .find() // Lookup O(1) via l'index au lieu de O(n) via .find()