mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 01:36:43 +02:00
Lot 4 : Grille servie depuis l'instantané et requêtes allégées
- Grille : lecture de l'instantané grid_rows (gamme INIT et dernières gammes enregistrées reportées), recalcul en arrière-plan au-delà de 20 h, version de format dans chaque ligne pour écarter les anciens instantanés. - Navigateur : lignes réutilisées au retour sur la Grille (< 10 min), Grille conservée pendant un changement de magasin ou une actualisation, React.memo sur la Grille, les filtres et la barre du bas, en-tête mémorisé. - Fiche produit : toutes les lectures en parallèle. - Ventes par mois et stock sans vente : plages de dates au lieu de TO_CHAR. - Accueil : vagues de 12 appels, cache de 30 min. - API v1 : comptage et page en parallèle, last_used_at au plus une fois par minute, version interne non exposée. - Plan : suivi du lot 4, avec ce qui reste à valider sur la base réelle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
13 files changed
+393
-119
No files matched your search
@@ -18,7 +18,8 @@ export const metadata: Metadata = { title: "Accueil" };
|
||||
const getCachedDashboardData = unstable_cache(
|
||||
pgGetDashboardData,
|
||||
["dashboard-data"],
|
||||
{ revalidate: 600 } // 10 minutes
|
||||
// 30 minutes : ce sont les ventes de la veille, elles ne changent pas dans la journée.
|
||||
{ revalidate: 1800 }
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -18,7 +18,7 @@ import { eq, desc } from "drizzle-orm";
|
||||
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";
|
||||
import { readGridSnapshot, upsertGridRows } from "@/lib/grid-store";
|
||||
|
||||
interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
@@ -156,6 +156,14 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
||||
return pending;
|
||||
}
|
||||
|
||||
// Instantané persisté (calcul de la nuit ou de la dernière ouverture) : servi
|
||||
// en une lecture au lieu d'un recalcul de 10 à 40 s. Jamais en rechargement
|
||||
// forcé — c'est précisément ce que l'utilisateur demande d'éviter.
|
||||
if (!input.forceRefresh) {
|
||||
const servi = await serveFromSnapshot(input, cacheKey);
|
||||
if (servi) return servi;
|
||||
}
|
||||
|
||||
const versionAuDepart = gammeSaveVersion.get(input.codeFournisseur) ?? 0;
|
||||
const promise = buildProductRows(input).then(async (rows) => {
|
||||
if ((gammeSaveVersion.get(input.codeFournisseur) ?? 0) !== versionAuDepart) {
|
||||
@@ -178,6 +186,65 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Au-delà, l'instantané est servi mais un recalcul est relancé en arrière-plan. */
|
||||
const SNAPSHOT_REFRESH_AFTER_MS = 20 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Sert la Grille depuis `grid_rows` quand c'est possible : mêmes lignes que le
|
||||
* calcul en direct (elles en sont issues), avec la gamme FF du moment (INIT) et
|
||||
* les dernières gammes enregistrées reportées dessus. Hors « tous magasins »,
|
||||
* le rattrapage du magasin choisi est appliqué comme après un calcul.
|
||||
*
|
||||
* `null` = pas d'instantané utilisable (absent, ancien format, autre mois) :
|
||||
* l'appelant recalcule.
|
||||
*/
|
||||
async function serveFromSnapshot(input: GetProductRowsInput, cacheKey: string): Promise<ProductRow[] | null> {
|
||||
const { codeFournisseur, magasin = "TOTAL" } = input;
|
||||
let snapshot: Awaited<ReturnType<typeof readGridSnapshot>>;
|
||||
try {
|
||||
snapshot = await readGridSnapshot(codeFournisseur);
|
||||
} catch (e) {
|
||||
console.error("[getProductRows] lecture grid_rows KO:", (e as Error).message?.slice(0, 200));
|
||||
return null;
|
||||
}
|
||||
if (!snapshot || snapshot.rows.length === 0) return null;
|
||||
|
||||
const { rows } = snapshot;
|
||||
const [changes] = await Promise.all([
|
||||
loadLatestSnapshotChanges(codeFournisseur),
|
||||
refreshGammeInit(rows, codeFournisseur),
|
||||
]);
|
||||
applySnapshotChanges(rows, changes);
|
||||
|
||||
if (magasin !== "TOTAL") {
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, last12Periods());
|
||||
}
|
||||
|
||||
cacheGridRows(cacheKey, rows);
|
||||
|
||||
// Instantané qui n'a pas été recalculé par la nuit : on le rafraîchit sans
|
||||
// faire attendre l'utilisateur (la prochaine ouverture en profitera).
|
||||
// Toujours le calcul « tous magasins » : c'est lui qui réécrit l'instantané.
|
||||
if (Date.now() - snapshot.computedAt.getTime() > SNAPSHOT_REFRESH_AFTER_MS && !gridRowsPending.has(`${codeFournisseur}:TOTAL`)) {
|
||||
void getProductRows({ codeFournisseur, magasin: "TOTAL", forceRefresh: true }).catch((e) =>
|
||||
console.error("[getProductRows] rafraîchissement en arrière-plan KO:", (e as Error).message?.slice(0, 200)),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Les 12 derniers mois complets, clés « YYYYMM » triées (même fenêtre que le calcul). */
|
||||
function last12Periods(): string[] {
|
||||
const now = new Date();
|
||||
const periods: string[] = [];
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
periods.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return periods;
|
||||
}
|
||||
|
||||
async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
|
||||
const { codeFournisseur, magasin = "TOTAL" } = input;
|
||||
console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`);
|
||||
@@ -217,13 +284,8 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`);
|
||||
|
||||
// ─── Phase 2 : Fenêtre temporelle 12 mois complets ───────────────────
|
||||
const now = new Date();
|
||||
const allowedPeriods = new Set<string>();
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
allowedPeriods.add(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
const sortedPeriods = [...allowedPeriods].sort();
|
||||
const sortedPeriods = last12Periods();
|
||||
const allowedPeriods = new Set(sortedPeriods);
|
||||
|
||||
// ─── Phase 3 : Seed productMap depuis articles ────────────────────────
|
||||
const productMap = new Map<string, ProductRow>();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { SyncQlikButton } from "./sync-qlik-button";
|
||||
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
||||
@@ -49,7 +51,7 @@ function messageErreur(err: unknown): string {
|
||||
* sur les gammes (annuler, enregistrer, exporter). Il y avait des doublons dans
|
||||
* l'en-tête, avec des droits différents.
|
||||
*/
|
||||
export function FloatingSummaryBar({ isAdmin, nomFournisseur }: { isAdmin: boolean; nomFournisseur: string }) {
|
||||
function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean; nomFournisseur: string }) {
|
||||
const summary = useGridStore((s) => s.summary);
|
||||
const resetDrafts = useGridStore((s) => s.resetDrafts);
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
@@ -272,3 +274,9 @@ export function FloatingSummaryBar({ isAdmin, nomFournisseur }: { isAdmin: boole
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mémoïsé : ses props sont stables, il n'a donc pas à se redessiner quand le
|
||||
* parent change d'état (sélection, progression du chargement…).
|
||||
*/
|
||||
export const FloatingSummaryBar = React.memo(FloatingSummaryBarInner);
|
||||
@@ -114,9 +114,33 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const forceRefresh = refreshRequest !== servedRefreshRef.current;
|
||||
if (forceRefresh) params.set("refresh", "1");
|
||||
|
||||
const cle = `${codeFournisseur}:${magasin || "TOTAL"}`;
|
||||
const { rowsMeta, rows: lignesEnMemoire, setRowsMeta } = useGridStore.getState();
|
||||
|
||||
// Retour sur la Grille (même fournisseur, même magasin, chargée il y a
|
||||
// moins de 10 min) : les lignes sont encore en mémoire, rien à retélécharger.
|
||||
if (!forceRefresh && rowsMeta?.key === cle && lignesEnMemoire.length > 0
|
||||
&& Date.now() - rowsMeta.loadedAt < 10 * 60 * 1000) {
|
||||
setRowsLoaded(lignesEnMemoire.length);
|
||||
setTotalRows(lignesEnMemoire.length);
|
||||
setIsLoadingRows(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Changement de magasin (ou rechargement) sur le même fournisseur : la
|
||||
// Grille reste affichée — les lignes portent déjà le détail par magasin,
|
||||
// la bascule est immédiate — et les chiffres complétés pour ce magasin
|
||||
// remplacent l'affichage d'un bloc, une fois reçus.
|
||||
const memeFournisseur = rowsMeta?.key.startsWith(`${codeFournisseur}:`) === true && lignesEnMemoire.length > 0;
|
||||
|
||||
const accumulatedRows: ProductRow[] = [];
|
||||
let lastFlush = 0;
|
||||
setRows([]);
|
||||
if (!memeFournisseur) {
|
||||
setRows([]);
|
||||
// Les lignes vont être remplacées : tant que ce chargement n'est pas
|
||||
// terminé, elles ne correspondent plus à aucune clé.
|
||||
setRowsMeta(null);
|
||||
}
|
||||
setRowsLoaded(0);
|
||||
setTotalRows(null);
|
||||
setLoadError(null);
|
||||
@@ -130,10 +154,12 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
// 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();
|
||||
// Même fournisseur : on garde les lignes affichées jusqu'au bout.
|
||||
if (memeFournisseur && !final) return;
|
||||
setRows(final ? accumulatedRows : accumulatedRows.slice());
|
||||
};
|
||||
|
||||
async function loadRows() {
|
||||
@@ -184,6 +210,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
}
|
||||
|
||||
flush(loaded, total, true);
|
||||
setRowsMeta({ key: cle, loadedAt: Date.now() });
|
||||
if (forceRefresh) servedRefreshRef.current = refreshRequest;
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -265,7 +292,9 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
<div className="flex items-center justify-between gap-3 text-[13px] text-[var(--text-primary)]">
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[var(--accent)]" />
|
||||
Chargement des produits de {nomFournisseur}…
|
||||
{nbRows > 0
|
||||
? "Mise à jour des chiffres… la grille reste utilisable."
|
||||
: `Chargement des produits de ${nomFournisseur}…`}
|
||||
</span>
|
||||
<span className="tabular-nums text-[var(--text-secondary)]">
|
||||
{totalRows
|
||||
|
||||
@@ -18,7 +18,7 @@ interface GridFilterBarProps {
|
||||
|
||||
const EMPTY_DRAFT_CHANGES: Record<string, string> = {};
|
||||
|
||||
export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
function GridFilterBarInner({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
const filters = useGridStore((s) => s.filters);
|
||||
const setFilter = useGridStore((s) => s.setFilter);
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
@@ -204,3 +204,9 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mémoïsé : ses props sont stables, il n'a donc pas à se redessiner quand le
|
||||
* parent change d'état (sélection, progression du chargement…).
|
||||
*/
|
||||
export const GridFilterBar = React.memo(GridFilterBarInner);
|
||||
@@ -242,6 +242,100 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo
|
||||
});
|
||||
GammeCell.displayName = "GammeCell";
|
||||
|
||||
/**
|
||||
* En-tête du tableau, mémoïsé : sans cela, chaque événement de défilement
|
||||
* redessinait la trentaine d'en-têtes (tri, redimensionnement…). Il ne change
|
||||
* qu'avec les colonnes, le tri ou les largeurs.
|
||||
*/
|
||||
const GridHeader = React.memo(function GridHeader({ table }: {
|
||||
table: Table<ProductRow>;
|
||||
// Les props suivantes ne sont pas lues directement : elles servent à la
|
||||
// comparaison de React.memo (l'en-tête lit ces états via `table`).
|
||||
columnsKey: string;
|
||||
sorting: SortingState;
|
||||
columnSizing: Record<string, number>;
|
||||
/** Case « tout sélectionner ». */
|
||||
rowSelection: RowSelectionState;
|
||||
data: ProductRow[];
|
||||
}) {
|
||||
return (
|
||||
<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)",
|
||||
}}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="flex w-full">
|
||||
{(() => {
|
||||
const figesEntete = decalagesFiges(
|
||||
headerGroup.headers.map((h) => ({ id: h.column.id, taille: h.getSize() })),
|
||||
);
|
||||
const dernierFigeEntete = [...figesEntete.keys()].at(-1);
|
||||
return headerGroup.headers.map((header) => {
|
||||
const gaucheEntete = figesEntete.get(header.column.id);
|
||||
const enteteFigee = gaucheEntete !== undefined;
|
||||
const isFlexible = header.column.id === "libelle1" || header.column.id === "libelle3";
|
||||
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(header.column.id) || header.column.id === "prixVente" || header.column.id === "gamme";
|
||||
const size = header.getSize();
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
className={cn(
|
||||
"px-2 py-2 text-xs font-semibold leading-tight whitespace-nowrap select-none flex items-center transition-colors relative group/header",
|
||||
!enteteFigee && "hover:bg-white/5",
|
||||
)}
|
||||
style={{
|
||||
width: isFlexible ? "100%" : size,
|
||||
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
|
||||
minWidth: size,
|
||||
maxWidth: isFlexible ? "none" : size,
|
||||
color: "var(--text-secondary)",
|
||||
justifyContent: isCenter ? "center" : "flex-start",
|
||||
...(enteteFigee ? {
|
||||
position: "sticky" as const,
|
||||
left: gaucheEntete,
|
||||
zIndex: 2,
|
||||
// Le fond du <thead> est un dégradé translucide : sans
|
||||
// fond propre, les autres intitulés défileraient à
|
||||
// travers l'en-tête figé. On reprend le même dégradé
|
||||
// pour que la bande reste d'un seul tenant.
|
||||
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
|
||||
borderRight: header.column.id === dernierFigeEntete
|
||||
? "1px solid var(--border-strong)"
|
||||
: undefined,
|
||||
} : {}),
|
||||
}}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className={`flex items-center gap-1.5 cursor-pointer ${isCenter ? "justify-center w-full" : ""}`}>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.column.getCanSort() && (
|
||||
<div className="shrink-0 opacity-40">
|
||||
{header.column.getIsSorted() === "asc" ? <ChevronUp className="w-3 h-3 text-emerald-500" />
|
||||
: header.column.getIsSorted() === "desc" ? <ChevronDown className="w-3 h-3 text-emerald-500" />
|
||||
: <ChevronsUpDown className="w-3 h-3" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Handle de redimensionnement de la colonne */}
|
||||
{header.column.getCanResize() && (
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`absolute right-0 top-0 h-full w-1.5 cursor-col-resize user-select-none touch-none hover:bg-emerald-500/50 ${header.column.getIsResizing() ? "bg-emerald-500" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Libellés du menu « Colonnes ». Les en-têtes de colonnes sont des fonctions
|
||||
* (deux lignes, infobulles) : le menu affichait leur identifiant technique
|
||||
@@ -274,7 +368,7 @@ function libelleColonne(id: string, header: unknown): string {
|
||||
return typeof header === "string" ? header : id;
|
||||
}
|
||||
|
||||
import { Cell, Column, Row } from "@tanstack/react-table";
|
||||
import { Cell, Column, Row, type Table } from "@tanstack/react-table";
|
||||
import { VirtualItem } from "@tanstack/react-virtual";
|
||||
|
||||
// 2. Composant isolé pour la Ligne Virtuelle
|
||||
@@ -605,7 +699,7 @@ function VentilationParMagasin({ d }: { d: CellDetailData }) {
|
||||
|
||||
// =========================================================================
|
||||
|
||||
export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
|
||||
function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
|
||||
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const filters = useGridStore((s) => s.filters);
|
||||
@@ -1159,6 +1253,9 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
// Pas de rendu synchrone à chaque événement de défilement (flushSync) :
|
||||
// React regroupe les mises à jour, le défilement reste fluide.
|
||||
useFlushSync: false,
|
||||
count: tableRows.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: () => rowHeight,
|
||||
@@ -1328,80 +1425,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
}}
|
||||
>
|
||||
<table className="text-sm block" style={{ width: "100%", minWidth: totalWidth }}>
|
||||
<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)",
|
||||
}}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="flex w-full">
|
||||
{(() => {
|
||||
const figesEntete = decalagesFiges(
|
||||
headerGroup.headers.map((h) => ({ id: h.column.id, taille: h.getSize() })),
|
||||
);
|
||||
const dernierFigeEntete = [...figesEntete.keys()].at(-1);
|
||||
return headerGroup.headers.map((header) => {
|
||||
const gaucheEntete = figesEntete.get(header.column.id);
|
||||
const enteteFigee = gaucheEntete !== undefined;
|
||||
const isFlexible = header.column.id === "libelle1" || header.column.id === "libelle3";
|
||||
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(header.column.id) || header.column.id === "prixVente" || header.column.id === "gamme";
|
||||
const size = header.getSize();
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
className={cn(
|
||||
"px-2 py-2 text-xs font-semibold leading-tight whitespace-nowrap select-none flex items-center transition-colors relative group/header",
|
||||
!enteteFigee && "hover:bg-white/5",
|
||||
)}
|
||||
style={{
|
||||
width: isFlexible ? "100%" : size,
|
||||
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
|
||||
minWidth: size,
|
||||
maxWidth: isFlexible ? "none" : size,
|
||||
color: "var(--text-secondary)",
|
||||
justifyContent: isCenter ? "center" : "flex-start",
|
||||
...(enteteFigee ? {
|
||||
position: "sticky" as const,
|
||||
left: gaucheEntete,
|
||||
zIndex: 2,
|
||||
// Le fond du <thead> est un dégradé translucide : sans
|
||||
// fond propre, les autres intitulés défileraient à
|
||||
// travers l'en-tête figé. On reprend le même dégradé
|
||||
// pour que la bande reste d'un seul tenant.
|
||||
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
|
||||
borderRight: header.column.id === dernierFigeEntete
|
||||
? "1px solid var(--border-strong)"
|
||||
: undefined,
|
||||
} : {}),
|
||||
}}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className={`flex items-center gap-1.5 cursor-pointer ${isCenter ? "justify-center w-full" : ""}`}>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.column.getCanSort() && (
|
||||
<div className="shrink-0 opacity-40">
|
||||
{header.column.getIsSorted() === "asc" ? <ChevronUp className="w-3 h-3 text-emerald-500" />
|
||||
: header.column.getIsSorted() === "desc" ? <ChevronDown className="w-3 h-3 text-emerald-500" />
|
||||
: <ChevronsUpDown className="w-3 h-3" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Handle de redimensionnement de la colonne */}
|
||||
{header.column.getCanResize() && (
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`absolute right-0 top-0 h-full w-1.5 cursor-col-resize user-select-none touch-none hover:bg-emerald-500/50 ${header.column.getIsResizing() ? "bg-emerald-500" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<GridHeader table={table} columnsKey={columnsKey} sorting={sorting} columnSizing={columnSizing} rowSelection={rowSelection} data={filteredData} />
|
||||
{/*
|
||||
* La clé porte AUSSI la signature des colonnes : changer de jeu de
|
||||
* colonnes remonte le corps du tableau au lieu de compter sur la
|
||||
@@ -1470,3 +1494,9 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mémoïsé : ses props sont stables, il n'a donc pas à se redessiner quand le
|
||||
* parent change d'état (sélection, progression du chargement…).
|
||||
*/
|
||||
export const HeatmapGrid = React.memo(HeatmapGridInner);
|
||||
@@ -24,6 +24,13 @@ interface GridState {
|
||||
*/
|
||||
refreshRequest: number;
|
||||
requestRefresh: () => void;
|
||||
/**
|
||||
* Ce que contiennent `rows` (fournisseur:magasin) et quand elles ont été
|
||||
* chargées. Non persisté : sert à ne pas retélécharger la Grille quand on y
|
||||
* revient, et à changer de magasin sans vider l'écran.
|
||||
*/
|
||||
rowsMeta: { key: string; loadedAt: number } | null;
|
||||
setRowsMeta: (meta: { key: string; loadedAt: number } | null) => void;
|
||||
/** Persisted column visibility state */
|
||||
columnVisibility: Record<string, boolean>;
|
||||
/** Persisted column sizing state */
|
||||
@@ -135,6 +142,7 @@ export const useGridStore = create<GridState>()(
|
||||
displayDensity: "normal",
|
||||
activeGridQuery: "",
|
||||
refreshRequest: 0,
|
||||
rowsMeta: null,
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
showMonthlySales: true,
|
||||
@@ -222,6 +230,7 @@ export const useGridStore = create<GridState>()(
|
||||
setDisplayDensity: (density) => set({ displayDensity: density }),
|
||||
setActiveGridQuery: (query) => set({ activeGridQuery: sansParamRefresh(query) }),
|
||||
requestRefresh: () => set((state) => ({ refreshRequest: state.refreshRequest + 1 })),
|
||||
setRowsMeta: (meta) => set({ rowsMeta: meta }),
|
||||
restoreSnapshot: (changes) => {
|
||||
set({ draftChanges: changes, summary: computeSummary(get().rows, changes, get().activeMagasin) });
|
||||
},
|
||||
|
||||
@@ -95,18 +95,27 @@ export async function getProduitFiche(codein: string): Promise<ProduitFiche | nu
|
||||
const cleaned = codein.trim();
|
||||
if (!cleaned) return null;
|
||||
|
||||
const detail = await pgGetProduitDetail(cleaned);
|
||||
if (!detail) return null;
|
||||
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
|
||||
const [fournisseurs, stock, commandesEnCours, gammes, mensuelRows] = await Promise.all([
|
||||
// Tout part en même temps : l'identité du produit était lue seule d'abord,
|
||||
// puis les cinq autres lectures, puis les métriques réseau — trois attentes
|
||||
// successives. Les métriques réseau n'attendent que l'identité (code centrale).
|
||||
const detailPromise = pgGetProduitDetail(cleaned);
|
||||
const reseauPromise = detailPromise.then(async (d) => {
|
||||
if (!d?.code_centrale) return null;
|
||||
const map = await getNetworkMetricsByCodeCentrale([d.code_centrale]);
|
||||
return map.get(d.code_centrale) ?? null;
|
||||
});
|
||||
const [detail, fournisseurs, stock, commandesEnCours, gammes, mensuelRows, reseau] = await Promise.all([
|
||||
detailPromise,
|
||||
pgGetFournisseursByCodein(cleaned),
|
||||
pgGetStockByCodein(cleaned),
|
||||
pgGetCommandesByCodein(cleaned),
|
||||
pgGetGammeHistoryByCodein(cleaned),
|
||||
pgGetMensuelByCodein(cleaned, dateDebut, dateFin),
|
||||
reseauPromise,
|
||||
]);
|
||||
if (!detail) return null;
|
||||
|
||||
const months = buildMonthKeys();
|
||||
const allowed = new Set(months);
|
||||
@@ -179,12 +188,6 @@ export async function getProduitFiche(codein: string): Promise<ProduitFiche | nu
|
||||
.map(([site]) => site)
|
||||
.sort();
|
||||
|
||||
// Métriques réseau Qlik (jointure par code centrale).
|
||||
let reseau = null;
|
||||
if (detail.code_centrale) {
|
||||
const map = await getNetworkMetricsByCodeCentrale([detail.code_centrale]);
|
||||
reseau = map.get(detail.code_centrale) ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
detail,
|
||||
|
||||
+12
-3
@@ -31,6 +31,10 @@ export interface AuthContext {
|
||||
subject: string;
|
||||
}
|
||||
|
||||
/** Intervalle minimal entre deux mises à jour de `last_used_at` pour une même clé. */
|
||||
const TRACE_USAGE_MS = 60_000;
|
||||
const derniereTrace = new Map<number, number>();
|
||||
|
||||
/** Préfixe des clés CollectFlow, pour les reconnaître d'un coup d'œil. */
|
||||
const KEY_PREFIX = "cf_";
|
||||
|
||||
@@ -93,9 +97,14 @@ export async function requireApiAuth(req: NextRequest): Promise<AuthContext | Re
|
||||
return fail("unauthorized", "Clé d'API révoquée.");
|
||||
}
|
||||
|
||||
// Trace d'usage — jamais bloquante pour la requête en cours.
|
||||
void db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, row.id))
|
||||
.catch((e) => console.error("[api-auth] lastUsedAt KO:", (e as Error).message?.slice(0, 120)));
|
||||
// Trace d'usage — jamais bloquante, et au plus une écriture par minute et
|
||||
// par clé : un script qui pagine ne déclenche plus un UPDATE par requête.
|
||||
const maintenant = Date.now();
|
||||
if (maintenant - (derniereTrace.get(row.id) ?? 0) >= TRACE_USAGE_MS) {
|
||||
derniereTrace.set(row.id, maintenant);
|
||||
void db.update(apiKeys).set({ lastUsedAt: new Date(maintenant) }).where(eq(apiKeys.id, row.id))
|
||||
.catch((e) => console.error("[api-auth] lastUsedAt KO:", (e as Error).message?.slice(0, 120)));
|
||||
}
|
||||
|
||||
return { via: "api_key", role: row.role, subject: row.name };
|
||||
}
|
||||
|
||||
+55
-11
@@ -61,6 +61,13 @@ export interface GridQueryResult {
|
||||
computedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version du format des lignes persistées. À incrémenter dès qu'un champ de
|
||||
* `ProductRow` est ajouté ou change de sens : les instantanés plus anciens ne
|
||||
* seront plus servis à la Grille (recalcul), mais restent lisibles par /api/v1.
|
||||
*/
|
||||
export const GRID_PAYLOAD_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Persiste les lignes calculées pour un fournisseur.
|
||||
*
|
||||
@@ -97,7 +104,7 @@ export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[]
|
||||
nbMagasinsReseau: r.nbMagasinsReseau ?? null,
|
||||
caParMagasinReseau: r.caParMagasinReseau != null ? String(r.caParMagasinReseau) : null,
|
||||
margePctReseau: r.margePctReseau != null ? String(r.margePctReseau) : null,
|
||||
payload: r,
|
||||
payload: { ...r, payloadVersion: GRID_PAYLOAD_VERSION },
|
||||
computedAt,
|
||||
}));
|
||||
if (values.length === 0) return 0;
|
||||
@@ -147,6 +154,39 @@ export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[]
|
||||
return written;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantané complet d'un fournisseur, tel que persisté par le dernier calcul.
|
||||
*
|
||||
* Renvoie `null` (→ recalcul) si rien n'est persisté, si une ligne est d'une
|
||||
* version de format antérieure, ou si le calcul date d'un autre mois que le mois
|
||||
* en cours : la fenêtre des 12 mois glissants aurait changé entre-temps.
|
||||
* Les lignes sont rendues dans l'ordre du calcul en direct (identifiant article).
|
||||
*/
|
||||
export async function readGridSnapshot(
|
||||
codeFournisseur: string,
|
||||
): Promise<{ rows: ProductRow[]; computedAt: Date } | null> {
|
||||
const found = await db
|
||||
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
|
||||
.from(gridRows)
|
||||
.where(eq(gridRows.codeFournisseur, codeFournisseur));
|
||||
if (found.length === 0) return null;
|
||||
|
||||
let oldest: Date | null = null;
|
||||
const rows: ProductRow[] = [];
|
||||
for (const r of found) {
|
||||
const row = r.payload as ProductRow;
|
||||
if (row.payloadVersion !== GRID_PAYLOAD_VERSION) return null;
|
||||
if (!oldest || r.computedAt < oldest) oldest = r.computedAt;
|
||||
rows.push(row);
|
||||
}
|
||||
const now = new Date();
|
||||
if (!oldest || oldest.getFullYear() !== now.getFullYear() || oldest.getMonth() !== now.getMonth()) {
|
||||
return null;
|
||||
}
|
||||
rows.sort((a, b) => (a.noid ?? 0) - (b.noid ?? 0));
|
||||
return { rows, computedAt: oldest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reporte des gammes enregistrées dans l'instantané persisté (colonne et payload),
|
||||
* pour que /api/v1 les voie sans attendre un recalcul complet de la grille.
|
||||
@@ -221,20 +261,17 @@ export async function queryGridRows(q: GridQuery): Promise<GridQueryResult> {
|
||||
// NULLS LAST dans les deux sens : un produit sans CA ne doit jamais occuper la tête.
|
||||
const orderBy = q.order === "asc" ? asc(sortCol) : desc(sortCol);
|
||||
|
||||
const [countRow] = await db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(gridRows)
|
||||
.where(where);
|
||||
|
||||
const selection = db
|
||||
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
|
||||
.from(gridRows)
|
||||
.where(where)
|
||||
.orderBy(orderBy, asc(gridRows.codein));
|
||||
|
||||
const found = limit != null
|
||||
? await selection.limit(limit).offset((page - 1) * limit)
|
||||
: await selection;
|
||||
// Comptage et page en parallèle : deux connexions du pool, un seul temps d'attente.
|
||||
const [[countRow], found] = await Promise.all([
|
||||
db.select({ total: sql<number>`count(*)::int` }).from(gridRows).where(where),
|
||||
limit != null ? selection.limit(limit).offset((page - 1) * limit) : selection,
|
||||
]);
|
||||
|
||||
let oldest: Date | null = null;
|
||||
for (const r of found) {
|
||||
@@ -242,12 +279,19 @@ export async function queryGridRows(q: GridQuery): Promise<GridQueryResult> {
|
||||
}
|
||||
|
||||
return {
|
||||
rows: found.map((r) => r.payload as ProductRow),
|
||||
rows: found.map((r) => versLigneApi(r.payload)),
|
||||
total: countRow?.total ?? 0,
|
||||
computedAt: oldest ? oldest.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ligne exposée par l'API : la version de format est un détail interne. */
|
||||
function versLigneApi(payload: unknown): ProductRow {
|
||||
const ligne = { ...(payload as ProductRow) };
|
||||
delete ligne.payloadVersion;
|
||||
return ligne;
|
||||
}
|
||||
|
||||
/** Fiche complète d'un produit. `codeFournisseur` lève l'ambiguïté d'un article multi-fournisseurs. */
|
||||
export async function getGridRowByCodein(
|
||||
codein: string,
|
||||
@@ -265,7 +309,7 @@ export async function getGridRowByCodein(
|
||||
|
||||
if (!found) return null;
|
||||
return {
|
||||
row: found.payload as ProductRow,
|
||||
row: versLigneApi(found.payload),
|
||||
computedAt: found.computedAt ? found.computedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
+29
-5
@@ -674,8 +674,11 @@ export async function pgGetDashboardData(): Promise<DashboardData> {
|
||||
|
||||
if (allCodeins.length > 0) {
|
||||
try {
|
||||
for (let i = 0; i < allCodeins.length; i += 5) {
|
||||
const batch = allCodeins.slice(i, i + 5);
|
||||
// Vagues de 12 articles au lieu de 5 : chaque article coûte deux allers-
|
||||
// retours successifs, et la page d'accueil attendait une douzaine de vagues.
|
||||
const VAGUE = 12;
|
||||
for (let i = 0; i < allCodeins.length; i += VAGUE) {
|
||||
const batch = allCodeins.slice(i, i + VAGUE);
|
||||
await Promise.all(batch.map(async (codein) => {
|
||||
try {
|
||||
const artRes = await fetch(`${FF_API_BASE}/api/articles?codein=${encodeURIComponent(codein)}`, ffFetchInit());
|
||||
@@ -816,6 +819,27 @@ export interface CaByNomenclatureRow {
|
||||
* via une sous-requête artfou1 dédupliquée (DISTINCT ON) : un seul code par
|
||||
* article, aucune duplication possible des mouvements dans la somme.
|
||||
*/
|
||||
/**
|
||||
* Bornes d'un mois « YYYY-MM » : [premier jour, premier jour du mois suivant[.
|
||||
*
|
||||
* Filtrer `datmvt` sur ces bornes plutôt que sur `TO_CHAR(datmvt, 'YYYY-MM')`
|
||||
* donne exactement les mêmes lignes, mais sans formater en texte la date de
|
||||
* chaque mouvement de l'historique (et reste utilisable par un index sur datmvt).
|
||||
*/
|
||||
function bornesMois(mois: string): { debut: string; fin: string } {
|
||||
const [annee, m] = mois.split("-").map(Number);
|
||||
const suivant = m === 12 ? `${annee + 1}-01` : `${annee}-${String(m + 1).padStart(2, "0")}`;
|
||||
return { debut: `${mois}-01`, fin: `${suivant}-01` };
|
||||
}
|
||||
|
||||
/** Mouvements du mois OU du mois N-1 (cf. `bornesMois`). */
|
||||
function filtreDeuxMois(mois: string, moisN1: string): SQL {
|
||||
const a = bornesMois(mois);
|
||||
const b = bornesMois(moisN1);
|
||||
return sql`((m.datmvt >= ${a.debut}::date AND m.datmvt < ${a.fin}::date)
|
||||
OR (m.datmvt >= ${b.debut}::date AND m.datmvt < ${b.fin}::date))`;
|
||||
}
|
||||
|
||||
export async function pgGetCaByFournisseur(
|
||||
mois: string,
|
||||
moisN1: string
|
||||
@@ -836,7 +860,7 @@ export async function pgGetCaByFournisseur(
|
||||
ORDER BY art_no_id, code
|
||||
) af ON af.art_no_id = a.no_id
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
WHERE (TO_CHAR(m.datmvt, 'YYYY-MM') = ${mois} OR TO_CHAR(m.datmvt, 'YYYY-MM') = ${moisN1})
|
||||
WHERE ${filtreDeuxMois(mois, moisN1)}
|
||||
AND m.site IN ('292', '579')
|
||||
AND m.genremvt = 3
|
||||
GROUP BY af.code, fi.nom, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
|
||||
@@ -865,7 +889,7 @@ export async function pgGetCaByNomenclature(
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
|
||||
WHERE (TO_CHAR(m.datmvt, 'YYYY-MM') = ${mois} OR TO_CHAR(m.datmvt, 'YYYY-MM') = ${moisN1})
|
||||
WHERE ${filtreDeuxMois(mois, moisN1)}
|
||||
AND m.site IN ('292', '579')
|
||||
AND m.genremvt = 3
|
||||
GROUP BY n.no_id, n.code, n.libelle, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
|
||||
@@ -1211,7 +1235,7 @@ export async function pgGetStockSansVente(site?: string): Promise<PgStockSansVen
|
||||
${lastEntryJoin(link, sql`a.no_id`, sql`m.site`)}
|
||||
WHERE m.genremvt IN (1, 2)
|
||||
AND m.site IN ('292', '579')
|
||||
AND date_trunc('month', m.datmvt) < date_trunc('month', CURRENT_DATE)
|
||||
AND m.datmvt < date_trunc('month', CURRENT_DATE)
|
||||
${siteFilter}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM mvtart mv2
|
||||
|
||||
@@ -145,6 +145,12 @@ export interface ProductRow {
|
||||
nbMagReseauByMonth?: Record<string, number> | null;
|
||||
/** Fraîcheur des données réseau (ISO) */
|
||||
networkFetchedAt?: string;
|
||||
/**
|
||||
* Version du format de la ligne, posée à l'écriture dans `grid_rows`. Une
|
||||
* ligne d'une version antérieure n'est pas servie telle quelle à la Grille
|
||||
* (des champs récents lui manqueraient) : elle est recalculée.
|
||||
*/
|
||||
payloadVersion?: number;
|
||||
}
|
||||
|
||||
/** Summary bar totals for the currently visible/filtered rows */
|
||||
|
||||
Reference in new issue
Block a user