mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 09:46:56 +02:00
Le filtre était un <select> à valeur unique : impossible de retenir plusieurs nomenclatures, ni de partir de tout pour retirer les postes non voulus — ce qui est la manière naturelle de travailler quand un fournisseur en compte des dizaines. Remplacé par une liste déroulante à cases, avec recherche, « Tout cocher », « Tout décocher », « Ne garder que ces N » sur le résultat d'une recherche, et une croix pour tout réafficher. Décocher depuis l'état « tout » matérialise la liste complète puis en retire le poste, sinon le premier décochage n'aurait rien fait. filters.code3 passe de `string | null` à `string[] | null` : `null` = aucun filtre, `[]` = rien de coché. Distinguer les deux évite la valeur sentinelle qu'imposerait un simple tableau, et rend « Tout décocher » sans ambiguïté. L'URL accepte une liste séparée par des virgules. Au passage : la Grille rechargeait tout depuis le serveur à chaque changement de nomenclature, alors que getProductRows ignore ce filtre et que le tri se fait en local. Le paramètre n'est plus transmis ni dans les dépendances de l'effet — le filtrage est désormais instantané, ce qui compte d'autant plus avec plusieurs cases à cocher. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
389 lines
20 KiB
TypeScript
389 lines
20 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 setCode3Filter = useGridStore((s) => s.setCode3Filter);
|
|
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);
|
|
setCode3Filter(null);
|
|
prevFournisseurRef.current = codeFournisseur;
|
|
}
|
|
}, [codeFournisseur, setFilter, setCode3Filter, 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");
|
|
// `code3` n'est volontairement pas transmis : le serveur ignore ce filtre
|
|
// (getProductRows ne s'en sert pas) et la Grille l'applique en local. Le
|
|
// faire voyager relançait un chargement complet à chaque case cochée.
|
|
if (filters.code1) params.set("code1", filters.code1);
|
|
if (filters.code2) params.set("code2", filters.code2);
|
|
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, 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'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>
|
|
);
|
|
}
|