mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +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;
|
||||
tooltipStock?: number | null;
|
||||
tooltipReceptions?: number | null;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function getHeatmapClass(value: number | null): string {
|
||||
@@ -15,7 +16,7 @@ function getHeatmapClass(value: number | null): string {
|
||||
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 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 (
|
||||
<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}
|
||||
onClick={onClick}
|
||||
>
|
||||
{display}
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
RowSelectionState,
|
||||
} from "@tanstack/react-table";
|
||||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
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 type { ProductRow, GammeCode } from "@/types/grid";
|
||||
@@ -180,6 +181,72 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisi
|
||||
});
|
||||
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) {
|
||||
@@ -206,6 +273,9 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
||||
const { columnVisibility, setColumnVisibility, columnSizing, setColumnSizing } = useGridStore();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
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);
|
||||
|
||||
// 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"
|
||||
? (row.original.receptions12m[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 (
|
||||
<>
|
||||
<div className="h-full w-full relative">
|
||||
{portalContainer && createPortal(
|
||||
<DropdownMenu>
|
||||
@@ -733,5 +814,11 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
||||
</table>
|
||||
</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