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

+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">