perf: lot 1 — chargements plus rapides et corrections, sans changement visuel

Grille (navigateur)
- « Rafraîchir » ne passe plus par l'URL : le paramètre _refresh restait
  mémorisé et chaque retour par le menu forçait un recalcul serveur complet
  (migration du store pour nettoyer les valeurs déjà enregistrées).
- Lignes reçues regroupées toutes les 300 ms au lieu de reconstruire tout le
  tableau à chaque paquet de 150 (chargement quadratique).
- Sélection indexée par code article (getRowId) : après un filtre, l'action
  groupée visait d'autres produits. Seules les lignes affichées comptent.
- Recherche : un passage par ligne au lieu d'un par colonne ; formateurs de
  nombres partagés (lib/format.ts) ; colonnes mensuelles mémorisées ;
  plus de transition-all sur les lignes positionnées par transform.
- exceljs, jspdf et xlsx chargés au clic seulement.

Grille (serveur)
- Requêtes Qlik et snapshot lancées en parallèle de la phase SQL.
- Cache mémoire borné (LRU) ; calcul en cours réutilisé même en forcé ;
  flux interrompu quand le client part.
- Après un enregistrement, les gammes sont reportées dans le cache et dans
  grid_rows au lieu d'invalider (recalcul de ~40 s évité).

Données et API
- lib/ff-cache.ts : cache mémoire 30 min des lectures FF (stock, hit-parade,
  CA mensuel, fournisseurs, dernière réception, publicités), vidé en fin de
  synchro nocturne ; erreurs jamais gardées.
- Pool PostgreSQL unique (globalThis), réglage sans workers parallèles posé
  une fois par connexion au lieu d'une transaction autour de chaque requête.
- Délai maximal sur tous les appels à l'API FF.
- Index session_snapshots ; commandes : plus de double rechargement ;
  synchro : saisies regroupées ; paramètres : configuration lue une fois ;
  journal de capture en ajout seul, rien de formaté sans capture ouverte.

Corrections
- Historique : l'échec de chargement s'affiche (au lieu de « Aucun snapshot »).
- Sessions et validations enregistrent le magasin affiché, plus « TOTAL ».
- Publicités : libellé du statut « passées ».

Plan complet : docs/plan-optimisation-webui.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
Claude committed 2026-10-03 20:28:14 +00:00
1 parent 2c84cfda53
commit 7da4c6d5fd
35 files changed
+1010 -240

No files matched your search

