mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-23 06:58:10 +02:00
feat: introduce virtualized heatmap grid component with dynamic monthly data and optimized cell rendering.
This commit is contained in:
1 parent
ffa257fda1
commit
8ecba594cf
2 files changed
+93
-4
No files matched your search
@@ -4,6 +4,7 @@ interface HeatmapCellProps {
|
|||||||
value: number | null;
|
value: number | null;
|
||||||
tooltipStock?: number | null;
|
tooltipStock?: number | null;
|
||||||
tooltipReceptions?: number | null;
|
tooltipReceptions?: number | null;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHeatmapClass(value: number | null): string {
|
function getHeatmapClass(value: number | null): string {
|
||||||
@@ -15,7 +16,7 @@ function getHeatmapClass(value: number | null): string {
|
|||||||
return "heatmap-zero";
|
return "heatmap-zero";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HeatmapCell = React.memo(({ value, tooltipStock, tooltipReceptions }: HeatmapCellProps) => {
|
export const HeatmapCell = React.memo(({ value, tooltipStock, tooltipReceptions, onClick }: HeatmapCellProps) => {
|
||||||
const heatClass = getHeatmapClass(value);
|
const heatClass = getHeatmapClass(value);
|
||||||
const display = value === null || value === 0 ? "0" : Math.round(Math.abs(value)).toString();
|
const display = value === null || value === 0 ? "0" : Math.round(Math.abs(value)).toString();
|
||||||
|
|
||||||
@@ -28,8 +29,9 @@ export const HeatmapCell = React.memo(({ value, tooltipStock, tooltipReceptions
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded text-center text-[12px] tabular-nums px-0.5 py-0.5 font-bold tracking-tighter transition-colors min-h-[24px] flex items-center justify-center font-mono-nums ${heatClass}`}
|
className={`rounded text-center text-[12px] tabular-nums px-0.5 py-0.5 font-bold tracking-tighter transition-colors min-h-[24px] flex items-center justify-center font-mono-nums ${heatClass} ${onClick ? "cursor-pointer hover:ring-1 hover:ring-white/30" : ""}`}
|
||||||
title={title}
|
title={title}
|
||||||
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{display}
|
{display}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 } from "lucide-react";
|
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
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 { GammeSelect } from "@/features/grid/components/gamme-select";
|
||||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
||||||
import type { ProductRow, GammeCode } from "@/types/grid";
|
import type { ProductRow, GammeCode } from "@/types/grid";
|
||||||
@@ -180,6 +181,72 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisi
|
|||||||
});
|
});
|
||||||
GridRow.displayName = "GridRow";
|
GridRow.displayName = "GridRow";
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Composant modal pour le détail d'une cellule mensuelle
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
interface CellDetailData {
|
||||||
|
row: ProductRow;
|
||||||
|
monthKey: string;
|
||||||
|
qty: number | null;
|
||||||
|
stock: number | null;
|
||||||
|
receptions: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; activeMagasin: string; onClose: () => void }) {
|
||||||
|
const hasReceptions = (d.receptions ?? 0) > 0;
|
||||||
|
const storeLabel = activeMagasin === "TOTAL" ? "Total réseau" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContent className="max-w-sm" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{d.row.libelle1}
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{formatMonthLabel(d.monthKey)}{" · "}
|
||||||
|
<span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{storeLabel}</span>
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3 mt-1">
|
||||||
|
{/* Ventes */}
|
||||||
|
<div className="flex flex-col items-center gap-1.5 rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||||
|
<ShoppingCart className="w-4 h-4 opacity-60" style={{ color: "var(--text-muted)" }} />
|
||||||
|
<span className="text-[22px] font-black tabular-nums leading-none" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{d.qty != null ? Math.round(d.qty).toLocaleString("fr-FR") : "—"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Ventes</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entrées */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center gap-1.5 rounded-xl p-3"
|
||||||
|
style={{
|
||||||
|
background: hasReceptions ? "rgba(16,185,129,0.08)" : "var(--bg-elevated)",
|
||||||
|
border: `1px solid ${hasReceptions ? "rgba(16,185,129,0.25)" : "var(--border)"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PackageOpen className="w-4 h-4" style={{ color: hasReceptions ? "rgb(16,185,129)" : "var(--text-muted)" }} />
|
||||||
|
<span className="text-[22px] font-black tabular-nums leading-none" style={{ color: hasReceptions ? "rgb(16,185,129)" : "var(--text-primary)" }}>
|
||||||
|
{hasReceptions ? `+${Math.round(d.receptions!).toLocaleString("fr-FR")}` : "—"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Entrées</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stock fin de mois */}
|
||||||
|
<div className="flex flex-col items-center gap-1.5 rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||||
|
<Warehouse className="w-4 h-4 opacity-60" style={{ color: "var(--text-muted)" }} />
|
||||||
|
<span className="text-[22px] font-black tabular-nums leading-none" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{d.stock != null ? Math.round(d.stock).toLocaleString("fr-FR") : "—"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-center" style={{ color: "var(--text-muted)" }}>Stock fin mois</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
||||||
@@ -206,6 +273,9 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
const { columnVisibility, setColumnVisibility, columnSizing, setColumnSizing } = useGridStore();
|
const { columnVisibility, setColumnVisibility, columnSizing, setColumnSizing } = useGridStore();
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
|
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// État pour le détail d'une cellule mensuelle (modal)
|
||||||
|
const [cellDetail, setCellDetail] = useState<CellDetailData | 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
|
||||||
@@ -466,7 +536,17 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
const receptions = activeMagasin === "TOTAL"
|
const receptions = activeMagasin === "TOTAL"
|
||||||
? (row.original.receptions12m[monthKey] ?? null)
|
? (row.original.receptions12m[monthKey] ?? null)
|
||||||
: (row.original.receptions12mByStore?.[activeMagasin]?.[monthKey] ?? null);
|
: (row.original.receptions12mByStore?.[activeMagasin]?.[monthKey] ?? null);
|
||||||
return <HeatmapCell value={qty} tooltipStock={stock} tooltipReceptions={receptions} />;
|
return (
|
||||||
|
<HeatmapCell
|
||||||
|
value={qty}
|
||||||
|
tooltipStock={stock}
|
||||||
|
tooltipReceptions={receptions}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCellDetail({ row: row.original, monthKey, qty, stock, receptions });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
@@ -611,6 +691,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="h-full w-full relative">
|
<div className="h-full w-full relative">
|
||||||
{portalContainer && createPortal(
|
{portalContainer && createPortal(
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -733,5 +814,11 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modal détail cellule mensuelle */}
|
||||||
|
<Dialog open={cellDetail !== null} onOpenChange={(open) => { if (!open) setCellDetail(null); }}>
|
||||||
|
{cellDetail !== null && <CellDetailModal d={cellDetail!} activeMagasin={activeMagasin} onClose={() => setCellDetail(null)} />}
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user