diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 71e76e9..e76b6cc 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -14,7 +14,7 @@ import { type FilterFn, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, PackageX, Warehouse, AlertTriangle, CalendarRange, Tag, Eye, EyeOff } from "lucide-react"; +import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, PackageX, Warehouse, AlertTriangle, CalendarRange, Tag, Eye, EyeOff, Layers } from "lucide-react"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -29,6 +29,7 @@ import { HeatmapCell } from "@/features/grid/components/heatmap-cell"; import { ProductMonthlyModal } from "@/features/grid/components/product-monthly-modal"; import { NetworkMonthlyModal } from "@/features/grid/components/network-monthly-modal"; import { AssortmentGapsModal, PROFONDEUR_MAX, type LigneClassee } from "@/features/grid/components/assortment-gaps-modal"; +import { NomenclatureCaModal } from "@/features/grid/components/nomenclature-ca-modal"; import type { ProductRow, GammeCode } from "@/types/grid"; import { cn } from "@/lib/utils"; import { @@ -720,6 +721,8 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour const [monthlyModal, setMonthlyModal] = useState(null); // Trous d'assortiment : ce qu'un magasin ne travaille pas, en haut du classement affiché const [gapsOpen, setGapsOpen] = useState(false); + // CA par nomenclature et par magasin, face au réseau, sur les lignes affichées + const [nomenclaturesOpen, setNomenclaturesOpen] = useState(false); const tableContainerRef = useRef(null); // Les 12 mois viennent des données, pas de l'horloge du navigateur : les @@ -1259,6 +1262,12 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour })); }, [gapsOpen, tableRows, sorting]); + /** Les produits affichés (filtres et recherche compris), pour le CA par nomenclature. */ + const lignesAffichees: ProductRow[] = useMemo( + () => (nomenclaturesOpen ? tableRows.map((r) => r.original) : []), + [nomenclaturesOpen, tableRows], + ); + if (!isMounted) { return (
{portalContainer && createPortal(
+ {/* CA par nomenclature et par magasin, comparé au réseau. */} + {/* Trous d'assortiment — lit le classement affiché, tri compris. */} + + ); +} + +export function NomenclatureCaModal({ rows, mois, nomFournisseur, onClose }: { + /** Les produits affichés dans la Grille (filtres et recherche compris). */ + rows: ProductRow[]; + /** Les 12 clés de mois de la Grille, du plus ancien au plus récent. */ + mois: string[]; + nomFournisseur?: string; + onClose: () => void; +}) { + const sites = useMemo(() => sitesDe(rows), [rows]); + const { lignes, total } = useMemo(() => regrouper(rows, sites), [rows, sites]); + + const [tri, setTri] = useState<{ cle: CleTri; desc: boolean }>({ cle: "caNous", desc: true }); + const [recherche, setRecherche] = useState(""); + const [exportEnCours, setExportEnCours] = useState(false); + const [exportErreur, setExportErreur] = useState(null); + + const changerTri = (cle: CleTri) => + setTri((t) => (t.cle === cle ? { cle, desc: !t.desc } : { cle, desc: cle !== "libelle" })); + + const affichees = useMemo(() => { + const q = recherche.trim().toLowerCase(); + const filtrees = q + ? lignes.filter((l) => l.libelle.toLowerCase().includes(q) || l.code.toLowerCase().includes(q)) + : lignes; + const sens = tri.desc ? -1 : 1; + return [...filtrees].sort((a, b) => { + const va = valeurTri(a, tri.cle); + const vb = valeurTri(b, tri.cle); + if (typeof va === "string" || typeof vb === "string") return String(va).localeCompare(String(vb), "fr") * sens; + if (Number.isNaN(va)) return Number.isNaN(vb) ? 0 : 1; + if (Number.isNaN(vb)) return -1; + return (va - vb) * sens; + }); + }, [lignes, recherche, tri]); + + const periode = mois.length > 0 + ? `${formatMonthLabel(mois[0])} → ${formatMonthLabel(mois[mois.length - 1])}` + : "12 derniers mois"; + const absentesChezNous = lignes.filter((l) => l.caNous <= 0 && l.caReseau > 0).length; + const indicesTotal = sites.map((s) => indice(total.caParSite[s] ?? 0, total.caMagasinMoyenReseau)); + + const exporter = async () => { + if (affichees.length === 0 || exportEnCours) return; + setExportEnCours(true); + setExportErreur(null); + try { + const blob = await construireClasseur(affichees, total, sites, { + fournisseur: nomFournisseur, + periode, + nbLignesGrille: rows.length, + }); + const url = URL.createObjectURL(blob); + const lien = document.createElement("a"); + lien.href = url; + lien.download = [ + "CA_nomenclatures", + nomFournisseur?.replace(/[^\w]+/g, "_"), + new Date().toISOString().slice(0, 10), + ].filter(Boolean).join("_") + ".xlsx"; + lien.click(); + URL.revokeObjectURL(url); + } catch (e) { + console.error("[CA par nomenclature] export Excel :", e); + setExportErreur("L'export a échoué. Réessayez, ou signalez-le si cela persiste."); + } finally { + setExportEnCours(false); + } + }; + + const celluleSite = (l: LigneNomenclature, s: string, gras = false) => { + const ca = l.caParSite[s] ?? 0; + const ind = indice(ca, l.caMagasinMoyenReseau); + return ( + +
+ {fmtEurOuTiret(ca)} +
+
+ {ind == null ? "—" : `indice ${fmtEntier(ind)}`} +
+ + ); + }; + + return ( + + + + + CA par nomenclature + +

+ {nomFournisseur ? <>{nomFournisseur} — : null} + {periode} + {` — ${fmtEntier(rows.length)} produits affichés dans la Grille, par magasin et face au réseau.`} +

+
+ +
+ + {sites.map((s, i) => ( + + ))} + + 0 ? "var(--accent-warning)" : undefined} + /> +
+ +
+
+ + setRecherche(e.target.value)} + className="apple-input w-60" + style={{ paddingLeft: "32px" }} + /> +
+ +
+ + {exportErreur && ( +
+ {exportErreur} +
+ )} + + {affichees.length === 0 ? ( +
+ {lignes.length === 0 + ? "Aucune nomenclature avec du CA sur la période pour les produits affichés." + : "Aucune nomenclature ne correspond à ce filtre."} +
+ ) : ( +
+ + + + + {sites.map((s) => ( + + ))} + + + + + + + + + {affichees.map((l) => ( + + + {sites.map((s) => celluleSite(l, s))} + + + + + + + ))} + + + + + {sites.map((s) => celluleSite(total, s, true))} + + + + + + +
+
{l.libelle}
+
+ {l.code !== SANS_NOMENCLATURE && <>{l.code} · } + {fmtEntier(l.nbVendus)} / {fmtEntier(l.nbProduits)} produits vendus + {l.nbSansReseau > 0 && ( + + {" "}· {fmtEntier(l.nbSansReseau)} sans réseau + + )} +
+
+ {fmtEurOuTiret(l.caNous)} + + {fmtPct(l.partNous)} + +
{fmtEurOuTiret(l.caReseau)}
+
+ {l.caReseau !== 0 ? `${fmtEur2(l.caMagasinMoyenReseau)} / mag.` : ""} +
+
+ {fmtPct(l.partReseau)} + + {fmtPoints(l.ecartPoints)} +
+ Total +
+ {fmtEntier(total.nbVendus)} / {fmtEntier(total.nbProduits)} produits vendus +
+
+ {fmtEur2(total.caNous)} + + {fmtPct(total.partNous)} + +
{fmtEur2(total.caReseau)}
+
{fmtEur2(total.caMagasinMoyenReseau)} / mag.
+
+ {fmtPct(total.partReseau)} + +
+
+ )} + +

+ Période : les 12 mois complets de la Grille, communs à nos magasins et au réseau. Le poids est la part de la + nomenclature dans le CA des produits affichés ; l'écart compare notre mix à celui du réseau. L'indice + rapporte le CA d'un magasin à celui du magasin moyen du réseau (CA réseau ÷ {NB_MAGASINS_RESEAU}) : 100 = au niveau. + {total.nbSansReseau > 0 && ` ${fmtEntier(total.nbSansReseau)} produits n'ont pas de chiffre réseau : le CA réseau des nomenclatures concernées est sous-estimé.`} +

+
+ ); +}