From 3519597fb712e29e0644216525aec5fda77cfde9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 10:12:49 +0000 Subject: [PATCH] =?UTF-8?q?R=C3=A9vision=20d'assortiment=20:=20CA=20par=20?= =?UTF-8?q?nomenclature,=20par=20magasin=20et=20face=20au=20r=C3=A9seau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouveau bouton « Nomenclatures » dans la barre d'outils de la Grille : il ouvre une modale qui regroupe les produits affichés par nomenclature (code3) et donne, sur les 12 mois de la Grille, le CA de chaque magasin, le CA de nos magasins, le CA réseau, le poids de la nomenclature chez nous et dans le réseau (écart en points) et l'indice de chaque magasin face au magasin moyen du réseau. Tri par colonne, filtre texte, ligne de total et export Excel. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KvNrtCGyjYgPHFoiUkJzhM --- src/features/grid/components/heatmap-grid.tsx | 33 +- .../grid/components/nomenclature-ca-modal.tsx | 516 ++++++++++++++++++ 2 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 src/features/grid/components/nomenclature-ca-modal.tsx 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é.`} +

+
+ ); +}