mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-19 13:08:11 +02:00
feat: implement infinite scrolling and background data loading for the product grid component
This commit is contained in:
1 parent
f70249555e
commit
4dfdcc1144
4 files changed
+47
-10
No files matched your search
@@ -17,6 +17,8 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const page = parseInt(searchParams.get("page") ?? "0", 10);
|
const page = parseInt(searchParams.get("page") ?? "0", 10);
|
||||||
const pageSize = parseInt(searchParams.get("pageSize") ?? "200", 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 search = searchParams.get("search") ?? undefined;
|
||||||
const gamme = searchParams.get("gamme") ?? undefined;
|
const gamme = searchParams.get("gamme") ?? undefined;
|
||||||
const code3 = searchParams.get("code3") ?? undefined;
|
const code3 = searchParams.get("code3") ?? undefined;
|
||||||
@@ -26,6 +28,7 @@ export async function GET(request: NextRequest) {
|
|||||||
codeFournisseur,
|
codeFournisseur,
|
||||||
page: isNaN(page) ? 0 : page,
|
page: isNaN(page) ? 0 : page,
|
||||||
pageSize: isNaN(pageSize) ? 200 : pageSize,
|
pageSize: isNaN(pageSize) ? 200 : pageSize,
|
||||||
|
offset: offset !== undefined && !isNaN(offset) ? offset : undefined,
|
||||||
search,
|
search,
|
||||||
gamme: gamme || null,
|
gamme: gamme || null,
|
||||||
code3: code3 || null,
|
code3: code3 || null,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface GetProductRowsInput {
|
|||||||
filters?: Partial<GridFilters>;
|
filters?: Partial<GridFilters>;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
offset?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
gamme?: string | null;
|
gamme?: string | null;
|
||||||
code3?: 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 ─────────
|
// ─── Phase 10 : Filtrer gamme Y sans ventes + compute scores ─────────
|
||||||
const allRows = Array.from(productMap.values());
|
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;
|
const excludedY = allRows.length - rows.length;
|
||||||
console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`);
|
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,
|
codeFournisseur,
|
||||||
page,
|
page,
|
||||||
pageSize = 200,
|
pageSize = 200,
|
||||||
|
offset,
|
||||||
search,
|
search,
|
||||||
gamme,
|
gamme,
|
||||||
code3,
|
code3,
|
||||||
@@ -379,9 +381,16 @@ export async function getProductRows(input: GetProductRowsInput): Promise<GetPro
|
|||||||
|
|
||||||
const safePageSize = Math.max(1, Math.min(pageSize, 20000));
|
const safePageSize = Math.max(1, Math.min(pageSize, 20000));
|
||||||
const totalPages = Math.ceil(total / safePageSize);
|
const totalPages = Math.ceil(total / safePageSize);
|
||||||
const safePage = Math.max(0, Math.min(page, totalPages - 1));
|
const safePage = Math.max(0, Math.min(page ?? 0, Math.max(0, totalPages - 1)));
|
||||||
const start = safePage * safePageSize;
|
|
||||||
const rows = filtered.slice(start, start + safePageSize);
|
// 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 };
|
return { rows, total, page: safePage, pageSize: safePageSize, totalPages };
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ import { useGridStore } from "@/features/grid/store/use-grid-store";
|
|||||||
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
||||||
import { useInfiniteGrid } from "@/features/grid/hooks/use-infinite-grid";
|
import { useInfiniteGrid } from "@/features/grid/hooks/use-infinite-grid";
|
||||||
import type { ProductRow } from "@/types/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 { useSession } from "next-auth/react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
|||||||
appendRows(newRows);
|
appendRows(newRows);
|
||||||
}, [appendRows]);
|
}, [appendRows]);
|
||||||
|
|
||||||
const { total, isLoadingMore, hasMore, loadNextPage, progress } = useInfiniteGrid({
|
const { total, isLoadingMore, hasMore, loadNextPage, stopLoading, progress } = useInfiniteGrid({
|
||||||
codeFournisseur,
|
codeFournisseur,
|
||||||
initialTotal,
|
initialTotal,
|
||||||
pageSize: 10000,
|
pageSize: 10000,
|
||||||
@@ -164,6 +164,14 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
|||||||
style={{ width: `${progress}%`, background: "var(--accent)" }}
|
style={{ width: `${progress}%`, background: "var(--accent)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface InfiniteGridState {
|
|||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
loadNextPage: () => void;
|
loadNextPage: () => void;
|
||||||
|
stopLoading: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
progress: number;
|
progress: number;
|
||||||
loadedPagesCount: number;
|
loadedPagesCount: number;
|
||||||
@@ -35,6 +36,7 @@ export function useInfiniteGrid({
|
|||||||
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 [isStopped, setIsStopped] = useState(false);
|
||||||
const [loadedPagesCount, setLoadedPagesCount] = useState(1); // Page 0 est initialRows
|
const [loadedPagesCount, setLoadedPagesCount] = useState(1); // Page 0 est initialRows
|
||||||
|
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
@@ -48,6 +50,11 @@ export function useInfiniteGrid({
|
|||||||
page: String(page),
|
page: String(page),
|
||||||
pageSize: String(pageSize),
|
pageSize: String(pageSize),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (page > 0) {
|
||||||
|
params.set("offset", String(200 + (page - 1) * pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
if (search) params.set("search", search);
|
if (search) params.set("search", search);
|
||||||
if (gamme) params.set("gamme", gamme);
|
if (gamme) params.set("gamme", gamme);
|
||||||
if (code3) params.set("code3", code3);
|
if (code3) params.set("code3", code3);
|
||||||
@@ -60,12 +67,13 @@ export function useInfiniteGrid({
|
|||||||
setTotal(initialTotal);
|
setTotal(initialTotal);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0);
|
||||||
setLoadedPagesCount(1);
|
setLoadedPagesCount(1);
|
||||||
|
setIsStopped(false);
|
||||||
loadedPagesRef.current = new Set([0]);
|
loadedPagesRef.current = new Set([0]);
|
||||||
fetchIdRef.current += 1;
|
fetchIdRef.current += 1;
|
||||||
}, [codeFournisseur, initialTotal, search, gamme, code3]);
|
}, [codeFournisseur, initialTotal, search, gamme, code3]);
|
||||||
|
|
||||||
const loadNextPage = useCallback(async () => {
|
const loadNextPage = useCallback(async () => {
|
||||||
if (!codeFournisseur || isLoadingMore) return;
|
if (!codeFournisseur || isLoadingMore || isStopped) return;
|
||||||
|
|
||||||
const nextPage = currentPage + 1;
|
const nextPage = currentPage + 1;
|
||||||
const alreadyLoaded = loadedPagesRef.current.has(nextPage);
|
const alreadyLoaded = loadedPagesRef.current.has(nextPage);
|
||||||
@@ -102,13 +110,19 @@ export function useInfiniteGrid({
|
|||||||
// Envoyer directement au Store Zustand pour bypasser le cycle React DOM
|
// Envoyer directement au Store Zustand pour bypasser le cycle React DOM
|
||||||
onPageLoaded(data.rows);
|
onPageLoaded(data.rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if ((error as Error).name === "AbortError") return;
|
||||||
console.error("[useInfiniteGrid] loadNextPage error:", error);
|
console.error("[useInfiniteGrid] loadNextPage error:", error);
|
||||||
} finally {
|
} finally {
|
||||||
if (currentFetchId === fetchIdRef.current) {
|
if (currentFetchId === fetchIdRef.current) {
|
||||||
setIsLoadingMore(false);
|
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(() => {
|
const reset = useCallback(() => {
|
||||||
fetchIdRef.current += 1;
|
fetchIdRef.current += 1;
|
||||||
@@ -117,10 +131,12 @@ export function useInfiniteGrid({
|
|||||||
setCurrentPage(0);
|
setCurrentPage(0);
|
||||||
setLoadedPagesCount(1);
|
setLoadedPagesCount(1);
|
||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
|
setIsStopped(false);
|
||||||
}, [initialTotal]);
|
}, [initialTotal]);
|
||||||
|
|
||||||
const loadedItemsEstimation = loadedPagesCount * pageSize;
|
// On part du principe qu'il y en a 200 + n * 10 000
|
||||||
const hasMore = loadedItemsEstimation < total && loadedPagesCount < Math.ceil(total / pageSize);
|
const loadedItemsEstimation = 200 + (loadedPagesCount - 1) * pageSize;
|
||||||
|
const hasMore = !isStopped && loadedItemsEstimation < total;
|
||||||
const progress = total > 0 ? Math.round(Math.min((loadedItemsEstimation / total) * 100, 100)) : 100;
|
const progress = total > 0 ? Math.round(Math.min((loadedItemsEstimation / total) * 100, 100)) : 100;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -128,6 +144,7 @@ export function useInfiniteGrid({
|
|||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
hasMore,
|
hasMore,
|
||||||
loadNextPage,
|
loadNextPage,
|
||||||
|
stopLoading,
|
||||||
reset,
|
reset,
|
||||||
progress,
|
progress,
|
||||||
loadedPagesCount,
|
loadedPagesCount,
|
||||||
|
|||||||
Reference in new issue
Block a user