mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
Un bouton « Export Excel » dans la fenêtre des produits non travaillés. Il reprend EXACTEMENT ce qui est à l'écran — même magasin, même profondeur, même classement — plutôt que de recalculer une liste : un fichier qui ne correspond pas à la fenêtre d'où il sort ne se vérifie plus. Deux feuilles. Les données seules d'abord : en-têtes en ligne 1, filtre automatique, volet figé, et surtout des valeurs NUMÉRIQUES — le tableur doit pouvoir trier et sommer, ce qu'une chaîne « 89 863 € » lui interdit. La mise en forme des nombres suit le critère de tri (euros, unités, pourcentage). Le contexte ensuite, sur sa propre feuille : fournisseur, magasin examiné, classement retenu, définition du « non travaillé », date. Mêler les deux dans une feuille unique aurait condamné filtre et tri. `exceljs` est chargé à la demande plutôt qu'importé en tête : il pèse lourd, et la Grille n'a pas à le transporter pour tous ceux qui n'exportent jamais. Un échec de génération s'affiche dans la fenêtre — un export qui échoue en silence laisse croire au téléchargement. Le nom du fournisseur descend jusqu'à la fenêtre pour nommer et documenter le fichier. Vérifié sur build de production, en téléchargeant le fichier puis en le relisant : « Non_travailles_Fournisseur_Test_Houdemont_Top200_2026-08-14 », deux feuilles, 15 lignes conformes au décompte affiché, filtre automatique sur A1:J1, valeurs lues comme des nombres et format « #,##0 "€" » sur la colonne du critère. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
390 lines
20 KiB
TypeScript
390 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}
|
|
nomFournisseur={nomFournisseur}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|