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,
|
RowSelectionState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
@@ -24,6 +24,8 @@ import { useGridStore } from "@/features/grid/store/use-grid-store";
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { GammeSelect } from "@/features/grid/components/gamme-select";
|
import { GammeSelect } from "@/features/grid/components/gamme-select";
|
||||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
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 type { ProductRow, GammeCode } from "@/types/grid";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -245,36 +247,6 @@ interface CellDetailData {
|
|||||||
receptions: number | null;
|
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
|
* Carte « tendance réseau » : le verdict en tuiles, puis les trois séries
|
||||||
* mensuelles en petits multiples.
|
* mensuelles en petits multiples.
|
||||||
@@ -465,6 +437,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
const displayDensity = useGridStore((s) => s.displayDensity);
|
const displayDensity = useGridStore((s) => s.displayDensity);
|
||||||
const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES);
|
const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES);
|
||||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
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
|
// Filtre client-side par code3 (famille) et codeGamme
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
@@ -500,6 +474,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
// État pour le détail d'une cellule mensuelle (modal)
|
// État pour le détail d'une cellule mensuelle (modal)
|
||||||
const [cellDetail, setCellDetail] = useState<CellDetailData | null>(null);
|
const [cellDetail, setCellDetail] = useState<CellDetailData | null>(null);
|
||||||
const [networkModal, setNetworkModal] = useState<ProductRow | 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);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client
|
// 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}`,
|
id: `month_${monthKey}`,
|
||||||
header: () => <div className="text-center w-full">{formatMonthLabel(monthKey)}</div>,
|
header: () => <div className="text-center w-full">{formatMonthLabel(monthKey)}</div>,
|
||||||
size: 68,
|
size: 68,
|
||||||
@@ -804,7 +784,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
})),
|
})) : []),
|
||||||
{
|
{
|
||||||
id: "totalQuantite",
|
id: "totalQuantite",
|
||||||
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalQuantite : (row.quantiteByStore?.[activeMagasin] ?? 0),
|
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"
|
const qty = activeMagasin === "TOTAL"
|
||||||
? row.original.totalQuantite
|
? row.original.totalQuantite
|
||||||
: (row.original.quantiteByStore?.[activeMagasin] ?? 0);
|
: (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 (
|
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
|
||||||
background: "var(--bg-elevated)",
|
type="button"
|
||||||
color: "var(--text-primary)",
|
onClick={(e) => {
|
||||||
borderColor: "var(--border-strong)"
|
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")}
|
{Math.round(qty).toLocaleString("fr-FR")}
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -884,7 +875,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
size: 110,
|
size: 110,
|
||||||
cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />,
|
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({
|
const table = useReactTable({
|
||||||
data: filteredData,
|
data: filteredData,
|
||||||
@@ -939,6 +930,23 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
<>
|
<>
|
||||||
<div className="h-full w-full relative">
|
<div className="h-full w-full relative">
|
||||||
{portalContainer && createPortal(
|
{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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -971,7 +979,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>,
|
</DropdownMenu>
|
||||||
|
</div>,
|
||||||
portalContainer
|
portalContainer
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1067,6 +1076,17 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
<Dialog open={networkModal !== null} onOpenChange={(open) => { if (!open) setNetworkModal(null); }}>
|
<Dialog open={networkModal !== null} onOpenChange={(open) => { if (!open) setNetworkModal(null); }}>
|
||||||
{networkModal !== null && <NetworkMonthlyModal row={networkModal} onClose={() => setNetworkModal(null)} />}
|
{networkModal !== null && <NetworkMonthlyModal row={networkModal} onClose={() => setNetworkModal(null)} />}
|
||||||
</Dialog>
|
</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>;
|
columnVisibility: Record<string, boolean>;
|
||||||
/** Persisted column sizing state */
|
/** Persisted column sizing state */
|
||||||
columnSizing: Record<string, number>;
|
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" */
|
/** Currently displayed store: "TOTAL" | "292" | "579" */
|
||||||
activeMagasin: string;
|
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`). */
|
/** Sous-ensemble réellement écrit dans le navigateur (cf. `partialize`). */
|
||||||
type GridPersisted = Pick<
|
type GridPersisted = Pick<
|
||||||
GridState,
|
GridState,
|
||||||
"filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing"
|
"filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing" | "showMonthlySales"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const useGridStore = create<GridState>()(
|
export const useGridStore = create<GridState>()(
|
||||||
@@ -106,6 +116,9 @@ export const useGridStore = create<GridState>()(
|
|||||||
activeGridQuery: "",
|
activeGridQuery: "",
|
||||||
columnVisibility: {},
|
columnVisibility: {},
|
||||||
columnSizing: {},
|
columnSizing: {},
|
||||||
|
showMonthlySales: true,
|
||||||
|
|
||||||
|
setShowMonthlySales: (visible) => set({ showMonthlySales: visible }),
|
||||||
|
|
||||||
setActiveMagasin: (code) => {
|
setActiveMagasin: (code) => {
|
||||||
set({ activeMagasin: code });
|
set({ activeMagasin: code });
|
||||||
@@ -239,6 +252,10 @@ export const useGridStore = create<GridState>()(
|
|||||||
const c = etat.filters.code3;
|
const c = etat.filters.code3;
|
||||||
etat.filters.code3 = typeof c === "string" && c !== "" ? [c] : Array.isArray(c) ? c : null;
|
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;
|
return etat;
|
||||||
},
|
},
|
||||||
// Only persist filters, display density, drafts, active grid query, and column visibility/sizing
|
// 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,
|
activeGridQuery: state.activeGridQuery,
|
||||||
columnVisibility: state.columnVisibility,
|
columnVisibility: state.columnVisibility,
|
||||||
columnSizing: state.columnSizing,
|
columnSizing: state.columnSizing,
|
||||||
|
showMonthlySales: state.showMonthlySales,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user