diff --git a/src/app/(dashboard)/grid/page.tsx b/src/app/(dashboard)/grid/page.tsx index 427cd49..2185bec 100644 --- a/src/app/(dashboard)/grid/page.tsx +++ b/src/app/(dashboard)/grid/page.tsx @@ -33,16 +33,15 @@ export default async function GridPage({ searchParams }: GridPageProps) { return ; } - // 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 ( , - page: number = 0, - pageSize: number = 200, -): Promise { - return getProductRows({ codeFournisseur, magasin, filters, page, pageSize }); + filters?: Partial +): Promise { + return getProductRows({ codeFournisseur, magasin, filters }); } diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 2f35b4d..5e3476d 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -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; - 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 { - 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 { + 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 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 { - 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 }; -} diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx index e4b0707..02c1a96 100644 --- a/src/features/grid/components/grid-client.tsx +++ b/src/features/grid/components/grid-client.tsx @@ -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 {nomFournisseur} {" "}• {activeStoreNom} - {" "}• {total.toLocaleString("fr-FR")} références + {" "}• {initialRows.length} références @@ -150,32 +127,6 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour - {/* Barre de progression chargement en arrière-plan */} - {(hasMore || isLoadingMore) && ( - - - - Téléchargement : {rows.length.toLocaleString("fr-FR")} / {total.toLocaleString("fr-FR")} références - - - - - - - Stop - - - )} - {/* Filters */} diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index e591693..47dd142 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -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([]); const [rowSelection, setRowSelection] = useState({}); - - // 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(null); diff --git a/src/features/grid/hooks/use-infinite-grid.ts b/src/features/grid/hooks/use-infinite-grid.ts deleted file mode 100644 index b9cf781..0000000 --- a/src/features/grid/hooks/use-infinite-grid.ts +++ /dev/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([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, - }; -} diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index 1e3d9b2..3591dac 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -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) */ - rowsIndex: Map; /** Draft edits: codein → new GammeCode */ draftChanges: Record; 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()( persist( (set, get) => ({ rows: [], - rowsIndex: new Map(), activeMagasin: "TOTAL", draftChanges: {}, filters: { @@ -98,27 +94,12 @@ export const useGridStore = create()( }, setRows: (rows) => { - // Construire l'index en O(n) une seule fois au lieu de O(n) à chaque lookup. - const rowsIndex = new Map(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()( }, 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) {
{nomFournisseur} {" "}• {activeStoreNom} - {" "}• {total.toLocaleString("fr-FR")} références + {" "}• {initialRows.length} références