mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-21 05:58:10 +02:00
feat: implement grid state management, data fetching, and infinite scroll infrastructure
This commit is contained in:
1 parent
2893114fca
commit
88a3121c92
4 files changed
+61
-37
No files matched your search
@@ -16,6 +16,7 @@ import {
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { sessionSnapshots } from "@/db/schema";
|
import { sessionSnapshots } from "@/db/schema";
|
||||||
import { eq, desc } from "drizzle-orm";
|
import { eq, desc } from "drizzle-orm";
|
||||||
|
import { unstable_cache } from "next/cache";
|
||||||
|
|
||||||
// ─── Interfaces ───────────────────────────────────────────────────────────────
|
// ─── Interfaces ───────────────────────────────────────────────────────────────
|
||||||
export interface GetProductRowsInput {
|
export interface GetProductRowsInput {
|
||||||
@@ -37,13 +38,19 @@ export interface GetProductRowsResult {
|
|||||||
totalPages: 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) ───────────────────
|
// ─── Fonction interne : fetch complet non-paginé (cachée) ───────────────────
|
||||||
// Cette fonction est le « dataloader » central. Elle est enveloppée dans
|
|
||||||
// unstable_cache pour ne jamais refaire les 6 requêtes SQL tant que le cache
|
|
||||||
// est valide (revalidate : 300s). Le tag permet une invalidation ciblée.
|
|
||||||
async function _fetchAllProductRows(codeFournisseur: string): Promise<ProductRow[]> {
|
async function _fetchAllProductRows(codeFournisseur: string): Promise<ProductRow[]> {
|
||||||
const magasin = "TOTAL"; // Le cache est toujours TOTAL ; le switch magasin est client-side
|
const magasin = "TOTAL"; // Le cache est toujours TOTAL ; le switch magasin est client-side
|
||||||
console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`);
|
console.log(`\n>>> [_fetchAllProductRows] (NO CACHE) supplier: ${codeFournisseur}, magasin: ${magasin}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||||
@@ -337,8 +344,8 @@ export async function getProductRows(input: GetProductRowsInput): Promise<GetPro
|
|||||||
code3,
|
code3,
|
||||||
} = input;
|
} = 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)
|
// Récupérer toutes les lignes mises en cache pour éviter les 650 requêtes SQL
|
||||||
const allRows = await _fetchAllProductRows(codeFournisseur);
|
const allRows = await getCachedSupplierRows(codeFournisseur);
|
||||||
|
|
||||||
let filtered = allRows;
|
let filtered = allRows;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useTransition } from "react";
|
import { useEffect, useRef, useState, useTransition, useCallback } from "react";
|
||||||
import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
|
import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
|
||||||
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
|
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
|
||||||
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
||||||
@@ -28,6 +28,8 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const isAdmin = (session?.user as { role?: string })?.role === "admin";
|
const isAdmin = (session?.user as { role?: string })?.role === "admin";
|
||||||
const setRows = useGridStore((s) => s.setRows);
|
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 setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
|
||||||
const setFilter = useGridStore((s) => s.setFilter);
|
const setFilter = useGridStore((s) => s.setFilter);
|
||||||
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
||||||
@@ -43,11 +45,16 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
|||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
// ─── Chargement progressif des pages suivantes ─────────────────────────────
|
// ─── Chargement progressif des pages suivantes ─────────────────────────────
|
||||||
const { rows: allRows, total, isLoadingMore, hasMore, loadNextPage, progress } = useInfiniteGrid({
|
const handlePageLoaded = useCallback((newRows: ProductRow[]) => {
|
||||||
|
// Ajout O(n_nouveaux_items) au lieu de O(N_total)
|
||||||
|
appendRows(newRows);
|
||||||
|
}, [appendRows]);
|
||||||
|
|
||||||
|
const { total, isLoadingMore, hasMore, loadNextPage, progress } = useInfiniteGrid({
|
||||||
codeFournisseur,
|
codeFournisseur,
|
||||||
initialRows,
|
|
||||||
initialTotal,
|
initialTotal,
|
||||||
pageSize: 200,
|
pageSize: 10000,
|
||||||
|
onPageLoaded: handlePageLoaded
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -70,10 +77,9 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
|||||||
setFilter("code3", null);
|
setFilter("code3", null);
|
||||||
prevFournisseurRef.current = codeFournisseur;
|
prevFournisseurRef.current = codeFournisseur;
|
||||||
}
|
}
|
||||||
// Les scores ont déjà été calculés côté serveur.
|
// Reset du store pour ce fournisseur
|
||||||
// On pousse toutes les lignes accumulées par l'infinite scroll dans le store.
|
setRows(initialRows);
|
||||||
setRows(allRows);
|
}, [codeFournisseur, initialRows, setRows, setFilter, isMounted]);
|
||||||
}, [codeFournisseur, allRows, setRows, setFilter, isMounted]);
|
|
||||||
|
|
||||||
// Synchroniser le magasin actif
|
// Synchroniser le magasin actif
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -112,12 +118,7 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
|||||||
<p className="text-[13px] mt-0.5" style={{ color: "var(--text-secondary)" }}>
|
<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-semibold" style={{ color: "var(--text-primary)" }}>{nomFournisseur}</span>
|
||||||
{" "}• <span className="font-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span>
|
{" "}• <span className="font-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span>
|
||||||
{" "}• {allRows.length.toLocaleString("fr-FR")} références
|
{" "}• {total.toLocaleString("fr-FR")} références
|
||||||
{hasMore && (
|
|
||||||
<span className="text-[11px] opacity-60">
|
|
||||||
{" "}/ {total.toLocaleString("fr-FR")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -155,15 +156,14 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
|||||||
>
|
>
|
||||||
<Loader2 className="w-3 h-3 animate-spin shrink-0" style={{ color: "var(--accent)" }} />
|
<Loader2 className="w-3 h-3 animate-spin shrink-0" style={{ color: "var(--accent)" }} />
|
||||||
<span>
|
<span>
|
||||||
Chargement : {allRows.length.toLocaleString("fr-FR")} / {total.toLocaleString("fr-FR")} références
|
Téléchargement : {rows.length.toLocaleString("fr-FR")} / {total.toLocaleString("fr-FR")} références
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 h-1 rounded-full overflow-hidden" style={{ background: "var(--border)" }}>
|
<div className="flex-1 h-1 w-[100px] rounded-full overflow-hidden" style={{ background: "var(--border)" }}>
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full transition-all duration-500"
|
className="h-full rounded-full transition-all duration-500"
|
||||||
style={{ width: `${progress}%`, background: "var(--accent)" }}
|
style={{ width: `${progress}%`, background: "var(--accent)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span>{progress}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,37 +5,37 @@ import type { ProductRow } from "@/types/grid";
|
|||||||
|
|
||||||
export interface InfiniteGridOptions {
|
export interface InfiniteGridOptions {
|
||||||
codeFournisseur: string | null;
|
codeFournisseur: string | null;
|
||||||
initialRows: ProductRow[];
|
|
||||||
initialTotal: number;
|
initialTotal: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
gamme?: string | null;
|
gamme?: string | null;
|
||||||
code3?: string | null;
|
code3?: string | null;
|
||||||
|
onPageLoaded: (newRows: ProductRow[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InfiniteGridState {
|
export interface InfiniteGridState {
|
||||||
rows: ProductRow[];
|
|
||||||
total: number;
|
total: number;
|
||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
loadNextPage: () => void;
|
loadNextPage: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
progress: number;
|
progress: number;
|
||||||
|
loadedPagesCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useInfiniteGrid({
|
export function useInfiniteGrid({
|
||||||
codeFournisseur,
|
codeFournisseur,
|
||||||
initialRows,
|
|
||||||
initialTotal,
|
initialTotal,
|
||||||
pageSize = 200,
|
pageSize = 10000,
|
||||||
search,
|
search,
|
||||||
gamme,
|
gamme,
|
||||||
code3,
|
code3,
|
||||||
|
onPageLoaded,
|
||||||
}: InfiniteGridOptions): InfiniteGridState {
|
}: InfiniteGridOptions): InfiniteGridState {
|
||||||
const [rows, setRows] = useState<ProductRow[]>(initialRows);
|
|
||||||
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 [loadedPagesCount, setLoadedPagesCount] = useState(1); // Page 0 est initialRows
|
||||||
|
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
const loadedPagesRef = useRef(new Set<number>([0]));
|
const loadedPagesRef = useRef(new Set<number>([0]));
|
||||||
@@ -57,12 +57,12 @@ export function useInfiniteGrid({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRows(initialRows);
|
|
||||||
setTotal(initialTotal);
|
setTotal(initialTotal);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0);
|
||||||
|
setLoadedPagesCount(1);
|
||||||
loadedPagesRef.current = new Set([0]);
|
loadedPagesRef.current = new Set([0]);
|
||||||
fetchIdRef.current += 1;
|
fetchIdRef.current += 1;
|
||||||
}, [codeFournisseur, initialRows, initialTotal, search, gamme, code3]);
|
}, [codeFournisseur, initialTotal, search, gamme, code3]);
|
||||||
|
|
||||||
const loadNextPage = useCallback(async () => {
|
const loadNextPage = useCallback(async () => {
|
||||||
if (!codeFournisseur || isLoadingMore) return;
|
if (!codeFournisseur || isLoadingMore) return;
|
||||||
@@ -95,9 +95,12 @@ export function useInfiniteGrid({
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadedPagesRef.current.add(nextPage);
|
loadedPagesRef.current.add(nextPage);
|
||||||
setRows(prev => [...prev, ...data.rows]);
|
setLoadedPagesCount(prev => prev + 1);
|
||||||
setTotal(data.total);
|
setTotal(data.total);
|
||||||
setCurrentPage(nextPage);
|
setCurrentPage(nextPage);
|
||||||
|
|
||||||
|
// Envoyer directement au Store Zustand pour bypasser le cycle React DOM
|
||||||
|
onPageLoaded(data.rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[useInfiniteGrid] loadNextPage error:", error);
|
console.error("[useInfiniteGrid] loadNextPage error:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -105,27 +108,28 @@ export function useInfiniteGrid({
|
|||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [codeFournisseur, currentPage, total, pageSize, isLoadingMore, buildUrl]);
|
}, [codeFournisseur, currentPage, total, pageSize, isLoadingMore, buildUrl, onPageLoaded]);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
fetchIdRef.current += 1;
|
fetchIdRef.current += 1;
|
||||||
loadedPagesRef.current = new Set([0]);
|
loadedPagesRef.current = new Set([0]);
|
||||||
setRows(initialRows);
|
|
||||||
setTotal(initialTotal);
|
setTotal(initialTotal);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0);
|
||||||
|
setLoadedPagesCount(1);
|
||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
}, [initialRows, initialTotal]);
|
}, [initialTotal]);
|
||||||
|
|
||||||
const hasMore = rows.length < total;
|
const loadedItemsEstimation = loadedPagesCount * pageSize;
|
||||||
const progress = total > 0 ? Math.round((rows.length / total) * 100) : 100;
|
const hasMore = loadedItemsEstimation < total && loadedPagesCount < Math.ceil(total / pageSize);
|
||||||
|
const progress = total > 0 ? Math.round(Math.min((loadedItemsEstimation / total) * 100, 100)) : 100;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows,
|
|
||||||
total,
|
total,
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
hasMore,
|
hasMore,
|
||||||
loadNextPage,
|
loadNextPage,
|
||||||
reset,
|
reset,
|
||||||
progress,
|
progress,
|
||||||
|
loadedPagesCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,7 @@ interface GridState {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setRows: (rows: ProductRow[]) => void;
|
setRows: (rows: ProductRow[]) => void;
|
||||||
|
appendRows: (newRows: ProductRow[]) => void;
|
||||||
setDraftGamme: (codein: string, gamme: GammeCode) => void;
|
setDraftGamme: (codein: string, gamme: GammeCode) => void;
|
||||||
resetDrafts: () => void;
|
resetDrafts: () => void;
|
||||||
/** Clear specific draft changes by codein */
|
/** Clear specific draft changes by codein */
|
||||||
@@ -102,6 +103,18 @@ export const useGridStore = create<GridState>()(
|
|||||||
set({ rows, rowsIndex, summary: computeSummary(rows, get().draftChanges, get().activeMagasin) });
|
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)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
setDraftGamme: (codein, gamme) => {
|
setDraftGamme: (codein, gamme) => {
|
||||||
const { rows, rowsIndex, draftChanges: oldDrafts } = get();
|
const { rows, rowsIndex, draftChanges: oldDrafts } = get();
|
||||||
// Lookup O(1) via l'index au lieu de O(n) via .find()
|
// Lookup O(1) via l'index au lieu de O(n) via .find()
|
||||||
|
|||||||
Reference in new issue
Block a user