"use client"; /** * CollectFlow — Détail mensuel d'un produit, ouvert depuis la case « Tot. 12m ». * * La colonne de total dit COMBIEN, jamais COMMENT. On empile donc les trois * séries qui expliquent le total, dans l'ordre où on les lit : ce qui est sorti * (ventes), ce qui est entré (réceptions), puis ce qui reste — magasin par * magasin, parce qu'un stock de 40 réparti sur deux sites et un stock de 40 * bloqué sur un seul ne se pilotent pas pareil. * * SVG fait main comme le reste des graphiques du dossier : aucune librairie. */ import React from "react"; import { ShoppingCart, PackageOpen, Warehouse } from "lucide-react"; import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { TuileStat } from "@/features/grid/components/stat-tile"; import { formatMonthLabel, formatDate, SITE_LABELS } from "@/features/grid/lib/months"; import type { ProductRow } from "@/types/grid"; /** Couleurs de séries : slots de la palette catégorielle validée (cf. `globals.css`). */ const COULEUR_VENTES = "var(--viz-1)"; const COULEUR_ENTREES = "var(--viz-3)"; const fmt = (v: number) => Math.round(v).toLocaleString("fr-FR"); /** * Barres mensuelles sur une ligne de zéro explicite. * * Les quantités peuvent être NÉGATIVES (retours supérieurs aux ventes sur un * mois) : l'échelle part donc du minimum réel et les barres se dessinent de part * et d'autre du zéro, sinon un mois de retours sortirait du cadre par le bas. * * Chaque barre porte sa valeur écrite : douze nombres restent lisibles à cette * largeur, et aucune valeur n'est ainsi accessible au seul survol. */ function BarresMensuelles({ mois, valeurs, couleur, aria }: { mois: string[]; valeurs: number[]; couleur: string; aria: string; }) { const n = valeurs.length; const min = Math.min(0, ...valeurs); const max = Math.max(0, ...valeurs); // Une barre négative écrit sa valeur SOUS elle : sans marge basse // supplémentaire, ce nombre se poserait sur les libellés de mois. const W = 660, hautPlot = 104, padH = 18; const padB = min < 0 ? 42 : 24; const H = padH + hautPlot + padB; const etendue = max - min || 1; const y = (v: number) => padH + hautPlot - ((v - min) / etendue) * hautPlot; const yZero = y(0); const pas = W / n; const largeur = Math.min(pas * 0.6, 34); return (
{valeurs.map((v, i) => { const cx = pas * i + pas / 2; const yVal = y(v); const haut = Math.min(yVal, yZero); const hauteur = Math.max(Math.abs(yVal - yZero), 1.5); return ( {v !== 0 && ( )} {fmt(v)} {formatMonthLabel(mois[i])} ); })}
); } /** En-tête de section : repère coloré + titre + cumul de la série. */ function EnteteSection({ icone: Icone, titre, detail, couleur }: { icone: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; titre: string; detail: string; couleur: string; }) { return (

{titre}

{detail}
); } /** * Une série mensuelle ventilée par magasin, un magasin par ligne. * * Sert aux ventes (flux : une colonne « 12 m » cumule la ligne) comme au stock * (niveau : additionner douze fins de mois n'aurait aucun sens, pas de cumul). * * La ligne de total est recalculée depuis les lignes affichées : un total qui ne * correspond pas à la somme visible fait douter du tableau entier. */ function TableauParMagasin({ row, mois, activeMagasin, parSite, agrege, sousLigne, cumul, aria }: { row: ProductRow; mois: string[]; activeMagasin: string; /** Série par magasin (site → mois → valeur). */ parSite: Record> | undefined; /** Série agrégée réseau, affichée seule quand aucune ventilation n'existe. */ agrege: Record; /** Texte secondaire sous le nom du magasin (`null` pour la ligne réseau). */ sousLigne: (site: string | null, valeurs: number[]) => string; /** Ajoute une colonne de cumul sur 12 mois (flux uniquement). */ cumul?: boolean; aria: string; }) { const sites = [...new Set([ ...Object.keys(row.stock12mByStore ?? {}), ...Object.keys(row.sales12mByStore ?? {}), ])].sort(); // Sans ventilation par site (produit jamais mouvementé, données partielles), // on montre au moins la série agrégée plutôt qu'un tableau vide. const lignes = sites.length > 0 ? sites.map((site) => { const valeurs = mois.map((m) => parSite?.[site]?.[m] ?? 0); return { cle: site, nom: SITE_LABELS[site]?.nom ?? site, actif: activeMagasin === site, valeurs, sous: sousLigne(site, valeurs) }; }) : [(() => { const valeurs = mois.map((m) => agrege[m] ?? 0); return { cle: "TOTAL", nom: "Total réseau", actif: true, valeurs, sous: sousLigne(null, valeurs) }; })()]; const totaux = mois.map((_, i) => lignes.reduce((t, l) => t + l.valeurs[i], 0)); const somme = (v: number[]) => v.reduce((t, x) => t + x, 0); const cellule = (v: number, key: string, gras = false) => ( {fmt(v)} ); return (
{mois.map((m) => ( ))} {cumul && ( )} {lignes.map((l) => { const surligne = l.actif && activeMagasin !== "TOTAL"; return ( {l.valeurs.map((v, i) => cellule(v, mois[i]))} {cumul && ( )} ); })} {lignes.length > 1 && ( {totaux.map((v, i) => cellule(v, mois[i], true))} {cumul && ( )} )}
Magasin {formatMonthLabel(m)} 12 m
{l.nom}
{/* L'information secondaire (dernière entrée, mois avec vente) est écrite, pas réservée au survol. */}
{l.sous}
{fmt(somme(l.valeurs))}
Total {fmt(somme(totaux))}
); } /** * Détail 12 mois d'un produit : ventes, entrées en stock, stock par magasin. * * Les graphiques suivent le magasin actif de la Grille (cohérence avec la case * cliquée) ; les tableaux de ventes et de stock restent toujours ventilés par * magasin, c'est leur objet. * * `mois` est la fenêtre du serveur (lue dans les données, cf. `months.ts`) : * c'est ce qui garantit que la tuile « Ventes 12 m » retombe sur le total de la * Grille. */ export function ProductMonthlyModal({ row, mois, activeMagasin, onClose }: { row: ProductRow; mois: string[]; activeMagasin: string; onClose: () => void; }) { const surTotal = activeMagasin === "TOTAL"; const libelleMagasin = surTotal ? "Tous magasins" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin); const ventes = mois.map((m) => (surTotal ? row.sales12m[m] : row.sales12mByStore?.[activeMagasin]?.[m]) ?? 0); const entrees = mois.map((m) => (surTotal ? row.receptions12m[m] : row.receptions12mByStore?.[activeMagasin]?.[m]) ?? 0); const stocks = mois.map((m) => (surTotal ? row.stock12m[m] : row.stock12mByStore?.[activeMagasin]?.[m]) ?? 0); // Sur un magasin donné, la dernière entrée du réseau n'est pas la sienne. const derniereEntree = surTotal ? row.derniereLivraison : row.derniereLivraisonByStore?.[activeMagasin]; const totalVentes = ventes.reduce((t, v) => t + v, 0); const totalEntrees = entrees.reduce((t, v) => t + v, 0); const stockFin = stocks[stocks.length - 1] ?? 0; const moisAvecVente = ventes.filter((v) => v !== 0).length; // Couverture : combien de mois le stock actuel tient-il au rythme des 12 // derniers mois ? Sans vente, le ratio n'existe pas — on ne l'invente pas. const rythme = totalVentes / mois.length; const couverture = rythme > 0 ? stockFin / rythme : null; return ( {row.libelle1}

