feat: implement infinite scrolling and background data loading for the product grid component

This commit is contained in:
Michael committed 2026-04-02 17:47:10 +02:00
1 parent f70249555e
commit 4dfdcc1144
4 files changed
+47 -10

No files matched your search

+3
View File
@@ -17,6 +17,8 @@ export async function GET(request: NextRequest) {
const page = parseInt(searchParams.get("page") ?? "0", 10);
const pageSize = parseInt(searchParams.get("pageSize") ?? "200", 10);
const offsetRaw = searchParams.get("offset");
const offset = offsetRaw ? parseInt(offsetRaw, 10) : undefined;
const search = searchParams.get("search") ?? undefined;
const gamme = searchParams.get("gamme") ?? undefined;
const code3 = searchParams.get("code3") ?? undefined;
@@ -26,6 +28,7 @@ export async function GET(request: NextRequest) {
codeFournisseur,
page: isNaN(page) ? 0 : page,
pageSize: isNaN(pageSize) ? 200 : pageSize,
offset: offset !== undefined && !isNaN(offset) ? offset : undefined,
search,
gamme: gamme || null,
code3: code3 || null,
+13 -4
View File
@@ -25,6 +25,7 @@ export interface GetProductRowsInput {
filters?: Partial<GridFilters>;
page?: number;
pageSize?: number;
offset?: number;
search?: string;
gamme?: string | null;
code3?: string | null;
@@ -317,7 +318,7 @@ async function _fetchAllProductRows(codeFournisseur: string): Promise<ProductRow
// ─── Phase 10 : Filtrer gamme Y sans ventes + compute scores ─────────
const allRows = Array.from(productMap.values());
const rows = allRows.filter(p => p.codeGamme !== "Y" || p.totalQuantite > 0);
const rows = allRows.filter(p => p.codeGamme?.trim().toUpperCase() !== "Y" || p.totalQuantite > 0);
const excludedY = allRows.length - rows.length;
console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`);
@@ -339,6 +340,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise<GetPro
codeFournisseur,
page,
pageSize = 200,
offset,
search,
gamme,
code3,
@@ -379,9 +381,16 @@ export async function getProductRows(input: GetProductRowsInput): Promise<GetPro
const safePageSize = Math.max(1, Math.min(pageSize, 20000));
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);
const safePage = Math.max(0, Math.min(page ?? 0, Math.max(0, totalPages - 1)));
// Priorité à l'offset s'il est fourni (pour la pagination côté client qui ne respecte pas les blocs de 10k au départ)
const startIdx = offset !== undefined ? offset : safePage * safePageSize;
if (startIdx >= total) {
return { rows: [], total, page: safePage, pageSize: safePageSize, totalPages };
}
const rows = filtered.slice(startIdx, startIdx + safePageSize);
return { rows, total, page: safePage, pageSize: safePageSize, totalPages };
}
+10 -2
View File
@@ -10,7 +10,7 @@ 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 { CheckCircle, AlertCircle, Loader2, StopCircle } from "lucide-react";
import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation";
@@ -50,7 +50,7 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
appendRows(newRows);
}, [appendRows]);
const { total, isLoadingMore, hasMore, loadNextPage, progress } = useInfiniteGrid({
const { total, isLoadingMore, hasMore, loadNextPage, stopLoading, progress } = useInfiniteGrid({
codeFournisseur,
initialTotal,
pageSize: 10000,
@@ -164,6 +164,14 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
style={{ width: `${progress}%`, background: "var(--accent)" }}
/>
</div>
<button
onClick={stopLoading}
className="ml-2 flex items-center gap-1 px-2 py-0.5 rounded-full hover:bg-red-500/10 text-red-600 transition-colors"
title="Arrêter le téléchargement du reste des références"
>
<StopCircle className="w-3.5 h-3.5" />
<span className="text-[10px] uppercase font-bold">Stop</span>
</button>
</div>
)}
+21 -4
View File
@@ -18,6 +18,7 @@ export interface InfiniteGridState {
isLoadingMore: boolean;
hasMore: boolean;
loadNextPage: () => void;
stopLoading: () => void;
reset: () => void;
progress: number;
loadedPagesCount: number;
@@ -35,6 +36,7 @@ export function useInfiniteGrid({
const [total, setTotal] = useState(initialTotal);
const [currentPage, setCurrentPage] = useState(0);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isStopped, setIsStopped] = useState(false);
const [loadedPagesCount, setLoadedPagesCount] = useState(1); // Page 0 est initialRows
const fetchIdRef = useRef(0);
@@ -48,6 +50,11 @@ export function useInfiniteGrid({
page: String(page),
pageSize: String(pageSize),
});
if (page > 0) {
params.set("offset", String(200 + (page - 1) * pageSize));
}
if (search) params.set("search", search);
if (gamme) params.set("gamme", gamme);
if (code3) params.set("code3", code3);
@@ -60,12 +67,13 @@ export function useInfiniteGrid({
setTotal(initialTotal);
setCurrentPage(0);
setLoadedPagesCount(1);
setIsStopped(false);
loadedPagesRef.current = new Set([0]);
fetchIdRef.current += 1;
}, [codeFournisseur, initialTotal, search, gamme, code3]);
const loadNextPage = useCallback(async () => {
if (!codeFournisseur || isLoadingMore) return;
if (!codeFournisseur || isLoadingMore || isStopped) return;
const nextPage = currentPage + 1;
const alreadyLoaded = loadedPagesRef.current.has(nextPage);
@@ -102,13 +110,19 @@ export function useInfiniteGrid({
// Envoyer directement au Store Zustand pour bypasser le cycle React DOM
onPageLoaded(data.rows);
} catch (error) {
if ((error as Error).name === "AbortError") return;
console.error("[useInfiniteGrid] loadNextPage error:", error);
} finally {
if (currentFetchId === fetchIdRef.current) {
setIsLoadingMore(false);
}
}
}, [codeFournisseur, currentPage, total, pageSize, isLoadingMore, buildUrl, onPageLoaded]);
}, [codeFournisseur, currentPage, total, pageSize, isLoadingMore, isStopped, buildUrl, onPageLoaded]);
const stopLoading = useCallback(() => {
setIsStopped(true);
setIsLoadingMore(false);
}, []);
const reset = useCallback(() => {
fetchIdRef.current += 1;
@@ -117,10 +131,12 @@ export function useInfiniteGrid({
setCurrentPage(0);
setLoadedPagesCount(1);
setIsLoadingMore(false);
setIsStopped(false);
}, [initialTotal]);
const loadedItemsEstimation = loadedPagesCount * pageSize;
const hasMore = loadedItemsEstimation < total && loadedPagesCount < Math.ceil(total / pageSize);
// On part du principe qu'il y en a 200 + n * 10 000
const loadedItemsEstimation = 200 + (loadedPagesCount - 1) * pageSize;
const hasMore = !isStopped && loadedItemsEstimation < total;
const progress = total > 0 ? Math.round(Math.min((loadedItemsEstimation / total) * 100, 100)) : 100;
return {
@@ -128,6 +144,7 @@ export function useInfiniteGrid({
isLoadingMore,
hasMore,
loadNextPage,
stopLoading,
reset,
progress,
loadedPagesCount,