"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 (
);
}
/** 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) => (
);
}
/**
* 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"}
{/* 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`}
/>
);
}