diff --git a/src/app/(dashboard)/produits/fiche.tsx b/src/app/(dashboard)/produits/fiche.tsx index e764601..b011ce0 100644 --- a/src/app/(dashboard)/produits/fiche.tsx +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -148,9 +148,8 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba const serie = magasin === "TOTAL" ? fiche.mensuelTotal : (fiche.mensuelParSite[magasin] ?? {}); const totauxAffiches = magasin === "TOTAL" ? fiche.totaux : (fiche.totauxParSite[magasin] ?? { qte: 0, ca: 0, marge: 0, tauxMarge: 0 }); - const trend = useMemo(() => computeNetworkTrend(reseau?.qteByMonth ?? null), [reseau]); - /** Nombre de magasins vendeurs par mois — deuxième courbe de la carte tendance. */ - const magasinsParMois = useMemo(() => { + /** Magasins vendeurs par mois — dénominateur de la tendance ET 3e bande du graphique. */ + const nbMagByMonth = useMemo(() => { const detailMensuel = reseau?.metricsByMonth; if (!detailMensuel) return null; const parMois: Record = {}; @@ -161,8 +160,13 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba const nb = Number(mesures?.nbMag); parMois[mois] = Number.isFinite(nb) ? nb : 0; } - return computeStoresSeries(parMois); + return parMois; }, [reseau]); + const trend = useMemo( + () => computeNetworkTrend(reseau?.qteByMonth ?? null, nbMagByMonth), + [reseau, nbMagByMonth], + ); + const magasinsParMois = useMemo(() => computeStoresSeries(nbMagByMonth), [nbMagByMonth]); const comparatif = useMemo(() => computeComparatif(fiche), [fiche]); const verdict = indiceVerdict(comparatif.indiceQte); @@ -292,13 +296,14 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba {trend.hasData && (
- Quantités vendues réseau, mois par mois + Tendance réseau — la qté/magasin d'abord, les volumes en contexte
)} diff --git a/src/app/(dashboard)/produits/results.tsx b/src/app/(dashboard)/produits/results.tsx index 1190f3e..4c1e823 100644 --- a/src/app/(dashboard)/produits/results.tsx +++ b/src/app/(dashboard)/produits/results.tsx @@ -313,7 +313,7 @@ function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: stri {rows.map((r) => { - const trend = computeNetworkTrend(r.qteByMonth); + const trend = computeNetworkTrend(r.qteByMonth, r.nbMagByMonth); const href = r.codein ? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}` : `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`; diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 325b11d..e5c9b40 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -196,7 +196,7 @@ interface CellDetailData { /** Modal : quantités vendues réseau sur les 12 derniers mois, en une seule courbe. */ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) { - const trend = computeNetworkTrend(row.qteReseauByMonth); + const trend = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth); const { values, labels, direction, pct } = trend; const color = TREND_COLOR[direction]; // Deuxième courbe : le nombre de magasins vendeurs, pour distinguer une @@ -215,6 +215,9 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => <> · {pct != null && <>{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · } {trendLabel(pct, trend.nouveau)} + {trend.surQteParMagasin && ( + (qté/magasin) + )} )}

@@ -224,7 +227,7 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => Pas encore de détail mensuel pour ce produit.
Relancez un Sync Qlik pour le remplir. ) : ( - + )} ); @@ -564,7 +567,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { { id: "tendanceReseau", accessorFn: (row) => { - const t = computeNetworkTrend(row.qteReseauByMonth); + const t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth); if (!t.hasData) return Number.NEGATIVE_INFINITY; // Un produit « nouveau » n'a pas de pourcentage mais c'est la plus // forte progression possible : il doit remonter en tête du tri, pas @@ -575,7 +578,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { header: () =>
Tendance
Réseau · 12 m
, size: 78, cell: ({ row }) => { - const trend = computeNetworkTrend(row.original.qteReseauByMonth); + const trend = computeNetworkTrend(row.original.qteReseauByMonth, row.original.nbMagReseauByMonth); if (!trend.hasData) return
-
; // Cliquable → modal des ventes réseau mois par mois (12 derniers mois). return ( diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx index 776a1cc..5529723 100644 --- a/src/features/grid/components/network-charts.tsx +++ b/src/features/grid/components/network-charts.tsx @@ -50,197 +50,195 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) { ); } -/** Couleur par défaut de la courbe « magasins vendeurs » : indigo franc. */ -export const STORES_COLOR = "#6366f1"; -/** Repli chaud, quand la courbe des quantités est elle-même dans les tons froids. */ -export const STORES_COLOR_ALT = "#f59e0b"; +/** Courbe des quantités vendues (contexte) — bleu ciel. */ +export const QTY_COLOR = "#0ea5e9"; +/** Courbe des magasins vendeurs (contexte) — violet. */ +export const STORES_COLOR = "#8b5cf6"; -/** - * Couleur de la courbe « magasins » garantie DISTINCTE de celle des quantités. - * - * La courbe des quantités prend la couleur de la tendance : vert (#22c55e), - * rouge (#ef4444) ou gris-bleu (#94a3b8) quand elle est stable. L'indigo se - * détache nettement du vert et du rouge, mais il est trop proche du gris-bleu — - * or « stable » est le cas le plus fréquent. Dans ce cas précis on bascule sur - * l'ambre : gris froid contre orange chaud, impossible à confondre. - */ -export function storesColorFor(couleurQuantites: string): string { - return couleurQuantites.toLowerCase() === TREND_COLOR.flat.toLowerCase() - ? STORES_COLOR_ALT - : STORES_COLOR; +/** Une bande du graphique : une série, son échelle, son style. */ +interface Bande { + titre: string; + valeurs: number[]; + couleur: string; + hauteur: number; + /** Aire douce sous la courbe (réservée à la bande principale). */ + aire?: boolean; + /** Trait pointillé + points évidés (séries de contexte). */ + pointille?: boolean; + /** Toutes les valeurs écrites, ou seulement la dernière. */ + etiquettes: "toutes" | "derniere"; + /** Décimales des étiquettes (la qté/magasin est souvent < 10). */ + decimales?: number; + /** Suffixe de la dernière étiquette (« mag. »). */ + suffixe?: string; } /** - * Ventes réseau mensuelles, avec en option le nombre de magasins vendeurs. + * Ventes réseau mensuelles : jusqu'à trois bandes empilées sur le même axe. * - * ⚠️ DEUX BANDES SÉPARÉES, PAS DEUX COURBES SUPERPOSÉES. + * ⚠️ LA BANDE PRINCIPALE EST LA QUANTITÉ PAR MAGASIN, pas la quantité brute. + * La quantité brute confond la performance du produit et sa diffusion : elle + * monte dès qu'on référence le produit ailleurs, sans qu'il se vende mieux nulle + * part. C'est `qté / magasins vendeurs` qui dit si un produit marche ; les deux + * autres bandes sont là pour expliquer ses mouvements, pas pour les juger. * - * La superposition a été essayée et rejetée à l'usage : les quantités se - * comptent en milliers, les magasins plafonnent à ~270. Sur une échelle commune - * la courbe des magasins s'écrase sur l'axe ; sur deux échelles superposées elle - * passe AU-DESSUS de celle des quantités, ce qui se lit spontanément comme « il - * y a plus de magasins que de ventes » — un contresens. + * ⚠️ DES BANDES, PAS DES COURBES SUPERPOSÉES. Deux échelles superposées + * plaçaient la courbe des magasins au-dessus de celle des quantités, ce qui se + * lit comme « il y a plus de magasins que de ventes » — un contresens. Chaque + * série a sa bande, alignée sur le même axe des mois : on compare des formes, + * jamais des hauteurs. * - * ⚠️ LA DENSITÉ EST LE VRAI ENNEMI. Une première version imprimait les 24 - * valeurs (12 quantités + 12 magasins) et titrait chaque bande dans le SVG : - * titres par-dessus les courbes, nombres des extrémités coupés par le bord, - * étiquettes des magasins collées au trait. Illisible. D'où : - * - les titres sortent du dessin (HTML au-dessus, plus aucun recouvrement) ; - * - la bande des magasins ne porte plus que sa dernière valeur, son niveau se - * lisant à la forme et au maximum rappelé dans la légende ; - * - les étiquettes des bords sont ancrées vers l'intérieur, jamais tronquées ; - * - chaque mois expose une infobulle native avec les valeurs exactes. - * - * Lecture : quantités qui montent avec les magasins = élargissement de la - * diffusion ; quantités qui montent à magasins constants = vraie accélération - * des ventes ; quantités qui baissent alors que les magasins tiennent = essoufflement. + * ⚠️ LA DENSITÉ EST L'ENNEMI. Une version imprimait les 24 valeurs et titrait + * chaque bande dans le SVG : titres par-dessus les courbes, nombres des bords + * tronqués, étiquettes collées au trait. D'où les titres en HTML au-dessus du + * dessin, les étiquettes de bord ancrées vers l'intérieur, les bandes de + * contexte réduites à leur dernière valeur, et une infobulle par mois qui donne + * les chiffres exacts sans rien encombrer. */ export function NetworkLineChart({ labels, values, color, stores, + perStore, }: { labels: string[]; values: number[]; color: string; /** Nombre de magasins vendeurs, même longueur et même ordre que `values`. */ stores?: number[] | null; + /** Quantité par magasin vendeur — la vraie tendance, en bande principale. */ + perStore?: number[] | null; }) { - const avecMagasins = Array.isArray(stores) && stores.length === values.length && stores.length > 0; const n = values.length; + const memeTaille = (s?: number[] | null) => Array.isArray(s) && s.length === n && n > 0; + const avecParMag = memeTaille(perStore); + const avecMagasins = memeTaille(stores); + + const fmt = (v: number, d = 0) => + v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }); + + // La bande principale porte la couleur de la tendance ; les bandes de + // contexte ont des couleurs fixes, pour que la teinte « tendance » ne + // désigne jamais qu'une seule chose dans le graphique. + const bandes: Bande[] = avecParMag + ? [ + { titre: "Qté / magasin", valeurs: perStore!, couleur: color, hauteur: 96, aire: true, etiquettes: "toutes", decimales: perStore!.some((v) => v > 0 && v < 10) ? 1 : 0 }, + { titre: "Quantités vendues", valeurs: values, couleur: QTY_COLOR, hauteur: 74, etiquettes: "toutes" }, + ] + : [ + { titre: "Quantités vendues", valeurs: values, couleur: color, hauteur: 116, aire: true, etiquettes: "toutes" }, + ]; + if (avecMagasins) { + bandes.push({ titre: "Magasins vendeurs", valeurs: stores!, couleur: STORES_COLOR, hauteur: 42, pointille: true, etiquettes: "derniere", suffixe: " mag." }); + } const W = 520; - const padL = 14, padR = 14; - const hautQ = 16; // marge haute : place aux étiquettes du sommet - const bandeQ = 116; - const ecart = avecMagasins ? 12 : 0; - const bandeM = avecMagasins ? 44 : 0; - const moisH = 18; - const H = hautQ + bandeQ + ecart + bandeM + moisH; + const padL = 14, padR = 14, moisH = 18, hautPremiere = 16, ecart = 15; const plotW = W - padL - padR; - - // Les quantités Qlik peuvent être NÉGATIVES (retours supérieurs aux ventes - // sur un mois) : l'échelle part du minimum réel, sinon le point sortirait - // du cadre par le bas. - const minQ = Math.min(0, ...values); - const maxQ = Math.max(...values, 1); - const etendueQ = maxQ - minQ || 1; - const maxM = avecMagasins ? Math.max(...stores!, 1) : 1; + const H = hautPremiere + bandes.reduce((t, b) => t + b.hauteur, 0) + ecart * (bandes.length - 1) + moisH; const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2); - const y = (v: number) => hautQ + bandeQ - ((v - minQ) / etendueQ) * bandeQ; - const yM = (v: number) => hautQ + bandeQ + ecart + bandeM - (v / maxM) * bandeM; - const zero = y(0); - - /** Aux extrémités, on ancre l'étiquette vers l'intérieur : sinon elle sort du cadre. */ + /** Aux extrémités on ancre vers l'intérieur : sinon l'étiquette sort du cadre. */ const ancrage = (i: number): "start" | "middle" | "end" => i === 0 ? "start" : i === n - 1 ? "end" : "middle"; - const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); - const areaPts = `${padL},${zero.toFixed(1)} ${linePts} ${(padL + plotW).toFixed(1)},${zero.toFixed(1)}`; - const storesColor = storesColorFor(color); - const storePts = avecMagasins - ? stores!.map((v, i) => `${x(i).toFixed(1)},${yM(v).toFixed(1)}`).join(" ") - : ""; - const fmt = (v: number) => Math.round(v).toLocaleString("fr-FR"); + // Position verticale de chaque bande, empilée de haut en bas. + let curseur = hautPremiere; + const disposees = bandes.map((b) => { + const haut = curseur; + curseur += b.hauteur + ecart; + // Les quantités Qlik peuvent être NÉGATIVES (retours > ventes sur un + // mois) : l'échelle part du minimum réel, sinon le point sort du cadre. + const min = Math.min(0, ...b.valeurs); + const max = Math.max(...b.valeurs, min + 1); + const etendue = max - min || 1; + const y = (v: number) => haut + b.hauteur - ((v - min) / etendue) * b.hauteur; + return { ...b, haut, max, y }; + }); return (
- {/* Titres HORS du dessin : plus rien ne recouvre les courbes. */} + {/* Titres HORS du dessin : plus aucun recouvrement possible. */}
- - - Quantités vendues - · max {fmt(maxQ)} - - {avecMagasins && ( - + {disposees.map((b) => ( + - Magasins vendeurs - · max {fmt(maxM)} + {b.titre} + · max {fmt(b.max, b.decimales ?? 0)} - )} + ))}
b.titre).join(", ")} > - {/* ─── Bande 1 : quantités ─────────────────────────────── */} - - {n > 1 && } - {n > 1 && } - {values.map((v, i) => ( - - - = 0 ? y(v) - 6 : y(v) + 12} - textAnchor={ancrage(i)} - fontSize={8.5} - fontWeight={700} - fill="var(--text-primary)" - > - {fmt(v)} - - - ))} + {disposees.map((b) => { + const pts = b.valeurs.map((v, i) => `${x(i).toFixed(1)},${b.y(v).toFixed(1)}`).join(" "); + const base = b.y(Math.min(0, ...b.valeurs)); + return ( + + + {b.aire && n > 1 && ( + + )} + {n > 1 && ( + + )} + {b.valeurs.map((v, i) => ( + + ))} + {b.valeurs.map((v, i) => { + if (b.etiquettes === "derniere" && i !== n - 1) return null; + return ( + = 0 ? b.y(v) - 6 : b.y(v) + 12} + textAnchor={ancrage(i)} + fontSize={8.5} + fontWeight={700} + fill={b.etiquettes === "derniere" ? b.couleur : "var(--text-primary)"} + > + {fmt(v, b.decimales ?? 0)}{i === n - 1 ? b.suffixe ?? "" : ""} + + ); + })} + + ); + })} - {/* ─── Bande 2 : magasins vendeurs ─────────────────────── */} - {avecMagasins && ( - <> - - {n > 1 && ( - - )} - {stores!.map((v, i) => ( - // Point évidé : lisible même sans couleur (impression N&B, daltonisme). - - ))} - {/* Seule la dernière valeur est écrite : le reste se lit à la forme. */} - - {fmt(stores![n - 1])} mag. - - - )} - - {/* ─── Axe des mois, commun aux deux bandes ────────────── */} + {/* Axe des mois, commun à toutes les bandes */} {labels.map((lab, i) => ( {fmtMonthShort(lab)} @@ -258,7 +256,8 @@ export function NetworkLineChart({ fill="transparent" > - {`${fmtMonthShort(lab)} · ${fmt(values[i])} vendues${avecMagasins ? ` · ${fmt(stores![i])} magasins` : ""}`} + {`${fmtMonthShort(lab)} · ` + + disposees.map((b) => `${b.titre} ${fmt(b.valeurs[i], b.decimales ?? 0)}`).join(" · ")} ))} diff --git a/src/features/grid/lib/network-trend.ts b/src/features/grid/lib/network-trend.ts index 8069f42..88f2a7e 100644 --- a/src/features/grid/lib/network-trend.ts +++ b/src/features/grid/lib/network-trend.ts @@ -15,10 +15,20 @@ export const NB_MAGASINS_RESEAU = 270; export type NetworkTrend = { values: number[]; // quantités mensuelles, ordre chronologique labels: string[]; // "YYYY-MM" + /** + * Quantité moyenne par magasin vendeur, mois par mois. `null` si le nombre + * de magasins n'est pas connu sur les 12 mois. + */ + perStore: number[] | null; + /** + * `true` si la tendance porte sur la qté/magasin, `false` si elle a dû se + * rabattre sur les quantités brutes faute de nombre de magasins. + */ + surQteParMagasin: boolean; direction: "up" | "down" | "flat"; /** Variation des 4 derniers mois vs les 4 premiers. `null` si aucune base. */ pct: number | null; - /** Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers. */ + /** Rien sur les 4 premiers mois, des ventes sur les 4 derniers. */ nouveau: boolean; hasData: boolean; }; @@ -96,13 +106,31 @@ export const TREND_COLOR: Record = { * mois isolé. Sans base de comparaison (rien vendu sur les 4 premiers mois), * aucun pourcentage n'est inventé : le produit est signalé « nouveau ». * + * ⚠️ LA TENDANCE PORTE SUR LA QUANTITÉ PAR MAGASIN, PAS SUR LA QUANTITÉ BRUTE. + * + * La quantité brute confond deux phénomènes distincts : un produit peut vendre + * davantage simplement parce qu'il est référencé dans plus de magasins, sans + * mieux se vendre nulle part. Inversement, un produit retiré de 60 magasins peut + * s'effondrer en volume tout en performant mieux là où il reste. + * + * C'est donc `quantité / magasins vendeurs` qui dit si un produit marche — et + * c'est sur elle que l'évolution est calculée. La courbe des quantités reste + * affichée, mais comme contexte, pas comme indicateur. + * + * Repli : sans nombre de magasins sur les 12 mois, on retombe sur les quantités + * brutes et `surQteParMagasin` vaut `false`, pour que l'UI puisse le dire. + * * Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%. */ export function computeNetworkTrend( qteByMonth?: Record | null, + nbMagByMonth?: Record | null, now: Date = new Date(), ): NetworkTrend { - const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, nouveau: false, hasData: false }; + const empty: NetworkTrend = { + values: [], labels: [], perStore: null, surQteParMagasin: false, + direction: "flat", pct: null, nouveau: false, hasData: false, + }; if (!qteByMonth) return empty; const labels = buildRolling12QlikMonths(now); const complet = labels.every((label) => @@ -113,10 +141,20 @@ export function computeNetworkTrend( const values = labels.map((l) => Number(qteByMonth[l])); const n = values.length; + + // Quantité par magasin vendeur : l'indicateur qui isole la performance du + // produit de sa diffusion. Un mois sans magasin vendeur vaut 0 — c'est le + // seul choix cohérent avec une quantité elle-même nulle. + const magasins = computeStoresSeries(nbMagByMonth, now); + const perStore = magasins + ? values.map((v, i) => (magasins.values[i] > 0 ? v / magasins.values[i] : 0)) + : null; + const serie = perStore ?? values; + const taille = Math.min(TREND_WINDOW, Math.floor(n / 2)); const moyenne = (xs: number[]) => (xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : 0); - const debut = moyenne(values.slice(0, taille)); - const fin = moyenne(values.slice(n - taille)); + const debut = moyenne(serie.slice(0, taille)); + const fin = moyenne(serie.slice(n - taille)); let pct: number | null = null; let nouveau = false; @@ -134,7 +172,7 @@ export function computeNetworkTrend( direction = "up"; } - return { values, labels, direction, pct, nouveau, hasData: true }; + return { values, labels, perStore, surQteParMagasin: perStore != null, direction, pct, nouveau, hasData: true }; } /** diff --git a/src/features/produits/api/search-produits.ts b/src/features/produits/api/search-produits.ts index 9e3f3c0..02c70a5 100644 --- a/src/features/produits/api/search-produits.ts +++ b/src/features/produits/api/search-produits.ts @@ -37,6 +37,25 @@ const CACHE_TTL_MS = 10 * 60 * 1000; /** Durée de conservation d'un job terminé (le client a le temps de le relire). */ const JOB_TTL_MS = 5 * 60 * 1000; +/** + * Série « magasins vendeurs » extraite du détail mensuel du cache. + * + * Un mois PRÉSENT dans le détail est un mois extrait : sans `nbMag`, c'est + * qu'aucun magasin n'a vendu, donc zéro. Le traiter comme manquant amputerait la + * série et priverait la tendance de son dénominateur. + */ +function nbMagParMois( + metricsByMonth: Record | null | undefined, +): Record | null { + if (!metricsByMonth) return null; + const parMois: Record = {}; + for (const mois of Object.keys(metricsByMonth)) { + const nb = Number(metricsByMonth[mois]?.nbMag); + parMois[mois] = Number.isFinite(nb) ? nb : 0; + } + return Object.keys(parMois).length > 0 ? parMois : null; +} + /** Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. */ function cacheReseauComplet( cache: NetworkMetricCached | undefined, @@ -341,6 +360,7 @@ async function executerRecherche( margePctReseau: normalizeMargePct(margePct), // Le mensuel ne vient que du cache : le cube de recherche est agrégé. qteByMonth: fiable ? cache.qteByMonth : null, + nbMagByMonth: fiable ? nbMagParMois(cache.metricsByMonth) : null, periode: qlik.periode, }; }); @@ -398,6 +418,7 @@ function rowDepuisCatalogueSeul(r: PgProduitSearchRow): ProduitRechercheRow { prixMoyenReseau: null, margePctReseau: null, qteByMonth: null, + nbMagByMonth: null, periode: null, }; } diff --git a/src/features/produits/types.ts b/src/features/produits/types.ts index 03f76c5..4aeb292 100644 --- a/src/features/produits/types.ts +++ b/src/features/produits/types.ts @@ -49,6 +49,11 @@ export interface ProduitRechercheRow { margePctReseau: number | null; /** Quantité réseau par mois `"YYYY-MM"` — alimente la sparkline de tendance. */ qteByMonth: Record | null; + /** + * Magasins vendeurs par mois `"YYYY-MM"`. La tendance porte sur la + * qté/magasin : sans cette série, elle se rabat sur les quantités brutes. + */ + nbMagByMonth: Record | null; periode: string | null; }