mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
Grille : détail mensuel au clic sur le total, bloc mensuel repliable
La colonne « Tot. 12m » dit combien, jamais comment. Un clic sur la case ouvre désormais le détail des 12 mois glissants, empilé dans l'ordre où on le lit : les ventes mensuelles, puis les entrées en stock, puis le stock de fin de mois magasin par magasin — un stock de 40 réparti sur deux sites et un stock de 40 bloqué sur un seul ne se pilotent pas pareil. Les deux premières séries suivent le magasin actif de la Grille, par cohérence avec la case cliquée ; la ventilation par magasin reste entière, c'est son objet. Les barres se dessinent de part et d'autre d'un zéro explicite : une quantité mensuelle peut être négative (retours supérieurs aux ventes) et sortirait sinon du cadre par le bas. Le bloc des douze colonnes mensuelles se replie d'un bouton. L'état est un drapeau à part, et non douze entrées de `columnVisibility` : masquer douze colonnes une par une n'est pas une manipulation, et les identifiants `month_YYYYMM` changent à chaque nouveau mois alors que le drapeau, lui, survit à la fenêtre glissante. Les colonnes sont retirées du modèle plutôt que cachées — inutile de faire calculer douze cellules par ligne à TanStack pour ne rien peindre. `TuileStat` sort de heatmap-grid.tsx vers un module partagé, la modale de tendance réseau et la nouvelle modale s'en servant toutes deux. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
This commit is contained in:
4 files changed
+465
-39
No files matched your search
@@ -13,7 +13,7 @@ import {
|
||||
RowSelectionState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus, CalendarRange, Eye, EyeOff } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -24,6 +24,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 type { ProductRow, GammeCode } from "@/types/grid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -245,36 +247,6 @@ interface CellDetailData {
|
||||
receptions: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuile de statistique : intitulé en phrase, valeur en chiffres proportionnels.
|
||||
*
|
||||
* Pas de `tabular-nums` ici : à grande taille, des chiffres de largeur égale font
|
||||
* paraître un nombre court anormalement lâche. Les chiffres tabulaires sont
|
||||
* réservés aux colonnes qui s'alignent verticalement (tableau, axes).
|
||||
*/
|
||||
function TuileStat({ label, valeur, indice, couleur, icone: Icone }: {
|
||||
label: string;
|
||||
valeur: string;
|
||||
indice?: string;
|
||||
couleur?: string;
|
||||
icone?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl px-3.5 py-3 flex-1 min-w-[160px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{Icone && <Icone className="w-5 h-5 shrink-0" style={{ color: couleur ?? "var(--text-primary)" }} />}
|
||||
<span className="text-[26px] font-bold leading-tight" style={{ color: couleur ?? "var(--text-primary)" }}>
|
||||
{valeur}
|
||||
</span>
|
||||
</div>
|
||||
{indice && <div className="text-[12px] mt-1 leading-snug" style={{ color: "var(--text-muted)" }}>{indice}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte « tendance réseau » : le verdict en tuiles, puis les trois séries
|
||||
* mensuelles en petits multiples.
|
||||
@@ -465,6 +437,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
const displayDensity = useGridStore((s) => s.displayDensity);
|
||||
const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES);
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
const showMonthlySales = useGridStore((s) => s.showMonthlySales);
|
||||
const setShowMonthlySales = useGridStore((s) => s.setShowMonthlySales);
|
||||
|
||||
// Filtre client-side par code3 (famille) et codeGamme
|
||||
const filteredData = useMemo(() => {
|
||||
@@ -500,6 +474,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
// État pour le détail d'une cellule mensuelle (modal)
|
||||
const [cellDetail, setCellDetail] = useState<CellDetailData | null>(null);
|
||||
const [networkModal, setNetworkModal] = useState<ProductRow | null>(null);
|
||||
// Détail 12 mois du produit, ouvert depuis la case de total
|
||||
const [monthlyModal, setMonthlyModal] = useState<ProductRow | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client
|
||||
@@ -777,7 +753,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
);
|
||||
},
|
||||
},
|
||||
...MONTHS_12.map((monthKey) => ({
|
||||
// Bloc mensuel repliable : les colonnes sont retirées du modèle, pas
|
||||
// seulement cachées — inutile de faire calculer douze cellules par ligne
|
||||
// à TanStack pour ne rien peindre. Le détail reste accessible d'un clic
|
||||
// sur la case de total.
|
||||
...(showMonthlySales ? MONTHS_12.map((monthKey) => ({
|
||||
id: `month_${monthKey}`,
|
||||
header: () => <div className="text-center w-full">{formatMonthLabel(monthKey)}</div>,
|
||||
size: 68,
|
||||
@@ -804,7 +784,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
/>
|
||||
);
|
||||
},
|
||||
})),
|
||||
})) : []),
|
||||
{
|
||||
id: "totalQuantite",
|
||||
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalQuantite : (row.quantiteByStore?.[activeMagasin] ?? 0),
|
||||
@@ -814,14 +794,25 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
const qty = activeMagasin === "TOTAL"
|
||||
? row.original.totalQuantite
|
||||
: (row.original.quantiteByStore?.[activeMagasin] ?? 0);
|
||||
// Cliquable → détail 12 mois : ventes, entrées en stock, stock par
|
||||
// magasin. `stopPropagation` sinon le clic sélectionne la ligne.
|
||||
return (
|
||||
<div className="mx-auto w-[60px] text-center px-1 tabular-nums text-sm font-black py-1.5 rounded-md border" style={{
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMonthlyModal(row.original);
|
||||
}}
|
||||
title="Voir le détail des 12 mois : ventes mensuelles, entrées en stock et stock par magasin"
|
||||
className="mx-auto w-[60px] text-center px-1 tabular-nums text-sm font-black py-1.5 rounded-md border cursor-pointer transition-all hover:brightness-110 active:scale-95"
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
color: "var(--text-primary)",
|
||||
borderColor: "var(--border-strong)"
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{Math.round(qty).toLocaleString("fr-FR")}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -884,7 +875,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
size: 110,
|
||||
cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />,
|
||||
},
|
||||
], [MONTHS_12, activeMagasin, isAdmin, sorting]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
|
||||
], [MONTHS_12, activeMagasin, isAdmin, sorting, showMonthlySales]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
@@ -939,6 +930,23 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
<>
|
||||
<div className="h-full w-full relative">
|
||||
{portalContainer && createPortal(
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Repli du bloc mensuel : douze colonnes d'un coup, pas une par une. */}
|
||||
<button
|
||||
onClick={() => setShowMonthlySales(!showMonthlySales)}
|
||||
aria-pressed={showMonthlySales}
|
||||
title={showMonthlySales
|
||||
? "Masquer les 12 colonnes de ventes mensuelles — le détail reste accessible en cliquant sur la case de total"
|
||||
: "Afficher les 12 colonnes de ventes mensuelles"}
|
||||
className="flex items-center justify-center gap-2 px-3 py-1.5 rounded-md text-sm font-semibold border transition-all shadow-sm apple-btn-secondary"
|
||||
style={showMonthlySales
|
||||
? { background: "var(--accent-bg)", borderColor: "var(--accent-border)", color: "var(--accent)" }
|
||||
: { background: "var(--action-secondary-bg)", borderColor: "var(--border-strong)", color: "var(--action-secondary-text)" }}
|
||||
>
|
||||
<CalendarRange className="w-4 h-4" />
|
||||
<span className="hidden lg:inline">Ventes mensuelles</span>
|
||||
{showMonthlySales ? <Eye className="w-3.5 h-3.5 opacity-70" /> : <EyeOff className="w-3.5 h-3.5 opacity-70" />}
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
@@ -971,7 +979,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
</DropdownMenu>
|
||||
</div>,
|
||||
portalContainer
|
||||
)}
|
||||
|
||||
@@ -1067,6 +1076,17 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
<Dialog open={networkModal !== null} onOpenChange={(open) => { if (!open) setNetworkModal(null); }}>
|
||||
{networkModal !== null && <NetworkMonthlyModal row={networkModal} onClose={() => setNetworkModal(null)} />}
|
||||
</Dialog>
|
||||
{/* Modal détail 12 mois (ventes / entrées / stock par magasin) */}
|
||||
<Dialog open={monthlyModal !== null} onOpenChange={(open) => { if (!open) setMonthlyModal(null); }}>
|
||||
{monthlyModal !== null && (
|
||||
<ProductMonthlyModal
|
||||
row={monthlyModal}
|
||||
mois={MONTHS_12}
|
||||
activeMagasin={activeMagasin}
|
||||
onClose={() => setMonthlyModal(null)}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
"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 (
|
||||
<div className="overflow-x-auto min-w-0">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
width="100%"
|
||||
height={H}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
// `display:block` : un SVG en ligne traîne l'espace sous la ligne de
|
||||
// base et décale la section suivante.
|
||||
style={{ minWidth: 520, display: "block" }}
|
||||
role="img"
|
||||
aria-label={aria}
|
||||
>
|
||||
<line x1={0} y1={yZero} x2={W} y2={yZero} stroke="var(--border)" strokeWidth={1} />
|
||||
{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 (
|
||||
<g key={mois[i]}>
|
||||
{v !== 0 && (
|
||||
<rect
|
||||
x={cx - largeur / 2}
|
||||
y={haut}
|
||||
width={largeur}
|
||||
height={hauteur}
|
||||
rx={2.5}
|
||||
fill={couleur}
|
||||
opacity={0.9}
|
||||
/>
|
||||
)}
|
||||
<text
|
||||
x={cx}
|
||||
y={v < 0 ? yZero + hauteur + 12 : haut - 5}
|
||||
textAnchor="middle"
|
||||
fontSize={11.5}
|
||||
fontWeight={700}
|
||||
fill={v === 0 ? "var(--text-muted)" : "var(--text-secondary)"}
|
||||
>
|
||||
{fmt(v)}
|
||||
</text>
|
||||
<text x={cx} y={H - 8} textAnchor="middle" fontSize={11} fill="var(--text-muted)">
|
||||
{formatMonthLabel(mois[i])}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="rounded-sm shrink-0" style={{ width: 14, height: 4, background: couleur }} />
|
||||
<Icone className="w-4 h-4 shrink-0" style={{ color: "var(--text-muted)" }} />
|
||||
<h3 className="text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>{titre}</h3>
|
||||
<span className="text-[12px]" style={{ color: "var(--text-muted)" }}>{detail}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock de fin de mois, un magasin par ligne.
|
||||
*
|
||||
* 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 TableauStockMagasins({ row, mois, activeMagasin }: {
|
||||
row: ProductRow;
|
||||
mois: string[];
|
||||
activeMagasin: 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) => ({
|
||||
cle: site,
|
||||
nom: SITE_LABELS[site]?.nom ?? site,
|
||||
actif: activeMagasin === site,
|
||||
derniereEntree: row.derniereLivraisonByStore?.[site],
|
||||
valeurs: mois.map((m) => row.stock12mByStore?.[site]?.[m] ?? 0),
|
||||
}))
|
||||
: [{
|
||||
cle: "TOTAL",
|
||||
nom: "Total réseau",
|
||||
actif: true,
|
||||
derniereEntree: row.derniereLivraison,
|
||||
valeurs: mois.map((m) => row.stock12m[m] ?? 0),
|
||||
}];
|
||||
|
||||
const totaux = mois.map((_, i) => lignes.reduce((t, l) => t + l.valeurs[i], 0));
|
||||
|
||||
return (
|
||||
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[11.5px]" style={{ minWidth: 600 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>
|
||||
Magasin
|
||||
</th>
|
||||
{mois.map((m) => (
|
||||
<th key={m} className="text-right px-1.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
{formatMonthLabel(m)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lignes.map((l) => {
|
||||
const surligne = l.actif && activeMagasin !== "TOTAL";
|
||||
return (
|
||||
<tr key={l.cle} style={{ borderTop: "1px solid var(--border)", background: surligne ? "var(--accent-bg)" : undefined }}>
|
||||
<td className="px-2.5 py-1.5 whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
|
||||
<div className="font-semibold">{l.nom}</div>
|
||||
{/* La date de dernière entrée explique un stock qui ne bouge
|
||||
plus : elle est écrite, pas réservée au survol. */}
|
||||
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>
|
||||
{l.derniereEntree ? `dern. entrée ${formatDate(l.derniereEntree)}` : "aucune entrée"}
|
||||
</div>
|
||||
</td>
|
||||
{l.valeurs.map((v, i) => (
|
||||
<td
|
||||
key={mois[i]}
|
||||
className="px-1.5 py-1.5 text-right tabular-nums"
|
||||
style={{ color: v === 0 ? "var(--text-muted)" : "var(--text-primary)" }}
|
||||
>
|
||||
{fmt(v)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{lignes.length > 1 && (
|
||||
<tr style={{ borderTop: "1px solid var(--border-strong)", background: "var(--bg-elevated)" }}>
|
||||
<td className="px-2.5 py-1.5 font-bold" style={{ color: "var(--text-primary)" }}>
|
||||
Total
|
||||
</td>
|
||||
{totaux.map((v, i) => (
|
||||
<td key={mois[i]} className="px-1.5 py-1.5 text-right tabular-nums font-bold" style={{ color: "var(--text-primary)" }}>
|
||||
{fmt(v)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Détail 12 mois d'un produit : ventes, entrées en stock, stock par magasin.
|
||||
*
|
||||
* Les deux premières séries suivent le magasin actif de la Grille (cohérence
|
||||
* avec la case cliquée) ; la troisième reste toujours ventilée, c'est son objet.
|
||||
*/
|
||||
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 (
|
||||
<DialogContent
|
||||
// `grid-cols-1` : sans colonne explicite, la grille de la modale se
|
||||
// dimensionne sur son contenu le plus large (le tableau des magasins)
|
||||
// et déborde par la droite au lieu de le laisser défiler.
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-4xl grid-cols-1 max-h-[88vh] overflow-y-auto"
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
||||
onInteractOutside={onClose}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||
{row.libelle1}
|
||||
</DialogTitle>
|
||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
{row.codein}
|
||||
{row.reference ? ` · ${row.reference}` : ""}
|
||||
{" · "}
|
||||
<span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{libelleMagasin}</span>
|
||||
{" · 12 mois glissants, mois en cours exclu"}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TuileStat
|
||||
label="Ventes 12 m"
|
||||
valeur={fmt(totalVentes)}
|
||||
indice={`${moisAvecVente} mois avec vente · ${rythme.toLocaleString("fr-FR", { maximumFractionDigits: 1 })} / mois`}
|
||||
icone={ShoppingCart}
|
||||
/>
|
||||
<TuileStat
|
||||
label="Entrées 12 m"
|
||||
valeur={totalEntrees > 0 ? `+${fmt(totalEntrees)}` : "—"}
|
||||
indice={derniereEntree ? `dernière entrée ${formatDate(derniereEntree)}` : "aucune entrée enregistrée"}
|
||||
couleur={totalEntrees > 0 ? COULEUR_ENTREES : undefined}
|
||||
icone={PackageOpen}
|
||||
/>
|
||||
<TuileStat
|
||||
label="Stock fin de période"
|
||||
valeur={fmt(stockFin)}
|
||||
indice={couverture != null
|
||||
? `≈ ${couverture.toLocaleString("fr-FR", { maximumFractionDigits: 1 })} mois de couverture`
|
||||
: "pas de vente sur la période"}
|
||||
icone={Warehouse}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="space-y-1">
|
||||
<EnteteSection
|
||||
icone={ShoppingCart}
|
||||
titre="Ventes mensuelles"
|
||||
detail={`${fmt(totalVentes)} unités sur 12 mois`}
|
||||
couleur={COULEUR_VENTES}
|
||||
/>
|
||||
<BarresMensuelles
|
||||
mois={mois}
|
||||
valeurs={ventes}
|
||||
couleur={COULEUR_VENTES}
|
||||
aria={`Ventes mensuelles de ${row.libelle1} sur 12 mois`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-1">
|
||||
<EnteteSection
|
||||
icone={PackageOpen}
|
||||
titre="Entrées en stock"
|
||||
detail={totalEntrees > 0 ? `+${fmt(totalEntrees)} unités reçues sur 12 mois` : "aucune réception sur la période"}
|
||||
couleur={COULEUR_ENTREES}
|
||||
/>
|
||||
<BarresMensuelles
|
||||
mois={mois}
|
||||
valeurs={entrees}
|
||||
couleur={COULEUR_ENTREES}
|
||||
aria={`Entrées en stock de ${row.libelle1} sur 12 mois`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-1.5">
|
||||
<EnteteSection
|
||||
icone={Warehouse}
|
||||
titre="Stock par magasin"
|
||||
detail="stock de fin de mois"
|
||||
couleur="var(--border-strong)"
|
||||
/>
|
||||
<TableauStockMagasins row={row} mois={mois} activeMagasin={activeMagasin} />
|
||||
</section>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Tuile de statistique : intitulé en phrase, valeur en chiffres proportionnels.
|
||||
*
|
||||
* Pas de `tabular-nums` ici : à grande taille, des chiffres de largeur égale font
|
||||
* paraître un nombre court anormalement lâche. Les chiffres tabulaires sont
|
||||
* réservés aux colonnes qui s'alignent verticalement (tableau, axes).
|
||||
*
|
||||
* Extraite de `heatmap-grid.tsx` pour être partagée entre la modale de tendance
|
||||
* réseau et la modale de détail mensuel produit. Aucun changement de rendu.
|
||||
*/
|
||||
export function TuileStat({ label, valeur, indice, couleur, icone: Icone }: {
|
||||
label: string;
|
||||
valeur: string;
|
||||
indice?: string;
|
||||
couleur?: string;
|
||||
icone?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl px-3.5 py-3 flex-1 min-w-[160px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{Icone && <Icone className="w-5 h-5 shrink-0" style={{ color: couleur ?? "var(--text-primary)" }} />}
|
||||
<span className="text-[26px] font-bold leading-tight" style={{ color: couleur ?? "var(--text-primary)" }}>
|
||||
{valeur}
|
||||
</span>
|
||||
</div>
|
||||
{indice && <div className="text-[12px] mt-1 leading-snug" style={{ color: "var(--text-muted)" }}>{indice}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,16 @@ interface GridState {
|
||||
columnVisibility: Record<string, boolean>;
|
||||
/** Persisted column sizing state */
|
||||
columnSizing: Record<string, number>;
|
||||
/**
|
||||
* Bloc des 12 colonnes mensuelles affiché ou non dans la Grille.
|
||||
*
|
||||
* Séparé de `columnVisibility` : masquer douze colonnes une par une n'est pas
|
||||
* une manipulation, et l'état « bloc replié » doit survivre au renouvellement
|
||||
* de la fenêtre glissante — les identifiants `month_YYYYMM` changent chaque
|
||||
* mois, un drapeau ne change pas.
|
||||
*/
|
||||
showMonthlySales: boolean;
|
||||
setShowMonthlySales: (visible: boolean) => void;
|
||||
|
||||
/** Currently displayed store: "TOTAL" | "292" | "579" */
|
||||
activeMagasin: string;
|
||||
@@ -75,7 +85,7 @@ function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>, m
|
||||
/** Sous-ensemble réellement écrit dans le navigateur (cf. `partialize`). */
|
||||
type GridPersisted = Pick<
|
||||
GridState,
|
||||
"filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing"
|
||||
"filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing" | "showMonthlySales"
|
||||
>;
|
||||
|
||||
export const useGridStore = create<GridState>()(
|
||||
@@ -106,6 +116,9 @@ export const useGridStore = create<GridState>()(
|
||||
activeGridQuery: "",
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
showMonthlySales: true,
|
||||
|
||||
setShowMonthlySales: (visible) => set({ showMonthlySales: visible }),
|
||||
|
||||
setActiveMagasin: (code) => {
|
||||
set({ activeMagasin: code });
|
||||
@@ -239,6 +252,10 @@ export const useGridStore = create<GridState>()(
|
||||
const c = etat.filters.code3;
|
||||
etat.filters.code3 = typeof c === "string" && c !== "" ? [c] : Array.isArray(c) ? c : null;
|
||||
}
|
||||
// Drapeau ajouté après coup : un état persisté antérieur n'en a
|
||||
// pas, et une valeur `undefined` écraserait la valeur par défaut
|
||||
// à la fusion (le bloc mensuel disparaîtrait sans raison).
|
||||
if (typeof etat.showMonthlySales !== "boolean") etat.showMonthlySales = true;
|
||||
return etat;
|
||||
},
|
||||
// Only persist filters, display density, drafts, active grid query, and column visibility/sizing
|
||||
@@ -249,6 +266,7 @@ export const useGridStore = create<GridState>()(
|
||||
activeGridQuery: state.activeGridQuery,
|
||||
columnVisibility: state.columnVisibility,
|
||||
columnSizing: state.columnSizing,
|
||||
showMonthlySales: state.showMonthlySales,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user