mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
Merge pull request #9 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Grille : détail mensuel au clic sur le total, bloc mensuel repliable
This commit is contained in:
4 files changed
+468
-42
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={{
|
||||
background: "var(--bg-elevated)",
|
||||
color: "var(--text-primary)",
|
||||
borderColor: "var(--border-strong)"
|
||||
}}>
|
||||
<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