+4
View File
@@ -33,6 +33,7 @@ import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
import { readSyncSettings, type SyncSettings } from "@/features/admin/api/sync-settings";
import { invalidateFFCache } from "@/lib/ff-cache";
export type { SyncSettings };
@@ -338,6 +339,9 @@ export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre
etat.derniereErreur = e instanceof Error ? e.message : String(e);
console.error("[sync-nuit] round interrompu :", etat.derniereErreur);
} finally {
// Les pages (stock, hit-parade, CA…) relisent la base fraîche au lieu
// d'attendre la fin de vie de leur cache.
invalidateFFCache();
etat.enCours = false;
etat.fournisseurCourant = null;
etat.etape = null;
+3 -3
View File
@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
import { and, eq } from "drizzle-orm";
import { db } from "@/db";
import { commandeCadences } from "@/db/schema";
import { pgGetFournisseurs, pgGetDerniereReceptionParFournisseur } from "@/lib/pg-ff-client";
import { getDerniereReceptionCached, getFournisseursCached } from "@/lib/ff-cache";
export type CadenceStatut = "a_commander" | "bientot" | "ok" | "inconnu";
@@ -37,7 +37,7 @@ function calcStatut(joursRestants: number | null): CadenceStatut {
/** Liste des fournisseurs (référentiel) pour le sélecteur de cadence. */
export async function getFournisseursPourCadence(): Promise<{ code: string; nom: string }[]> {
return pgGetFournisseurs();
return getFournisseursCached();
}
/**
@@ -50,7 +50,7 @@ export async function listCadences(): Promise<CadenceView[]> {
try {
[rows, receptions] = await Promise.all([
db.select().from(commandeCadences),
pgGetDerniereReceptionParFournisseur(),
getDerniereReceptionCached(),
]);
} catch (e) {
console.error("[cadence] listCadences error:", (e as Error).message);
+4 -2
View File
@@ -1,7 +1,7 @@
"use server";
import { getSitesFromApi } from "@/lib/api-ff-client";
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
import { getFournisseursCached } from "@/lib/ff-cache";
import { getProductRows } from "./api/get-product-rows";
import type { ProductRow, GridFilters } from "@/types/grid";
@@ -9,7 +9,9 @@ import type { ProductRow, GridFilters } from "@/types/grid";
* Get the list of all suppliers from PostgreSQL (fouadr1).
*/
export async function getFournisseurs() {
return pgGetFournisseurs();
// Relu à chaque navigation dans la Grille (changement de fournisseur, de
// magasin…) : le référentiel ne change qu'avec la recopie nocturne.
return getFournisseursCached();
}
/**
+142 -61
View File
@@ -15,7 +15,7 @@ import {
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache";
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée.
import { upsertGridRows } from "@/lib/grid-store";
@@ -28,8 +28,32 @@ interface GetProductRowsInput {
}
const GRID_ROWS_CACHE_TTL_MS = 10 * 60 * 1000;
/**
* Bornes du cache mémoire. La synchro nocturne et le préchauffage passent par
* `getProductRows()` pour CHAQUE fournisseur : sans plafond, le processus finissait
* par garder toutes les grilles en mémoire (plusieurs centaines de Mo), ce qui
* ralentissait toutes les requêtes (ramasse-miettes). Ordre d'insertion de la Map
* = ordre d'usage : on évince les entrées les moins récemment servies.
*/
const GRID_ROWS_CACHE_MAX_ENTRIES = Number(process.env.GRID_ROWS_CACHE_MAX_ENTRIES ?? 30);
const GRID_ROWS_CACHE_MAX_ROWS = Number(process.env.GRID_ROWS_CACHE_MAX_ROWS ?? 150_000);
const gridRowsCache = new Map<string, { rows: ProductRow[]; createdAt: number }>();
const gridRowsPending = new Map<string, Promise<ProductRow[]>>();
/** Nombre d'enregistrements de gammes par fournisseur (cf. patchGridRowsCache). */
const gammeSaveVersion = new Map<string, number>();
function cacheGridRows(key: string, rows: ProductRow[]): void {
gridRowsCache.delete(key);
gridRowsCache.set(key, { rows, createdAt: Date.now() });
let totalRows = 0;
for (const entry of gridRowsCache.values()) totalRows += entry.rows.length;
for (const [oldKey, entry] of gridRowsCache) {
if (gridRowsCache.size <= 1) break;
if (gridRowsCache.size <= GRID_ROWS_CACHE_MAX_ENTRIES && totalRows <= GRID_ROWS_CACHE_MAX_ROWS) break;
gridRowsCache.delete(oldKey);
totalRows -= entry.rows.length;
}
}
function gridRowsCacheKey(input: GetProductRowsInput): string {
return `${input.codeFournisseur}:${input.magasin ?? "TOTAL"}`;
@@ -49,6 +73,31 @@ export function invalidateGridRowsCache(codeFournisseur?: string) {
gridRowsPending.clear();
}
/**
* Reporte des gammes enregistrées sur les lignes déjà en cache, au lieu de jeter
* le cache : l'invalidation forçait un recalcul complet (jusqu'à ~40 s sur un gros
* fournisseur) à la réouverture suivante. Le résultat est celui qu'aurait donné
* ce recalcul : la colonne Gamme reprend la valeur du dernier snapshot (Phase 9),
* la colonne INIT reste l'état serveur.
*/
export function patchGridRowsCache(
codeFournisseur: string,
changes: ReadonlyArray<{ codein: string; codeGamme: string }>,
): void {
if (changes.length === 0) return;
// Un calcul en cours pour ce fournisseur a lu les gammes avant cet
// enregistrement : le compteur lui signale de les relire avant d'être caché.
gammeSaveVersion.set(codeFournisseur, (gammeSaveVersion.get(codeFournisseur) ?? 0) + 1);
const byCodein = new Map(changes.map((c) => [c.codein, c.codeGamme as GammeCode]));
for (const [key, entry] of gridRowsCache) {
if (!key.startsWith(`${codeFournisseur}:`)) continue;
for (const row of entry.rows) {
const gamme = byCodein.get(row.codein);
if (gamme !== undefined) row.codeGamme = gamme;
}
}
}
/**
* Rafraîchit UNIQUEMENT la colonne INIT (codeGammeInit = gamme serveur courante)
* sur des lignes servies depuis le cache. La colonne Gamme (codeGamme) n'est pas
@@ -90,19 +139,30 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
const cacheKey = gridRowsCacheKey(input);
const cached = gridRowsCache.get(cacheKey);
if (!input.forceRefresh && cached && Date.now() - cached.createdAt < GRID_ROWS_CACHE_TTL_MS) {
// Remonte l'entrée en tête de l'ordre d'usage (éviction LRU).
gridRowsCache.delete(cacheKey);
gridRowsCache.set(cacheKey, cached);
// La colonne INIT doit refléter l'état serveur à CHAQUE chargement,
// y compris sur un hit de cache (les données lourdes restent, elles, cachées).
await refreshGammeInit(cached.rows, input.codeFournisseur);
return cached.rows;
}
// Un calcul déjà en cours est réutilisé même en rechargement forcé : il part
// de données fraîches, et en lancer un second en parallèle doublait la charge
// (double clic, onglet rouvert…) sans rien apporter.
const pending = gridRowsPending.get(cacheKey);
if (!input.forceRefresh && pending) {
if (pending) {
return pending;
}
const promise = buildProductRows(input).then((rows) => {
gridRowsCache.set(cacheKey, { rows, createdAt: Date.now() });
const versionAuDepart = gammeSaveVersion.get(input.codeFournisseur) ?? 0;
const promise = buildProductRows(input).then(async (rows) => {
if ((gammeSaveVersion.get(input.codeFournisseur) ?? 0) !== versionAuDepart) {
// Gammes enregistrées pendant le calcul : on reprend le dernier snapshot.
applySnapshotChanges(rows, await loadLatestSnapshotChanges(input.codeFournisseur));
}
cacheGridRows(cacheKey, rows);
gridRowsPending.delete(cacheKey);
// Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on
// arrête simplement de jeter le résultat. Volontairement NON bloquant —
@@ -127,6 +187,15 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
// ─── Phase 1 : 7 requêtes SQL en parallèle ────────────────────────────
// Remplace des centaines/milliers d'appels HTTP per-article.
const articlesPromise = pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; });
// Phases 8 et 9 lancées dès maintenant : elles n'attendent que la liste des
// articles (codes centraux) ou le seul code fournisseur. Exécutées après la
// Phase 1, elles ajoutaient deux allers-retours en série à chaque calcul.
const networkPromise = articlesPromise.then((arts) =>
fetchNetworkMetrics(arts.map((a) => (a.codeCentrale ? String(a.codeCentrale).trim() : ""))),
);
const snapshotPromise = loadLatestSnapshotChanges(codeFournisseur);
const [
articles,
mensuelRows,
@@ -136,7 +205,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
commandesMap,
prixVenteMap,
] = await Promise.all([
pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }),
articlesPromise,
pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin).catch(e => { console.error("[getProductRows] pgGetMensuelByFournisseur ERROR:", e); return []; }),
pgGetGammesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetGammesByFournisseur ERROR:", e); return new Map<string, string>(); }),
pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }),
@@ -407,34 +476,13 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
}
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
await enrichWithNetworkMetrics(productMap);
enrichWithNetworkMetrics(productMap, await networkPromise);
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
// La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle.
// La colonne INIT (codeGammeInit) reste, elle, l'état LIVE du serveur
// rechargé en Phase 6 — jamais écrasée ici.
try {
const snaps = await db
.select()
.from(sessionSnapshots)
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
.orderBy(desc(sessionSnapshots.createdAt))
.limit(1);
if (snaps.length > 0) {
const changes = snaps[0].changes as Record<string, { before: string | null; after: string }>;
for (const [codein, change] of Object.entries(changes)) {
const product = productMap.get(codein);
if (product && change.after) {
// codeGammeInit reste figé (état serveur) — seul codeGamme est overridé
product.codeGamme = change.after as GammeCode;
}
}
console.log(`[getProductRows] Gammes restaurées depuis snapshot`);
}
} catch (snapErr) {
console.error("[getProductRows] Snapshot restore error:", snapErr);
}
applySnapshotChanges(productMap.values(), await snapshotPromise);
// ─── Phase 10 : Filtrer gamme Y sans ventes ──────────────────────────
const allRows = Array.from(productMap.values());
@@ -601,41 +649,74 @@ function nbMagParMois(
return Object.keys(parMois).length > 0 ? parMois : null;
}
/**
* Enrichit les produits avec les metriques reseau Qlik (cache qlik_network_metrics),
* jointes par code centrale. Degradation propre si la sync Qlik n'a jamais tourne
* ou si le code centrale n'est pas encore disponible.
*/
async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Promise<void> {
try {
const byCodeCentrale = new Map<string, ProductRow[]>();
for (const product of productMap.values()) {
const cc = product.codeCentrale;
if (!cc) continue;
if (!byCodeCentrale.has(cc)) byCodeCentrale.set(cc, []);
byCodeCentrale.get(cc)!.push(product);
}
if (byCodeCentrale.size === 0) return;
type SnapshotChanges = Record<string, { before: string | null; after: string }>;
const metrics = await getNetworkMetricsByCodeCentrale([...byCodeCentrale.keys()]);
const sampleKey = [...byCodeCentrale.keys()].find((cc) => (metrics.get(cc)?.caReseau ?? 0) > 0) ?? [...byCodeCentrale.keys()].find((cc) => metrics.has(cc));
console.log(`[getProductRows] réseau: ${byCodeCentrale.size} codes centraux → ${metrics.size} matchés en cache. Sample:`, sampleKey ? JSON.stringify(metrics.get(sampleKey)) : "aucun match");
for (const [cc, products] of byCodeCentrale.entries()) {
const m = metrics.get(cc);
if (!m) continue;
for (const product of products) {
product.caReseau = m.caReseau;
product.qteReseau = m.qteReseau;
product.nbMagasinsReseau = m.nbMagasinsReseau;
product.caParMagasinReseau = m.caParMagasinReseau;
product.margePctReseau = m.margePctReseau;
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
product.qteReseauByMonth = m.qteByMonth ?? null;
product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth);
product.networkFetchedAt = m.fetchedAt ?? undefined;
}
}
/**
* Phase 9 : la colonne Gamme (codeGamme) reprend la valeur du snapshot ;
* codeGammeInit reste figé (état serveur).
*/
function applySnapshotChanges(rows: Iterable<ProductRow>, changes: SnapshotChanges | null): void {
if (!changes) return;
for (const row of rows) {
const change = changes[row.codein];
if (change?.after) row.codeGamme = change.after as GammeCode;
}
}
/** Dernières gammes enregistrées pour ce fournisseur (Phase 9), ou `null`. */
async function loadLatestSnapshotChanges(codeFournisseur: string): Promise<SnapshotChanges | null> {
try {
const snaps = await db
.select({ changes: sessionSnapshots.changes })
.from(sessionSnapshots)
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
.orderBy(desc(sessionSnapshots.createdAt))
.limit(1);
return snaps.length > 0 ? (snaps[0].changes as SnapshotChanges) : null;
} catch (snapErr) {
console.error("[getProductRows] Snapshot restore error:", snapErr);
return null;
}
}
/**
* Lit les metriques reseau Qlik (cache qlik_network_metrics) des codes centraux
* donnes. `null` en cas d'erreur : la grille s'affiche alors sans donnees reseau.
*/
async function fetchNetworkMetrics(
codesCentrale: string[],
): Promise<Map<string, NetworkMetricCached> | null> {
try {
const codes = [...new Set(codesCentrale.filter(Boolean))];
if (codes.length === 0) return null;
return await getNetworkMetricsByCodeCentrale(codes);
} catch (error) {
console.error("[getProductRows] enrichWithNetworkMetrics error:", error);
return null;
}
}
/**
* Enrichit les produits avec les metriques reseau Qlik, jointes par code centrale.
* Degradation propre si la sync Qlik n'a jamais tourne ou si le code centrale
* n'est pas encore disponible.
*/
function enrichWithNetworkMetrics(
productMap: Map<string, ProductRow>,
metrics: Map<string, NetworkMetricCached> | null,
): void {
if (!metrics || metrics.size === 0) return;
for (const product of productMap.values()) {
const m = product.codeCentrale ? metrics.get(product.codeCentrale) : undefined;
if (!m) continue;
product.caReseau = m.caReseau;
product.qteReseau = m.qteReseau;
product.nbMagasinsReseau = m.nbMagasinsReseau;
product.caParMagasinReseau = m.caParMagasinReseau;
product.margePctReseau = m.margePctReseau;
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
product.qteReseauByMonth = m.qteByMonth ?? null;
product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth);
product.networkFetchedAt = m.fetchedAt ?? undefined;
}
}
+9 -2
View File
@@ -5,7 +5,8 @@ import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { invalidateGridRowsCache } from "./get-product-rows";
import { patchGridRowsCache } from "./get-product-rows";
import { updateGridRowsGamme } from "@/lib/grid-store";
const SaveDraftsSchema = z.object({
codeFournisseur: z.string(),
@@ -70,7 +71,13 @@ export async function saveDraftChanges(
type: "snapshot",
});
invalidateGridRowsCache(codeFournisseur);
// Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées :
// l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la
// réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer.
patchGridRowsCache(codeFournisseur, changes);
void updateGridRowsGamme(codeFournisseur, changes).catch((e) =>
console.error("[saveDraftChanges] mise à jour grid_rows KO:", (e as Error).message?.slice(0, 200)),
);
return { success: true, saved: changes.length };
} catch (err) {
@@ -2,17 +2,19 @@
import { useState } from "react";
import { Download, FileSpreadsheet, FileText, ChevronDown, Table2 } from "lucide-react";
import * as ExcelJS from "exceljs";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import { useGridStore } from "@/features/grid/store/use-grid-store";
// exceljs, jspdf et jspdf-autotable (~1 Mo) ne sont chargés qu'au clic : importés
// en tête de fichier, ils alourdissaient la page Grille pour tous les utilisateurs.
// Les lignes sont lues dans le store au moment de l'export, sans abonnement : le
// menu n'a pas à se redessiner à chaque modification de la Grille.
export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string }) {
const [isOpen, setIsOpen] = useState(false);
const { rows, draftChanges } = useGridStore();
const handleExportAllProducts = async () => {
setIsOpen(false);
const { rows, draftChanges } = useGridStore.getState();
if (rows.length === 0) {
alert("Aucune donnée à exporter.");
return;
@@ -59,7 +61,9 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
const handleExportExcel = async () => {
setIsOpen(false);
const workbook = new ExcelJS.Workbook();
const { rows, draftChanges } = useGridStore.getState();
const { Workbook } = await import("exceljs");
const workbook = new Workbook();
const worksheet = workbook.addWorksheet("Gamme A");
worksheet.columns = [
@@ -100,8 +104,13 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
URL.revokeObjectURL(url);
};
const handlePrintPDF = () => {
const handlePrintPDF = async () => {
setIsOpen(false);
const { rows, draftChanges } = useGridStore.getState();
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
import("jspdf"),
import("jspdf-autotable"),
]);
const doc = new jsPDF({ orientation: "landscape", unit: "mm", format: "a4" });
doc.setFontSize(16);
@@ -36,7 +36,10 @@ export function FloatingSummaryBar() {
const summary = useGridStore((s) => s.summary);
const resetDrafts = useGridStore((s) => s.resetDrafts);
const rows = useGridStore((s) => s.rows);
const filters = useGridStore((s) => s.filters);
const filterFournisseur = useGridStore((s) => s.filters.codeFournisseur);
// Magasin affiché (bascule de la Grille). `filters.magasin` n'est jamais
// renseigné : les sessions et validations étaient toutes notées « TOTAL ».
const activeMagasin = useGridStore((s) => s.activeMagasin);
const draftChanges = useGridStore((s) => s.draftChanges);
const [isPending, startTransition] = useTransition();
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
@@ -54,9 +57,9 @@ export function FloatingSummaryBar() {
});
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
const { save, hasDrafts, count } = useSaveDrafts(filters.magasin || "TOTAL", visibleCodeins);
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL", visibleCodeins);
const supplierCode = filters.codeFournisseur || rows[0]?.codeFournisseur;
const supplierCode = filterFournisseur || rows[0]?.codeFournisseur;
const lastQlikUpdate = useMemo(() => {
let max: string | null = null;
for (const r of rows) {
@@ -99,9 +102,9 @@ export function FloatingSummaryBar() {
);
const res = await saveSnapshot({
codeFournisseur: filters.codeFournisseur || rows[0].codeFournisseur,
codeFournisseur: filterFournisseur || rows[0].codeFournisseur,
nomFournisseur: rows[0].nomFournisseur,
magasin: filters.magasin || "TOTAL",
magasin: activeMagasin || "TOTAL",
label: labelOverride || `${type === 'export' ? 'Export' : 'Session'} ${rows[0].nomFournisseur} — ${new Date().toLocaleTimeString()}`,
changes,
type,
+50 -24
View File
@@ -12,7 +12,7 @@ import type { ProductRow } from "@/types/grid";
import { CheckCircle, AlertCircle, Loader2, RefreshCw } from "lucide-react";
import { useSession } from "next-auth/react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { useSearchParams } from "next/navigation";
import type { GridFilters } from "@/types/grid";
interface GridClientProps {
@@ -51,7 +51,7 @@ function MetricPill({ label, value, accent = false }: { label: string; value: st
);
}
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin, filters }: GridClientProps) {
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
const { data: session } = useSession();
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
const setRows = useGridStore((s) => s.setRows);
@@ -61,8 +61,6 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
@@ -101,7 +99,13 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
}
}, [codeFournisseur, setFilter, setCode3Filter, isMounted]);
const refreshToken = searchParams.get("_refresh");
// Rechargement forcé (bouton « Rafraîchir », fin de synchro Qlik) : demandé via
// le store, pas via l'URL. On retient la dernière demande déjà servie pour ne
// pas re-forcer le recalcul serveur à chaque remontage ou changement de filtre.
const refreshRequest = useGridStore((s) => s.refreshRequest);
const requestRefresh = useGridStore((s) => s.requestRefresh);
const servedRefreshRef = useRef(refreshRequest);
useEffect(() => {
if (!isMounted) return;
@@ -109,14 +113,14 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const params = new URLSearchParams();
params.set("fournisseur", codeFournisseur);
params.set("magasin", magasin || "TOTAL");
// `code3` n'est volontairement pas transmis : le serveur ignore ce filtre
// (getProductRows ne s'en sert pas) et la Grille l'applique en local. Le
// faire voyager relançait un chargement complet à chaque case cochée.
if (filters.code1) params.set("code1", filters.code1);
if (filters.code2) params.set("code2", filters.code2);
if (refreshToken) params.set("refresh", "1");
// Les filtres de nomenclature (code1/code2/code3) ne sont pas transmis : le
// serveur ne s'en sert pas et la Grille les applique en local. Les faire
// voyager relançait un chargement complet à chaque changement.
const forceRefresh = refreshRequest !== servedRefreshRef.current;
if (forceRefresh) params.set("refresh", "1");
let accumulatedRows: ProductRow[] = [];
const accumulatedRows: ProductRow[] = [];
let lastFlush = 0;
setRows([]);
setRowsLoaded(0);
setTotalRows(null);
@@ -124,6 +128,20 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
setIsLoadingRows(true);
setShowStartOverlay(true);
// Chaque mise à jour du store reconstruit tout le tableau (index, tri,
// filtres, compteurs) : la faire à chaque paquet de 150 lignes rendait le
// chargement quadratique. On regroupe donc les paquets et on ne pousse les
// lignes que toutes les FLUSH_MS (et une dernière fois à la fin).
const FLUSH_MS = 300;
// Les lignes déjà poussées dans le store ne doivent plus bouger : on lui
// passe une copie, et on continue d'accumuler dans le tableau privé.
const flush = (loaded: number, total: number | null, final = false) => {
setRows(final ? accumulatedRows : accumulatedRows.slice());
setRowsLoaded(loaded);
setTotalRows(total);
lastFlush = Date.now();
};
async function loadRows() {
try {
const response = await fetch(`/api/grid/rows?${params.toString()}`, {
@@ -137,6 +155,9 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let loaded = 0;
let total: number | null = null;
let pending = false;
while (true) {
const { done, value } = await reader.read();
@@ -150,18 +171,26 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
if (!line.trim()) continue;
const message = JSON.parse(line) as GridRowsStreamMessage;
if (message.type === "chunk") {
accumulatedRows = accumulatedRows.concat(message.rows);
setRows(accumulatedRows);
setRowsLoaded(message.loaded);
setTotalRows(message.total);
for (const row of message.rows) accumulatedRows.push(row);
loaded = message.loaded;
total = message.total;
pending = true;
} else if (message.type === "done") {
setRowsLoaded(message.loaded);
setTotalRows(message.total);
loaded = message.loaded;
total = message.total;
} else if (message.type === "error") {
throw new Error(message.error);
}
}
if (pending && Date.now() - lastFlush >= FLUSH_MS) {
flush(loaded, total);
pending = false;
}
}
flush(loaded, total, true);
if (forceRefresh) servedRefreshRef.current = refreshRequest;
} catch (error) {
if (!controller.signal.aborted) {
setLoadError(error instanceof Error ? error.message : "Erreur de chargement");
@@ -176,7 +205,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
loadRows();
return () => controller.abort();
}, [codeFournisseur, magasin, filters.code1, filters.code2, refreshToken, setRows, isMounted]);
}, [codeFournisseur, magasin, refreshRequest, setRows, isMounted]);
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
useEffect(() => {
@@ -193,13 +222,10 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
};
// Force le rechargement des données depuis le serveur en ignorant le cache
// 10 min : on change le paramètre URL `_refresh`, ce qui relance le fetch
// avec `refresh=1` (→ forceRefresh) et remonte l'état serveur à jour (INIT).
// 10 min (`refresh=1` → forceRefresh) et remonte l'état serveur à jour (INIT).
const handleForceRefresh = () => {
if (isLoadingRows) return;
const params = new URLSearchParams(searchParams.toString());
params.set("_refresh", String(Date.now()));
router.replace(`${pathname}?${params.toString()}`);
requestRefresh();
};
if (!isMounted) {
@@ -104,11 +104,10 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
router.replace(`/grid?${params.toString()}`, { scroll: false });
};
const requestRefresh = useGridStore((s) => s.requestRefresh);
const handleRefresh = () => {
setIsRefreshing(true);
const params = new URLSearchParams(searchParams.toString());
params.set("_refresh", Date.now().toString());
router.replace(`/grid?${params.toString()}`, { scroll: false });
requestRefresh();
// Visual feedback delay
setTimeout(() => setIsRefreshing(false), 800);
};
+48 -35
View File
@@ -46,6 +46,7 @@ import {
NB_MAGASINS_RESEAU,
} from "@/features/grid/lib/network-trend";
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
interface HeatmapGridProps {
onSelectionChange?: (codeins: string[]) => void;
@@ -94,8 +95,7 @@ function prixMoyenReseau(row: ProductRow): number | null {
}
/** Montant en euros au centime — l'usage sur des prix unitaires. */
const fmtEuro2 = (v: number) =>
v.toLocaleString("fr-FR", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 });
const fmtEuro2 = fmtEur2;
/**
* Prix de vente à montrer pour le magasin consulté.
@@ -315,7 +315,9 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey
data-index={virtualRow.index}
onClick={() => row.toggleSelected()}
className={cn(
"absolute w-full flex items-center cursor-pointer transition-all duration-200 group/row",
// Pas de `transition-all` : la ligne est positionnée par `transform`
// et chaque défilement l'aurait animée pendant 200 ms.
"absolute w-full flex items-center cursor-pointer transition-[opacity,filter] duration-200 group/row",
effectiveGamme === "Z" && "opacity-40 grayscale-[0.5] hover:grayscale-0 hover:opacity-100"
)}
style={{
@@ -625,7 +627,10 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
// totaux sont figés sur la fenêtre du serveur, les colonnes doivent l'être
// aussi, sinon un mois manque aux cases mais pas au total (cf. months.ts).
// Le calcul local ne sert que tant qu'aucune ligne n'est chargée.
const MONTHS_12 = useMemo(() => getMonthsFromRows(rows) ?? getLast12Months(), [rows]);
// Mémorisé sur sa clé texte : un nouveau tableau à chaque arrivée de lignes
// reconstruisait toutes les définitions de colonnes (donc tout le tableau).
const monthsKey = useMemo(() => (getMonthsFromRows(rows) ?? getLast12Months()).join(","), [rows]);
const MONTHS_12 = useMemo(() => monthsKey.split(","), [monthsKey]);
useEffect(() => {
setIsMounted(true);
@@ -647,28 +652,6 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
setRowSelection({});
}, [rows]);
const prevSelectionRef = useRef<string[]>([]);
// Propagate selection changes via useEffect to avoid "update during render" error
useEffect(() => {
if (!onSelectionChange) return;
const selectedIdxs = Object.keys(rowSelection).filter((k) => rowSelection[k]);
const selectedCodeins = selectedIdxs
.map((idx) => filteredData[parseInt(idx)]?.codein ?? "")
.filter(Boolean);
// Only update if selection actually changed to avoid re-render loops & console warnings
const currentString = JSON.stringify(selectedCodeins);
const prevString = JSON.stringify(prevSelectionRef.current);
if (currentString !== prevString) {
prevSelectionRef.current = selectedCodeins;
// Delay update to next tick to ensure we're out of any render cycles
setTimeout(() => onSelectionChange(selectedCodeins), 0);
}
}, [rowSelection, onSelectionChange, filteredData]);
const columns = React.useMemo<ColumnDef<ProductRow>[]>(() => [
{
id: "select",
@@ -807,7 +790,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
const val = row.original.caReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold text-emerald-600 dark:text-emerald-400">
{val != null ? val.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }) : "-"}
{val != null ? fmtEur0(val) : "-"}
</div>
);
},
@@ -822,7 +805,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
const val = row.original.qteReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
{val != null ? Math.round(val).toLocaleString("fr-FR") : "-"}
{val != null ? fmtEntier(val) : "-"}
</div>
);
},
@@ -936,7 +919,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
const val = row.original.caParMagasinReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
{val != null ? val.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }) : "-"}
{val != null ? fmtEur0(val) : "-"}
</div>
);
},
@@ -954,7 +937,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
const color = pct >= 30 ? "text-emerald-500" : pct >= 15 ? "text-amber-500" : "text-rose-500";
return (
<div className={cn("text-center font-bold text-[12px] tabular-nums", color)}>
{pct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}%
{fmtDecimal1(pct)}%
</div>
);
},
@@ -1017,7 +1000,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
borderColor: "var(--border-strong)"
}}
>
{Math.round(qty).toLocaleString("fr-FR")}
{fmtEntier(qty)}
</button>
);
},
@@ -1033,7 +1016,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
: (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;€
{fmtEntier(ca)}&nbsp;€
</div>
);
},
@@ -1054,7 +1037,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
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;€
{fmtEntier(marge)}&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)"
@@ -1095,10 +1078,17 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
columnResizeMode: "onChange",
// Identifiant stable : avec l'index par défaut, la sélection désignait
// d'autres produits dès qu'un filtre changeait l'ordre des lignes.
getRowId: (row) => row.codein,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true,
// La recherche ne dépend pas de la colonne (elle lit libellé, code, référence
// et GTIN de la ligne) : un seul passage par ligne suffit, au lieu d'un par
// colonne filtrable (une quinzaine) à chaque frappe.
getColumnCanGlobalFilter: (column) => column.id === "codein",
globalFilterFn: (row, _columnId, filterValue) => {
const search = String(filterValue).toLowerCase();
const libelle = String(row.original.libelle1 || "").toLowerCase();
@@ -1111,6 +1101,31 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
const { rows: tableRows } = table.getRowModel();
const prevSelectionRef = useRef<string[]>([]);
// Propagate selection changes via useEffect to avoid "update during render" error
useEffect(() => {
if (!onSelectionChange) return;
// La sélection est indexée par code article (`getRowId`), et seules les
// lignes encore affichées comptent : une action groupée ne doit jamais
// toucher un produit masqué par un filtre ou par la recherche.
const selectedCodeins = table
.getFilteredSelectedRowModel()
.rows.map((r) => r.original.codein);
// Only update if selection actually changed to avoid re-render loops & console warnings
const currentString = JSON.stringify(selectedCodeins);
const prevString = JSON.stringify(prevSelectionRef.current);
if (currentString !== prevString) {
prevSelectionRef.current = selectedCodeins;
// Delay update to next tick to ensure we're out of any render cycles
setTimeout(() => onSelectionChange(selectedCodeins), 0);
}
}, [table, rowSelection, onSelectionChange, filteredData, filters.search]);
const rowVirtualizer = useVirtualizer({
count: tableRows.length,
getScrollElement: () => tableContainerRef.current,
@@ -1291,8 +1306,6 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
<thead className="sticky top-0 z-10 block" style={{
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
borderBottom: "1px solid var(--border-strong)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)"
}}>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="flex w-full">
@@ -1,7 +1,6 @@
"use client";
import { useCallback } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
@@ -28,21 +27,12 @@ function formatLastUpdate(iso?: string | null): string {
* dans `useQlikSyncJob`, partagée avec la fiche produit.
*/
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
/** Force le refresh de la grille (avec cache-buster) et de la barre de résumé. */
const refreshGrid = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.set("_refresh", String(Date.now()));
router.replace(`${pathname}?${params.toString()}`);
router.refresh();
}, [pathname, router, searchParams]);
/** Recharge la grille en ignorant le cache serveur (la barre de résumé suit). */
const requestRefresh = useGridStore((s) => s.requestRefresh);
const { status, message, start } = useQlikSyncJob({
target: { mode: "fournisseur", fournisseur: codeFournisseur },
onSuccess: refreshGrid,
onSuccess: requestRefresh,
});
return (
+27 -2
View File
@@ -16,6 +16,14 @@ interface GridState {
displayDensity: "compact" | "normal" | "comfortable";
/** The active URL search parameters (e.g. ?fournisseur=123&magasin=TOTAL) */
activeGridQuery: string;
/**
* Compteur de demandes de rechargement forcé (ignore le cache serveur).
* Volontairement hors URL et non persisté : un paramètre `_refresh` dans
* l'URL restait mémorisé dans `activeGridQuery`, et chaque retour sur la
* Grille par le menu relançait un recalcul complet.
*/
refreshRequest: number;
requestRefresh: () => void;
/** Persisted column visibility state */
columnVisibility: Record<string, boolean>;
/** Persisted column sizing state */
@@ -59,6 +67,15 @@ interface GridState {
let summaryTimer: ReturnType<typeof setTimeout> | null = null;
/** Retire l'ancien paramètre `_refresh` (cache-buster) d'une query string mémorisée. */
function sansParamRefresh(query: string): string {
if (!query.includes("_refresh")) return query;
const params = new URLSearchParams(query.startsWith("?") ? query.slice(1) : query);
params.delete("_refresh");
const qs = params.toString();
return qs ? `?${qs}` : "";
}
function indexRows(rows: ProductRow[]): Record<string, ProductRow> {
const indexed: Record<string, ProductRow> = {};
for (const row of rows) indexed[row.codein] = row;
@@ -117,6 +134,7 @@ export const useGridStore = create<GridState>()(
},
displayDensity: "normal",
activeGridQuery: "",
refreshRequest: 0,
columnVisibility: {},
columnSizing: {},
showMonthlySales: true,
@@ -202,7 +220,8 @@ export const useGridStore = create<GridState>()(
set((state) => ({ ...state, filters: { ...state.filters, code3: codes } }));
},
setDisplayDensity: (density) => set({ displayDensity: density }),
setActiveGridQuery: (query) => set({ activeGridQuery: query }),
setActiveGridQuery: (query) => set({ activeGridQuery: sansParamRefresh(query) }),
requestRefresh: () => set((state) => ({ refreshRequest: state.refreshRequest + 1 })),
restoreSnapshot: (changes) => {
set({ draftChanges: changes, summary: computeSummary(get().rows, changes, get().activeMagasin) });
},
@@ -249,8 +268,11 @@ export const useGridStore = create<GridState>()(
* dans le navigateur restait une chaîne : `new Set("320211")` produit un
* ensemble de caractères, plus aucune ligne ne correspond, et la Grille
* apparaît vide. Le numéro de version force la reprise de l'existant.
*
* v2 — `activeGridQuery` pouvait contenir `_refresh=…` : le lien « Grille »
* du menu forçait alors un recalcul serveur complet à chaque retour.
*/
version: 1,
version: 2,
migrate: (persisted: unknown, version: number): GridPersisted => {
const etat = (persisted ?? {}) as GridPersisted & { filters?: Record<string, unknown> };
if (version < 1 && etat.filters) {
@@ -262,6 +284,9 @@ export const useGridStore = create<GridState>()(
// à la fusion (le bloc disparaîtrait sans raison).
if (typeof etat.showMonthlySales !== "boolean") etat.showMonthlySales = true;
if (typeof etat.showPrices !== "boolean") etat.showPrices = true;
if (typeof etat.activeGridQuery === "string") {
etat.activeGridQuery = sansParamRefresh(etat.activeGridQuery);
}
return etat;
},
// Only persist filters, display density, drafts, active grid query, and column visibility/sizing
+3 -1
View File
@@ -27,6 +27,8 @@ export async function getSnapshots(type?: "snapshot" | "export") {
} catch (err: any) {
const msg = (err?.message || String(err)).split("\n")[0];
console.error(`[getSnapshots] ERROR (type=${type}):`, msg, err);
return [];
// Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot »
// au lieu de signaler que le chargement a échoué.
throw new Error("Chargement de l'historique impossible.");
}
}
@@ -13,6 +13,7 @@ import {
History,
ChevronRight,
Loader2,
AlertCircle,
} from "lucide-react";
import { SuccessModal } from "@/components/shared/success-modal";
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
@@ -78,6 +79,7 @@ export function SnapshotList({ type }: SnapshotListProps) {
const fetchSnapshots = async () => {
setLoading(true);
setError(null);
try {
const data = await getSnapshots(type);
setSnapshots(data);
@@ -146,6 +148,19 @@ export function SnapshotList({ type }: SnapshotListProps) {
);
}
if (error && snapshots.length === 0) {
return (
<div className="border border-[var(--accent-error)] rounded-2xl p-10 text-center bg-[var(--accent-error-bg)]">
<AlertCircle className="w-8 h-8 mx-auto mb-3 text-[var(--accent-error)]" />
<h3 className="text-[var(--text-primary)] font-bold mb-1">L&apos;historique n&apos;a pas pu être chargé</h3>
<p className="text-[var(--text-secondary)] text-sm mb-4">{error}</p>
<button onClick={fetchSnapshots} className="btn-action btn-action-secondary">
Réessayer
</button>
</div>
);
}
if (snapshots.length === 0) {
return (
<div className="border border-dashed border-[var(--border-strong)] rounded-2xl p-16 text-center bg-[var(--bg-elevated)]/30">