mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: add server-side API for fetching and processing comprehensive product data for the grid.
This commit is contained in:
1 parent
99954fb001
commit
53b85381fa
7 files changed
+152
-59
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")} €
|
||||
</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")} €
|
||||
</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")} €
|
||||
</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")} €
|
||||
</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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user