diff --git a/src/app/(dashboard)/produits/fiche.tsx b/src/app/(dashboard)/produits/fiche.tsx index 78f4499..ebbb27d 100644 --- a/src/app/(dashboard)/produits/fiche.tsx +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -9,7 +9,7 @@ import { } from "lucide-react"; import { HeatmapCell } from "@/features/grid/components/heatmap-cell"; import { NetworkLineChart, DualLineChart, TrendSparkline } from "@/features/grid/components/network-charts"; -import { computeNetworkTrend, trendLabel, TREND_COLOR, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; +import { computeNetworkTrend, computeStoresSeries, trendLabel, TREND_COLOR, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; import { formatMonthLabel, formatDate, SITE_LABELS, ffMonthToQlik } from "@/features/grid/lib/months"; import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job"; import { computeComparatif, indiceVerdict, normalizeMargePct } from "@/features/produits/lib/compare-reseau"; @@ -149,6 +149,17 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba 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(() => { + const detailMensuel = reseau?.metricsByMonth; + if (!detailMensuel) return null; + const parMois: Record = {}; + for (const [mois, mesures] of Object.entries(detailMensuel)) { + const nb = Number(mesures?.nbMag); + if (Number.isFinite(nb)) parMois[mois] = nb; + } + return computeStoresSeries(parMois); + }, [reseau]); const comparatif = useMemo(() => computeComparatif(fiche), [fiche]); const verdict = indiceVerdict(comparatif.indiceQte); @@ -280,7 +291,12 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
Quantités vendues réseau, mois par mois
- + )} diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 38456e6..1e36831 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -484,6 +484,25 @@ async function reconcileSelectedStoreFromMensuelApi( } } +/** + * Extrait la série « nombre de magasins vendeurs » du détail mensuel complet. + * + * `metricsByMonth` porte les cinq mesures Qlik du mois ; seule `nbMag` sert à la + * deuxième courbe de la carte tendance. Renvoie `null` plutôt qu'un objet vide + * pour que l'UI distingue « pas de donnée » de « zéro magasin ». + */ +function nbMagParMois( + metricsByMonth: Record | null | undefined, +): Record | null { + if (!metricsByMonth) return null; + const parMois: Record = {}; + for (const [mois, mesures] of Object.entries(metricsByMonth)) { + const nb = Number(mesures?.nbMag); + if (Number.isFinite(nb)) parMois[mois] = nb; + } + return Object.keys(parMois).length > 0 ? parMois : null; +} + /** * Enrichit les produits avec les metriques reseau Qlik (cache qlik_network_metrics), * jointes par code centrale. Degradation propre si la sync Qlik n'a jamais tourne @@ -514,6 +533,7 @@ async function enrichWithNetworkMetrics(productMap: Map): Pr product.margePctReseau = m.margePctReseau; product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU; product.qteReseauByMonth = m.qteByMonth ?? null; + product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth); product.networkFetchedAt = m.fetchedAt ?? undefined; } } diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 4843eff..5873980 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -35,6 +35,7 @@ import { } from "@/features/grid/lib/months"; import { computeNetworkTrend, + computeStoresSeries, trendLabel, TREND_COLOR, } from "@/features/grid/lib/network-trend"; @@ -198,6 +199,9 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => const trend = computeNetworkTrend(row.qteReseauByMonth); const { values, labels, direction, pct } = trend; const color = TREND_COLOR[direction]; + // Deuxième courbe : le nombre de magasins vendeurs, pour distinguer une + // hausse due à l'élargissement de la diffusion d'une vraie accélération. + const magasins = computeStoresSeries(row.nbMagReseauByMonth); return ( @@ -219,7 +223,7 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => Pas encore de détail mensuel pour ce produit.
Relancez un Sync Qlik pour le remplir. ) : ( - + )}
); diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx index 050061a..6542155 100644 --- a/src/features/grid/components/network-charts.tsx +++ b/src/features/grid/components/network-charts.tsx @@ -43,24 +43,95 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) { ); } -/** Courbe unique des ventes réseau mensuelles (SVG). */ -export function NetworkLineChart({ labels, values, color }: { labels: string[]; values: number[]; color: string }) { - const W = 460, H = 200; - const padL = 10, padR = 10, padT = 22, padB = 30; +/** + * Couleur de la courbe « magasins vendeurs ». + * + * Indigo choisi pour ne jamais entrer en collision avec les couleurs de + * tendance (vert / rouge / gris) portées par la courbe des quantités, et pour + * rester lisible sur fond clair comme sur fond sombre. + */ +export const STORES_COLOR = "#6366f1"; + +/** + * Ventes réseau mensuelles, avec en option la courbe du nombre de magasins. + * + * ⚠️ DEUX ÉCHELLES INDÉPENDANTES. Les quantités se comptent en milliers, les + * magasins plafonnent à ~270 : sur une échelle commune la courbe des magasins + * serait écrasée sur l'axe et ne montrerait plus rien. Chaque série est donc + * normalisée sur son propre maximum, rappelé en haut du graphique — c'est la + * FORME des deux courbes qu'on compare, pas leurs hauteurs. + * + * 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. + */ +export function NetworkLineChart({ + labels, + values, + color, + stores, +}: { + labels: string[]; + values: number[]; + color: string; + /** Nombre de magasins vendeurs, même longueur et même ordre que `values`. */ + stores?: number[] | null; +}) { + const avecMagasins = Array.isArray(stores) && stores.length === values.length && stores.length > 0; + const W = 460, H = avecMagasins ? 224 : 200; + const padL = 10, padR = 10, padT = avecMagasins ? 40 : 22, padB = 30; const plotW = W - padL - padR, plotH = H - padT - padB; const n = values.length; const maxV = Math.max(...values, 1); + const maxS = avecMagasins ? Math.max(...stores!, 1) : 1; const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2); const y = (v: number) => padT + plotH - (v / maxV) * plotH; + const yS = (v: number) => padT + plotH - (v / maxS) * plotH; const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); const areaPts = `${padL},${padT + plotH} ${linePts} ${(padL + plotW).toFixed(1)},${(padT + plotH).toFixed(1)}`; + const storePts = avecMagasins + ? stores!.map((v, i) => `${x(i).toFixed(1)},${yS(v).toFixed(1)}`).join(" ") + : ""; return (
- + {avecMagasins && ( +
+ + + Quantités vendues (max {maxV.toLocaleString("fr-FR")}) + + + + Magasins vendeurs (max {maxS.toLocaleString("fr-FR")}) + +
+ )} + {/* ligne de base */} {/* aire douce sous la courbe */} {n > 1 && } + {/* courbe des magasins, dessinée SOUS celle des quantités (série de contexte) */} + {avecMagasins && n > 1 && ( + + )} + {avecMagasins && stores!.map((v, i) => ( + + ))} {/* courbe */} {n > 1 && } {values.map((v, i) => ( @@ -74,6 +145,12 @@ export function NetworkLineChart({ labels, values, color }: { labels: string[]; {fmtMonthShort(labels[i])} + {/* nb de magasins sous l'axe, juste au-dessus du mois */} + {avecMagasins && ( + + {Math.round(stores![i]).toLocaleString("fr-FR")} + + )} ))} diff --git a/src/features/grid/lib/network-trend.ts b/src/features/grid/lib/network-trend.ts index 1bf3497..34e637b 100644 --- a/src/features/grid/lib/network-trend.ts +++ b/src/features/grid/lib/network-trend.ts @@ -110,6 +110,33 @@ export function computeNetworkTrend( return { values, labels, direction, pct, hasData: true }; } +/** + * Série « nombre de magasins vendeurs » sur la MÊME fenêtre que + * `computeNetworkTrend`, pour la superposer à la courbe des quantités. + * + * Pourquoi cette deuxième courbe : une quantité qui monte parce que le produit + * est diffusé dans plus de magasins ne raconte pas la même histoire qu'une + * quantité qui monte à diffusion constante. Sans elle, la tendance seule ne + * permet pas de distinguer un succès produit d'un simple élargissement. + * + * Même règle du tout ou rien que la courbe des quantités : les 12 clés doivent + * être explicitement présentes, sinon `null` — un mois non extrait ne doit pas + * se lire comme « zéro magasin », ce qui simulerait un arrêt de diffusion. + */ +export function computeStoresSeries( + nbMagByMonth?: Record | null, + now: Date = new Date(), +): { values: number[]; labels: string[] } | null { + if (!nbMagByMonth) return null; + const labels = buildRolling12QlikMonths(now); + const complet = labels.every((label) => + Object.prototype.hasOwnProperty.call(nbMagByMonth, label) && + Number.isFinite(Number(nbMagByMonth[label])), + ); + if (!complet) return null; + return { values: labels.map((l) => Number(nbMagByMonth[l])), labels }; +} + /** Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »… */ export function trendLabel(pct: number | null): string { if (pct == null) return "Stable"; diff --git a/src/types/grid.ts b/src/types/grid.ts index 6b6336a..43f5cc5 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -121,6 +121,14 @@ export interface ProductRow { tauxPresenceReseau?: number; /** Quantité vendue réseau par mois : { "YYYY-MM": qté } (tendance / modal). */ qteReseauByMonth?: Record | null; + /** + * Nombre de magasins vendeurs par mois : { "YYYY-MM": nb }. + * + * Deuxième courbe de la carte tendance : une quantité qui monte parce que le + * produit est diffusé dans plus de magasins ne raconte pas la même histoire + * qu'une quantité qui monte à diffusion constante. + */ + nbMagReseauByMonth?: Record | null; /** Fraîcheur des données réseau (ISO) */ networkFetchedAt?: string; }