"use client"; import { useEffect, useRef, useState } 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"; import { GridFilterBar } from "@/features/grid/components/grid-filter-bar"; import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store"; import { useStorePatch } from "@/features/grid/hooks/use-store-patch"; import { getGammesAValider } from "@/features/grid/api/gammes-a-valider-actions"; import type { ProductRow } from "@/types/grid"; import { AlertCircle, Loader2, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { nomMagasin } from "@/lib/magasins"; import { useSearchParams } from "next/navigation"; import type { GridFilters } from "@/types/grid"; interface GridClientProps { codeFournisseur: string; nomFournisseur: string; fournisseurs: { code: string; nom: string }[]; magasins: { code: string; nom: string }[]; magasin: string; filters: Pick; /** Calculé côté serveur : les droits ne changent plus d'un rendu à l'autre. */ isAdmin: boolean; } type GridRowsStreamMessage = | { type: "start"; loaded: number; total: null } | { type: "chunk"; rows: ProductRow[]; loaded: number; total: number } | { type: "done"; loaded: number; total: number } | { type: "error"; error: string }; /** Petite pastille métrique (libellé + valeur) — densité pro, thème Slate/teal. */ function MetricPill({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) { return (
{label} {value}
); } export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin, isAdmin }: GridClientProps) { const setRows = useGridStore((s) => s.setRows); // Seul le nombre de lignes sert ici : un abonnement au tableau entier // redessinait toute la page à chaque modification de gamme. const nbRows = useGridStore((s) => s.rows.length); const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery); const setFilter = useGridStore((s) => s.setFilter); const setCode3Filter = useGridStore((s) => s.setCode3Filter); const setActiveMagasin = useGridStore((s) => s.setActiveMagasin); const activeMagasin = useGridStore((s) => s.activeMagasin); const searchParams = useSearchParams(); const [selectedCodeins, setSelectedCodeins] = useState([]); const [isLoadingRows, setIsLoadingRows] = useState(false); const [rowsLoaded, setRowsLoaded] = useState(0); const [totalRows, setTotalRows] = useState(null); const [loadError, setLoadError] = useState(null); const [isMounted, setIsMounted] = useState(false); useEffect(() => { setIsMounted(true); }, []); // Sync active search params to the store 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]); // Réinitialiser les filtres UNIQUEMENT quand le fournisseur change const prevFournisseurRef = useRef(null); useEffect(() => { if (!isMounted) return; if (prevFournisseurRef.current !== codeFournisseur) { setFilter("codeGamme", null); setCode3Filter(null); prevFournisseurRef.current = codeFournisseur; } }, [codeFournisseur, setFilter, setCode3Filter, isMounted]); // Rechargement forcé (bouton « Rafraîchir », fin de synchro Qlik) : demandé via // le store, pas via l'URL. On retient la dernière demande déjà servie pour ne // pas re-forcer le recalcul serveur à chaque remontage ou changement de filtre. const refreshRequest = useGridStore((s) => s.refreshRequest); const requestRefresh = useGridStore((s) => s.requestRefresh); const servedRefreshRef = useRef(refreshRequest); useEffect(() => { if (!isMounted) return; const controller = new AbortController(); const params = new URLSearchParams(); params.set("fournisseur", codeFournisseur); // Toujours « tous magasins » : les lignes portent le détail de chaque // magasin, le changement de magasin se fait donc sans rien recharger // (les compléments de l'API FF arrivent à part, cf. useStorePatch). params.set("magasin", "TOTAL"); // Les filtres de nomenclature (code1/code2/code3) ne sont pas transmis : le // serveur ne s'en sert pas et la Grille les applique en local. Les faire // voyager relançait un chargement complet à chaque changement. const forceRefresh = refreshRequest !== servedRefreshRef.current; if (forceRefresh) params.set("refresh", "1"); const cle = rowsKeyFor(codeFournisseur); const { rowsMeta, rows: lignesEnMemoire, setRowsMeta } = useGridStore.getState(); // Retour sur la Grille (même fournisseur, chargée il y a moins de 10 min) : // les lignes sont encore en mémoire, rien à retélécharger. if (!forceRefresh && rowsMeta?.key === cle && lignesEnMemoire.length > 0 && Date.now() - rowsMeta.loadedAt < 10 * 60 * 1000) { setRowsLoaded(lignesEnMemoire.length); setTotalRows(lignesEnMemoire.length); setIsLoadingRows(false); return; } // « Actualiser » (ou lignes de plus de 10 min) sur le même fournisseur : la // Grille reste affichée, et les nouvelles lignes la remplacent d'un bloc. const memeFournisseur = rowsMeta?.key === cle && lignesEnMemoire.length > 0; const accumulatedRows: ProductRow[] = []; let lastFlush = 0; if (!memeFournisseur) { setRows([]); // Les lignes vont être remplacées : tant que ce chargement n'est pas // terminé, elles ne correspondent plus à aucune clé. setRowsMeta(null); } setRowsLoaded(0); setTotalRows(null); setLoadError(null); setIsLoadingRows(true); // Chaque mise à jour du store reconstruit tout le tableau (index, tri, // filtres, compteurs) : la faire à chaque paquet de 150 lignes rendait le // chargement quadratique. On regroupe donc les paquets et on ne pousse les // lignes que toutes les FLUSH_MS (et une dernière fois à la fin). const FLUSH_MS = 300; // Les lignes déjà poussées dans le store ne doivent plus bouger : on lui // passe une copie, et on continue d'accumuler dans le tableau privé. const flush = (loaded: number, total: number | null, final = false) => { setRowsLoaded(loaded); setTotalRows(total); lastFlush = Date.now(); // Même fournisseur : on garde les lignes affichées jusqu'au bout. if (memeFournisseur && !final) return; setRows(final ? accumulatedRows : accumulatedRows.slice()); }; async function loadRows() { try { const response = await fetch(`/api/grid/rows?${params.toString()}`, { signal: controller.signal, cache: "no-store", }); if (!response.ok || !response.body) { throw new Error(`Chargement impossible (${response.status})`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let loaded = 0; let total: number | null = null; let pending = false; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (!line.trim()) continue; const message = JSON.parse(line) as GridRowsStreamMessage; if (message.type === "chunk") { for (const row of message.rows) accumulatedRows.push(row); loaded = message.loaded; total = message.total; pending = true; } else if (message.type === "done") { loaded = message.loaded; total = message.total; } else if (message.type === "error") { throw new Error(message.error); } } if (pending && Date.now() - lastFlush >= FLUSH_MS) { flush(loaded, total); pending = false; } } flush(loaded, total, true); setRowsMeta({ key: cle, loadedAt: Date.now(), patchedStores: [] }); if (forceRefresh) servedRefreshRef.current = refreshRequest; } catch (error) { if (!controller.signal.aborted) { setLoadError(error instanceof Error ? error.message : "Erreur de chargement"); } } finally { if (!controller.signal.aborted) { setIsLoadingRows(false); } } } loadRows(); return () => controller.abort(); }, [codeFournisseur, refreshRequest, setRows, isMounted]); // Gammes proposées par l'API : relues une fois les lignes du fournisseur // chargées (ou retrouvées en mémoire), puis affichées comme modifications // non enregistrées, à valider. const lignesChargeesLe = useGridStore((s) => (s.rowsMeta?.key === rowsKeyFor(codeFournisseur) ? s.rowsMeta.loadedAt : null)); useEffect(() => { if (!isMounted || lignesChargeesLe === null) return; let annule = false; getGammesAValider(codeFournisseur) .then((res) => { if (annule) return; if (!res.success) { console.error("[grid] gammes à valider KO:", res.error); return; } useGridStore.getState().chargerGammesAValider(res.propositions); }) .catch((e) => console.error("[grid] gammes à valider KO:", e)); return () => { annule = true; }; }, [codeFournisseur, lignesChargeesLe, isMounted]); // Compléments de l'API FF pour le magasin choisi, appliqués à leur arrivée. useStorePatch(codeFournisseur, isMounted); // Magasin initial (ou porté par un lien) : la prop vient de l'URL. Les // changements suivants passent par le store et ne re-rendent pas la page. useEffect(() => { if (!isMounted) return; setActiveMagasin(magasin || "TOTAL"); }, [magasin, setActiveMagasin, isMounted]); // Force le rechargement des données depuis le serveur en ignorant le cache // 10 min (`refresh=1` → forceRefresh) et remonte l'état serveur à jour (INIT). const handleForceRefresh = () => { if (isLoadingRows) return; requestRefresh(); }; if (!isMounted) { return
Préparation de la grille…
; } const nbReferences = totalRows ?? nbRows; const progression = totalRows ? Math.round((rowsLoaded / totalRows) * 100) : null; return (

Révision d'assortiment

{nomFournisseur}

{loadError && ( {loadError} )}
{/* Chargement : bandeau discret, la grille reste utilisable. */} {isLoadingRows && (
{nbRows > 0 ? "Mise à jour des chiffres… la grille reste utilisable." : `Chargement des produits de ${nomFournisseur}…`} {totalRows ? `${rowsLoaded.toLocaleString("fr-FR")} sur ${totalRows.toLocaleString("fr-FR")}` : "Calcul en cours sur le serveur"}
)} {/* Filters */}
{/* Bulk toolbar (contextual) */}
setSelectedCodeins([])} />
{/* Main content */}
{/* Summary bar */}
); }