feat: implement infinite grid pagination with server-side data fetching and custom hook

This commit is contained in:
Michael committed 2026-04-02 17:17:32 +02:00
1 parent 4db7faa41f
commit 2893114fca
6 files changed
+302 -27

No files matched your search

+6 -6
View File
@@ -33,16 +33,16 @@ export default async function GridPage({ searchParams }: GridPageProps) {
return <SupplierSelectionLanding fournisseurs={fournisseurs} />;
}
// 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 (
<GridClient
initialRows={rows}
initialRows={initialRows}
initialTotal={total}
codeFournisseur={codeFournisseur}
nomFournisseur={selectedFournisseur?.nom || "Fournisseur inconnu"}
fournisseurs={fournisseurs}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { getProductRows } from "@/features/grid/api/get-product-rows";
import { auth } from "@/lib/auth";
export async function GET(request: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { searchParams } = request.nextUrl;
const codeFournisseur = searchParams.get("fournisseur");
if (!codeFournisseur) {
return NextResponse.json({ error: "Paramètre 'fournisseur' requis" }, { status: 400 });
}
const page = parseInt(searchParams.get("page") ?? "0", 10);
const pageSize = parseInt(searchParams.get("pageSize") ?? "200", 10);
const search = searchParams.get("search") ?? undefined;
const gamme = searchParams.get("gamme") ?? undefined;
const code3 = searchParams.get("code3") ?? undefined;
try {
const result = await getProductRows({
codeFournisseur,
page: isNaN(page) ? 0 : page,
pageSize: isNaN(pageSize) ? 200 : pageSize,
search,
gamme: gamme || null,
code3: code3 || null,
});
return NextResponse.json(result, {
status: 200,
headers: {
"Cache-Control": "private, max-age=30, stale-while-revalidate=60",
},
});
} catch (error) {
console.error("[GET /api/grid/rows] Error:", error);
return NextResponse.json({ error: "Erreur lors de la récupération des données" }, { status: 500 });
}
}
+8 -8
View File
@@ -2,8 +2,8 @@
import { getSitesFromApi } from "@/lib/api-ff-client";
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
import { getProductRows } from "./api/get-product-rows";
import type { ProductRow, GridFilters } from "@/types/grid";
import { getProductRows, type GetProductRowsResult } from "./api/get-product-rows";
import type { GridFilters } from "@/types/grid";
/**
* Get the list of all suppliers from PostgreSQL (fouadr1).
@@ -28,14 +28,14 @@ export async function getAvailableNomenclature() {
/**
* Retourne tous les produits d'un fournisseur.
* Le résultat est mis en cache 5 min côté serveur (unstable_cache par fournisseur).
* La première requête est lente (6 SQL + agrégation mémoire).
* Toutes les requêtes suivantes dans la fenêtre de 5 min sont instantanées.
* La charge est paginée (renvoie la première page en SSR) pour empêcher le figeage du thread principal UI.
*/
export async function getGridData(
codeFournisseur: string,
magasin: string = "TOTAL",
filters?: Partial<GridFilters>
): Promise<ProductRow[]> {
return getProductRows({ codeFournisseur, magasin, filters });
filters?: Partial<GridFilters>,
page: number = 0,
pageSize: number = 200,
): Promise<GetProductRowsResult> {
return getProductRows({ codeFournisseur, magasin, filters, page, pageSize });
}
+64 -6
View File
@@ -22,6 +22,19 @@ export interface GetProductRowsInput {
codeFournisseur: string;
magasin?: string;
filters?: Partial<GridFilters>;
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<ProductRow
// ─── Fonction publique ─────────────
/**
* Point d'entrée unique pour la grille.
* - Retourne toutes les lignes (filtrage/tri restent côté client)
* - Retourne les lignes paginées pour préserver le client.
*/
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
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<GetProductRowsResult> {
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 };
}
+49 -7
View File
@@ -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
<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-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span>
{" "}• {initialRows.length} références
{" "}• {allRows.length.toLocaleString("fr-FR")} références
{hasMore && (
<span className="text-[11px] opacity-60">
{" "}/ {total.toLocaleString("fr-FR")}
</span>
)}
</p>
</div>
<div className="flex items-center gap-3">
@@ -125,6 +148,25 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn
</div>
</div>
{/* Barre de progression chargement en arrière-plan */}
{(hasMore || isLoadingMore) && (
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 rounded-lg text-[11px] font-medium"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
>
<Loader2 className="w-3 h-3 animate-spin shrink-0" style={{ color: "var(--accent)" }} />
<span>
Chargement&nbsp;: {allRows.length.toLocaleString("fr-FR")} / {total.toLocaleString("fr-FR")} références
</span>
<div className="flex-1 h-1 rounded-full overflow-hidden" style={{ background: "var(--border)" }}>
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${progress}%`, background: "var(--accent)" }}
/>
</div>
<span>{progress}%</span>
</div>
)}
{/* Filters */}
<div className="shrink-0 print:hidden">
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
@@ -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<ProductRow[]>(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<number>([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,
};
}