From ffa257fda1f4899a8838d1f6963db6e77d437502 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 23 Mar 2026 16:09:09 +0100 Subject: [PATCH] feat: Implement product heatmap grid with comprehensive data aggregation and PostgreSQL client for product rows. --- src/features/grid/api/get-product-rows.ts | 36 ++++++++----- src/features/grid/components/heatmap-cell.tsx | 12 +++-- src/features/grid/components/heatmap-grid.tsx | 7 ++- src/lib/pg-ff-client.ts | 51 +++++++++++++------ src/types/grid.ts | 4 +- 5 files changed, 73 insertions(+), 37 deletions(-) diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index e513282..5e3476d 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -125,10 +125,11 @@ export async function getProductRows(input: GetProductRowsInput): Promise 0) { @@ -165,19 +166,28 @@ export async function getProductRows(input: GetProductRowsInput): Promise { +export const HeatmapCell = React.memo(({ value, tooltipStock, tooltipReceptions }: 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(" · "); + if (tooltipReceptions != null && tooltipReceptions > 0) + titleParts.push(`Entrées : +${Math.round(tooltipReceptions).toLocaleString("fr-FR")}`); + if (tooltipStock != null) + titleParts.push(`Stock fin de mois : ${Math.round(tooltipStock).toLocaleString("fr-FR")}`); + const title = titleParts.length > 0 ? titleParts.join(" · ") : (value ? `${Math.abs(value).toLocaleString("fr-FR")} unités` : "Aucune vente"); return (
; + const receptions = activeMagasin === "TOTAL" + ? (row.original.receptions12m[monthKey] ?? null) + : (row.original.receptions12mByStore?.[activeMagasin]?.[monthKey] ?? null); + return ; }, })), { @@ -571,7 +574,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), enableRowSelection: true, - globalFilterFn: (row, columnId, filterValue) => { + globalFilterFn: (row, _columnId, filterValue) => { const search = String(filterValue).toLowerCase(); const libelle = String(row.original.libelle1 || "").toLowerCase(); const code = String(row.original.codein || "").toLowerCase(); diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index b634490..7e114de 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -166,24 +166,43 @@ export async function pgGetMensuelByFournisseur( // IMPORTANT : utiliser DISTINCT sur artfou1 pour éviter les doublons de mouvements. // Un article peut avoir plusieurs lignes artfou1 pour le même fournisseur (pcb/ref différents), // ce qui multiplierait chaque mouvement et gonflerait les totaux. + // + // stock_fin_mois = qtestock du DERNIER mouvement du mois (tri par datmvt DESC). + // MAX(qtestock) donnait le stock de début de mois (avant les premières ventes), ce qui était faux. const result = await pgNoParallel(sql` + WITH base AS ( + SELECT + a.codein, + m.site, + TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, + m.qtemvt, + m.mntmvtttc, + m.margemvt, + m.genremvt, + m.qtestock, + ROW_NUMBER() OVER ( + PARTITION BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ORDER BY m.datmvt DESC + ) AS rn_last + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af + ON af.art_no_id = a.no_id + WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date + AND m.site IN ('292', '579') + ) SELECT - a.codein, - m.site, - TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, - SUM(CASE WHEN m.genremvt = 3 THEN -m.qtemvt ELSE 0 END)::float AS qte_vendue, - SUM(CASE WHEN m.genremvt = 3 THEN -m.mntmvtttc ELSE 0 END)::float AS ca_ht, - SUM(CASE WHEN m.genremvt = 3 THEN m.margemvt ELSE 0 END)::float AS marge, - MAX(m.qtestock)::float AS stock_fin_mois, - SUM(CASE WHEN m.genremvt IN (1, 2) THEN -m.qtemvt ELSE 0 END)::float AS qte_recue - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af - ON af.art_no_id = a.no_id - WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date - AND m.site IN ('292', '579') - GROUP BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') - ORDER BY a.codein, m.site, mois + codein, + site, + mois, + SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue, + SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht, + SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge, + MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois, + SUM(CASE WHEN genremvt IN (1, 2) THEN -qtemvt ELSE 0 END)::float AS qte_recue + FROM base + GROUP BY codein, site, mois + ORDER BY codein, site, mois `); return result.rows as unknown as PgMensuelRow[]; diff --git a/src/types/grid.ts b/src/types/grid.ts index 3a54c7d..f3a9dcb 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -54,8 +54,10 @@ export interface ProductRow { receptions12m: Record; /** Per-store breakdown (site → YYYYMM → qty) — always present even in TOTAL mode */ sales12mByStore?: Record>; - /** Per-store stock breakdown (site → YYYYMM → stock) */ + /** Per-store stock breakdown (site → YYYYMM → stock fin de mois) */ stock12mByStore?: Record>; + /** Per-store reception breakdown (site → YYYYMM → qté reçue) */ + receptions12mByStore?: Record>; /** Per-store CA totals */ caByStore?: Record; /** Per-store quantity totals */