From 8e379400406a537cd4aabcd5f3b26f5464fdf24a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 08:05:08 +0000 Subject: [PATCH] =?UTF-8?q?Carte=20tendance=20r=C3=A9seau=20:=20nos=20maga?= =?UTF-8?q?sins=20et=20une=20lecture=20en=20clair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phrase de lecture en tête : la tendance du réseau avec ses deux chiffres (qté par magasin, 4 derniers mois contre 4 premiers), puis notre situation (ventes 12 mois, stock, couverture, commandes en cours). - Nouvelle section « Nos magasins » : stock fin de mois, ventes 12 mois, rythme mensuel, couverture, dernière entrée et écart au magasin moyen du réseau, par magasin et pour l'ensemble. - Réseau : période affichée en clair, tuile CA réseau 12 mois, part du réseau dans la tuile des magasins vendeurs. - Modal sorti de heatmap-grid.tsx (network-monthly-modal.tsx). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015MouZgPPAuEZjHXXifW7bm --- src/features/grid/components/heatmap-grid.tsx | 91 +---- .../grid/components/network-monthly-modal.tsx | 313 ++++++++++++++++++ 2 files changed, 324 insertions(+), 80 deletions(-) create mode 100644 src/features/grid/components/network-monthly-modal.tsx diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index e87a76e..0b7f0b2 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, TrendingUp, TrendingDown, Minus, CalendarRange, Tag, Eye, EyeOff } from "lucide-react"; +import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, PackageX, Warehouse, AlertTriangle, CalendarRange, Tag, Eye, EyeOff } from "lucide-react"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -26,8 +26,8 @@ import { useGridStore } from "@/features/grid/store/use-grid-store"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 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 { NetworkMonthlyModal } from "@/features/grid/components/network-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"; @@ -36,18 +36,14 @@ import { getMonthsFromRows, formatMonthLabel, formatDate, - fmtMonthShort, SITE_LABELS, getStoreConfig, } from "@/features/grid/lib/months"; import { computeNetworkTrend, - computeStoresSeries, - trendLabel, - TREND_COLOR, NB_MAGASINS_RESEAU, } from "@/features/grid/lib/network-trend"; -import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts"; +import { TrendSparkline } from "@/features/grid/components/network-charts"; import type { NetworkTrend } from "@/features/grid/lib/network-trend"; import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format"; import { CLASSES_SANS_GAMME, trouverGamme } from "@/lib/gammes"; @@ -554,78 +550,6 @@ interface CellDetailData { receptions: number | null; } -/** - * Carte « tendance réseau » : le verdict en tuiles, puis les trois séries - * mensuelles en petits multiples. - * - * La couleur de tendance (vert / rouge / gris) ne vit QUE dans la tuile de - * verdict, accompagnée d'une flèche et d'un libellé : un état ne se signale - * jamais par la couleur seule. Les courbes, elles, portent des teintes - * d'identité — sans quoi la même couleur voudrait dire deux choses. - */ -function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) { - const trend = tendanceDe(row); - const { values, labels, direction, pct } = trend; - const color = TREND_COLOR[direction]; - const magasins = computeStoresSeries(row.nbMagReseauByMonth, labels); - const Fleche = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus; - - const dernier = values.length - 1; - const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }); - const verdict = trend.nouveau - ? "Nouveau" - : pct != null - ? `${pct >= 0 ? "+" : ""}${Math.round(pct * 100)} %` - : "—"; - - return ( - - - - {row.libelle1} - -

- Réseau · 12 mois glissants, mois en cours exclu - {trend.enRetard && labels.length > 0 && ` · jusqu'à ${fmtMonthShort(labels[labels.length - 1])}, le dernier mois n'est pas encore synchronisé`} -

-
- - {!trend.hasData ? ( -
- Pas encore de détail mensuel pour ce produit.
Relancez un Sync Qlik pour le remplir. -
- ) : ( - <> -
- - {trend.perStore && ( - t + v, 0) / trend.perStore.length, 1)} en moyenne`} - /> - )} - -
- - - )} -
- ); -} - function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; activeMagasin: string; onClose: () => void }) { const hasReceptions = (d.receptions ?? 0) > 0; const storeLabel = activeMagasin === "TOTAL" ? "Total réseau" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin); @@ -1507,7 +1431,14 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour {cellDetail !== null && setCellDetail(null)} />} { if (!open) setNetworkModal(null); }}> - {networkModal !== null && setNetworkModal(null)} />} + {networkModal !== null && ( + setNetworkModal(null)} + /> + )} {/* Trous d'assortiment sur le haut du classement affiché */} diff --git a/src/features/grid/components/network-monthly-modal.tsx b/src/features/grid/components/network-monthly-modal.tsx new file mode 100644 index 0000000..8e6b4d7 --- /dev/null +++ b/src/features/grid/components/network-monthly-modal.tsx @@ -0,0 +1,313 @@ +"use client"; + +/** + * CollectFlow — Carte « tendance réseau », ouverte depuis la colonne Tendance. + * + * Trois étages, dans l'ordre où on se pose les questions : + * 1. la lecture en une phrase (le produit progresse-t-il dans le réseau ?) ; + * 2. le réseau : verdict, rythme par magasin, diffusion, puis les courbes ; + * 3. nos magasins : stock, ventes, couverture, comparés au rythme du réseau. + * + * Sans le troisième étage, la carte disait comment le produit marche AILLEURS + * sans dire où on en est CHEZ NOUS — il fallait rouvrir un autre détail pour + * savoir s'il restait du stock. + * + * La couleur de tendance (vert / rouge / gris) ne vit que dans la tuile de + * verdict et la colonne « vs réseau », toujours accompagnée d'un signe ou d'un + * libellé : un état ne se signale jamais par la couleur seule. + */ + +import type React from "react"; +import { TrendingUp, TrendingDown, Minus, Store, Warehouse } from "lucide-react"; +import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { TuileStat } from "@/features/grid/components/stat-tile"; +import { NetworkLineChart } from "@/features/grid/components/network-charts"; +import { + computeStoresSeries, + trendLabel, + TREND_COLOR, + TREND_FLAT, + TREND_WINDOW, + NB_MAGASINS_RESEAU, + type NetworkTrend, +} from "@/features/grid/lib/network-trend"; +import { formatDate, formatMonthLabel, qlikMonthToFf } from "@/features/grid/lib/months"; +import { MAGASINS, LIBELLE_TOUS_MAGASINS } from "@/lib/magasins"; +import { fmtEur0 } from "@/lib/format"; +import type { ProductRow } from "@/types/grid"; + +const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }); +/** Une décimale sous 10, aucune au-delà : « 2,4 » mais « 37 ». */ +const fmtRythme = (v: number) => fmt(v, Math.abs(v) < 10 ? 1 : 0); +const moyenne = (xs: number[]) => (xs.length ? xs.reduce((t, v) => t + v, 0) / xs.length : 0); +const pctTexte = (p: number) => `${p >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(p * 100)))} %`; + +/** Mois de stock au rythme des ventes ; sans vente, le ratio n'existe pas et on le dit. */ +function couvertureTexte(stock: number, parMois: number): string { + if (parMois <= 0) return stock > 0 ? "sans vente" : "—"; + const c = stock / parMois; + if (c < 1) return "< 1 mois"; + if (c > 24) return "> 24 mois"; + return `${fmt(c, c < 10 ? 1 : 0)} mois`; +} + +interface LigneMagasin { + cle: string; + nom: string; + stock: number; + ventes: number; + parMois: number; + derniereEntree?: string; + /** Écart à la moyenne réseau par magasin, `null` si non comparable. */ + vsReseau: number | null; + total?: boolean; +} + +/** + * Nos magasins, un par ligne, puis l'ensemble. + * + * « vs réseau » compare nos ventes mensuelles moyennes à celles d'un magasin + * vendeur moyen du réseau sur la même période : c'est la seule assiette + * commune (le réseau n'a pas de stock par magasin à nous offrir). + */ +function lignesMagasins(row: ProductRow, mois: string[], moyenneReseau: number | null): LigneMagasin[] { + const dernier = mois[mois.length - 1]; + const ecart = (parMois: number) => + moyenneReseau != null && moyenneReseau > 0 ? parMois / moyenneReseau - 1 : null; + + const lignes: LigneMagasin[] = MAGASINS.map((m) => { + const ventes = mois.reduce((t, k) => t + (row.sales12mByStore?.[m.code]?.[k] ?? 0), 0); + const parMois = ventes / mois.length; + return { + cle: m.code, + nom: m.nom, + stock: row.stock12mByStore?.[m.code]?.[dernier] ?? 0, + ventes, + parMois, + derniereEntree: row.derniereLivraisonByStore?.[m.code], + vsReseau: ventes > 0 ? ecart(parMois) : null, + }; + }); + + const ventes = mois.reduce((t, k) => t + (row.sales12m?.[k] ?? 0), 0); + lignes.push({ + cle: "TOTAL", + nom: LIBELLE_TOUS_MAGASINS, + stock: row.stock12m?.[dernier] ?? lignes.reduce((t, l) => t + l.stock, 0), + ventes, + parMois: ventes / mois.length, + derniereEntree: row.derniereLivraison, + // Comparer deux magasins cumulés à UN magasin réseau n'aurait pas de sens. + vsReseau: null, + total: true, + }); + return lignes; +} + +function TableauNosMagasins({ lignes, finPeriode }: { lignes: LigneMagasin[]; finPeriode: string }) { + const entete = "px-2.5 py-2 font-semibold whitespace-nowrap"; + return ( +
+ + + + + + + + + + + + + {lignes.map((l) => { + const couleurEcart = l.vsReseau == null || Math.abs(l.vsReseau) <= TREND_FLAT + ? "var(--text-secondary)" + : TREND_COLOR[l.vsReseau > 0 ? "up" : "down"]; + return ( + + + + + + + + + ); + })} + +
MagasinStock fin {finPeriode}Ventes 12 mPar moisCouverturevs réseau
+
{l.nom}
+
+ {l.derniereEntree ? `dernière entrée ${formatDate(l.derniereEntree)}` : "aucune entrée enregistrée"} +
+
0 ? "var(--text-primary)" : "var(--text-muted)" }}> + {fmt(l.stock)} + + {fmt(l.ventes)} + + {l.ventes !== 0 ? fmtRythme(l.parMois) : "—"} + + {couvertureTexte(l.stock, l.parMois)} + + {l.vsReseau != null ? pctTexte(l.vsReseau) : "—"} +
+
+ ); +} + +/** Titre de section : icône + intitulé + précision en clair. */ +function TitreSection({ icone: Icone, titre, detail }: { + icone: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; + titre: string; + detail?: string; +}) { + return ( +
+ +

{titre}

+ {detail && {detail}} +
+ ); +} + +/** + * La tendance dite en une phrase, avec ses deux chiffres : on la lit avant + * d'avoir à interpréter les tuiles et les courbes. + */ +function phraseReseau(trend: NetworkTrend): string { + if (trend.nouveau) { + return `Dans le réseau, le produit est nouveau : aucune vente sur les ${TREND_WINDOW} premiers mois, des ventes sur les ${TREND_WINDOW} derniers.`; + } + const serie = trend.perStore ?? trend.values; + const n = serie.length; + const debut = moyenne(serie.slice(0, TREND_WINDOW)); + const fin = moyenne(serie.slice(n - TREND_WINDOW)); + const unite = trend.perStore ? "par magasin vendeur et par mois" : "par mois dans tout le réseau"; + const sens = trend.direction === "up" ? "progresse" : trend.direction === "down" ? "recule" : "est stable"; + return `Dans le réseau, le produit ${sens} : ${fmtRythme(fin)} unités ${unite} sur les ${TREND_WINDOW} derniers mois, contre ${fmtRythme(debut)} sur les ${TREND_WINDOW} premiers.`; +} + +export function NetworkMonthlyModal({ row, trend, mois, onClose }: { + row: ProductRow; + trend: NetworkTrend; + /** Fenêtre des ventes FF de la Grille (clés « YYYYMM »). */ + mois: string[]; + onClose: () => void; +}) { + const { values, labels, direction, pct } = trend; + const couleur = TREND_COLOR[direction]; + const magasins = computeStoresSeries(row.nbMagReseauByMonth, labels); + const Fleche = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus; + const dernier = values.length - 1; + + const verdict = trend.nouveau ? "Nouveau" : pct != null ? pctTexte(pct) : "—"; + const moyenneReseau = trend.perStore ? moyenne(trend.perStore) : null; + const magasinsDernierMois = magasins ? magasins.values[dernier] : (row.nbMagasinsReseau ?? 0); + + const lignes = lignesMagasins(row, mois, moyenneReseau); + const finPeriode = mois.length ? formatMonthLabel(mois[mois.length - 1]) : ""; + const nous = lignes[lignes.length - 1]; + + const periodeReseau = labels.length + ? `${formatMonthLabel(qlikMonthToFf(labels[0]))} → ${formatMonthLabel(qlikMonthToFf(labels[labels.length - 1]))}` + : "12 mois glissants"; + + return ( + + + + {row.libelle1} + +

+ {row.codein} + {row.reference ? ` · réf. ${row.reference}` : ""} + {row.codeCentrale ? ` · centrale ${row.codeCentrale}` : ""} +

+
+ + {/* Lecture en clair : réseau, puis nous. */} +
+ {trend.hasData + ?

{phraseReseau(trend)}

+ :

Pas encore de détail mensuel réseau pour ce produit : relancez une synchronisation Qlik.

} +

+ Chez nous : {fmt(nous.ventes)} unités vendues en 12 mois, {fmt(nous.stock)} en stock fin {finPeriode} + {nous.ventes > 0 ? ` (${couvertureTexte(nous.stock, nous.parMois)} de couverture)` : ""} + {row.commandesEnCours ? `, ${fmt(row.commandesEnCours)} en commande` : ""}. +

+
+ + {trend.hasData && ( +
+ + {trend.enRetard && ( +

+ Le dernier mois n'est pas encore synchronisé depuis Qlik : la période s'arrête un mois plus tôt. +

+ )} +
+ + {trend.perStore && moyenneReseau != null && ( + + )} + + {row.caReseau != null && row.caReseau > 0 && ( + + )} +
+ +
+ )} + +
+ + + {row.stockActuel != null && ( +

+ + Stock disponible aujourd'hui, tous magasins : {fmt(row.stockActuel)} +

+ )} +
+
+ ); +}