From 53b85381fa4e395239151a9ba59c5d87dd6fd620 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 23 Mar 2026 14:00:27 +0100 Subject: [PATCH] feat: add server-side API for fetching and processing comprehensive product data for the grid. --- src/features/grid/api/get-product-rows.ts | 53 +++++++++-- src/features/grid/components/grid-client.tsx | 4 +- .../grid/components/grid-filter-bar.tsx | 7 +- src/features/grid/components/heatmap-cell.tsx | 11 ++- src/features/grid/components/heatmap-grid.tsx | 93 ++++++++++++------- src/features/grid/store/use-grid-store.ts | 33 +++++-- src/types/grid.ts | 10 ++ 7 files changed, 152 insertions(+), 59 deletions(-) diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 7db38b6..e513282 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -97,22 +97,19 @@ export async function getProductRows(input: GetProductRowsInput): Promise>(); + const mensuelBySite = new Map>>(); // codein → site → periode → data const storeMonthsByCodein = new Map>>(); for (const row of mensuelRows) { - if (filterSite && row.site !== filterSite) continue; - const periode = row.mois.replace("-", ""); // "2026-02" → "202602" if (!allowedPeriods.has(periode)) continue; + // Accumulation TOTAL (toujours tous sites) if (!mensuelByCodein.has(row.codein)) mensuelByCodein.set(row.codein, new Map()); const byPeriod = mensuelByCodein.get(row.codein)!; - if (!byPeriod.has(periode)) byPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 }); const p = byPeriod.get(periode)!; p.stock += Number(row.stock_fin_mois) || 0; @@ -121,10 +118,22 @@ export async function getProductRows(input: GetProductRowsInput): Promise 0) { if (!storeMonthsByCodein.has(row.codein)) storeMonthsByCodein.set(row.codein, new Map()); - storeMonthsByCodein.get(row.codein)!.set(row.site, new Set()); // on garde juste le site + storeMonthsByCodein.get(row.codein)!.set(row.site, new Set()); } } @@ -153,6 +162,32 @@ export async function getProductRows(input: GetProductRowsInput): Promise 0 ? (product.totalMarge / product.totalCa) * 100 : 0; + // Breakdowns par site pour le switch client-side + const siteMap = mensuelBySite.get(codein); + if (siteMap) { + product.sales12mByStore = {}; + product.stock12mByStore = {}; + product.caByStore = {}; + product.quantiteByStore = {}; + product.margeByStore = {}; + for (const [site, siteByPeriod] of siteMap.entries()) { + product.sales12mByStore[site] = {}; + product.stock12mByStore[site] = {}; + let sQty = 0, sCa = 0, sMarge = 0; + for (const periode of sortedPeriods) { + const sp = siteByPeriod.get(periode); + product.sales12mByStore[site][periode] = sp?.qty ?? 0; + product.stock12mByStore[site][periode] = sp?.stock ?? 0; + sQty += sp?.qty ?? 0; + sCa += sp?.ca ?? 0; + sMarge += sp?.marge ?? 0; + } + product.quantiteByStore[site] = sQty; + product.caByStore[site] = sCa; + product.margeByStore[site] = sMarge; + } + } + // workingStores : sites avec au moins 1 vente sur les 12 derniers mois const storeMonths = storeMonthsByCodein.get(codein); if (storeMonths) { @@ -189,9 +224,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise s.site === filterSite) - : stocks; + const sitesStock = stocks; product.stockActuel = sitesStock.reduce((s, r) => s + (Number(r.stockdispo) || 0), 0); product.stockTotal = sitesStock.reduce((s, r) => s + (Number(r.qte) || 0), 0); diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx index f538556..3ce07d5 100644 --- a/src/features/grid/components/grid-client.tsx +++ b/src/features/grid/components/grid-client.tsx @@ -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([]); @@ -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 () => { diff --git a/src/features/grid/components/grid-filter-bar.tsx b/src/features/grid/components/grid-filter-bar.tsx index 9e825cf..2e9e76d 100644 --- a/src/features/grid/components/grid-filter-bar.tsx +++ b/src/features/grid/components/grid-filter-bar.tsx @@ -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 */} diff --git a/src/features/grid/components/heatmap-cell.tsx b/src/features/grid/components/heatmap-cell.tsx index aaa7f54..bc20b52 100644 --- a/src/features/grid/components/heatmap-cell.tsx +++ b/src/features/grid/components/heatmap-cell.tsx @@ -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 (
{display}
diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index e57e516..7c421d8 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -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: () =>
{formatMonthLabel(monthKey)}
, size: 68, enableSorting: false, - cell: ({ row }: { row: { original: ProductRow } }) => ( - - ), + 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 ; + }, })), { - accessorKey: "totalQuantite", + id: "totalQuantite", header: () =>
Tot. 12m
, size: 90, - cell: ({ getValue }) => ( -
- {Math.round(getValue()).toLocaleString("fr-FR")} -
- ), + cell: ({ row }: { row: { original: ProductRow } }) => { + const qty = activeMagasin === "TOTAL" + ? row.original.totalQuantite + : (row.original.quantiteByStore?.[activeMagasin] ?? 0); + return ( +
+ {Math.round(qty).toLocaleString("fr-FR")} +
+ ); + }, }, { - accessorKey: "totalCa", + id: "totalCa", header: () =>
CA
, size: 90, - cell: ({ getValue }) => ( -
- {Math.round(getValue()).toLocaleString("fr-FR")} € -
- ), + cell: ({ row }: { row: { original: ProductRow } }) => { + const ca = activeMagasin === "TOTAL" + ? row.original.totalCa + : (row.original.caByStore?.[activeMagasin] ?? 0); + return ( +
+ {Math.round(ca).toLocaleString("fr-FR")} € +
+ ); + }, }, { - accessorKey: "totalMarge", + id: "totalMarge", header: () =>
Marge
, size: 110, - cell: ({ row }) => ( -
- - {Math.round(row.original.totalMarge).toLocaleString("fr-FR")} € - - = 40 ? "var(--accent-success)" : row.original.tauxMarge >= 25 ? "var(--accent-warning)" : "var(--accent-error)" - }}> - {row.original.tauxMarge.toFixed(1)}% - -
- ), + 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 ( +
+ + {Math.round(marge).toLocaleString("fr-FR")} € + + = 40 ? "var(--accent-success)" : taux >= 25 ? "var(--accent-warning)" : "var(--accent-error)" + }}> + {taux.toFixed(1)}% + +
+ ); + }, }, { id: "gammeInitial", @@ -528,7 +553,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { ), }, - ], [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, diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index e336ed0..3591dac 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -19,6 +19,10 @@ interface GridState { /** Persisted column sizing state */ columnSizing: Record; + /** Currently displayed store: "TOTAL" | "292" | "579" */ + activeMagasin: string; + setActiveMagasin: (code: string) => void; + // Actions setRows: (rows: ProductRow[]) => void; setDraftGamme: (codein: string, gamme: GammeCode) => void; @@ -36,17 +40,20 @@ interface GridState { setColumnSizing: (updater: Record | ((old: Record) => Record)) => void; } -function computeSummary(rows: ProductRow[], drafts: Record): GridSummary { +function computeSummary(rows: ProductRow[], drafts: Record, magasin = "TOTAL"): GridSummary { const active = rows.filter((r) => { const g = drafts[r.codein] ?? r.codeGamme; return g !== "Z"; }); - const totalCa = active.reduce((s, r) => s + (r.totalCa ?? 0), 0); - const totalMarge = active.reduce((s, r) => s + (r.totalMarge ?? 0), 0); + const getCa = (r: ProductRow) => magasin === "TOTAL" ? (r.totalCa ?? 0) : (r.caByStore?.[magasin] ?? 0); + const getQte = (r: ProductRow) => magasin === "TOTAL" ? (r.totalQuantite ?? 0) : (r.quantiteByStore?.[magasin] ?? 0); + const getMarge = (r: ProductRow) => magasin === "TOTAL" ? (r.totalMarge ?? 0) : (r.margeByStore?.[magasin] ?? 0); + const totalCa = active.reduce((s, r) => s + getCa(r), 0); + const totalMarge = active.reduce((s, r) => s + getMarge(r), 0); return { totalProducts: rows.length, totalRows: active.length, - totalQuantite: active.reduce((s, r) => s + (r.totalQuantite ?? 0), 0), + totalQuantite: active.reduce((s, r) => s + getQte(r), 0), totalCa, totalMarge, tauxMargeGlobal: totalCa > 0 ? (totalMarge / totalCa) * 100 : 0, @@ -57,6 +64,7 @@ export const useGridStore = create()( persist( (set, get) => ({ rows: [], + activeMagasin: "TOTAL", draftChanges: {}, filters: { magasin: null, @@ -80,8 +88,13 @@ export const useGridStore = create()( columnVisibility: {}, columnSizing: {}, + setActiveMagasin: (code) => { + const { rows, draftChanges } = get(); + set({ activeMagasin: code, summary: computeSummary(rows, draftChanges, code) }); + }, + setRows: (rows) => { - set({ rows, summary: computeSummary(rows, get().draftChanges) }); + set({ rows, summary: computeSummary(rows, get().draftChanges, get().activeMagasin) }); }, setDraftGamme: (codein, gamme) => { @@ -99,16 +112,16 @@ export const useGridStore = create()( draftChanges[codein] = gamme; } - set({ draftChanges, summary: computeSummary(rows, draftChanges) }); + set({ draftChanges, summary: computeSummary(rows, draftChanges, get().activeMagasin) }); }, resetDrafts: () => { - set({ draftChanges: {}, summary: computeSummary(get().rows, {}) }); + set({ draftChanges: {}, summary: computeSummary(get().rows, {}, get().activeMagasin) }); }, clearDrafts: (codeins) => { const draftChanges = { ...get().draftChanges }; codeins.forEach((id) => delete draftChanges[id]); - set({ draftChanges, summary: computeSummary(get().rows, draftChanges) }); + set({ draftChanges, summary: computeSummary(get().rows, draftChanges, get().activeMagasin) }); }, /** Apply saved drafts onto row.codeGamme so isModified works after clearing drafts */ applyDraftsToRows: (draftsToApply: Record) => { @@ -127,7 +140,7 @@ export const useGridStore = create()( setDisplayDensity: (density) => set({ displayDensity: density }), setActiveGridQuery: (query) => set({ activeGridQuery: query }), restoreSnapshot: (changes) => { - set({ draftChanges: changes, summary: computeSummary(get().rows, changes) }); + set({ draftChanges: changes, summary: computeSummary(get().rows, changes, get().activeMagasin) }); }, batchSetDraftGamme: (newChanges) => { @@ -145,7 +158,7 @@ export const useGridStore = create()( } }); - set({ draftChanges: updatedDrafts, summary: computeSummary(rows, updatedDrafts) }); + set({ draftChanges: updatedDrafts, summary: computeSummary(rows, updatedDrafts, get().activeMagasin) }); }, setColumnVisibility: (updater) => { diff --git a/src/types/grid.ts b/src/types/grid.ts index fc8b4c4..3a54c7d 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -52,6 +52,16 @@ export interface ProductRow { stock12m: Record; /** Monthly reception quantities, indexed by YYYYMM key */ receptions12m: Record; + /** Per-store breakdown (site → YYYYMM → qty) — always present even in TOTAL mode */ + sales12mByStore?: Record>; + /** Per-store stock breakdown (site → YYYYMM → stock) */ + stock12mByStore?: Record>; + /** Per-store CA totals */ + caByStore?: Record; + /** Per-store quantity totals */ + quantiteByStore?: Record; + /** Per-store margin totals */ + margeByStore?: Record; totalQuantite: number; totalCa: number; totalMarge: number;