diff --git a/.claude/settings.json b/.claude/settings.json
index 84aa383..4d783f8 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -3,7 +3,31 @@
"allow": [
"Bash(git push:*)",
"Bash(npx next:*)",
- "Bash(git status:*)"
+ "Bash(git status:*)",
+ "Bash(find c:/Users/Michael/VSCODE/CollectFlow/src -name *.ts -o -name *.tsx)",
+ "Bash(curl -s \"http://localhost:3001/analytics\")",
+ "Bash(ls -la \"c:/Users/Michael/VSCODE/CollectFlow/src/app/\\(dashboard\\)/analytics/\")",
+ "Read(//tmp/**)",
+ "Bash(npm run:*)",
+ "Bash(find /c/Users/Michael/VSCODE/CollectFlow -type f -name *.sql)",
+ "WebFetch(domain:api.ffnancy.fr)",
+ "Bash(find . -name *.env* -o -name .env.local)",
+ "WebFetch(domain:localhost)",
+ "Bash(curl -s https://api.ffnancy.fr/api/fournisseurs?limit=200)",
+ "Bash(curl -s https://api.ffnancy.fr/api/fournisseurs?limit=500)",
+ "Bash(git rm:*)",
+ "Bash(curl -s -w \"\\\\n%{http_code}\" \"https://api.ffnancy.fr/api/performance/ca/fournisseur?dateDebut=2026-01-01&dateFin=2026-03-31&site=\")",
+ "Bash(curl -s -w \"\\\\n%{http_code}\" \"https://api.ffnancy.fr/api/performance/ca/nomenclature?dateDebut=2026-01-01&dateFin=2026-03-31&site=&niveau=1\")",
+ "Bash(curl -s -w \"\\\\nSTATUS:%{http_code}\\\\n\" \"https://api.ffnancy.fr/api/performance/ca\")",
+ "Bash(curl -s -w \"\\\\nSTATUS:%{http_code}\\\\n\" \"https://api.ffnancy.fr/api/performance\")",
+ "Bash(curl -s -w \"\\\\nSTATUS:%{http_code}\\\\n\" \"https://api.ffnancy.fr/api/performance/ca/fournisseur\")",
+ "Bash(curl -s \"https://api.ffnancy.fr/api/performance/ca/nomenclature?dateDebut=2025-03-01&dateFin=2025-03-31&site=&niveau=1\")",
+ "Bash(curl -s \"https://api.ffnancy.fr/api/performance/ca/nomenclature?dateDebut=2025-03-01&dateFin=2025-03-31&site=292&niveau=1\")",
+ "Bash(curl -s \"https://api.ffnancy.fr/api/performance/ca?dateDebut=2025-03-01&dateFin=2025-03-31&groupBy=mois\")",
+ "Bash(curl -i -s \"https://api.ffnancy.fr/api/performance/ca/nomenclature?dateDebut=2025-03-01&dateFin=2025-03-31&site=&niveau=1\")",
+ "Bash(curl -i -s \"https://api.ffnancy.fr/api/performance/ca?dateDebut=2025-03-01&dateFin=2025-03-31&groupBy=mois\")",
+ "WebFetch(domain:collect.ffnancy.fr)",
+ "Bash(curl -s \"https://collect.ffnancy.fr/api/diag-mvtart\")"
]
}
}
diff --git a/src/app/(dashboard)/grid/page.tsx b/src/app/(dashboard)/grid/page.tsx
index 2185bec..407e0b1 100644
--- a/src/app/(dashboard)/grid/page.tsx
+++ b/src/app/(dashboard)/grid/page.tsx
@@ -33,7 +33,8 @@ export default async function GridPage({ searchParams }: GridPageProps) {
return ;
}
- // 3. Load real product data for the selected supplier
+ // 3. Load product data — résultat mis en cache 5 min par fournisseur (unstable_cache).
+ // La première requête est lente mais les suivantes (refresh, navigation) sont instantanées.
console.log(`\n>>> [GridPage] Fetching data for: ${codeFournisseur}, shop: ${magasin}`);
const rows = await getGridData(codeFournisseur, magasin, filters);
console.log(`>>> [GridPage] Received ${rows.length} rows from getGridData`);
diff --git a/src/features/grid/actions.ts b/src/features/grid/actions.ts
index ddd53bb..555bac8 100644
--- a/src/features/grid/actions.ts
+++ b/src/features/grid/actions.ts
@@ -21,14 +21,16 @@ export async function getMagasins() {
/**
* Nomenclature filter is disabled — code3 is not available from the API.
- * Returns an empty hierarchy so the sidebar filter is hidden gracefully.
*/
export async function getAvailableNomenclature() {
return {};
}
/**
- * Get product data for a specific supplier and store.
+ * Retourne tous les produits d'un fournisseur.
+ * Le résultat est mis en cache 5 min côté serveur (unstable_cache par fournisseur).
+ * La première requête est lente (6 SQL + agrégation mémoire).
+ * Toutes les requêtes suivantes dans la fenêtre de 5 min sont instantanées.
*/
export async function getGridData(
codeFournisseur: string,
diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts
index 5e3476d..262dc99 100644
--- a/src/features/grid/api/get-product-rows.ts
+++ b/src/features/grid/api/get-product-rows.ts
@@ -16,15 +16,26 @@ import {
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
+import { unstable_cache } from "next/cache";
-interface GetProductRowsInput {
+// ─── Tag de cache par fournisseur ─────────────────────────────────────────────
+// Permet d'invalider précisément le cache d'un seul fournisseur lors d'une sauvegarde.
+export function getGridCacheTag(codeFournisseur: string): string {
+ return `grid-rows-${codeFournisseur}`;
+}
+
+export interface GetProductRowsInput {
codeFournisseur: string;
magasin?: string;
filters?: Partial;
}
-export async function getProductRows(input: GetProductRowsInput): Promise {
- const { codeFournisseur, magasin = "TOTAL" } = input;
+// ─── Fonction interne : fetch complet non-paginé (cachée) ───────────────────
+// Cette fonction est le « dataloader » central. Elle est enveloppée dans
+// unstable_cache pour ne jamais refaire les 6 requêtes SQL tant que le cache
+// est valide (revalidate : 300s). Le tag permet une invalidation ciblée.
+async function _fetchAllProductRows(codeFournisseur: string): Promise {
+ const magasin = "TOTAL"; // Le cache est toujours TOTAL ; le switch magasin est client-side
console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`);
try {
@@ -299,7 +310,36 @@ export async function getProductRows(input: GetProductRowsInput): Promise {
+ const { codeFournisseur } = input;
+
+ // Créer dynamiquement la fonction cachée avec le tag spécifique au fournisseur.
+ // unstable_cache mémoïse par le tableau de clés [`grid-rows-${codeFournisseur}`].
+ const cachedFn = unstable_cache(
+ async () => _fetchAllProductRows(codeFournisseur),
+ [`grid-rows-${codeFournisseur}`],
+ { revalidate: 300, tags: [`grid-rows-${codeFournisseur}`] }
+ );
+
+ try {
+ return await cachedFn();
+ } catch (e) {
+ console.error(`[getProductRows] Cache error for ${codeFournisseur}:`, e);
+ // Fallback sans cache si unstable_cache échoue
+ return _fetchAllProductRows(codeFournisseur);
+ }
+}
diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx
index 02c1a96..2d6931b 100644
--- a/src/features/grid/components/grid-client.tsx
+++ b/src/features/grid/components/grid-client.tsx
@@ -11,9 +11,7 @@ import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
import type { ProductRow } from "@/types/grid";
import { CheckCircle, AlertCircle, Loader2 } from "lucide-react";
import { useSession } from "next-auth/react";
-
import { useSearchParams } from "next/navigation";
-import { computeProductScores } from "@/lib/score-engine";
interface GridClientProps {
initialRows: ProductRow[];
@@ -26,7 +24,7 @@ interface GridClientProps {
export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
const { data: session } = useSession();
- const isAdmin = (session?.user as any)?.role === "admin";
+ const isAdmin = (session?.user as { role?: string })?.role === "admin";
const setRows = useGridStore((s) => s.setRows);
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
const setFilter = useGridStore((s) => s.setFilter);
@@ -50,7 +48,6 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn
useEffect(() => {
if (!isMounted) return;
const currentQueryString = searchParams.toString();
- // Set the active query, ensuring it starts with ? if not empty
setActiveGridQuery(currentQueryString ? `?${currentQueryString}` : "");
}, [searchParams, setActiveGridQuery, isMounted]);
@@ -63,8 +60,9 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn
setFilter("code3", null);
prevFournisseurRef.current = codeFournisseur;
}
- const scoredRows = computeProductScores([...initialRows]);
- setRows(scoredRows);
+ // Les scores ont déjà été calculés côté serveur dans _fetchAllProductRows.
+ // On pousse directement les lignes dans le store sans recalcul côté client.
+ setRows(initialRows);
}, [codeFournisseur, initialRows, setRows, setFilter, isMounted]);
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts
index 3591dac..4127cf8 100644
--- a/src/features/grid/store/use-grid-store.ts
+++ b/src/features/grid/store/use-grid-store.ts
@@ -7,6 +7,8 @@ import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/gr
interface GridState {
/** Source data from server */
rows: ProductRow[];
+ /** Index O(1) pour les lookups par codein (Map) */
+ rowsIndex: Map;
/** Draft edits: codein → new GammeCode */
draftChanges: Record;
filters: GridFilters;
@@ -64,6 +66,7 @@ export const useGridStore = create()(
persist(
(set, get) => ({
rows: [],
+ rowsIndex: new Map(),
activeMagasin: "TOTAL",
draftChanges: {},
filters: {
@@ -94,12 +97,15 @@ export const useGridStore = create()(
},
setRows: (rows) => {
- set({ rows, summary: computeSummary(rows, get().draftChanges, get().activeMagasin) });
+ // Construire l'index en O(n) une seule fois au lieu de O(n) à chaque lookup.
+ const rowsIndex = new Map(rows.map(r => [r.codein, r]));
+ set({ rows, rowsIndex, summary: computeSummary(rows, get().draftChanges, get().activeMagasin) });
},
setDraftGamme: (codein, gamme) => {
- const { rows, draftChanges: oldDrafts } = get();
- const originalRow = rows.find(r => r.codein === codein);
+ const { rows, rowsIndex, draftChanges: oldDrafts } = get();
+ // Lookup O(1) via l'index au lieu de O(n) via .find()
+ const originalRow = rowsIndex.get(codein);
const originalGamme = originalRow?.codeGamme;
const draftChanges = { ...oldDrafts };
@@ -144,11 +150,12 @@ export const useGridStore = create()(
},
batchSetDraftGamme: (newChanges) => {
- const { rows, draftChanges: oldDrafts } = get();
+ const { rows, rowsIndex, draftChanges: oldDrafts } = get();
const updatedDrafts = { ...oldDrafts };
Object.entries(newChanges).forEach(([codein, gamme]) => {
- const originalRow = rows.find(r => r.codein === codein);
+ // Lookup O(1) via l'index au lieu de O(n) via .find()
+ const originalRow = rowsIndex.get(codein);
const originalGamme = originalRow?.codeGamme;
if (gamme === originalGamme) {