{row.codein} {row.reference ? ` · ${row.reference}` : ""} {" · "} {libelleMagasin} {" · 12 mois glissants, mois en cours exclu"}

0 ? `+${fmt(totalEntrees)}` : "—"} indice={derniereEntree ? `dernière entrée ${formatDate(derniereEntree)}` : "aucune entrée enregistrée"} couleur={totalEntrees > 0 ? COULEUR_ENTREES : undefined} icone={PackageOpen} />
{/* Le graphique donne la forme, le tableau donne les chiffres par magasin — et son cumul « 12 m » est le même total que la tuile et que la case cliquée dans la Grille. */} { const n = valeurs.filter((v) => v !== 0).length; return n === 0 ? "aucune vente" : `${n} mois avec vente`; }} cumul aria={`Ventes mensuelles de ${row.libelle1} par magasin`} />
0 ? `+${fmt(totalEntrees)} unités reçues sur 12 mois` : "aucune réception sur la période"} couleur={COULEUR_ENTREES} />
{ // La date de dernière entrée explique un stock qui ne bouge plus. const d = site ? row.derniereLivraisonByStore?.[site] : row.derniereLivraison; return d ? `dern. entrée ${formatDate(d)}` : "aucune entrée"; }} aria={`Stock de fin de mois de ${row.libelle1} par magasin`} />
); }