diff --git a/src/features/grid/components/assortment-gaps-modal.tsx b/src/features/grid/components/assortment-gaps-modal.tsx new file mode 100644 index 0000000..22571ee --- /dev/null +++ b/src/features/grid/components/assortment-gaps-modal.tsx @@ -0,0 +1,288 @@ +"use client"; + +/** + * CollectFlow — Trous d'assortiment : ce qu'un magasin ne travaille pas. + * + * On part du CLASSEMENT AFFICHÉ, pas d'un classement maison : le tri en cours + * dans la Grille — CA réseau, quantité, marge, peu importe — fixe l'ordre et + * donc ce que « le haut du classement » veut dire. Recalculer un ordre ici + * répondrait à une autre question que celle qu'on vient de poser à l'écran. + * + * « Non travaillé » = aucune vente sur 12 mois ET aucun stock au dernier mois + * connu. Les deux conditions comptent : un article sans vente mais en stock est + * détenu par le magasin, ce n'est pas un trou d'assortiment mais un invendu. + */ + +import React, { useMemo, useState } from "react"; +import { PackageX, Store } from "lucide-react"; +import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { TuileStat } from "@/features/grid/components/stat-tile"; +import { SITE_LABELS } from "@/features/grid/lib/months"; +import type { ProductRow } from "@/types/grid"; + +/** Ligne du classement affiché : le produit et la valeur qui l'a classé. */ +export interface LigneClassee { + row: ProductRow; + valeurCritere: unknown; +} + +const PROFONDEURS = [100, 200, 500] as const; +export const PROFONDEUR_MAX = PROFONDEURS[PROFONDEURS.length - 1]; + +const nb = (v: number) => Math.round(v).toLocaleString("fr-FR"); +const eur0 = (v: number) => `${Math.round(v).toLocaleString("fr-FR")} €`; +const eur2 = (v: number) => v.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €"; + +/** + * Libellé et mise en forme de la colonne de tri. + * + * Le tri porte sur des grandeurs sans commune mesure — des euros, des unités, + * des pourcentages. Afficher la valeur brute rendrait la colonne illisible, et + * ne rien afficher priverait le classement de sa justification. + */ +const CRITERES: Record string }> = { + caReseau: { label: "CA réseau", format: eur0 }, + qteReseau: { label: "Qté réseau", format: nb }, + nbMagasinsReseau: { label: "Magasins réseau", format: nb }, + tendanceReseau: { label: "Tendance réseau", format: (v) => `${v >= 0 ? "+" : ""}${Math.round(v * 100)} %` }, + prixMoyenReseau: { label: "PV moyen réseau", format: eur2 }, + prixVente: { label: "PV magasin", format: eur2 }, + caParMagasinReseau: { label: "CA / magasin réseau", format: eur0 }, + margePctReseau: { label: "Marge % réseau", format: (v) => `${(Math.abs(v) <= 1 ? v * 100 : v).toFixed(1)} %` }, + totalQuantite: { label: "Qté 12 m", format: nb }, + totalCa: { label: "CA 12 m", format: eur0 }, + totalMarge: { label: "Marge 12 m", format: eur0 }, +}; + +function libelleCritere(id: string | null): string { + if (!id) return "ordre d'affichage"; + return CRITERES[id]?.label ?? id; +} + +function formatCritere(id: string | null, valeur: unknown): string { + if (valeur == null) return "—"; + if (typeof valeur !== "number") return String(valeur); + // Les sentinelles de tri (valeurs manquantes, produit « nouveau ») ne sont + // pas des mesures : les écrire donnerait un nombre qui n'existe pas. + if (!Number.isFinite(valeur)) return "—"; + return (id && CRITERES[id]?.format(valeur)) ?? nb(valeur); +} + +/** Ce que le magasin fait de ce produit, et ce que les autres en font. */ +interface Constat { + ventesMagasin: number; + stockMagasin: number; + /** Le magasin a déjà détenu ce produit sur la période (stock ou réception). */ + dejaDetenu: boolean; + ventesAilleurs: number; + stockAilleurs: number; +} + +function constater(row: ProductRow, site: string, mois: string[]): Constat { + const ventesParSite = row.sales12mByStore ?? {}; + const stockParSite = row.stock12mByStore ?? {}; + const receptionsParSite = row.receptions12mByStore ?? {}; + + const somme = (serie?: Record) => + mois.reduce((total, m) => total + (serie?.[m] ?? 0), 0); + const dernier = (serie?: Record) => serie?.[mois[mois.length - 1]] ?? 0; + + const ventesMagasin = row.quantiteByStore?.[site] ?? somme(ventesParSite[site]); + const stockMagasin = dernier(stockParSite[site]); + const maxStock = mois.reduce((max, m) => Math.max(max, stockParSite[site]?.[m] ?? 0), 0); + + let ventesAilleurs = 0; + let stockAilleurs = 0; + for (const autre of new Set([...Object.keys(ventesParSite), ...Object.keys(stockParSite)])) { + if (autre === site) continue; + ventesAilleurs += row.quantiteByStore?.[autre] ?? somme(ventesParSite[autre]); + stockAilleurs += dernier(stockParSite[autre]); + } + + return { + ventesMagasin, + stockMagasin, + dejaDetenu: maxStock > 0 || somme(receptionsParSite[site]) > 0, + ventesAilleurs, + stockAilleurs, + }; +} + +export function AssortmentGapsModal({ classement, critereId, mois, magasinInitial, onClose }: { + /** Le classement AFFICHÉ, dans son ordre — déjà tronqué à PROFONDEUR_MAX. */ + classement: LigneClassee[]; + critereId: string | null; + /** Les 12 clés de mois, du plus ancien au plus récent. */ + mois: string[]; + magasinInitial: string; + onClose: () => void; +}) { + // Magasins réellement présents dans les données, à défaut les deux sites connus. + const sites = useMemo(() => { + const trouves = new Set(); + for (const { row } of classement) { + for (const s of Object.keys(row.sales12mByStore ?? {})) trouves.add(s); + for (const s of Object.keys(row.stock12mByStore ?? {})) trouves.add(s); + } + return trouves.size > 0 ? [...trouves].sort() : Object.keys(SITE_LABELS); + }, [classement]); + + const [site, setSite] = useState(() => (sites.includes(magasinInitial) ? magasinInitial : sites[0])); + const [profondeur, setProfondeur] = useState(200); + + const examine = useMemo(() => classement.slice(0, profondeur), [classement, profondeur]); + + const trous = useMemo(() => examine + .map((ligne, index) => ({ ...ligne, rang: index + 1, constat: constater(ligne.row, site, mois) })) + .filter(({ constat }) => constat.ventesMagasin <= 0 && constat.stockMagasin <= 0), + [examine, site, mois]); + + const vendusAilleurs = trous.filter((t) => t.constat.ventesAilleurs > 0).length; + const dejaTentes = trous.filter((t) => t.constat.dejaDetenu).length; + const nomMagasin = SITE_LABELS[site]?.nom ?? site; + const autresMagasins = sites.filter((s) => s !== site).map((s) => SITE_LABELS[s]?.nom ?? s).join(", ") || "les autres"; + + return ( + + + + + Produits non travaillés par un magasin + +

+ Sur le classement affiché — trié par{" "} + {libelleCritere(critereId)} + {" — ni vente ni stock sur 12 mois."} +

+
+ + {/* Réglages : quel magasin, et jusqu'où dans le classement */} +
+
+ Magasin +
+ {sites.map((s) => ( + + ))} +
+
+
+ Profondeur +
+ {PROFONDEURS.map((p) => ( + + ))} +
+ {classement.length < profondeur && ( + + ({classement.length} lignes affichées seulement) + + )} +
+
+ +
+ 0 ? "var(--accent-warning)" : undefined} + icone={PackageX} + /> + + +
+ + {trous.length === 0 ? ( +
+ Aucun trou dans les {examine.length} premiers : {nomMagasin} travaille tout le haut de ce classement. +
+ ) : ( +
+ + + + + + + + + + + + + + {trous.map(({ row, valeurCritere, rang, constat }) => ( + + + + + + + + + + ))} + +
#CodeDésignation + {libelleCritere(critereId)} + Ventes ailleursStock ailleursConstat
{rang}{row.codein} + {row.libelle1} + {row.libelle3 && ( + {row.libelle3} + )} + + {formatCritere(critereId, valeurCritere)} + 0 ? "var(--accent-success)" : "var(--text-muted)" }}> + {constat.ventesAilleurs > 0 ? nb(constat.ventesAilleurs) : "—"} + + {constat.stockAilleurs > 0 ? nb(constat.stockAilleurs) : "—"} + + {constat.dejaDetenu ? "déjà détenu, sans vente" : "jamais détenu"} +
+
+ )} + +

