feat: add server-side API for fetching and processing comprehensive product data for the grid.

This commit is contained in:
Michael committed 2026-03-23 14:00:27 +01:00
1 parent 99954fb001
commit 53b85381fa
7 files changed
+152 -59

No files matched your search

+3 -1
View File
@@ -27,6 +27,7 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn
const setRows = useGridStore((s) => s.setRows);
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
const setFilter = useGridStore((s) => s.setFilter);
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
const searchParams = useSearchParams();
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
@@ -61,7 +62,8 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn
const scoredRows = computeProductScores([...initialRows]);
setRows(scoredRows);
}, [codeFournisseur, initialRows, setRows, setFilter, isMounted]);
setActiveMagasin(magasin || "TOTAL");
}, [codeFournisseur, initialRows, setRows, setFilter, setActiveMagasin, magasin, isMounted]);
const handleSave = () => {
startTransition(async () => {
@@ -85,10 +85,13 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
router.push(`/grid?${params.toString()}`);
};
const { setActiveMagasin, activeMagasin } = useGridStore();
const handleStoreSelect = (code: string) => {
setActiveMagasin(code);
const params = new URLSearchParams(searchParams.toString());
params.set("magasin", code);
router.push(`/grid?${params.toString()}`);
router.replace(`/grid?${params.toString()}`, { scroll: false });
};
const handleRefresh = () => {
@@ -125,7 +128,7 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
{/* Store Selector */}
<StoreCombobox
magasins={magasins}
selectedCode={searchParams.get("magasin")}
selectedCode={activeMagasin === "TOTAL" ? null : activeMagasin}
onSelect={handleStoreSelect}
className="w-[220px]"
/>
@@ -2,6 +2,7 @@ import React from "react";
interface HeatmapCellProps {
value: number | null;
tooltipStock?: number | null;
}
function getHeatmapClass(value: number | null): string {
@@ -13,14 +14,20 @@ function getHeatmapClass(value: number | null): string {
return "heatmap-zero";
}
export const HeatmapCell = React.memo(({ value }: HeatmapCellProps) => {
export const HeatmapCell = React.memo(({ value, tooltipStock }: HeatmapCellProps) => {
const heatClass = getHeatmapClass(value);
const display = value === null || value === 0 ? "0" : Math.round(Math.abs(value)).toString();
const titleParts: string[] = [];
if (value !== null && value !== 0) titleParts.push(`${Math.abs(value).toLocaleString("fr-FR")} unités`);
else titleParts.push("Aucune vente");
if (tooltipStock != null) titleParts.push(`Stock fin de mois : ${tooltipStock.toLocaleString("fr-FR")}`);
const title = titleParts.join(" · ");
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}`}
title={value !== null ? `${Math.abs(value).toLocaleString("fr-FR")} unités` : "Aucune vente"}
title={title}
>
{display}
</div>
+59 -34
View File
@@ -184,7 +184,7 @@ GridRow.displayName = "GridRow";
export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
const { rows, filters, displayDensity, draftChanges } = useGridStore();
const { rows, filters, displayDensity, draftChanges, activeMagasin } = useGridStore();
// Filtre client-side par code3 (famille) et codeGamme
const filteredData = useMemo(() => {
@@ -456,50 +456,75 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
header: () => <div className="text-center w-full">{formatMonthLabel(monthKey)}</div>,
size: 68,
enableSorting: false,
cell: ({ row }: { row: { original: ProductRow } }) => (
<HeatmapCell value={row.original.sales12m[monthKey] ?? null} />
),
cell: ({ row }: { row: { original: ProductRow } }) => {
const qty = activeMagasin === "TOTAL"
? (row.original.sales12m[monthKey] ?? null)
: (row.original.sales12mByStore?.[activeMagasin]?.[monthKey] ?? null);
const stock = activeMagasin === "TOTAL"
? (row.original.stock12m[monthKey] ?? null)
: (row.original.stock12mByStore?.[activeMagasin]?.[monthKey] ?? null);
return <HeatmapCell value={qty} tooltipStock={stock} />;
},
})),
{
accessorKey: "totalQuantite",
id: "totalQuantite",
header: () => <div className="text-center w-full">Tot. 12m</div>,
size: 90,
cell: ({ getValue }) => (
<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)"
}}>
{Math.round(getValue<number>()).toLocaleString("fr-FR")}
</div>
),
cell: ({ row }: { row: { original: ProductRow } }) => {
const qty = activeMagasin === "TOTAL"
? row.original.totalQuantite
: (row.original.quantiteByStore?.[activeMagasin] ?? 0);
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)"
}}>
{Math.round(qty).toLocaleString("fr-FR")}
</div>
);
},
},
{
accessorKey: "totalCa",
id: "totalCa",
header: () => <div className="text-center w-full">CA</div>,
size: 90,
cell: ({ getValue }) => (
<div className="text-center tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
{Math.round(getValue<number>()).toLocaleString("fr-FR")}&nbsp;€
</div>
),
cell: ({ row }: { row: { original: ProductRow } }) => {
const ca = activeMagasin === "TOTAL"
? row.original.totalCa
: (row.original.caByStore?.[activeMagasin] ?? 0);
return (
<div className="text-center tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
{Math.round(ca).toLocaleString("fr-FR")}&nbsp;€
</div>
);
},
},
{
accessorKey: "totalMarge",
id: "totalMarge",
header: () => <div className="text-center w-full">Marge</div>,
size: 110,
cell: ({ row }) => (
<div className="flex flex-col items-center justify-center">
<span className="tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
{Math.round(row.original.totalMarge).toLocaleString("fr-FR")}&nbsp;€
</span>
<span className="tabular-nums text-[10px] font-bold opacity-70" style={{
color: row.original.tauxMarge >= 40 ? "var(--accent-success)" : row.original.tauxMarge >= 25 ? "var(--accent-warning)" : "var(--accent-error)"
}}>
{row.original.tauxMarge.toFixed(1)}%
</span>
</div>
),
cell: ({ row }: { row: { original: ProductRow } }) => {
const marge = activeMagasin === "TOTAL"
? row.original.totalMarge
: (row.original.margeByStore?.[activeMagasin] ?? 0);
const ca = activeMagasin === "TOTAL"
? row.original.totalCa
: (row.original.caByStore?.[activeMagasin] ?? 0);
const taux = ca > 0 ? (marge / ca) * 100 : 0;
return (
<div className="flex flex-col items-center justify-center">
<span className="tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
{Math.round(marge).toLocaleString("fr-FR")}&nbsp;€
</span>
<span className="tabular-nums text-[10px] font-bold opacity-70" style={{
color: taux >= 40 ? "var(--accent-success)" : taux >= 25 ? "var(--accent-warning)" : "var(--accent-error)"
}}>
{taux.toFixed(1)}%
</span>
</div>
);
},
},
{
id: "gammeInitial",
@@ -528,7 +553,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
</div>
),
},
], [MONTHS_12]); // Dépendances extrêmement stables : pas de re-render du header !
], [MONTHS_12, activeMagasin]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
const table = useReactTable({
data: filteredData,