Files
CollectFlow/src/features/grid/components/grid-client.tsx
T
Claude 2e25bbc5b3 feat(grille): bouton "Rafraîchir" pour forcer le rechargement serveur
Ajoute un bouton dans l'en-tête de la Grille qui force le rechargement
des données depuis le serveur en ignorant le cache mémoire de 10 min
(via le paramètre URL _refresh → refresh=1 → forceRefresh). Utile pour
remonter immédiatement l'état serveur courant, notamment la colonne INIT.
Spinner pendant le chargement, désactivé si un chargement est en cours.
2026-07-21 21:11:28 +00:00

386 lines
19 KiB
TypeScript

"use client";
import { useEffect, useMemo, 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";
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 type { ProductRow } from "@/types/grid";
import { CheckCircle, AlertCircle, Loader2, RefreshCw } from "lucide-react";
import { useSession } from "next-auth/react";
import { useSearchParams, useRouter, usePathname } 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<GridFilters, "code1" | "code2" | "code3">;
}
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 (
<div
className="flex flex-col items-start px-2.5 py-1 rounded-lg shrink-0"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<span className="text-[8.5px] font-semibold uppercase tracking-wider leading-none mb-1" style={{ color: "var(--text-muted)" }}>
{label}
</span>
<span
className="text-[12px] font-bold leading-none tabular-nums max-w-[160px] truncate"
style={{ color: accent ? "var(--accent)" : "var(--text-primary)" }}
title={value}
>
{value}
</span>
</div>
);
}
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin, filters }: GridClientProps) {
const { data: session } = useSession();
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
const setRows = useGridStore((s) => s.setRows);
const rows = useGridStore((s) => s.rows);
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
const setFilter = useGridStore((s) => s.setFilter);
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const [isLoadingRows, setIsLoadingRows] = useState(false);
const [rowsLoaded, setRowsLoaded] = useState(0);
const [totalRows, setTotalRows] = useState<number | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [showStartOverlay, setShowStartOverlay] = useState(false);
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
const { save, hasDrafts, count } = useSaveDrafts(magasin, visibleCodeins);
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<string | null>(null);
useEffect(() => {
if (!isMounted) return;
if (prevFournisseurRef.current !== codeFournisseur) {
setFilter("codeGamme", null);
setFilter("code3", null);
prevFournisseurRef.current = codeFournisseur;
}
}, [codeFournisseur, setFilter, isMounted]);
const refreshToken = searchParams.get("_refresh");
useEffect(() => {
if (!isMounted) return;
const controller = new AbortController();
const params = new URLSearchParams();
params.set("fournisseur", codeFournisseur);
params.set("magasin", magasin || "TOTAL");
if (filters.code1) params.set("code1", filters.code1);
if (filters.code2) params.set("code2", filters.code2);
if (filters.code3) params.set("code3", filters.code3);
if (refreshToken) params.set("refresh", "1");
let accumulatedRows: ProductRow[] = [];
setRows([]);
setRowsLoaded(0);
setTotalRows(null);
setLoadError(null);
setIsLoadingRows(true);
setShowStartOverlay(true);
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 = "";
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") {
accumulatedRows = accumulatedRows.concat(message.rows);
setRows(accumulatedRows);
setRowsLoaded(message.loaded);
setTotalRows(message.total);
} else if (message.type === "done") {
setRowsLoaded(message.loaded);
setTotalRows(message.total);
} else if (message.type === "error") {
throw new Error(message.error);
}
}
}
} 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, magasin, filters.code1, filters.code2, filters.code3, refreshToken, setRows, isMounted]);
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
useEffect(() => {
if (!isMounted) return;
setActiveMagasin(magasin || "TOTAL");
}, [magasin, setActiveMagasin, isMounted]);
const handleSave = () => {
startTransition(async () => {
const result = await save();
setSaveStatus(result.success ? "success" : "error");
setTimeout(() => setSaveStatus("idle"), 3000);
});
};
// Force le rechargement des données depuis le serveur en ignorant le cache
// 10 min : on change le paramètre URL `_refresh`, ce qui relance le fetch
// avec `refresh=1` (→ forceRefresh) et remonte l'état serveur à jour (INIT).
const handleForceRefresh = () => {
if (isLoadingRows) return;
const params = new URLSearchParams(searchParams.toString());
params.set("_refresh", String(Date.now()));
router.replace(`${pathname}?${params.toString()}`);
};
if (!isMounted) {
return <div className="p-8 text-center animate-pulse text-muted italic">Initialisation de la grille...</div>;
}
const activeStoreNom = magasins.find(m => m.code === magasin)?.nom || "National (Total)";
return (
<div className="flex flex-col h-full space-y-3 min-h-0 pb-12">
{/* Header — chrome raffiné (eyebrow teal + fournisseur héro + pastilles métriques) */}
<header className="shrink-0 flex items-center justify-between gap-4 pb-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
<div className="flex items-center gap-3.5 min-w-0">
<div
className="h-10 w-[3px] rounded-full shrink-0"
style={{ background: "linear-gradient(to bottom, var(--accent), var(--accent-success))" }}
/>
<div className="min-w-0">
<div className="text-[10px] font-bold uppercase tracking-[0.18em] leading-none mb-1.5" style={{ color: "var(--accent)" }}>
Révision d&apos;assortiment
</div>
<h1 className="text-[19px] font-bold tracking-tight truncate leading-none" style={{ color: "var(--text-primary)" }}>
{nomFournisseur}
</h1>
</div>
<div className="hidden md:flex items-center gap-2 ml-1 pl-3 shrink-0" style={{ borderLeft: "1px solid var(--border)" }}>
<MetricPill label="Magasin" value={activeStoreNom} />
<MetricPill label="Références" value={(totalRows ?? rows.length).toLocaleString("fr-FR")} accent />
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{(isLoadingRows || loadError) && (
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold tabular-nums"
style={{
background: loadError ? "var(--accent-error-bg)" : "var(--accent-bg)",
border: `1px solid ${loadError ? "var(--accent-error)" : "var(--accent-border)"}`,
color: loadError ? "var(--accent-error)" : "var(--accent)",
}}
>
{isLoadingRows && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{loadError
? loadError
: totalRows
? `${rowsLoaded.toLocaleString("fr-FR")} / ${totalRows.toLocaleString("fr-FR")}`
: "Chargement…"}
</div>
)}
<button
onClick={handleForceRefresh}
disabled={isLoadingRows}
title="Forcer le rafraîchissement — recharge les données depuis le serveur en ignorant le cache (met à jour la colonne INIT)"
className="flex items-center gap-2 px-3 h-9 text-[13px] font-semibold rounded-xl disabled:opacity-50 transition-all hover:brightness-110 active:scale-[0.97]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
<RefreshCw className={`w-4 h-4 ${isLoadingRows ? "animate-spin" : ""}`} />
<span className="hidden sm:inline">Rafraîchir</span>
</button>
<div id="grid-toolbar-actions"></div>
{isAdmin && <ExportDropdown nomFournisseur={nomFournisseur} />}
{isAdmin && hasDrafts && (
<button
onClick={handleSave}
disabled={isPending}
className="flex items-center gap-2 px-4 h-9 text-white text-[13px] font-semibold rounded-xl disabled:opacity-60 transition-all hover:brightness-110 active:scale-[0.97]"
style={{
background: "linear-gradient(180deg, var(--brand-solid), var(--brand-hover))",
boxShadow: "0 4px 12px rgba(16,185,129,0.25), inset 0 1px 1px rgba(255,255,255,0.2)",
}}
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saveStatus === "success" ? (
<CheckCircle className="w-4 h-4" />
) : saveStatus === "error" ? (
<AlertCircle className="w-4 h-4" />
) : null}
{isPending
? "Sauvegarde…"
: saveStatus === "success"
? "Sauvegardé !"
: saveStatus === "error"
? "Erreur"
: `Valider ${count} changement${count > 1 ? "s" : ""}`}
</button>
)}
</div>
</header>
{/* Filters */}
<div className="shrink-0 print:hidden">
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
</div>
{/* Bulk toolbar (contextual) */}
<div className="shrink-0 print:hidden">
<BulkActionToolbar
selectedCodeins={selectedCodeins}
onClearSelection={() => setSelectedCodeins([])}
/>
</div>
{/* Main content */}
<div className="flex-1 min-h-0 min-w-0">
<HeatmapGrid
onSelectionChange={setSelectedCodeins}
isAdmin={isAdmin}
/>
</div>
{/* Summary bar */}
<div className="print:hidden">
<FloatingSummaryBar />
</div>
{/* Overlay de démarrage premium (Glassmorphism semi-bloquant) */}
{showStartOverlay && isLoadingRows && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 backdrop-blur-md bg-slate-950/30 dark:bg-black/40 animate-fade-in">
<div
className="w-full max-w-md rounded-2xl border p-6 shadow-2xl flex flex-col items-center text-center space-y-5 transition-transform duration-300 transform scale-100"
style={{
background: "rgba(255, 255, 255, 0.75)",
borderColor: "var(--border)",
backdropFilter: "blur(20px)",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)"
}}
>
{/* Header avec logo ou icône animée */}
<div className="relative flex items-center justify-center w-16 h-16 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<Loader2 className="w-8 h-8 animate-spin" />
<div className="absolute inset-0 rounded-full border border-emerald-500/20 animate-ping duration-1000" />
</div>
{/* Titre et descriptions */}
<div className="space-y-1">
<h3 className="text-lg font-bold tracking-tight text-emerald-800 dark:text-emerald-400">
Flux d'Assortiment en cours
</h3>
<p className="text-xs text-muted-foreground max-w-[280px] mx-auto animate-pulse" style={{ color: "var(--text-muted)" }}>
Chargement progressif et en temps réel des données pour <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{nomFournisseur}</span>.
</p>
</div>
{/* Barre de progression dynamique */}
<div className="w-full space-y-2">
<div className="flex justify-between text-xs font-semibold" style={{ color: "var(--text-secondary)" }}>
<span>Progression</span>
<span>
{totalRows
? `${Math.round((rowsLoaded / totalRows) * 100)}%`
: `${rowsLoaded.toLocaleString("fr-FR")} réfs`}
</span>
</div>
<div className="w-full h-2 rounded-full overflow-hidden bg-slate-100 dark:bg-slate-800" style={{ border: "1px solid var(--border)" }}>
<div
className="h-full bg-gradient-to-r from-emerald-500 to-teal-500 transition-all duration-300 ease-out rounded-full"
style={{
width: totalRows ? `${(rowsLoaded / totalRows) * 100}%` : "40%",
}}
/>
</div>
<p className="text-[11px] font-medium animate-pulse" style={{ color: "var(--text-muted)" }}>
{totalRows
? `${rowsLoaded.toLocaleString("fr-FR")} sur ${totalRows.toLocaleString("fr-FR")} références chargées`
: `${rowsLoaded.toLocaleString("fr-FR")} références récupérées...`}
</p>
</div>
{/* Boutons d'action */}
<div className="w-full pt-2 flex flex-col gap-2">
<button
onClick={() => setShowStartOverlay(false)}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-sm font-semibold text-white bg-emerald-600 hover:bg-emerald-500 active:scale-95 hover:shadow-lg hover:shadow-emerald-500/20 transition-all duration-200"
>
Explorer les premières données
</button>
<p className="text-[10px] text-muted italic" style={{ color: "var(--text-muted)" }}>
Le chargement continuera en arrière-plan sans bloquer votre navigation.
</p>
</div>
</div>
</div>
)}
</div>
);
}