mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
revert: restore grid to pre-infinite-scroll state
Remove infinite grid loading, useInfiniteGrid hook, and server-side pagination API route. Revert grid components to the simpler version that avoids 2MB+ cache failures from unstable_cache. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
bd21f07c8e
commit
2aa9cbecaa
8 files changed
+40
-422
No files matched your search
@@ -33,16 +33,15 @@ export default async function GridPage({ searchParams }: GridPageProps) {
|
||||
return <SupplierSelectionLanding fournisseurs={fournisseurs} />;
|
||||
}
|
||||
|
||||
// 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`);
|
||||
// 3. Load real product data for the selected supplier
|
||||
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`);
|
||||
const selectedFournisseur = fournisseurs.find((f: { code: string; nom: string }) => f.code === codeFournisseur);
|
||||
|
||||
return (
|
||||
<GridClient
|
||||
initialRows={initialRows}
|
||||
initialTotal={total}
|
||||
initialRows={rows}
|
||||
codeFournisseur={codeFournisseur}
|
||||
nomFournisseur={selectedFournisseur?.nom || "Fournisseur inconnu"}
|
||||
fournisseurs={fournisseurs}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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 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;
|
||||
const magasin = searchParams.get("magasin") ?? undefined;
|
||||
|
||||
try {
|
||||
const result = await getProductRows({
|
||||
codeFournisseur,
|
||||
magasin,
|
||||
page: isNaN(page) ? 0 : page,
|
||||
pageSize: isNaN(pageSize) ? 200 : pageSize,
|
||||
offset: offset !== undefined && !isNaN(offset) ? offset : undefined,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { getSitesFromApi } from "@/lib/api-ff-client";
|
||||
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
import { getProductRows, type GetProductRowsResult } from "./api/get-product-rows";
|
||||
import type { GridFilters } from "@/types/grid";
|
||||
import { getProductRows } from "./api/get-product-rows";
|
||||
import type { ProductRow, GridFilters } from "@/types/grid";
|
||||
|
||||
/**
|
||||
* Get the list of all suppliers from PostgreSQL (fouadr1).
|
||||
@@ -21,21 +21,19 @@ export async function getMagasins() {
|
||||
|
||||
/**
|
||||
* Nomenclature filter is disabled — code3 is not available from the API.
|
||||
* Returns an empty hierarchy so the sidebar filter is hidden gracefully.
|
||||
*/
|
||||
export async function getAvailableNomenclature() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne tous les produits d'un fournisseur.
|
||||
* La charge est paginée (renvoie la première page en SSR) pour empêcher le figeage du thread principal UI.
|
||||
* Get product data for a specific supplier and store.
|
||||
*/
|
||||
export async function getGridData(
|
||||
codeFournisseur: string,
|
||||
magasin: string = "TOTAL",
|
||||
filters?: Partial<GridFilters>,
|
||||
page: number = 0,
|
||||
pageSize: number = 200,
|
||||
): Promise<GetProductRowsResult> {
|
||||
return getProductRows({ codeFournisseur, magasin, filters, page, pageSize });
|
||||
filters?: Partial<GridFilters>
|
||||
): Promise<ProductRow[]> {
|
||||
return getProductRows({ codeFournisseur, magasin, filters });
|
||||
}
|
||||
@@ -16,42 +16,16 @@ 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 {
|
||||
interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
magasin?: string;
|
||||
filters?: Partial<GridFilters>;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
gamme?: string | null;
|
||||
code3?: string | null;
|
||||
}
|
||||
|
||||
export interface GetProductRowsResult {
|
||||
rows: ProductRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: 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) ───────────────────
|
||||
async function _fetchAllProductRows(codeFournisseur: string): Promise<ProductRow[]> {
|
||||
const magasin = "TOTAL"; // Le cache est toujours TOTAL ; le switch magasin est client-side
|
||||
console.log(`\n>>> [_fetchAllProductRows] (NO CACHE) supplier: ${codeFournisseur}, magasin: ${magasin}`);
|
||||
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
|
||||
const { codeFournisseur, magasin = "TOTAL" } = input;
|
||||
console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`);
|
||||
|
||||
try {
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
@@ -318,79 +292,14 @@ 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?.trim().toUpperCase() !== "Y" || p.totalQuantite > 0);
|
||||
const rows = allRows.filter(p => p.codeGamme !== "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`);
|
||||
|
||||
return computeProductScores(rows);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[_fetchAllProductRows] Error for ${codeFournisseur}:`, error);
|
||||
console.error(`[getProductRows] Error for ${codeFournisseur}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fonction publique ─────────────
|
||||
/**
|
||||
* Point d'entrée unique pour la grille.
|
||||
* - Retourne les lignes paginées pour préserver le client.
|
||||
*/
|
||||
export async function getProductRows(input: GetProductRowsInput): Promise<GetProductRowsResult> {
|
||||
const {
|
||||
codeFournisseur,
|
||||
page,
|
||||
pageSize = 200,
|
||||
offset,
|
||||
search,
|
||||
gamme,
|
||||
code3,
|
||||
} = input;
|
||||
|
||||
// Récupérer toutes les lignes mises en cache pour éviter les 650 requêtes SQL
|
||||
const allRows = await getCachedSupplierRows(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, 20000));
|
||||
const totalPages = Math.ceil(total / 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 };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useTransition, useCallback } from "react";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
|
||||
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
|
||||
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
||||
@@ -8,15 +8,15 @@ 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, StopCircle } from "lucide-react";
|
||||
import { CheckCircle, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { computeProductScores } from "@/lib/score-engine";
|
||||
|
||||
interface GridClientProps {
|
||||
initialRows: ProductRow[];
|
||||
initialTotal: number;
|
||||
codeFournisseur: string;
|
||||
nomFournisseur: string;
|
||||
fournisseurs: { code: string; nom: string }[];
|
||||
@@ -24,12 +24,10 @@ interface GridClientProps {
|
||||
magasin: string;
|
||||
}
|
||||
|
||||
export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
|
||||
export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = (session?.user as { role?: string })?.role === "admin";
|
||||
const isAdmin = (session?.user as any)?.role === "admin";
|
||||
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 setFilter = useGridStore((s) => s.setFilter);
|
||||
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
||||
@@ -44,20 +42,6 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// ─── Chargement progressif des pages suivantes ─────────────────────────────
|
||||
const handlePageLoaded = useCallback((newRows: ProductRow[]) => {
|
||||
// Ajout O(n_nouveaux_items) au lieu de O(N_total)
|
||||
appendRows(newRows);
|
||||
}, [appendRows]);
|
||||
|
||||
const { total, isLoadingMore, hasMore, loadNextPage, stopLoading, progress } = useInfiniteGrid({
|
||||
codeFournisseur,
|
||||
magasin: magasin || "TOTAL",
|
||||
initialTotal,
|
||||
pageSize: 10000,
|
||||
onPageLoaded: handlePageLoaded
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
@@ -66,6 +50,7 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
const currentQueryString = searchParams.toString();
|
||||
// Set the active query, ensuring it starts with ? if not empty
|
||||
setActiveGridQuery(currentQueryString ? `?${currentQueryString}` : "");
|
||||
}, [searchParams, setActiveGridQuery, isMounted]);
|
||||
|
||||
@@ -78,24 +63,16 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
setFilter("code3", null);
|
||||
prevFournisseurRef.current = codeFournisseur;
|
||||
}
|
||||
// Reset du store pour ce fournisseur
|
||||
setRows(initialRows);
|
||||
const scoredRows = computeProductScores([...initialRows]);
|
||||
setRows(scoredRows);
|
||||
}, [codeFournisseur, initialRows, setRows, setFilter, isMounted]);
|
||||
|
||||
// Synchroniser le magasin actif
|
||||
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
|
||||
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();
|
||||
@@ -119,7 +96,7 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
<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>
|
||||
{" "}• {total.toLocaleString("fr-FR")} références
|
||||
{" "}• {initialRows.length} références
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -150,32 +127,6 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
</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>
|
||||
Téléchargement : {rows.length.toLocaleString("fr-FR")} / {total.toLocaleString("fr-FR")} références
|
||||
</span>
|
||||
<div className="flex-1 h-1 w-[100px] 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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="shrink-0 print:hidden">
|
||||
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
|
||||
|
||||
@@ -268,39 +268,27 @@ function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; act
|
||||
// =========================================================================
|
||||
|
||||
export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
// L'abonnement doit être ultra-minimal ! JAMAIS de destructuring brut sur useGridStore().
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const filters = useGridStore((s) => s.filters);
|
||||
const displayDensity = useGridStore((s) => s.displayDensity);
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
|
||||
const { rows, filters, displayDensity, draftChanges, activeMagasin } = useGridStore();
|
||||
|
||||
// Filtre client-side par code3 (famille) et codeGamme sans abonnement réactif aux brouillons pour éviter le freeze.
|
||||
// Filtre client-side par code3 (famille) et codeGamme
|
||||
const filteredData = useMemo(() => {
|
||||
const { code3, codeGamme } = filters;
|
||||
if (!code3 && !codeGamme) return rows;
|
||||
|
||||
// On récupère le state de façon non-réactive pour ne pas trigger 130k renders
|
||||
const currentDrafts = useGridStore.getState().draftChanges;
|
||||
|
||||
return rows.filter(r => {
|
||||
if (code3 && r.code3 !== code3) return false;
|
||||
if (codeGamme) {
|
||||
const g = currentDrafts[r.codein] ?? r.codeGamme ?? "";
|
||||
const g = draftChanges[r.codein] ?? r.codeGamme ?? "";
|
||||
const norm = g.trim() === "" ? "Aucune" : g;
|
||||
if (norm !== codeGamme) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [rows, filters]); // ❌ Surtout pas draftChanges ici
|
||||
}, [rows, filters, draftChanges]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
// Selectors granulaires
|
||||
const columnVisibility = useGridStore((s) => s.columnVisibility);
|
||||
const setColumnVisibility = useGridStore((s) => s.setColumnVisibility);
|
||||
const columnSizing = useGridStore((s) => s.columnSizing);
|
||||
const setColumnSizing = useGridStore((s) => s.setColumnSizing);
|
||||
const { columnVisibility, setColumnVisibility, columnSizing, setColumnSizing } = useGridStore();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
|
||||
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
export interface InfiniteGridOptions {
|
||||
codeFournisseur: string | null;
|
||||
initialTotal: number;
|
||||
pageSize?: number;
|
||||
magasin?: string;
|
||||
search?: string;
|
||||
gamme?: string | null;
|
||||
code3?: string | null;
|
||||
onPageLoaded: (newRows: ProductRow[]) => void;
|
||||
}
|
||||
|
||||
export interface InfiniteGridState {
|
||||
total: number;
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
loadNextPage: () => void;
|
||||
stopLoading: () => void;
|
||||
reset: () => void;
|
||||
progress: number;
|
||||
loadedPagesCount: number;
|
||||
}
|
||||
|
||||
export function useInfiniteGrid({
|
||||
codeFournisseur,
|
||||
initialTotal,
|
||||
pageSize = 10000,
|
||||
magasin,
|
||||
search,
|
||||
gamme,
|
||||
code3,
|
||||
onPageLoaded,
|
||||
}: InfiniteGridOptions): InfiniteGridState {
|
||||
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);
|
||||
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 (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);
|
||||
if (magasin) params.set("magasin", magasin);
|
||||
return `/api/grid/rows?${params.toString()}`;
|
||||
},
|
||||
[codeFournisseur, pageSize, search, gamme, code3, magasin]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTotal(initialTotal);
|
||||
setCurrentPage(0);
|
||||
setLoadedPagesCount(1);
|
||||
setIsStopped(false);
|
||||
loadedPagesRef.current = new Set([0]);
|
||||
fetchIdRef.current += 1;
|
||||
}, [codeFournisseur, initialTotal, search, gamme, code3, magasin]);
|
||||
|
||||
const loadNextPage = useCallback(async () => {
|
||||
if (!codeFournisseur || isLoadingMore || isStopped) return;
|
||||
|
||||
const nextPage = currentPage + 1;
|
||||
const alreadyLoaded = loadedPagesRef.current.has(nextPage);
|
||||
|
||||
// Page 0 comprenait 200 éléments. Le reste est divisé par pageSize (10000).
|
||||
const remainingItems = Math.max(0, total - 200);
|
||||
const totalPages = 1 + Math.ceil(remainingItems / 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);
|
||||
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) {
|
||||
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, isStopped, buildUrl, onPageLoaded]);
|
||||
|
||||
const stopLoading = useCallback(() => {
|
||||
setIsStopped(true);
|
||||
setIsLoadingMore(false);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
fetchIdRef.current += 1;
|
||||
loadedPagesRef.current = new Set([0]);
|
||||
setTotal(initialTotal);
|
||||
setCurrentPage(0);
|
||||
setLoadedPagesCount(1);
|
||||
setIsLoadingMore(false);
|
||||
setIsStopped(false);
|
||||
}, [initialTotal]);
|
||||
|
||||
// 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 {
|
||||
total,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
loadNextPage,
|
||||
stopLoading,
|
||||
reset,
|
||||
progress,
|
||||
loadedPagesCount,
|
||||
};
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/gr
|
||||
interface GridState {
|
||||
/** Source data from server */
|
||||
rows: ProductRow[];
|
||||
/** Index O(1) pour les lookups par codein (Map<codein, ProductRow>) */
|
||||
rowsIndex: Map<string, ProductRow>;
|
||||
/** Draft edits: codein → new GammeCode */
|
||||
draftChanges: Record<string, GammeCode>;
|
||||
filters: GridFilters;
|
||||
@@ -27,7 +25,6 @@ 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 */
|
||||
@@ -67,7 +64,6 @@ export const useGridStore = create<GridState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
rows: [],
|
||||
rowsIndex: new Map(),
|
||||
activeMagasin: "TOTAL",
|
||||
draftChanges: {},
|
||||
filters: {
|
||||
@@ -98,27 +94,12 @@ export const useGridStore = create<GridState>()(
|
||||
},
|
||||
|
||||
setRows: (rows) => {
|
||||
// Construire l'index en O(n) une seule fois au lieu de O(n) à chaque lookup.
|
||||
const rowsIndex = new Map<string, ProductRow>(rows.map(r => [r.codein, r]));
|
||||
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)
|
||||
});
|
||||
set({ rows, summary: computeSummary(rows, get().draftChanges, get().activeMagasin) });
|
||||
},
|
||||
|
||||
setDraftGamme: (codein, gamme) => {
|
||||
const { rows, rowsIndex, draftChanges: oldDrafts } = get();
|
||||
// Lookup O(1) via l'index au lieu de O(n) via .find()
|
||||
const originalRow = rowsIndex.get(codein);
|
||||
const { rows, draftChanges: oldDrafts } = get();
|
||||
const originalRow = rows.find(r => r.codein === codein);
|
||||
const originalGamme = originalRow?.codeGamme;
|
||||
|
||||
const draftChanges = { ...oldDrafts };
|
||||
@@ -163,12 +144,11 @@ export const useGridStore = create<GridState>()(
|
||||
},
|
||||
|
||||
batchSetDraftGamme: (newChanges) => {
|
||||
const { rows, rowsIndex, draftChanges: oldDrafts } = get();
|
||||
const { rows, draftChanges: oldDrafts } = get();
|
||||
const updatedDrafts = { ...oldDrafts };
|
||||
|
||||
Object.entries(newChanges).forEach(([codein, gamme]) => {
|
||||
// Lookup O(1) via l'index au lieu de O(n) via .find()
|
||||
const originalRow = rowsIndex.get(codein);
|
||||
const originalRow = rows.find(r => r.codein === codein);
|
||||
const originalGamme = originalRow?.codeGamme;
|
||||
|
||||
if (gamme === originalGamme) {
|
||||
|
||||
Reference in new issue
Block a user