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

+43 -10
View File
@@ -97,22 +97,19 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
// ─── Phase 4 : Agréger mensuelRows → byPeriod par codein ─────────────
// SQL a déjà fait l'agrégation par (codein, site, mois).
// On somme ici les 2 sites (292 + 579) par période.
const filterSite = magasin !== "TOTAL" ? magasin : null;
// On somme les 2 sites (TOTAL) ET on accumule par site pour le switch client-side.
type PeriodData = { qty: number; ca: number; marge: number; stock: number; receptions: number };
const mensuelByCodein = new Map<string, Map<string, PeriodData>>();
const mensuelBySite = new Map<string, Map<string, Map<string, PeriodData>>>(); // codein → site → periode → data
const storeMonthsByCodein = new Map<string, Map<string, Set<string>>>();
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<Produc
p.marge += Number(row.marge) || 0;
p.receptions += Number(row.qte_recue) || 0;
// Accumulation par site (pour switch client-side)
if (!mensuelBySite.has(row.codein)) mensuelBySite.set(row.codein, new Map());
const bySite = mensuelBySite.get(row.codein)!;
if (!bySite.has(row.site)) bySite.set(row.site, new Map());
const siteByPeriod = bySite.get(row.site)!;
if (!siteByPeriod.has(periode)) siteByPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 });
const sp = siteByPeriod.get(periode)!;
sp.qty += Number(row.qte_vendue) || 0;
sp.ca += Number(row.ca_ht) || 0;
sp.marge += Number(row.marge) || 0;
sp.stock = Math.max(sp.stock, Number(row.stock_fin_mois) || 0);
// Suivi magasins actifs (pour workingStores) : 1 vente suffit
if (Number(row.qte_vendue) > 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<Produc
product.tauxMarge = product.totalCa > 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<Produc
const product = productMap.get(codein);
if (!product) continue;
const sitesStock = filterSite
? stocks.filter(s => 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);
+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,
+23 -10
View File
@@ -19,6 +19,10 @@ interface GridState {
/** Persisted column sizing state */
columnSizing: Record<string, number>;
/** 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<string, number> | ((old: Record<string, number>) => Record<string, number>)) => void;
}
function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>): GridSummary {
function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>, 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<GridState>()(
persist(
(set, get) => ({
rows: [],
activeMagasin: "TOTAL",
draftChanges: {},
filters: {
magasin: null,
@@ -80,8 +88,13 @@ export const useGridStore = create<GridState>()(
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<GridState>()(
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<string, GammeCode>) => {
@@ -127,7 +140,7 @@ export const useGridStore = create<GridState>()(
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<GridState>()(
}
});
set({ draftChanges: updatedDrafts, summary: computeSummary(rows, updatedDrafts) });
set({ draftChanges: updatedDrafts, summary: computeSummary(rows, updatedDrafts, get().activeMagasin) });
},
setColumnVisibility: (updater) => {
+10
View File
@@ -52,6 +52,16 @@ export interface ProductRow {
stock12m: Record<string, number>;
/** Monthly reception quantities, indexed by YYYYMM key */
receptions12m: Record<string, number>;
/** Per-store breakdown (site → YYYYMM → qty) — always present even in TOTAL mode */
sales12mByStore?: Record<string, Record<string, number>>;
/** Per-store stock breakdown (site → YYYYMM → stock) */
stock12mByStore?: Record<string, Record<string, number>>;
/** Per-store CA totals */
caByStore?: Record<string, number>;
/** Per-store quantity totals */
quantiteByStore?: Record<string, number>;
/** Per-store margin totals */
margeByStore?: Record<string, number>;
totalQuantite: number;
totalCa: number;
totalMarge: number;