+ Non travaillé = aucune vente sur les 12 mois glissants ET aucun stock au dernier mois connu, à {nomMagasin}. + Un produit sans vente mais en stock n'apparaît pas ici : le magasin le détient, c'est un invendu, pas un trou d'assortiment. +

+
+ ); +} diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 3582e4c..53047b9 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -13,7 +13,7 @@ import { RowSelectionState, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus, CalendarRange, Tag, Eye, EyeOff } from "lucide-react"; +import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, PackageX, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus, CalendarRange, Tag, Eye, EyeOff } from "lucide-react"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -27,6 +27,7 @@ import { GammeSelect } from "@/features/grid/components/gamme-select"; import { HeatmapCell } from "@/features/grid/components/heatmap-cell"; import { TuileStat } from "@/features/grid/components/stat-tile"; import { ProductMonthlyModal } from "@/features/grid/components/product-monthly-modal"; +import { AssortmentGapsModal, PROFONDEUR_MAX, type LigneClassee } from "@/features/grid/components/assortment-gaps-modal"; import type { ProductRow, GammeCode } from "@/types/grid"; import { cn } from "@/lib/utils"; import { @@ -559,6 +560,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { const [networkModal, setNetworkModal] = useState(null); // Détail 12 mois du produit, ouvert depuis la case de total 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); const tableContainerRef = useRef(null); // Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client @@ -1064,6 +1067,24 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { /** Blocs de colonnes affichés — badge du menu « Vues ». */ const nbVues = (showMonthlySales ? 1 : 0) + (showPrices ? 1 : 0); + /** + * Le haut du classement TEL QU'IL EST AFFICHÉ, pour les trous d'assortiment. + * + * Les lignes viennent du modèle trié et filtré de TanStack : le tri en cours + * — CA, quantité, marge, peu importe — fixe l'ordre, et la valeur qui a servi + * à classer est relue par `getValue` sur la colonne triée. Tronqué à la + * profondeur maximale offerte : au-delà, on copierait 130 000 lignes pour en + * regarder 500. Calculé seulement quand la fenêtre est ouverte. + */ + const classementAffiche: LigneClassee[] = useMemo(() => { + if (!gapsOpen) return []; + const critereId = sorting[0]?.id ?? null; + return tableRows.slice(0, PROFONDEUR_MAX).map((r) => ({ + row: r.original, + valeurCritere: critereId ? r.getValue(critereId) : null, + })); + }, [gapsOpen, tableRows, sorting]); + if (!isMounted) { return (
{portalContainer && createPortal(
+ {/* Trous d'assortiment — lit le classement affiché, tri compris. */} + {/* * « Vues » replie des BLOCS de colonnes d'un geste, là où le menu * « Colonnes » voisin agit colonne par colonne. Douze cellules @@ -1280,6 +1312,18 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { { if (!open) setNetworkModal(null); }}> {networkModal !== null && setNetworkModal(null)} />} + {/* Trous d'assortiment sur le haut du classement affiché */} + + {gapsOpen && ( + setGapsOpen(false)} + /> + )} + {/* Modal détail 12 mois (ventes / entrées / stock par magasin) */} { if (!open) setMonthlyModal(null); }}> {monthlyModal !== null && (