From 83d101ddc13ae2a1d1af00549eae299bf2d5ffae Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 3 Apr 2026 10:46:09 +0200 Subject: [PATCH 01/57] feat: add interactive client component for Hit Parade data visualization with sorting, filtering, and date range controls --- src/app/(dashboard)/hit-parade/client.tsx | 30 +++++++++++------------ src/app/(dashboard)/settings/page.tsx | 2 +- src/app/api/ai/analyze/route.ts | 2 +- src/app/api/ai/batch-analyze/route.ts | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/app/(dashboard)/hit-parade/client.tsx b/src/app/(dashboard)/hit-parade/client.tsx index 53015df..336dcc8 100644 --- a/src/app/(dashboard)/hit-parade/client.tsx +++ b/src/app/(dashboard)/hit-parade/client.tsx @@ -194,16 +194,16 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) { - % Marge + - % Marge + - % Marge + @@ -218,36 +218,36 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) { {row.ca292 > 0 ? formatCA(row.ca292) : —} - - {row.stock292 > 0 ? formatQte(row.stock292) : —} - {row.ca292 > 0 ? formatPct(row.ca292, row.marge292) : ""} + + {row.stock292 > 0 ? formatQte(row.stock292) : —} + {row.qte579 > 0 ? formatQte(row.qte579) : —} {row.ca579 > 0 ? formatCA(row.ca579) : —} - - {row.stock579 > 0 ? formatQte(row.stock579) : —} - {row.ca579 > 0 ? formatPct(row.ca579, row.marge579) : ""} + + {row.stock579 > 0 ? formatQte(row.stock579) : —} + {row.qteTotal > 0 ? formatQte(row.qteTotal) : —} {row.caTotal > 0 ? formatCA(row.caTotal) : —} - - {row.stockTotal > 0 ? formatQte(row.stockTotal) : 0} - {row.caTotal > 0 ? formatPct(row.caTotal, row.margeTotal) : ""} + + {row.stockTotal > 0 ? formatQte(row.stockTotal) : 0} + ))} @@ -258,16 +258,16 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) { {formatQte(totals.qte292)} {formatCA(totals.ca292)} - {formatQte(totals.stock292)} {formatPct(totals.ca292, totals.marge292)} + {formatQte(totals.stock292)} {formatQte(totals.qte579)} {formatCA(totals.ca579)} - {formatQte(totals.stock579)} {formatPct(totals.ca579, totals.marge579)} + {formatQte(totals.stock579)} {formatQte(totals.qteTotal)} {formatCA(totals.caTotal)} - {formatQte(totals.stockTotal)} {formatPct(totals.caTotal, totals.margeTotal)} + {formatQte(totals.stockTotal)} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 55c7d78..9e8dca3 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -118,7 +118,7 @@ export default function SettingsPage() { const [dbError, setDbError] = useState(null); const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle"); const [models, setModels] = useState([]); - const [selectedModel, setSelectedModel] = useState("google/gemini-flash-1.5"); + const [selectedModel, setSelectedModel] = useState("google/gemini-2.0-flash-001"); const [modelsStatus, setModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle"); const [isMounted, setIsMounted] = useState(false); diff --git a/src/app/api/ai/analyze/route.ts b/src/app/api/ai/analyze/route.ts index 91cd9d8..c2dd588 100644 --- a/src/app/api/ai/analyze/route.ts +++ b/src/app/api/ai/analyze/route.ts @@ -13,7 +13,7 @@ const SITE_LABELS: Record = { "292": "Frouard", "579": "Houdemon export async function POST(req: NextRequest) { const config = await getSavedDatabaseConfig(); const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey; - const model = config?.openRouterModel || "google/gemini-flash-1.5"; + const model = config?.openRouterModel || "google/gemini-2.0-flash-001"; if (!apiKey) { console.error("[AI] OPENROUTER_API_KEY is missing."); diff --git a/src/app/api/ai/batch-analyze/route.ts b/src/app/api/ai/batch-analyze/route.ts index 40a085f..88f4fba 100644 --- a/src/app/api/ai/batch-analyze/route.ts +++ b/src/app/api/ai/batch-analyze/route.ts @@ -36,7 +36,7 @@ const BatchAnalyzeSchema = z.object({ export async function POST(req: NextRequest) { const config = await getSavedDatabaseConfig(); const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey; - const model = config?.openRouterModel || "google/gemini-flash-1.5"; + const model = config?.openRouterModel || "google/gemini-2.0-flash-001"; if (!apiKey) { console.error("[batch-analyze] API key manquante."); From ebd819bfa4c2ac9f81d299793ca2e071748d9ed9 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 3 Apr 2026 10:58:43 +0200 Subject: [PATCH 02/57] feat: implement PostgreSQL bulk data access client to replace per-article HTTP requests --- src/db/index.ts | 5 +++++ src/lib/pg-ff-client.ts | 28 +++++++++++++--------------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/db/index.ts b/src/db/index.ts index 4ebe51b..c894abc 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -7,6 +7,11 @@ import path from "path"; let pool: Pool | null = null; let currentDb: NodePgDatabase | null = null; +export function getPool(): Pool { + getDb(); // ensure pool is initialized + return pool!; +} + function maskUrl(url: string | undefined) { if (!url) return "undefined"; return url.replace(/:([^@]+)@/, ":****@"); diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index d4f20dc..85b2c75 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -7,7 +7,7 @@ * Chaque fonction fait 1 seule requête filtrée par codefou — O(1) au lieu de O(N). */ -import { db } from "@/db"; +import { db, getPool } from "@/db"; import { sql, type SQL } from "drizzle-orm"; // Cache module-level pour éviter les requêtes information_schema répétées @@ -753,22 +753,20 @@ export interface StockBySite { export async function pgGetStockForCodeins(codeins: string[]): Promise> { if (codeins.length === 0) return new Map(); - // Drizzle expands JS arrays into individual $1,$2,... params regardless of syntax, - // hitting PostgreSQL's 1664-entry ROW expression limit. Use sql.raw() with an escaped - // literal array instead — codeins come from the DB so single-quote escaping is sufficient. - const arrayLiteral = codeins.map(c => `'${String(c).replace(/'/g, "''")}'`).join(","); - const result = await pgNoParallel(sql` - SELECT - a.codein, - cs.site, - cs.stockdispo::float AS stockdispo - FROM cube_stock cs - JOIN articles a ON a.no_id = cs.artnoid - WHERE a.codein = ANY(ARRAY[${sql.raw(arrayLiteral)}]) - `); + // Bypass Drizzle entirely — it always expands arrays into individual params, + // hitting PG's 1664-entry ROW expression limit. Use the raw pg Pool directly + // with a native $1::text[] binding (entire array = single parameter). + const res = await getPool().query<{ codein: string; site: string; stockdispo: number }>( + `SELECT a.codein, cs.site, cs.stockdispo::float AS stockdispo + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + WHERE a.codein = ANY($1::text[])`, + [codeins] + ); + const rows = res.rows; const map = new Map(); - for (const r of result.rows as Array<{ codein: string; site: string; stockdispo: number }>) { + for (const r of rows) { if (!map.has(r.codein)) map.set(r.codein, { stock292: 0, stock579: 0, stockTotal: 0 }); const entry = map.get(r.codein)!; if (r.site === "292") entry.stock292 += r.stockdispo; From b4ea795770b437121e0106bff88aabc5f214a425 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 3 Apr 2026 11:07:34 +0200 Subject: [PATCH 03/57] feat: add settings page with database configuration, API status monitoring, and theme toggling --- src/app/(dashboard)/settings/page.tsx | 30 ++++++++++++++++----------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 9e8dca3..e9d5352 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -342,21 +342,27 @@ export default function SettingsPage() { label={`Modèle IA${models.length > 0 ? ` (${models.length} disponibles)` : ""}`} hint={modelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : modelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined} > - setSelectedModel(e.target.value)} + className="apple-input" + > + {models.map((m) => ( - )) - } - + ))} + + ) : ( + setSelectedModel(e.target.value)} + placeholder="ex: google/gemini-2.0-flash-001" + className="apple-input" + /> + )} From e90a567446f3713037ae8369180bc205557c24a8 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 3 Apr 2026 11:17:04 +0200 Subject: [PATCH 04/57] feat: implement bulk PostgreSQL data fetching client to replace per-article HTTP requests --- src/lib/pg-ff-client.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 85b2c75..3bb5dd7 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -7,7 +7,7 @@ * Chaque fonction fait 1 seule requête filtrée par codefou — O(1) au lieu de O(N). */ -import { db, getPool } from "@/db"; +import { db } from "@/db"; import { sql, type SQL } from "drizzle-orm"; // Cache module-level pour éviter les requêtes information_schema répétées @@ -749,21 +749,22 @@ export interface StockBySite { /** * Retourne le stock actuel (stockdispo) par codein pour une liste de codeins. * 1 seule requête bulk — utilisé pour enrichir le Hit Parade. + * + * On passe les codeins comme une SEULE chaîne CSV ($1) et on utilise + * string_to_array côté PG — cela évite l'expansion Drizzle en $1,$2,...$N + * qui déclencherait le PG error 54011 (ROW > 1664 entries). */ export async function pgGetStockForCodeins(codeins: string[]): Promise> { if (codeins.length === 0) return new Map(); - // Bypass Drizzle entirely — it always expands arrays into individual params, - // hitting PG's 1664-entry ROW expression limit. Use the raw pg Pool directly - // with a native $1::text[] binding (entire array = single parameter). - const res = await getPool().query<{ codein: string; site: string; stockdispo: number }>( - `SELECT a.codein, cs.site, cs.stockdispo::float AS stockdispo - FROM cube_stock cs - JOIN articles a ON a.no_id = cs.artnoid - WHERE a.codein = ANY($1::text[])`, - [codeins] + const codeinsStr = codeins.join(","); + const result = await pgNoParallel( + sql`SELECT a.codein, cs.site, cs.stockdispo::float AS stockdispo + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + WHERE a.codein = ANY(string_to_array(${codeinsStr}, ','))` ); - const rows = res.rows; + const rows = result.rows as { codein: string; site: string; stockdispo: number }[]; const map = new Map(); for (const r of rows) { From bd21f07c8e2446e7d7ec95169a0b5753949482f2 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 3 Apr 2026 11:21:38 +0200 Subject: [PATCH 05/57] feat: implement bulk PostgreSQL client to replace per-article HTTP API calls --- src/app/(dashboard)/hit-parade/page.tsx | 18 +++---- src/lib/pg-ff-client.ts | 64 ++++++++++++++++++------- 2 files changed, 53 insertions(+), 29 deletions(-) diff --git a/src/app/(dashboard)/hit-parade/page.tsx b/src/app/(dashboard)/hit-parade/page.tsx index cca29c5..65b9c6b 100644 --- a/src/app/(dashboard)/hit-parade/page.tsx +++ b/src/app/(dashboard)/hit-parade/page.tsx @@ -1,4 +1,4 @@ -import { pgGetHitParade, pgGetStockForCodeins } from "@/lib/pg-ff-client"; +import { pgGetHitParade, HitParadeRow } from "@/lib/pg-ff-client"; import { HitParadeClient } from "./client"; export interface HitParadePivotRow { @@ -19,9 +19,7 @@ export interface HitParadePivotRow { stockTotal: number; } -function pivotHitParade( - rows: Array<{ codein: string; libelle: string; fournisseur: string; site: string; qte_vendue: number; ca_ttc: number; marge: number }> -): HitParadePivotRow[] { +function pivotHitParade(rows: HitParadeRow[]): HitParadePivotRow[] { const map = new Map(); for (const row of rows) { @@ -33,7 +31,10 @@ function pivotHitParade( qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, - stock292: 0, stock579: 0, stockTotal: 0, + // Stock comes from the SQL query directly (LEFT JOIN cube_stock) + stock292: row.stock292 ?? 0, + stock579: row.stock579 ?? 0, + stockTotal: row.stockTotal ?? 0, }); } const entry = map.get(row.codein)!; @@ -75,13 +76,6 @@ export default async function HitParadePage(props: { const rows = await pgGetHitParade(dateDebut, dateFin); const pivotted = pivotHitParade(rows); - const codeins = pivotted.map(r => r.codein); - const stockMap = await pgGetStockForCodeins(codeins); - for (const row of pivotted) { - const s = stockMap.get(row.codein); - if (s) { row.stock292 = s.stock292; row.stock579 = s.stock579; row.stockTotal = s.stockTotal; } - } - return (
diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 3bb5dd7..b870110 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -706,30 +706,60 @@ export interface HitParadeRow { qte_vendue: number; ca_ttc: number; marge: number; + stock292: number; + stock579: number; + stockTotal: number; } /** - * Retourne les ventes produit sur une période, avec fournisseur, qté, CA TTC et marge par site. + * Retourne les ventes produit sur une période, avec fournisseur, qté, CA TTC, marge et stock par site. + * Le stock est intégré via un LEFT JOIN — pas de requête séparée, pas de tableau de paramètres. */ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promise { const result = await pgNoParallel(sql` + WITH ventes AS ( + SELECT + a.no_id AS art_no_id, + a.codein::text AS codein, + a.libelle1::text AS libelle, + COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, + m.site, + SUM(ABS(m.qtemvt))::float AS qte_vendue, + ABS(SUM(m.mntmvtttc))::float AS ca_ttc, + SUM(m.margemvt)::float AS marge + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 + LEFT JOIN fouident fi ON fi.code = af.code + WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date + AND m.site IN ('292', '579') + AND m.genremvt = 3 + GROUP BY a.no_id, a.codein, a.libelle1, fi.nom, af.code, m.site + ), + stock_agg AS ( + SELECT + cs.artnoid, + SUM(CASE WHEN cs.site = '292' THEN cs.stockdispo ELSE 0 END)::float AS stock292, + SUM(CASE WHEN cs.site = '579' THEN cs.stockdispo ELSE 0 END)::float AS stock579, + SUM(cs.stockdispo)::float AS stockTotal + FROM cube_stock cs + WHERE cs.artnoid IN (SELECT DISTINCT art_no_id FROM ventes) + GROUP BY cs.artnoid + ) SELECT - a.codein::text AS codein, - a.libelle1::text AS libelle, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, - m.site, - SUM(ABS(m.qtemvt))::float AS qte_vendue, - ABS(SUM(m.mntmvtttc))::float AS ca_ttc, - SUM(m.margemvt)::float AS marge - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 - LEFT JOIN fouident fi ON fi.code = af.code - WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date - AND m.site IN ('292', '579') - AND m.genremvt = 3 - GROUP BY a.codein, a.libelle1, fi.nom, af.code, m.site - ORDER BY ca_ttc DESC + v.codein, + v.libelle, + v.fournisseur, + v.site, + v.qte_vendue, + v.ca_ttc, + v.marge, + COALESCE(s.stock292, 0) AS stock292, + COALESCE(s.stock579, 0) AS stock579, + COALESCE(s.stockTotal, 0) AS "stockTotal" + FROM ventes v + LEFT JOIN stock_agg s ON s.artnoid = v.art_no_id + ORDER BY v.ca_ttc DESC `); console.log(`[pg-ff] HitParade: ${result.rows.length} lignes pour ${dateDebut}→${dateFin}`); From 2aa9cbecaa21af43aa0565ba373de6dafe2bb2a8 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 3 Apr 2026 11:26:13 +0200 Subject: [PATCH 06/57] revert: restore grid to pre-infinite-scroll state Remove infinite grid loading, useInfiniteGrid hook, and server-side pagination API route. Revert grid components to the simpler version that avoids 2MB+ cache failures from unstable_cache. Co-Authored-By: Claude Sonnet 4.6 --- src/app/(dashboard)/grid/page.tsx | 11 +- src/app/api/grid/rows/route.ts | 49 ------ src/features/grid/actions.ts | 16 +- src/features/grid/api/get-product-rows.ts | 103 +----------- src/features/grid/components/grid-client.tsx | 71 ++------ src/features/grid/components/heatmap-grid.tsx | 24 +-- src/features/grid/hooks/use-infinite-grid.ts | 158 ------------------ src/features/grid/store/use-grid-store.ts | 30 +--- 8 files changed, 40 insertions(+), 422 deletions(-) delete mode 100644 src/app/api/grid/rows/route.ts delete mode 100644 src/features/grid/hooks/use-infinite-grid.ts diff --git a/src/app/(dashboard)/grid/page.tsx b/src/app/(dashboard)/grid/page.tsx index 427cd49..2185bec 100644 --- a/src/app/(dashboard)/grid/page.tsx +++ b/src/app/(dashboard)/grid/page.tsx @@ -33,16 +33,15 @@ export default async function GridPage({ searchParams }: GridPageProps) { return ; } - // 3. Load product data — pagination pour ne pas crasher le JS client - console.log(`\n>>> [GridPage] Fetching page 0 for: ${codeFournisseur}, shop: ${magasin}`); - const { rows: initialRows, total } = await getGridData(codeFournisseur, magasin, filters, 0, 200); - console.log(`>>> [GridPage] Received ${initialRows.length}/${total} rows from getGridData`); + // 3. Load real product data for the selected supplier + 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`); const selectedFournisseur = fournisseurs.find((f: { code: string; nom: string }) => f.code === codeFournisseur); return (
@@ -150,32 +127,6 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
- {/* Barre de progression chargement en arrière-plan */} - {(hasMore || isLoadingMore) && ( -
- - - Téléchargement : {rows.length.toLocaleString("fr-FR")} / {total.toLocaleString("fr-FR")} références - -
-
-
- -
- )} - {/* Filters */}
diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index e591693..47dd142 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -268,39 +268,27 @@ function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; act // ========================================================================= export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { - // L'abonnement doit être ultra-minimal ! JAMAIS de destructuring brut sur useGridStore(). - const rows = useGridStore((s) => s.rows); - const filters = useGridStore((s) => s.filters); - const displayDensity = useGridStore((s) => s.displayDensity); - const activeMagasin = useGridStore((s) => s.activeMagasin); + // L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme. + const { rows, filters, displayDensity, draftChanges, activeMagasin } = useGridStore(); - // Filtre client-side par code3 (famille) et codeGamme sans abonnement réactif aux brouillons pour éviter le freeze. + // Filtre client-side par code3 (famille) et codeGamme const filteredData = useMemo(() => { const { code3, codeGamme } = filters; if (!code3 && !codeGamme) return rows; - - // On récupère le state de façon non-réactive pour ne pas trigger 130k renders - const currentDrafts = useGridStore.getState().draftChanges; - return rows.filter(r => { if (code3 && r.code3 !== code3) return false; if (codeGamme) { - const g = currentDrafts[r.codein] ?? r.codeGamme ?? ""; + const g = draftChanges[r.codein] ?? r.codeGamme ?? ""; const norm = g.trim() === "" ? "Aucune" : g; if (norm !== codeGamme) return false; } return true; }); - }, [rows, filters]); // ❌ Surtout pas draftChanges ici + }, [rows, filters, draftChanges]); const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState({}); - - // Selectors granulaires - const columnVisibility = useGridStore((s) => s.columnVisibility); - const setColumnVisibility = useGridStore((s) => s.setColumnVisibility); - const columnSizing = useGridStore((s) => s.columnSizing); - const setColumnSizing = useGridStore((s) => s.setColumnSizing); + const { columnVisibility, setColumnVisibility, columnSizing, setColumnSizing } = useGridStore(); const [isMounted, setIsMounted] = useState(false); const [portalContainer, setPortalContainer] = useState(null); diff --git a/src/features/grid/hooks/use-infinite-grid.ts b/src/features/grid/hooks/use-infinite-grid.ts deleted file mode 100644 index b9cf781..0000000 --- a/src/features/grid/hooks/use-infinite-grid.ts +++ /dev/null @@ -1,158 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import type { ProductRow } from "@/types/grid"; - -export interface InfiniteGridOptions { - codeFournisseur: string | null; - initialTotal: number; - pageSize?: number; - magasin?: string; - search?: string; - gamme?: string | null; - code3?: string | null; - onPageLoaded: (newRows: ProductRow[]) => void; -} - -export interface InfiniteGridState { - total: number; - isLoadingMore: boolean; - hasMore: boolean; - loadNextPage: () => void; - stopLoading: () => void; - reset: () => void; - progress: number; - loadedPagesCount: number; -} - -export function useInfiniteGrid({ - codeFournisseur, - initialTotal, - pageSize = 10000, - magasin, - search, - gamme, - code3, - onPageLoaded, -}: InfiniteGridOptions): InfiniteGridState { - const [total, setTotal] = useState(initialTotal); - const [currentPage, setCurrentPage] = useState(0); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const [isStopped, setIsStopped] = useState(false); - const [loadedPagesCount, setLoadedPagesCount] = useState(1); // Page 0 est initialRows - - const fetchIdRef = useRef(0); - const loadedPagesRef = useRef(new Set([0])); - - const buildUrl = useCallback( - (page: number): string => { - if (!codeFournisseur) return ""; - const params = new URLSearchParams({ - fournisseur: codeFournisseur, - page: String(page), - pageSize: String(pageSize), - }); - - if (page > 0) { - params.set("offset", String(200 + (page - 1) * pageSize)); - } - - if (search) params.set("search", search); - if (gamme) params.set("gamme", gamme); - if (code3) params.set("code3", code3); - if (magasin) params.set("magasin", magasin); - return `/api/grid/rows?${params.toString()}`; - }, - [codeFournisseur, pageSize, search, gamme, code3, magasin] - ); - - useEffect(() => { - setTotal(initialTotal); - setCurrentPage(0); - setLoadedPagesCount(1); - setIsStopped(false); - loadedPagesRef.current = new Set([0]); - fetchIdRef.current += 1; - }, [codeFournisseur, initialTotal, search, gamme, code3, magasin]); - - const loadNextPage = useCallback(async () => { - if (!codeFournisseur || isLoadingMore || isStopped) return; - - const nextPage = currentPage + 1; - const alreadyLoaded = loadedPagesRef.current.has(nextPage); - - // Page 0 comprenait 200 éléments. Le reste est divisé par pageSize (10000). - const remainingItems = Math.max(0, total - 200); - const totalPages = 1 + Math.ceil(remainingItems / pageSize); - - if (alreadyLoaded || nextPage >= totalPages) return; - - const currentFetchId = ++fetchIdRef.current; - setIsLoadingMore(true); - - try { - const url = buildUrl(nextPage); - const response = await fetch(url, { credentials: "same-origin" }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - if (currentFetchId !== fetchIdRef.current) return; - - const data = await response.json() as { - rows: ProductRow[]; - total: number; - page: number; - pageSize: number; - totalPages: number; - }; - - loadedPagesRef.current.add(nextPage); - setLoadedPagesCount(prev => prev + 1); - setTotal(data.total); - setCurrentPage(nextPage); - - // Envoyer directement au Store Zustand pour bypasser le cycle React DOM - onPageLoaded(data.rows); - } catch (error) { - if ((error as Error).name === "AbortError") return; - console.error("[useInfiniteGrid] loadNextPage error:", error); - } finally { - if (currentFetchId === fetchIdRef.current) { - setIsLoadingMore(false); - } - } - }, [codeFournisseur, currentPage, total, pageSize, isLoadingMore, isStopped, buildUrl, onPageLoaded]); - - const stopLoading = useCallback(() => { - setIsStopped(true); - setIsLoadingMore(false); - }, []); - - const reset = useCallback(() => { - fetchIdRef.current += 1; - loadedPagesRef.current = new Set([0]); - setTotal(initialTotal); - setCurrentPage(0); - setLoadedPagesCount(1); - setIsLoadingMore(false); - setIsStopped(false); - }, [initialTotal]); - - // On part du principe qu'il y en a 200 + n * 10 000 - const loadedItemsEstimation = 200 + (loadedPagesCount - 1) * pageSize; - const hasMore = !isStopped && loadedItemsEstimation < total; - const progress = total > 0 ? Math.round(Math.min((loadedItemsEstimation / total) * 100, 100)) : 100; - - return { - total, - isLoadingMore, - hasMore, - loadNextPage, - stopLoading, - reset, - progress, - loadedPagesCount, - }; -} diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index 1e3d9b2..3591dac 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -7,8 +7,6 @@ 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; @@ -27,7 +25,6 @@ interface GridState { // Actions setRows: (rows: ProductRow[]) => void; - appendRows: (newRows: ProductRow[]) => void; setDraftGamme: (codein: string, gamme: GammeCode) => void; resetDrafts: () => void; /** Clear specific draft changes by codein */ @@ -67,7 +64,6 @@ export const useGridStore = create()( persist( (set, get) => ({ rows: [], - rowsIndex: new Map(), activeMagasin: "TOTAL", draftChanges: {}, filters: { @@ -98,27 +94,12 @@ export const useGridStore = create()( }, setRows: (rows) => { - // 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) }); - }, - - appendRows: (newRows) => { - const { rows, rowsIndex, draftChanges, activeMagasin } = get(); - // O(n) uniquement sur les nouveaux éléments, au lieu de re-calculer les 130k existants - const mergedRows = [...rows, ...newRows]; - newRows.forEach(r => rowsIndex.set(r.codein, r)); - set({ - rows: mergedRows, - rowsIndex, - summary: computeSummary(mergedRows, draftChanges, activeMagasin) - }); + set({ rows, summary: computeSummary(rows, get().draftChanges, get().activeMagasin) }); }, setDraftGamme: (codein, gamme) => { - 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 { rows, draftChanges: oldDrafts } = get(); + const originalRow = rows.find(r => r.codein === codein); const originalGamme = originalRow?.codeGamme; const draftChanges = { ...oldDrafts }; @@ -163,12 +144,11 @@ export const useGridStore = create()( }, batchSetDraftGamme: (newChanges) => { - const { rows, rowsIndex, draftChanges: oldDrafts } = get(); + const { rows, draftChanges: oldDrafts } = get(); const updatedDrafts = { ...oldDrafts }; Object.entries(newChanges).forEach(([codein, gamme]) => { - // Lookup O(1) via l'index au lieu de O(n) via .find() - const originalRow = rowsIndex.get(codein); + const originalRow = rows.find(r => r.codein === codein); const originalGamme = originalRow?.codeGamme; if (gamme === originalGamme) { From bbb04a4d2ae44f112f6d76aa209e781a577c23fd Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 3 Apr 2026 11:34:14 +0200 Subject: [PATCH 07/57] fix: immediate AI loading feedback + auto-fallback for deprecated model - BulkAiAnalyzer: set isAnalyzing=true immediately on click (before context fetch), so the spinner appears instantly instead of after 1-2 seconds of silence - analyze/route.ts: if configured model returns 400/404 (e.g., deprecated google/gemini-flash-1.5), automatically retry with google/gemini-2.0-flash-001 - batch-analyze/route.ts: same fallback logic Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/ai/analyze/route.ts | 18 ++++++++++++++++-- src/app/api/ai/batch-analyze/route.ts | 18 ++++++++++++------ .../grid/components/bulk-ai-analyzer.tsx | 5 ++++- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/app/api/ai/analyze/route.ts b/src/app/api/ai/analyze/route.ts index c2dd588..8c9e842 100644 --- a/src/app/api/ai/analyze/route.ts +++ b/src/app/api/ai/analyze/route.ts @@ -54,8 +54,22 @@ export async function POST(req: NextRequest) { } } - const client = new OpenRouterClient({ apiKey, model }); - const result = await client.analyzeProduct(enrichedBody); + const FALLBACK_MODEL = "google/gemini-2.0-flash-001"; + let client = new OpenRouterClient({ apiKey, model }); + let result; + try { + result = await client.analyzeProduct(enrichedBody); + } catch (modelErr) { + // If configured model fails (e.g., deprecated), retry with fallback + const msg = modelErr instanceof Error ? modelErr.message : ""; + if (model !== FALLBACK_MODEL && (msg.includes("400") || msg.includes("404") || msg.includes("OpenRouter error"))) { + console.warn(`[AI] Model "${model}" failed (${msg}), retrying with fallback ${FALLBACK_MODEL}`); + client = new OpenRouterClient({ apiKey, model: FALLBACK_MODEL }); + result = await client.analyzeProduct(enrichedBody); + } else { + throw modelErr; + } + } return NextResponse.json(result); } catch (err) { diff --git a/src/app/api/ai/batch-analyze/route.ts b/src/app/api/ai/batch-analyze/route.ts index 88f4fba..15bcf45 100644 --- a/src/app/api/ai/batch-analyze/route.ts +++ b/src/app/api/ai/batch-analyze/route.ts @@ -107,14 +107,12 @@ REPONDS EN JSON VALIDE uniquement : const userPrompt = `${products.length} produits du rayon "${rayon}" (sur ${supplierStats?.totalProducts ?? products.length} au total). ${JSON.stringify(products, null, 2)}`; - const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + const FALLBACK_MODEL = "google/gemini-2.0-flash-001"; + const tryModel = async (modelToTry: string) => fetch("https://openrouter.ai/api/v1/chat/completions", { method: "POST", - headers: { - "Authorization": `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, + headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ - model, + model: modelToTry, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt } @@ -124,6 +122,14 @@ ${JSON.stringify(products, null, 2)}`; }), }); + let response = await tryModel(model); + + // Fallback if configured model is deprecated/not found + if (!response.ok && response.status === 400 && model !== FALLBACK_MODEL) { + console.warn(`[batch-analyze] Model "${model}" returned 400, retrying with fallback ${FALLBACK_MODEL}`); + response = await tryModel(FALLBACK_MODEL); + } + if (response.status === 429) { const retryAfter = response.headers.get("Retry-After") || response.headers.get("x-ratelimit-reset-requests"); const waitSeconds = retryAfter ? parseInt(retryAfter, 10) : 60; diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx index b315fdc..f1b8908 100644 --- a/src/features/grid/components/bulk-ai-analyzer.tsx +++ b/src/features/grid/components/bulk-ai-analyzer.tsx @@ -41,6 +41,10 @@ export function BulkAiAnalyzer() { const { rows } = useGridStore.getState(); isCancelledRef.current = false; + // Show immediate visual feedback before any async work + setIsAnalyzing(true); + setProgress({ current: 0, total: 0, message: "Chargement du contexte...", errors: 0 }); + // 1. Fetch AI Context for the Supplier let supplierContext = ""; if (supplierCode) { @@ -166,7 +170,6 @@ export function BulkAiAnalyzer() { // 2c. Context profiling en micro-batches asynchrones // Le score est déjà calculé par score-engine.ts (champ row.score sur chaque ProductRow). - setIsAnalyzing(true); setProgress({ current: 0, total: payloadsWithSales.length, message: "Calcul du contexte...", errors: 0 }); const SCORING_BATCH_SIZE = 25; From 6ef23de31763d4f0dae44e9dde2be0321745bd6d Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 3 Apr 2026 11:43:48 +0200 Subject: [PATCH 08/57] feat: restrict AI analysis to admin role only - FloatingSummaryBar: hide BulkAiAnalyzer button for non-admins - AiInsightBlock: hide individual Analyser button for non-admins - Both use useSession() to check role === "admin" Co-Authored-By: Claude Sonnet 4.6 --- src/features/ai-copilot/components/ai-insight-block.tsx | 4 ++++ src/features/grid/components/floating-summary-bar.tsx | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/features/ai-copilot/components/ai-insight-block.tsx b/src/features/ai-copilot/components/ai-insight-block.tsx index 3eefd60..58a42a2 100644 --- a/src/features/ai-copilot/components/ai-insight-block.tsx +++ b/src/features/ai-copilot/components/ai-insight-block.tsx @@ -6,6 +6,7 @@ import { Loader2, AlertCircle, Sparkles, Maximize2 } from "lucide-react"; import { AiExplanationModal } from "./ai-explanation-modal"; import { useAiCopilotStore } from "../store/use-ai-copilot-store"; import { useGridStore } from "@/features/grid/store/use-grid-store"; +import { useSession } from "next-auth/react"; import type { ProductRow } from "@/types/grid"; @@ -14,6 +15,8 @@ interface AiInsightBlockProps { } export function AiInsightBlock({ row }: AiInsightBlockProps) { + const { data: session } = useSession(); + const isAdmin = (session?.user as any)?.role === "admin"; const insight = useAiCopilotStore((s: any) => s.insights[row.codein]); const analyzeProduct = useAiCopilotStore((s: any) => s.analyzeProduct); const setLoading = useAiCopilotStore((s: any) => s.setLoading); @@ -125,6 +128,7 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) { }; if (status === "idle") { + if (!isAdmin) return null; return ( +
+
+ + {/* Table */} +
+ + + + {th("codein", "Code article")} + {th("libelle1", "Libellé")} + {th("fournisseur", "Fournisseur")} + {th("site", "Magasin")} + {th("stockdispo", "Stock négatif")} + {th("dernierevente", "Dernière vente")} + + + + {sorted.length === 0 && ( + + + + )} + {sorted.map((row, i) => ( + + + + + + + + + ))} + +
+ Aucun article en stock négatif +
{row.codein}{row.libelle1}{row.fournisseur} + + {row.site} + + + + {row.stockdispo} + + {fmtDate(row.dernierevente)}
+
+ + ); +} diff --git a/src/app/(dashboard)/stock-negatif/page.tsx b/src/app/(dashboard)/stock-negatif/page.tsx new file mode 100644 index 0000000..414b8f2 --- /dev/null +++ b/src/app/(dashboard)/stock-negatif/page.tsx @@ -0,0 +1,31 @@ +import { pgGetStockNegatif, PgStockNegatifRow } from "@/lib/pg-ff-client"; +import { StockNegatifClient } from "./client"; + +export type { PgStockNegatifRow }; + +const SITES = [ + { code: "292", label: "292 — Frouard / Nancy" }, + { code: "579", label: "579 — Houdemont" }, +]; + +export default async function StockNegatifPage(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const magasin = (searchParams.magasin as string) || ""; + + const rows = await pgGetStockNegatif(magasin || undefined); + + return ( +
+
+

Stock Négatif

+ +
+
+ ); +} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index 620e3e3..54f7650 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react"; +import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; @@ -14,6 +14,7 @@ const NAV_ITEMS = [ { icon: TrendingUp, label: "Analytics", href: "/analytics" }, { icon: Trophy, label: "Hit Parade", href: "/hit-parade" }, { icon: Megaphone, label: "Publicités", href: "/publicites" }, + { icon: PackageMinus, label: "Stock Négatif", href: "/stock-negatif" }, { icon: Camera, label: "Snapshots", href: "/snapshots" }, { icon: FileDown, label: "Exports", href: "/exports", adminOnly: true }, { icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true }, diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 2558625..f2070da 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -828,3 +828,41 @@ export async function pgGetStockForCodeins(codeins: string[]): Promise { + const siteFilter = site ? sql`AND cs.site = ${site}` : sql``; + const result = await pgNoParallel(sql` + SELECT + a.codein, + COALESCE(a.libelle1, '') AS libelle1, + COALESCE(f.nom, af.code) AS fournisseur, + cs.site, + cs.stockdispo::float, + cs.dernierevente::text + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 + JOIN fouident f ON f.code = af.code + WHERE cs.stockdispo < 0 + ${siteFilter} + ORDER BY cs.stockdispo ASC + `); + return result.rows as PgStockNegatifRow[]; +} From a378bff7e231faa25e2b0cb7e853ab7cb0864837 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 7 Apr 2026 15:37:01 +0200 Subject: [PATCH 25/57] feat: implement pg-ff-client to replace HTTP per-article requests with bulk SQL queries --- src/lib/pg-ff-client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index f2070da..5023bbe 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -861,6 +861,7 @@ export async function pgGetStockNegatif(site?: string): Promise Date: Tue, 7 Apr 2026 16:15:19 +0200 Subject: [PATCH 26/57] feat: implement PostgreSQL bulk data client to replace per-article HTTP requests --- src/lib/pg-ff-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 5023bbe..2f10a1d 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -854,13 +854,13 @@ export async function pgGetStockNegatif(site?: string): Promise Date: Tue, 7 Apr 2026 16:51:52 +0200 Subject: [PATCH 27/57] feat: implement collapsible sidebar, PostgreSQL client for bulk data fetching, and stock-negatif page --- src/app/(dashboard)/stock-negatif/client.tsx | 354 ++++++++++++------- src/app/(dashboard)/stock-negatif/page.tsx | 22 +- src/components/shared/sidebar.tsx | 2 +- src/lib/pg-ff-client.ts | 44 +++ 4 files changed, 290 insertions(+), 132 deletions(-) diff --git a/src/app/(dashboard)/stock-negatif/client.tsx b/src/app/(dashboard)/stock-negatif/client.tsx index 0005c9b..d0d3ebd 100644 --- a/src/app/(dashboard)/stock-negatif/client.tsx +++ b/src/app/(dashboard)/stock-negatif/client.tsx @@ -3,9 +3,8 @@ import { useRouter } from "next/navigation"; import { useState, useMemo } from "react"; import { ChevronUp, ChevronDown, ChevronsUpDown, Download } from "lucide-react"; -import type { PgStockNegatifRow } from "./page"; +import type { PgStockNegatifRow, PgStockSansVenteRow } from "./page"; -type SortKey = "codein" | "libelle1" | "fournisseur" | "site" | "stockdispo" | "dernierevente"; type SortDir = "asc" | "desc"; function fmtDate(raw: string | null) { @@ -15,107 +14,242 @@ function fmtDate(raw: string | null) { return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }); } -function SortIcon({ col, sortKey, sortDir }: { col: SortKey; sortKey: SortKey; sortDir: SortDir }) { +function SortIcon({ col, sortKey, sortDir }: { col: T; sortKey: T; sortDir: SortDir }) { if (col !== sortKey) return ; return sortDir === "asc" ? : ; } -interface Props { - rows: PgStockNegatifRow[]; - magasin: string; - sites: { code: string; label: string }[]; -} - -export function StockNegatifClient({ rows, magasin, sites }: Props) { - const router = useRouter(); - const [sortKey, setSortKey] = useState("stockdispo"); +function useSortedRows>(rows: T[], numericKeys: string[]) { + const [sortKey, setSortKey] = useState(Object.keys(rows[0] ?? { codein: "" })[0] as keyof T); const [sortDir, setSortDir] = useState("asc"); - function handleMagasinChange(e: React.ChangeEvent) { - const val = e.target.value; - if (val) { - router.push(`/stock-negatif?magasin=${val}`); - } else { - router.push("/stock-negatif"); - } + function handleSort(key: keyof T) { + if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc"); + else { setSortKey(key); setSortDir("asc"); } } - function handleSort(key: SortKey) { - if (sortKey === key) { - setSortDir(d => d === "asc" ? "desc" : "asc"); - } else { - setSortKey(key); - setSortDir("asc"); + const sorted = useMemo(() => [...rows].sort((a, b) => { + const av = a[sortKey]; + const bv = b[sortKey]; + if (numericKeys.includes(sortKey as string)) { + return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av); } - } + const as = String(av ?? "").toLowerCase(); + const bs = String(bv ?? "").toLowerCase(); + return sortDir === "asc" ? as.localeCompare(bs) : bs.localeCompare(as); + }), [rows, sortKey, sortDir]); - const sorted = useMemo(() => { - return [...rows].sort((a, b) => { - let av: string | number = a[sortKey] ?? ""; - let bv: string | number = b[sortKey] ?? ""; - if (sortKey === "stockdispo") { - av = Number(av); - bv = Number(bv); - return sortDir === "asc" ? av - bv : bv - av; - } - av = String(av).toLowerCase(); - bv = String(bv).toLowerCase(); - return sortDir === "asc" ? av.localeCompare(bv) : bv.localeCompare(av); - }); - }, [rows, sortKey, sortDir]); + return { sorted, sortKey, sortDir, handleSort }; +} + +// --------------------------------------------------------------------------- +// Onglet 1 — Stock Négatif +// --------------------------------------------------------------------------- + +function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin: string }) { + type SK = keyof PgStockNegatifRow; + const { sorted, sortKey, sortDir, handleSort } = useSortedRows( + rows.length ? rows : [] as PgStockNegatifRow[], + ["stockdispo"] + ); + + const th = (key: SK, label: string) => ( + handleSort(key)}> + {label} + + ); async function handleExport() { const ExcelJS = (await import("exceljs")).default; const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet("Stock Négatif"); - - const headers = ["Code article", "Libellé", "Fournisseur", "Stock négatif"]; - ws.addRow(headers); - const headerRow = ws.getRow(1); - headerRow.eachCell(cell => { + ws.addRow(["Code article", "Libellé", "Fournisseur", "Stock négatif"]); + ws.getRow(1).eachCell(cell => { cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } }; cell.font = { color: { argb: "FFFFFFFF" }, bold: true }; cell.alignment = { vertical: "middle", horizontal: "center" }; }); ws.getRow(1).height = 22; - - for (const r of sorted) { - ws.addRow([r.codein, r.libelle1, r.fournisseur, r.stockdispo]); - } - - ws.columns = [ - { key: "codein", width: 16 }, - { key: "libelle1", width: 40 }, - { key: "fournisseur", width: 30 }, - { key: "stockdispo", width: 14 }, - ]; - - const today = new Date().toISOString().slice(0, 10); - const siteSuffix = magasin || "Tous"; - const filename = `Stock_Negatif_${siteSuffix}_${today}.xlsx`; - + for (const r of sorted) ws.addRow([r.codein, r.libelle1, r.fournisseur, r.stockdispo]); + ws.columns = [{ width: 16 }, { width: 40 }, { width: 30 }, { width: 14 }]; const buffer = await wb.xlsx.writeBuffer(); const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = filename; + a.download = `Stock_Negatif_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`; a.click(); URL.revokeObjectURL(url); } - const th = (key: SortKey, label: string) => ( - handleSort(key)} - > - {label} - + return ( +
+
+ {sorted.length} article{sorted.length !== 1 ? "s" : ""} + +
+
+ + + + {th("codein", "Code article")} + {th("libelle1", "Libellé")} + {th("fournisseur", "Fournisseur")} + {th("site", "Magasin")} + {th("stockdispo", "Stock")} + {th("dernierevente", "Dernière vente")} + + + + {sorted.length === 0 && ( + + )} + {sorted.map((row, i) => ( + + + + + + + + + ))} + +
Aucun article en stock négatif
{row.codein}{row.libelle1}{row.fournisseur} + {row.site} + {row.stockdispo}{fmtDate(row.dernierevente)}
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Onglet 2 — Entrées sans vente +// --------------------------------------------------------------------------- + +function TabEntreesSansVente({ rows, magasin }: { rows: PgStockSansVenteRow[]; magasin: string }) { + type SK = keyof PgStockSansVenteRow; + const { sorted, sortKey, sortDir, handleSort } = useSortedRows( + rows.length ? rows : [] as PgStockSansVenteRow[], + ["stock_actuel"] + ); + + const th = (key: SK, label: string) => ( + handleSort(key)}> + {label} ); + async function handleExport() { + const ExcelJS = (await import("exceljs")).default; + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet("Entrées sans vente"); + ws.addRow(["Code article", "Libellé", "Fournisseur", "Magasin", "Stock actuel", "Dernière entrée"]); + ws.getRow(1).eachCell(cell => { + cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } }; + cell.font = { color: { argb: "FFFFFFFF" }, bold: true }; + cell.alignment = { vertical: "middle", horizontal: "center" }; + }); + ws.getRow(1).height = 22; + for (const r of sorted) ws.addRow([r.codein, r.libelle1, r.fournisseur, r.site, r.stock_actuel, fmtDate(r.derniere_entree)]); + ws.columns = [{ width: 16 }, { width: 40 }, { width: 30 }, { width: 10 }, { width: 14 }, { width: 16 }]; + const buffer = await wb.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `Entrees_Sans_Vente_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + } + + return ( +
+
+ {sorted.length} article{sorted.length !== 1 ? "s" : ""} + +
+
+ + + + {th("codein", "Code article")} + {th("libelle1", "Libellé")} + {th("fournisseur", "Fournisseur")} + {th("site", "Magasin")} + {th("stock_actuel", "Stock actuel")} + {th("derniere_entree", "Dernière entrée")} + + + + {sorted.length === 0 && ( + + )} + {sorted.map((row, i) => ( + + + + + + + + + ))} + +
Aucun article trouvé
{row.codein}{row.libelle1}{row.fournisseur} + {row.site} + {row.stock_actuel}{fmtDate(row.derniere_entree)}
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Composant principal avec onglets +// --------------------------------------------------------------------------- + +const TABS = [ + { key: "negatif", label: "Stock négatif" }, + { key: "sans-vente", label: "Entrées sans vente" }, +] as const; + +type TabKey = typeof TABS[number]["key"]; + +interface Props { + rowsNegatif: PgStockNegatifRow[]; + rowsSansVente: PgStockSansVenteRow[]; + magasin: string; + tab: string; + sites: { code: string; label: string }[]; +} + +export function GestionStockClient({ rowsNegatif, rowsSansVente, magasin, tab, sites }: Props) { + const router = useRouter(); + const activeTab: TabKey = (TABS.some(t => t.key === tab) ? tab : "negatif") as TabKey; + + function handleMagasinChange(e: React.ChangeEvent) { + const val = e.target.value; + const params = new URLSearchParams(); + if (val) params.set("magasin", val); + params.set("tab", activeTab); + router.push(`/stock-negatif?${params.toString()}`); + } + + function handleTabChange(key: TabKey) { + const params = new URLSearchParams(); + if (magasin) params.set("magasin", magasin); + params.set("tab", key); + router.push(`/stock-negatif?${params.toString()}`); + } + return (
{/* Toolbar */} @@ -133,63 +267,37 @@ export function StockNegatifClient({ rows, magasin, sites }: Props) { ))}
- -
- - {sorted.length} article{sorted.length !== 1 ? "s" : ""} - - -
- {/* Table */} -
- - - - {th("codein", "Code article")} - {th("libelle1", "Libellé")} - {th("fournisseur", "Fournisseur")} - {th("site", "Magasin")} - {th("stockdispo", "Stock négatif")} - {th("dernierevente", "Dernière vente")} - - - - {sorted.length === 0 && ( - - - - )} - {sorted.map((row, i) => ( - - - - - - - - - ))} - -
- Aucun article en stock négatif -
{row.codein}{row.libelle1}{row.fournisseur} - - {row.site} - - - - {row.stockdispo} - - {fmtDate(row.dernierevente)}
+ {/* Tabs */} +
+
+ + {/* Tab content */} + {activeTab === "negatif" && } + {activeTab === "sans-vente" && }
); } diff --git a/src/app/(dashboard)/stock-negatif/page.tsx b/src/app/(dashboard)/stock-negatif/page.tsx index 414b8f2..a059760 100644 --- a/src/app/(dashboard)/stock-negatif/page.tsx +++ b/src/app/(dashboard)/stock-negatif/page.tsx @@ -1,28 +1,34 @@ -import { pgGetStockNegatif, PgStockNegatifRow } from "@/lib/pg-ff-client"; -import { StockNegatifClient } from "./client"; +import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow } from "@/lib/pg-ff-client"; +import { GestionStockClient } from "./client"; -export type { PgStockNegatifRow }; +export type { PgStockNegatifRow, PgStockSansVenteRow }; const SITES = [ { code: "292", label: "292 — Frouard / Nancy" }, { code: "579", label: "579 — Houdemont" }, ]; -export default async function StockNegatifPage(props: { +export default async function GestionStockPage(props: { searchParams: Promise>; }) { const searchParams = await props.searchParams; const magasin = (searchParams.magasin as string) || ""; + const tab = (searchParams.tab as string) || "negatif"; - const rows = await pgGetStockNegatif(magasin || undefined); + const [rowsNegatif, rowsSansVente] = await Promise.all([ + pgGetStockNegatif(magasin || undefined), + pgGetStockSansVente(magasin || undefined), + ]); return (
-

Stock Négatif

- Gestion de Stock +
diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index 54f7650..90d0796 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -14,7 +14,7 @@ const NAV_ITEMS = [ { icon: TrendingUp, label: "Analytics", href: "/analytics" }, { icon: Trophy, label: "Hit Parade", href: "/hit-parade" }, { icon: Megaphone, label: "Publicités", href: "/publicites" }, - { icon: PackageMinus, label: "Stock Négatif", href: "/stock-negatif" }, + { icon: PackageMinus, label: "Gestion de Stock", href: "/stock-negatif" }, { icon: Camera, label: "Snapshots", href: "/snapshots" }, { icon: FileDown, label: "Exports", href: "/exports", adminOnly: true }, { icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true }, diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 2f10a1d..bf23158 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -846,6 +846,50 @@ export interface PgStockNegatifRow { * Retourne tous les articles avec un stock disponible négatif. * Si `site` est fourni, filtre sur ce magasin uniquement. */ +export interface PgStockSansVenteRow { + codein: string; + libelle1: string; + fournisseur: string; + site: string; + stock_actuel: number; + derniere_entree: string | null; +} + +/** + * Retourne les articles ayant des entrées en stock (hors mois courant) + * mais aucune vente enregistrée sur ce site. + */ +export async function pgGetStockSansVente(site?: string): Promise { + const siteFilter = site ? sql`AND m.site = ${site}` : sql``; + const result = await pgNoParallel(sql` + SELECT + a.codein, + COALESCE(a.libelle1, '') AS libelle1, + COALESCE(f.nom, af.code) AS fournisseur, + m.site, + COALESCE(cs.qte::float, 0) AS stock_actuel, + MAX(m.datmvt)::text AS derniere_entree + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 + JOIN fouident f ON f.code = af.code + LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = 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) + ${siteFilter} + AND NOT EXISTS ( + SELECT 1 FROM mvtart mv2 + WHERE mv2.artnoid = m.artnoid + AND mv2.site = m.site + AND mv2.genremvt = 3 + ) + GROUP BY a.codein, a.libelle1, f.nom, af.code, m.site, cs.qte + ORDER BY MAX(m.datmvt) DESC + `); + return result.rows as PgStockSansVenteRow[]; +} + export async function pgGetStockNegatif(site?: string): Promise { const siteFilter = site ? sql`AND cs.site = ${site}` : sql``; const result = await pgNoParallel(sql` From 2264ced494cfa53af63abd355baa1e11789e9166 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 7 Apr 2026 16:56:57 +0200 Subject: [PATCH 28/57] feat: add client-side data table components for negative stock and stagnant inventory with sorting and Excel export capabilities --- src/app/(dashboard)/stock-negatif/client.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/(dashboard)/stock-negatif/client.tsx b/src/app/(dashboard)/stock-negatif/client.tsx index d0d3ebd..9401e2a 100644 --- a/src/app/(dashboard)/stock-negatif/client.tsx +++ b/src/app/(dashboard)/stock-negatif/client.tsx @@ -134,8 +134,9 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin function TabEntreesSansVente({ rows, magasin }: { rows: PgStockSansVenteRow[]; magasin: string }) { type SK = keyof PgStockSansVenteRow; + const filteredRows = useMemo(() => rows.filter(r => r.stock_actuel !== 0), [rows]); const { sorted, sortKey, sortDir, handleSort } = useSortedRows( - rows.length ? rows : [] as PgStockSansVenteRow[], + filteredRows, ["stock_actuel"] ); @@ -288,7 +289,7 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, magasin, tab, s "ml-2 rounded-full px-2 py-0.5 text-xs font-semibold", activeTab === t.key ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500", ].join(" ")}> - {t.key === "negatif" ? rowsNegatif.length : rowsSansVente.length} + {t.key === "negatif" ? rowsNegatif.length : rowsSansVente.filter(r => r.stock_actuel !== 0).length} ))} From e8fb472a9f522547d070c2bfc4fabc5ea06e45cc Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 7 Apr 2026 17:16:33 +0200 Subject: [PATCH 29/57] feat: implement shared LoadingModal component and integrate into dashboard route loading states --- src/app/(dashboard)/analytics/loading.tsx | 31 ++----------- src/app/(dashboard)/dashboard/loading.tsx | 28 ++---------- src/app/(dashboard)/exports/loading.tsx | 22 ++------- src/app/(dashboard)/grid/loading.tsx | 40 ++--------------- src/app/(dashboard)/hit-parade/loading.tsx | 35 ++------------- src/app/(dashboard)/publicites/loading.tsx | 26 ++--------- src/app/(dashboard)/settings/loading.tsx | 18 ++------ src/app/(dashboard)/snapshots/loading.tsx | 19 ++------ src/app/(dashboard)/stock-negatif/loading.tsx | 5 +++ src/components/shared/loading-modal.tsx | 45 +++++++++++++++++++ 10 files changed, 74 insertions(+), 195 deletions(-) create mode 100644 src/app/(dashboard)/stock-negatif/loading.tsx create mode 100644 src/components/shared/loading-modal.tsx diff --git a/src/app/(dashboard)/analytics/loading.tsx b/src/app/(dashboard)/analytics/loading.tsx index dd99d6d..1f90c2f 100644 --- a/src/app/(dashboard)/analytics/loading.tsx +++ b/src/app/(dashboard)/analytics/loading.tsx @@ -1,30 +1,5 @@ +import LoadingModal from "@/components/shared/loading-modal"; + export default function AnalyticsLoading() { - return ( -
- {/* Header */} -
-
-
-
-
-
-
- - {/* Filter bar */} -
- - {/* Table skeleton */} -
-
- {Array.from({ length: 8 }).map((_, i) => ( -
-
-
-
-
-
- ))} -
-
- ); + return ; } diff --git a/src/app/(dashboard)/dashboard/loading.tsx b/src/app/(dashboard)/dashboard/loading.tsx index 89b212a..f3b9911 100644 --- a/src/app/(dashboard)/dashboard/loading.tsx +++ b/src/app/(dashboard)/dashboard/loading.tsx @@ -1,27 +1,5 @@ +import LoadingModal from "@/components/shared/loading-modal"; + export default function DashboardLoading() { - return ( -
- {/* Header skeleton */} -
-
-
-
- - {/* KPI cards row */} -
- {Array.from({ length: 4 }).map((_, i) => ( -
- ))} -
- - {/* Main content area */} -
-
-
-
- - {/* Bottom tables */} -
-
- ); + return ; } diff --git a/src/app/(dashboard)/exports/loading.tsx b/src/app/(dashboard)/exports/loading.tsx index 6c6c9ab..96505c3 100644 --- a/src/app/(dashboard)/exports/loading.tsx +++ b/src/app/(dashboard)/exports/loading.tsx @@ -1,21 +1,5 @@ -export default function ExportsLoading() { - return ( -
- {/* Header */} -
+import LoadingModal from "@/components/shared/loading-modal"; - {/* Table */} -
-
- {Array.from({ length: 8 }).map((_, i) => ( -
-
-
-
-
-
- ))} -
-
- ); +export default function ExportsLoading() { + return ; } diff --git a/src/app/(dashboard)/grid/loading.tsx b/src/app/(dashboard)/grid/loading.tsx index 4d3b3df..9744063 100644 --- a/src/app/(dashboard)/grid/loading.tsx +++ b/src/app/(dashboard)/grid/loading.tsx @@ -1,39 +1,5 @@ -export default function GridLoading() { - return ( -
-
- {/* Spinner */} -
-
-
+import LoadingModal from "@/components/shared/loading-modal"; -
- - Chargement des données - - - Récupération des articles et calcul des scores... - -
-
-
- ); +export default function GridLoading() { + return ; } diff --git a/src/app/(dashboard)/hit-parade/loading.tsx b/src/app/(dashboard)/hit-parade/loading.tsx index d85f2ae..276e208 100644 --- a/src/app/(dashboard)/hit-parade/loading.tsx +++ b/src/app/(dashboard)/hit-parade/loading.tsx @@ -1,34 +1,5 @@ +import LoadingModal from "@/components/shared/loading-modal"; + export default function HitParadeLoading() { - return ( -
- {/* Header */} -
-
-
-
-
-
-
- - {/* Podium / top cards */} -
- {Array.from({ length: 3 }).map((_, i) => ( -
- ))} -
- - {/* Rankings table */} -
-
- {Array.from({ length: 10 }).map((_, i) => ( -
-
-
-
-
-
- ))} -
-
- ); + return ; } diff --git a/src/app/(dashboard)/publicites/loading.tsx b/src/app/(dashboard)/publicites/loading.tsx index 8e74239..131b8b2 100644 --- a/src/app/(dashboard)/publicites/loading.tsx +++ b/src/app/(dashboard)/publicites/loading.tsx @@ -1,25 +1,5 @@ +import LoadingModal from "@/components/shared/loading-modal"; + export default function PublicitesLoading() { - return ( -
-
-
-
-
-
-
- {Array.from({ length: 8 }).map((_, i) => ( -
-
-
-
-
-
-
-
- ))} -
-
-
-
- ); + return ; } diff --git a/src/app/(dashboard)/settings/loading.tsx b/src/app/(dashboard)/settings/loading.tsx index 0d0039b..7b00b11 100644 --- a/src/app/(dashboard)/settings/loading.tsx +++ b/src/app/(dashboard)/settings/loading.tsx @@ -1,17 +1,5 @@ -export default function SettingsLoading() { - return ( -
- {/* Header */} -
+import LoadingModal from "@/components/shared/loading-modal"; - {/* Settings sections */} - {Array.from({ length: 3 }).map((_, i) => ( -
-
-
-
-
- ))} -
- ); +export default function SettingsLoading() { + return ; } diff --git a/src/app/(dashboard)/snapshots/loading.tsx b/src/app/(dashboard)/snapshots/loading.tsx index 4a4e705..346c5ef 100644 --- a/src/app/(dashboard)/snapshots/loading.tsx +++ b/src/app/(dashboard)/snapshots/loading.tsx @@ -1,18 +1,5 @@ -export default function SnapshotsLoading() { - return ( -
- {/* Header */} -
-
-
-
+import LoadingModal from "@/components/shared/loading-modal"; - {/* Cards grid */} -
- {Array.from({ length: 6 }).map((_, i) => ( -
- ))} -
-
- ); +export default function SnapshotsLoading() { + return ; } diff --git a/src/app/(dashboard)/stock-negatif/loading.tsx b/src/app/(dashboard)/stock-negatif/loading.tsx new file mode 100644 index 0000000..f934a03 --- /dev/null +++ b/src/app/(dashboard)/stock-negatif/loading.tsx @@ -0,0 +1,5 @@ +import LoadingModal from "@/components/shared/loading-modal"; + +export default function StockNegatifLoading() { + return ; +} diff --git a/src/components/shared/loading-modal.tsx b/src/components/shared/loading-modal.tsx new file mode 100644 index 0000000..dc0fd02 --- /dev/null +++ b/src/components/shared/loading-modal.tsx @@ -0,0 +1,45 @@ +interface LoadingModalProps { + message?: string; + subMessage?: string; +} + +export default function LoadingModal({ + message = "Chargement des données", + subMessage = "Veuillez patienter...", +}: LoadingModalProps) { + return ( +
+
+
+
+
+
+ + {message} + + + {subMessage} + +
+
+
+ ); +} From 5a13640884143b0da9004aceb105a19b93a3edc9 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 7 Apr 2026 17:36:27 +0200 Subject: [PATCH 30/57] feat: implement pg-ff-client to replace per-article HTTP calls with bulk PostgreSQL queries --- src/app/(dashboard)/stock-negatif/client.tsx | 10 ++++++---- src/lib/pg-ff-client.ts | 10 +++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/app/(dashboard)/stock-negatif/client.tsx b/src/app/(dashboard)/stock-negatif/client.tsx index 9401e2a..673a1f3 100644 --- a/src/app/(dashboard)/stock-negatif/client.tsx +++ b/src/app/(dashboard)/stock-negatif/client.tsx @@ -66,15 +66,15 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin const ExcelJS = (await import("exceljs")).default; const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet("Stock Négatif"); - ws.addRow(["Code article", "Libellé", "Fournisseur", "Stock négatif"]); + ws.addRow(["Code article", "Libellé", "Fournisseur", "Magasin", "Stock négatif", "Dernière vente", "Dernière entrée en stock"]); ws.getRow(1).eachCell(cell => { cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } }; cell.font = { color: { argb: "FFFFFFFF" }, bold: true }; cell.alignment = { vertical: "middle", horizontal: "center" }; }); ws.getRow(1).height = 22; - for (const r of sorted) ws.addRow([r.codein, r.libelle1, r.fournisseur, r.stockdispo]); - ws.columns = [{ width: 16 }, { width: 40 }, { width: 30 }, { width: 14 }]; + for (const r of sorted) ws.addRow([r.codein, r.libelle1, r.fournisseur, r.site, r.stockdispo, fmtDate(r.dernierevente), fmtDate(r.derniereentree)]); + ws.columns = [{ width: 16 }, { width: 40 }, { width: 30 }, { width: 10 }, { width: 14 }, { width: 16 }, { width: 22 }]; const buffer = await wb.xlsx.writeBuffer(); const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); const url = URL.createObjectURL(blob); @@ -103,11 +103,12 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin {th("site", "Magasin")} {th("stockdispo", "Stock")} {th("dernierevente", "Dernière vente")} + {th("derniereentree", "Dernière entrée")} {sorted.length === 0 && ( - Aucun article en stock négatif + Aucun article en stock négatif )} {sorted.map((row, i) => ( @@ -119,6 +120,7 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin {row.stockdispo} {fmtDate(row.dernierevente)} + {fmtDate(row.derniereentree)} ))} diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index bf23158..784e61e 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -840,6 +840,7 @@ export interface PgStockNegatifRow { site: string; stockdispo: number; dernierevente: string | null; + derniereentree: string | null; } /** @@ -899,7 +900,14 @@ export async function pgGetStockNegatif(site?: string): Promise Date: Wed, 8 Apr 2026 09:59:14 +0200 Subject: [PATCH 31/57] feat: implement interactive stock negative and no-sales data table with filtering, sorting, and Excel export functionality --- src/app/(dashboard)/stock-negatif/client.tsx | 119 +++++++++++++++++-- 1 file changed, 106 insertions(+), 13 deletions(-) diff --git a/src/app/(dashboard)/stock-negatif/client.tsx b/src/app/(dashboard)/stock-negatif/client.tsx index 673a1f3..86f3ef7 100644 --- a/src/app/(dashboard)/stock-negatif/client.tsx +++ b/src/app/(dashboard)/stock-negatif/client.tsx @@ -14,6 +14,13 @@ function fmtDate(raw: string | null) { return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }); } +function getYear(raw: string | null): string { + if (!raw) return ""; + const d = new Date(raw); + if (isNaN(d.getTime())) return ""; + return String(d.getFullYear()); +} + function SortIcon({ col, sortKey, sortDir }: { col: T; sortKey: T; sortDir: SortDir }) { if (col !== sortKey) return ; return sortDir === "asc" @@ -21,6 +28,27 @@ function SortIcon({ col, sortKey, sortDir }: { col: T; sortKey : ; } +function FilterSelect({ label, value, onChange, options }: { + label: string; + value: string; + onChange: (v: string) => void; + options: string[]; +}) { + return ( +
+ + +
+ ); +} + function useSortedRows>(rows: T[], numericKeys: string[]) { const [sortKey, setSortKey] = useState(Object.keys(rows[0] ?? { codein: "" })[0] as keyof T); const [sortDir, setSortDir] = useState("asc"); @@ -50,8 +78,26 @@ function useSortedRows>(rows: T[], numericKeys function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin: string }) { type SK = keyof PgStockNegatifRow; + + const [filterFournisseur, setFilterFournisseur] = useState(""); + const [filterAnnee, setFilterAnnee] = useState(""); + + const fournisseurs = useMemo(() => + [...new Set(rows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")), + [rows] + ); + const annees = useMemo(() => + [...new Set(rows.map(r => getYear(r.derniereentree)).filter(Boolean))].sort((a, b) => b.localeCompare(a)), + [rows] + ); + + const filtered = useMemo(() => rows.filter(r => + (!filterFournisseur || r.fournisseur === filterFournisseur) && + (!filterAnnee || getYear(r.derniereentree) === filterAnnee) + ), [rows, filterFournisseur, filterAnnee]); + const { sorted, sortKey, sortDir, handleSort } = useSortedRows( - rows.length ? rows : [] as PgStockNegatifRow[], + filtered, ["stockdispo"] ); @@ -87,11 +133,25 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin return (
-
- {sorted.length} article{sorted.length !== 1 ? "s" : ""} - +
+
+ + + {(filterFournisseur || filterAnnee) && ( + + )} +
+
+ {sorted.length} article{sorted.length !== 1 ? "s" : ""} + +
@@ -136,9 +196,28 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin function TabEntreesSansVente({ rows, magasin }: { rows: PgStockSansVenteRow[]; magasin: string }) { type SK = keyof PgStockSansVenteRow; - const filteredRows = useMemo(() => rows.filter(r => r.stock_actuel !== 0), [rows]); + + const [filterFournisseur, setFilterFournisseur] = useState(""); + const [filterAnnee, setFilterAnnee] = useState(""); + + const baseRows = useMemo(() => rows.filter(r => r.stock_actuel !== 0), [rows]); + + const fournisseurs = useMemo(() => + [...new Set(baseRows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")), + [baseRows] + ); + const annees = useMemo(() => + [...new Set(baseRows.map(r => getYear(r.derniere_entree)).filter(Boolean))].sort((a, b) => b.localeCompare(a)), + [baseRows] + ); + + const filtered = useMemo(() => baseRows.filter(r => + (!filterFournisseur || r.fournisseur === filterFournisseur) && + (!filterAnnee || getYear(r.derniere_entree) === filterAnnee) + ), [baseRows, filterFournisseur, filterAnnee]); + const { sorted, sortKey, sortDir, handleSort } = useSortedRows( - filteredRows, + filtered, ["stock_actuel"] ); @@ -174,11 +253,25 @@ function TabEntreesSansVente({ rows, magasin }: { rows: PgStockSansVenteRow[]; m return (
-
- {sorted.length} article{sorted.length !== 1 ? "s" : ""} - +
+
+ + + {(filterFournisseur || filterAnnee) && ( + + )} +
+
+ {sorted.length} article{sorted.length !== 1 ? "s" : ""} + +
From d25ffdb3e7706f18889cfa53fbd49ca0d11ba280 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 8 Apr 2026 12:14:18 +0200 Subject: [PATCH 32/57] feat: implement pg-ff-client to replace per-article HTTP calls with bulk PostgreSQL queries --- src/lib/pg-ff-client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 784e61e..6d66ed7 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -759,9 +759,9 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis stock_agg AS ( SELECT cs.artnoid, - SUM(CASE WHEN cs.site = '292' THEN cs.stockdispo ELSE 0 END)::float AS stock292, - SUM(CASE WHEN cs.site = '579' THEN cs.stockdispo ELSE 0 END)::float AS stock579, - SUM(cs.stockdispo)::float AS stockTotal + SUM(CASE WHEN cs.site = '292' THEN cs.qte ELSE 0 END)::float AS stock292, + SUM(CASE WHEN cs.site = '579' THEN cs.qte ELSE 0 END)::float AS stock579, + SUM(cs.qte)::float AS stockTotal FROM cube_stock cs WHERE cs.artnoid IN (SELECT DISTINCT art_no_id FROM ventes) GROUP BY cs.artnoid From 70c425befb4fb389d249e9facd3a5ad40fb6857d Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 8 Apr 2026 15:53:59 +0200 Subject: [PATCH 33/57] feat: implement sidebar navigation and automated order management module with PostgreSQL client integration --- src/app/(dashboard)/commandes-auto/client.tsx | 223 ++++++++++++++++++ .../(dashboard)/commandes-auto/loading.tsx | 5 + src/app/(dashboard)/commandes-auto/page.tsx | 16 ++ src/app/api/commandes-auto/route.ts | 33 +++ src/components/shared/sidebar.tsx | 3 +- src/lib/pg-ff-client.ts | 72 ++++++ 6 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 src/app/(dashboard)/commandes-auto/client.tsx create mode 100644 src/app/(dashboard)/commandes-auto/loading.tsx create mode 100644 src/app/(dashboard)/commandes-auto/page.tsx create mode 100644 src/app/api/commandes-auto/route.ts diff --git a/src/app/(dashboard)/commandes-auto/client.tsx b/src/app/(dashboard)/commandes-auto/client.tsx new file mode 100644 index 0000000..1621b14 --- /dev/null +++ b/src/app/(dashboard)/commandes-auto/client.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ChevronUp, ChevronDown, ChevronsUpDown, CheckCircle2, AlertCircle, Minus } from "lucide-react"; +import type { PgCommandeAutoRow } from "./page"; + +type SortDir = "asc" | "desc"; + +const SITES: { code: string; label: string }[] = [ + { code: "292", label: "292 — Frouard / Nancy" }, + { code: "579", label: "579 — Houdemont" }, +]; + +function fmt(n: number) { + return n.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €"; +} + +function SortIcon({ col, sortKey, sortDir }: { col: string; sortKey: string; sortDir: SortDir }) { + if (col !== sortKey) return ; + return sortDir === "asc" + ? + : ; +} + +type SK = keyof PgCommandeAutoRow; +const NUMERIC: SK[] = ["franco", "nb_articles", "montant_cde", "ecart_franco"]; + +function FrancoCell({ atteint, ecart, franco }: { atteint: boolean | null; ecart: number; franco: number }) { + if (franco === 0 || atteint === null) { + return ( + + Pas de franco + + ); + } + if (atteint) { + return ( + + Atteint + + ); + } + return ( + + −{fmt(Math.abs(ecart))} manquant + + ); +} + +function StoreTable({ site, label, rows }: { site: string; label: string; rows: PgCommandeAutoRow[] }) { + const [sortKey, setSortKey] = useState("nomfou"); + const [sortDir, setSortDir] = useState("asc"); + const [search, setSearch] = useState(""); + + function handleSort(key: SK) { + if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc"); + else { setSortKey(key); setSortDir("asc"); } + } + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return rows.filter(r => !q || r.nomfou.toLowerCase().includes(q) || r.codefou.toLowerCase().includes(q)); + }, [rows, search]); + + const sorted = useMemo(() => [...filtered].sort((a, b) => { + const av = a[sortKey]; + const bv = b[sortKey]; + if (NUMERIC.includes(sortKey)) { + return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av); + } + return sortDir === "asc" + ? String(av ?? "").localeCompare(String(bv ?? ""), "fr") + : String(bv ?? "").localeCompare(String(av ?? ""), "fr"); + }), [filtered, sortKey, sortDir]); + + const nbFrancoAtteint = rows.filter(r => r.franco_atteint === true).length; + const totalMontant = rows.reduce((s, r) => s + Number(r.montant_cde), 0); + + const th = (key: SK, label: string, right = false) => ( + + ); + + return ( +
+ {/* En-tête magasin */} +
+
+

{label}

+ + {rows.length} fournisseur{rows.length !== 1 ? "s" : ""} + + {nbFrancoAtteint > 0 && ( + + {nbFrancoAtteint} franco atteint + + )} + + Total : {fmt(totalMontant)} + +
+ setSearch(e.target.value)} + className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]" + /> +
+ + {/* Tableau */} +
+
handleSort(key)} + > + {label} +
+ + + {th("codefou", "Code")} + {th("nomfou", "Fournisseur")} + {th("nb_articles", "Articles", true)} + {th("montant_cde", "Montant CDE auto", true)} + {th("franco", "Franco", true)} + + {th("ecart_franco", "Écart", true)} + + + + {sorted.length === 0 && ( + + + + )} + {sorted.map(row => { + const francoAtteint = row.franco_atteint === true; + const hasFranco = row.franco > 0 && row.franco_atteint !== null; + return ( + + + + + + + + + + ); + })} + +
+ Statut franco +
+ Aucun fournisseur en commande automatique +
{row.codefou}{row.nomfou}{row.nb_articles} + {fmt(Number(row.montant_cde))} + + {row.franco > 0 ? fmt(Number(row.franco)) : —} + + + + {hasFranco + ? (Number(row.ecart_franco) > 0 + ? `−${fmt(Number(row.ecart_franco))}` + : `+${fmt(Math.abs(Number(row.ecart_franco)))}`) + : "—"} +
+
+
+ ); +} + +export function CommandesAutoClient({ rows }: { rows: PgCommandeAutoRow[] }) { + const bySite = useMemo(() => { + const map = new Map(); + for (const r of rows) { + if (!map.has(r.site)) map.set(r.site, []); + map.get(r.site)!.push(r); + } + return map; + }, [rows]); + + if (rows.length === 0) { + return ( +
+ Aucune commande automatique trouvée. +
+ ); + } + + return ( +
+ {SITES.map(({ code, label }) => { + const siteRows = bySite.get(code) ?? []; + return ( + + ); + })} +
+ ); +} diff --git a/src/app/(dashboard)/commandes-auto/loading.tsx b/src/app/(dashboard)/commandes-auto/loading.tsx new file mode 100644 index 0000000..6d6405d --- /dev/null +++ b/src/app/(dashboard)/commandes-auto/loading.tsx @@ -0,0 +1,5 @@ +import LoadingModal from "@/components/shared/loading-modal"; + +export default function CommandesAutoLoading() { + return ; +} diff --git a/src/app/(dashboard)/commandes-auto/page.tsx b/src/app/(dashboard)/commandes-auto/page.tsx new file mode 100644 index 0000000..44ec19d --- /dev/null +++ b/src/app/(dashboard)/commandes-auto/page.tsx @@ -0,0 +1,16 @@ +import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client"; +import { CommandesAutoClient } from "./client"; + +export type { PgCommandeAutoRow }; + +export default async function CommandesAutoPage() { + const rows = await pgGetCommandesAuto(); + return ( +
+
+

Commandes automatiques

+ +
+
+ ); +} diff --git a/src/app/api/commandes-auto/route.ts b/src/app/api/commandes-auto/route.ts new file mode 100644 index 0000000..d3c1a6e --- /dev/null +++ b/src/app/api/commandes-auto/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { pgGetCommandesAuto } from "@/lib/pg-ff-client"; + +/** + * GET /api/commandes-auto + * + * Retourne la liste des fournisseurs en commande automatique, + * groupés par site (292, 579), avec franco et écart franco. + * + * Réponse : + * [ + * { + * site: "292", + * codefou: "FOU123", + * nomfou: "Nom Fournisseur", + * franco: 500, + * nb_articles: 12, + * montant_cde: 620.50, + * franco_atteint: true, + * ecart_franco: -120.50 + * }, + * ... + * ] + */ +export async function GET() { + try { + const rows = await pgGetCommandesAuto(); + return NextResponse.json(rows); + } catch (e) { + console.error("[api/commandes-auto]", e); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index 90d0796..f9b4971 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react"; +import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; @@ -15,6 +15,7 @@ const NAV_ITEMS = [ { icon: Trophy, label: "Hit Parade", href: "/hit-parade" }, { icon: Megaphone, label: "Publicités", href: "/publicites" }, { icon: PackageMinus, label: "Gestion de Stock", href: "/stock-negatif" }, + { icon: ShoppingCart, label: "Commandes auto", href: "/commandes-auto" }, { icon: Camera, label: "Snapshots", href: "/snapshots" }, { icon: FileDown, label: "Exports", href: "/exports", adminOnly: true }, { icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true }, diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 6d66ed7..b54cfaa 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -919,3 +919,75 @@ export async function pgGetStockNegatif(site?: string): Promise { + try { + const result = await pgNoParallel(sql` + SELECT + cs.site, + fi.code AS codefou, + COALESCE(fi.nom, af.code) AS nomfou, + COALESCE(fi.franco::numeric, 0) AS franco, + COUNT(DISTINCT a.codein) AS nb_articles, + ROUND(SUM( + GREATEST(1, COALESCE(af.pcb, 1))::numeric + * COALESCE(pa.pa, 0)::numeric + ), 2) AS montant_cde, + CASE + WHEN COALESCE(fi.franco::numeric, 0) = 0 THEN NULL + WHEN SUM( + GREATEST(1, COALESCE(af.pcb, 1))::numeric + * COALESCE(pa.pa, 0)::numeric + ) >= COALESCE(fi.franco::numeric, 0) THEN true + ELSE false + END AS franco_atteint, + ROUND( + COALESCE(fi.franco::numeric, 0) - SUM( + GREATEST(1, COALESCE(af.pcb, 1))::numeric + * COALESCE(pa.pa, 0)::numeric + ), 2 + ) AS ecart_franco + FROM artfou1 af + JOIN articles a ON a.no_id = af.art_no_id + JOIN fouident fi ON fi.code = af.code + JOIN cube_stock cs ON cs.artnoid = a.no_id + LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id + WHERE af.cdeauto::text IN ('1', 'true', 't', 'yes', 'y', 'on') + AND cs.site IN ('292', '579') + AND (af.suspendu IS NULL OR af.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) + AND (a.suspendu IS NULL OR a.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) + GROUP BY cs.site, fi.code, fi.nom, af.code, fi.franco + ORDER BY cs.site, COALESCE(fi.nom, af.code) + `); + console.log(`[pg-ff] pgGetCommandesAuto: ${result.rows.length} lignes`); + return result.rows as unknown as PgCommandeAutoRow[]; + } catch (e) { + console.error("[pg-ff] pgGetCommandesAuto error:", (e as Error).message?.slice(0, 300)); + return []; + } +} From 638c4b5f3b9bf3caf8eeebdceb3cbacf02ab6687 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 8 Apr 2026 16:37:42 +0200 Subject: [PATCH 34/57] feat: implement direct PostgreSQL data access and API proxy for automated orders --- src/app/api/commandes-auto/route.ts | 44 +++++++++--------- src/lib/pg-ff-client.ts | 72 ++++++++++------------------- 2 files changed, 46 insertions(+), 70 deletions(-) diff --git a/src/app/api/commandes-auto/route.ts b/src/app/api/commandes-auto/route.ts index d3c1a6e..2f01fae 100644 --- a/src/app/api/commandes-auto/route.ts +++ b/src/app/api/commandes-auto/route.ts @@ -1,31 +1,29 @@ -import { NextResponse } from "next/server"; -import { pgGetCommandesAuto } from "@/lib/pg-ff-client"; +import { NextRequest, NextResponse } from "next/server"; + +const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; /** - * GET /api/commandes-auto + * GET /api/commandes-auto[?site=292&codefou=N009] * - * Retourne la liste des fournisseurs en commande automatique, - * groupés par site (292, 579), avec franco et écart franco. - * - * Réponse : - * [ - * { - * site: "292", - * codefou: "FOU123", - * nomfou: "Nom Fournisseur", - * franco: 500, - * nb_articles: 12, - * montant_cde: 620.50, - * franco_atteint: true, - * ecart_franco: -120.50 - * }, - * ... - * ] + * Proxy vers https://api.ffnancy.fr/api/commandes-auto + * Transfère les query params site et codefou si présents. */ -export async function GET() { +export async function GET(req: NextRequest) { try { - const rows = await pgGetCommandesAuto(); - return NextResponse.json(rows); + const { searchParams } = req.nextUrl; + const params = new URLSearchParams(); + if (searchParams.get("site")) params.set("site", searchParams.get("site")!); + if (searchParams.get("codefou")) params.set("codefou", searchParams.get("codefou")!); + + const qs = params.toString(); + const url = `${FF_API_BASE}/api/commandes-auto${qs ? `?${qs}` : ""}`; + + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) { + return NextResponse.json({ error: `API upstream HTTP ${res.status}` }, { status: res.status }); + } + const data = await res.json(); + return NextResponse.json(data); } catch (e) { console.error("[api/commandes-auto]", e); return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index b54cfaa..f9a5cbd 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -936,56 +936,34 @@ export interface PgCommandeAutoRow { } /** - * Retourne la liste des fournisseurs ayant des articles en commande auto, - * groupés par site + fournisseur, avec le montant total (pcb × pa), - * le franco du fournisseur et l'écart franco. - * - * Hypothèses sur le schéma FF Nancy : - * - artfou1.cdeauto : flag commande auto (boolean-like) - * - fouident.franco : franco de port (montant minimum de commande) - * - cube_pa.pa : prix d'achat + * Retourne la liste des fournisseurs en commande automatique via l'API Hostinger. + * GET https://api.ffnancy.fr/api/commandes-auto */ export async function pgGetCommandesAuto(): Promise { + const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; try { - const result = await pgNoParallel(sql` - SELECT - cs.site, - fi.code AS codefou, - COALESCE(fi.nom, af.code) AS nomfou, - COALESCE(fi.franco::numeric, 0) AS franco, - COUNT(DISTINCT a.codein) AS nb_articles, - ROUND(SUM( - GREATEST(1, COALESCE(af.pcb, 1))::numeric - * COALESCE(pa.pa, 0)::numeric - ), 2) AS montant_cde, - CASE - WHEN COALESCE(fi.franco::numeric, 0) = 0 THEN NULL - WHEN SUM( - GREATEST(1, COALESCE(af.pcb, 1))::numeric - * COALESCE(pa.pa, 0)::numeric - ) >= COALESCE(fi.franco::numeric, 0) THEN true - ELSE false - END AS franco_atteint, - ROUND( - COALESCE(fi.franco::numeric, 0) - SUM( - GREATEST(1, COALESCE(af.pcb, 1))::numeric - * COALESCE(pa.pa, 0)::numeric - ), 2 - ) AS ecart_franco - FROM artfou1 af - JOIN articles a ON a.no_id = af.art_no_id - JOIN fouident fi ON fi.code = af.code - JOIN cube_stock cs ON cs.artnoid = a.no_id - LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id - WHERE af.cdeauto::text IN ('1', 'true', 't', 'yes', 'y', 'on') - AND cs.site IN ('292', '579') - AND (af.suspendu IS NULL OR af.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) - AND (a.suspendu IS NULL OR a.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) - GROUP BY cs.site, fi.code, fi.nom, af.code, fi.franco - ORDER BY cs.site, COALESCE(fi.nom, af.code) - `); - console.log(`[pg-ff] pgGetCommandesAuto: ${result.rows.length} lignes`); - return result.rows as unknown as PgCommandeAutoRow[]; + const res = await fetch(`${FF_API_BASE}/api/commandes-auto`, { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + // L'API peut retourner directement un tableau ou un objet wrappé + const rows: unknown[] = Array.isArray(data) + ? data + : (data?.data ?? data?.items ?? data?.results ?? []); + console.log(`[pg-ff] pgGetCommandesAuto: ${rows.length} lignes (API Hostinger)`); + // Normalise chaque ligne vers PgCommandeAutoRow + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (rows as any[]).map((r): PgCommandeAutoRow => ({ + site: String(r.site ?? r.Site ?? ""), + codefou: String(r.codefou ?? r.CodeFou ?? r.code ?? ""), + nomfou: String(r.nomfou ?? r.NomFou ?? r.nom ?? ""), + franco: Number(r.franco ?? r.Franco ?? 0), + nb_articles: Number(r.nb_articles ?? r.nbArticles ?? r.count ?? 0), + montant_cde: Number(r.montant_cde ?? r.montantCde ?? r.montant ?? 0), + franco_atteint: r.franco_atteint != null + ? Boolean(r.franco_atteint) + : null, + ecart_franco: Number(r.ecart_franco ?? r.ecartFranco ?? 0), + })); } catch (e) { console.error("[pg-ff] pgGetCommandesAuto error:", (e as Error).message?.slice(0, 300)); return []; From 910b0c96ef70737441813c5ddd048eb517544a8f Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 8 Apr 2026 16:41:28 +0200 Subject: [PATCH 35/57] feat: add PostgreSQL client for bulk data retrieval and configure Claude permissions --- .claude/settings.json | 3 ++- src/lib/pg-ff-client.ts | 25 +++++++++++++------------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 6458876..4d5d390 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -31,7 +31,8 @@ "Read(//c/Users/Michael/VSCODE/CollectFlow/**)", "Bash(xargs wc:*)", "Bash(ls /c/Users/Michael/VSCODE/CollectFlow/.next/static/chunks/*.css)", - "Bash(find /c/Users/Michael/VSCODE/CollectFlow/src/app/api -type f \\\\\\( -name \"*.ts\" -o -name \"*.tsx\" \\\\\\) -exec ls -lh {} \\\\;)" + "Bash(find /c/Users/Michael/VSCODE/CollectFlow/src/app/api -type f \\\\\\( -name \"*.ts\" -o -name \"*.tsx\" \\\\\\) -exec ls -lh {} \\\\;)", + "Bash(curl -s \"https://api.ffnancy.fr/api/commandes-auto\")" ] } } diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index f9a5cbd..9b1ee8f 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -946,23 +946,24 @@ export async function pgGetCommandesAuto(): Promise { if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); // L'API peut retourner directement un tableau ou un objet wrappé + // L'API retourne { count: N, propositions: [...] } const rows: unknown[] = Array.isArray(data) ? data - : (data?.data ?? data?.items ?? data?.results ?? []); + : (data?.propositions ?? data?.data ?? data?.items ?? data?.results ?? []); console.log(`[pg-ff] pgGetCommandesAuto: ${rows.length} lignes (API Hostinger)`); - // Normalise chaque ligne vers PgCommandeAutoRow // eslint-disable-next-line @typescript-eslint/no-explicit-any return (rows as any[]).map((r): PgCommandeAutoRow => ({ - site: String(r.site ?? r.Site ?? ""), - codefou: String(r.codefou ?? r.CodeFou ?? r.code ?? ""), - nomfou: String(r.nomfou ?? r.NomFou ?? r.nom ?? ""), - franco: Number(r.franco ?? r.Franco ?? 0), - nb_articles: Number(r.nb_articles ?? r.nbArticles ?? r.count ?? 0), - montant_cde: Number(r.montant_cde ?? r.montantCde ?? r.montant ?? 0), - franco_atteint: r.franco_atteint != null - ? Boolean(r.franco_atteint) - : null, - ecart_franco: Number(r.ecart_franco ?? r.ecartFranco ?? 0), + site: String(r.site ?? ""), + codefou: String(r.codefou ?? ""), + nomfou: String(r.nom_fou ?? r.nomfou ?? ""), + franco: Number(r.franco_ht ?? r.franco ?? 0), + nb_articles: Number(r.nb_articles ?? 0), + montant_cde: Number(r.montant_propo_ht ?? r.montant_cde ?? 0), + franco_atteint: r.franco_atteint === "OUI" ? true + : r.franco_atteint === "NON" ? false + : r.franco_atteint != null ? Boolean(r.franco_atteint) + : null, + ecart_franco: Number(r.ecart_franco ?? 0), })); } catch (e) { console.error("[pg-ff] pgGetCommandesAuto error:", (e as Error).message?.slice(0, 300)); From f21f38f80586c25d71fe9f8b7844d3b3060a5e4f Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 8 Apr 2026 16:47:39 +0200 Subject: [PATCH 36/57] feat: add client-side component to display and filter automatic order data by site --- src/app/(dashboard)/commandes-auto/client.tsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/app/(dashboard)/commandes-auto/client.tsx b/src/app/(dashboard)/commandes-auto/client.tsx index 1621b14..6e2fe41 100644 --- a/src/app/(dashboard)/commandes-auto/client.tsx +++ b/src/app/(dashboard)/commandes-auto/client.tsx @@ -23,7 +23,7 @@ function SortIcon({ col, sortKey, sortDir }: { col: string; sortKey: string; sor } type SK = keyof PgCommandeAutoRow; -const NUMERIC: SK[] = ["franco", "nb_articles", "montant_cde", "ecart_franco"]; +const NUMERIC: SK[] = ["franco", "nb_articles", "montant_cde"]; function FrancoCell({ atteint, ecart, franco }: { atteint: boolean | null; ecart: number; franco: number }) { if (franco === 0 || atteint === null) { @@ -128,13 +128,13 @@ function StoreTable({ site, label, rows }: { site: string; label: string; rows: Statut franco - {th("ecart_franco", "Écart", true)} + {sorted.length === 0 && ( - + Aucun fournisseur en commande automatique @@ -171,17 +171,6 @@ function StoreTable({ site, label, rows }: { site: string; label: string; rows: franco={Number(row.franco)} /> - - {hasFranco - ? (Number(row.ecart_franco) > 0 - ? `−${fmt(Number(row.ecart_franco))}` - : `+${fmt(Math.abs(Number(row.ecart_franco)))}`) - : "—"} - ); })} From 78cdc608d097a91eb2ee8bb85e0db7819caa3deb Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 10 Apr 2026 13:23:35 +0200 Subject: [PATCH 37/57] feat: implement admin AI chat interface with model selection and real-time cost tracking --- src/app/(dashboard)/admin/ai-chat/page.tsx | 437 +++++++++++++++++++++ src/app/api/admin/ai-chat/route.ts | 353 +++++++++++++++++ src/app/api/openrouter/models/route.ts | 4 +- src/components/shared/sidebar.tsx | 3 +- 4 files changed, 795 insertions(+), 2 deletions(-) create mode 100644 src/app/(dashboard)/admin/ai-chat/page.tsx create mode 100644 src/app/api/admin/ai-chat/route.ts diff --git a/src/app/(dashboard)/admin/ai-chat/page.tsx b/src/app/(dashboard)/admin/ai-chat/page.tsx new file mode 100644 index 0000000..823265e --- /dev/null +++ b/src/app/(dashboard)/admin/ai-chat/page.tsx @@ -0,0 +1,437 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; +import { Send, Bot, User, Database, Loader2, AlertCircle, ChevronDown, Trash2, DollarSign } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ModelInfo { + id: string; + name: string; + free: boolean; + promptPrice: number; // $ per token + completionPrice: number; +} + +interface ToolEvent { + name: string; + args: Record; + result?: string; +} + +interface Message { + role: "user" | "assistant"; + content: string; + toolEvents?: ToolEvent[]; +} + +interface SessionUsage { + promptTokens: number; + completionTokens: number; + costUsd: number; +} + +const DEFAULT_MODEL = "google/gemini-2.0-flash-001"; + +// Compute cost from token counts and model pricing +function computeCost(promptTokens: number, completionTokens: number, model: ModelInfo | null): number { + if (!model) return 0; + return promptTokens * model.promptPrice + completionTokens * model.completionPrice; +} + +function formatCost(usd: number): string { + if (usd === 0) return "Gratuit"; + if (usd < 0.001) return `$${(usd * 1000).toFixed(4)}m`; + return `$${usd.toFixed(6)}`; +} + +function ToolCallBlock({ event }: { event: ToolEvent }) { + const [open, setOpen] = useState(false); + + const label = event.name === "execute_sql" + ? `SQL: ${String(event.args.query ?? "").substring(0, 60)}${String(event.args.query ?? "").length > 60 ? "…" : ""}` + : event.name === "get_db_schema" + ? "Schéma DB" + : event.name; + + return ( +
+ + {open && event.result && ( +
+                    {event.result}
+                
+ )} +
+ ); +} + +function MessageBubble({ msg }: { msg: Message }) { + const isUser = msg.role === "user"; + + return ( +
+ {/* Avatar */} +
+ {isUser + ? + : + } +
+ + {/* Content */} +
+ {/* Tool events (assistant only) */} + {msg.toolEvents && msg.toolEvents.length > 0 && ( +
+ {msg.toolEvents.map((ev, i) => ( + + ))} +
+ )} + + {/* Text bubble */} + {msg.content && ( +
+ {msg.content} +
+ )} +
+
+ ); +} + +export default function AdminAiChatPage() { + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [loadingModels, setLoadingModels] = useState(true); + const [sessionUsage, setSessionUsage] = useState({ promptTokens: 0, completionTokens: 0, costUsd: 0 }); + + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + const abortRef = useRef(null); + + const selectedModelInfo = models.find(m => m.id === selectedModel) ?? null; + + // Fetch models + useEffect(() => { + setLoadingModels(true); + fetch("/api/openrouter/models") + .then(r => r.json()) + .then(d => { + if (d.models) setModels(d.models); + }) + .catch(() => { }) + .finally(() => setLoadingModels(false)); + }, []); + + // Auto-scroll + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, loading]); + + const sendMessage = useCallback(async () => { + if (!input.trim() || loading) return; + + const userMsg: Message = { role: "user", content: input.trim() }; + setInput(""); + setError(null); + setMessages(prev => [...prev, userMsg]); + setLoading(true); + + // Build messages to send (excluding tool events, which are local display only) + const apiMessages = [...messages, userMsg].map(m => ({ + role: m.role, + content: m.content, + })); + + const assistantMsg: Message = { role: "assistant", content: "", toolEvents: [] }; + setMessages(prev => [...prev, assistantMsg]); + + abortRef.current = new AbortController(); + + try { + const res = await fetch("/api/admin/ai-chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: apiMessages, model: selectedModel }), + signal: abortRef.current.signal, + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Erreur serveur"); + } + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const pendingToolEvents: ToolEvent[] = []; + let lastToolStart: ToolEvent | null = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const raw = line.slice(6).trim(); + try { + const event = JSON.parse(raw); + + if (event.type === "text") { + setMessages(prev => { + const copy = [...prev]; + const last = { ...copy[copy.length - 1] }; + last.content += event.content; + copy[copy.length - 1] = last; + return copy; + }); + } else if (event.type === "tool_start") { + lastToolStart = { name: event.name, args: event.args }; + } else if (event.type === "tool_result") { + if (lastToolStart) { + const ev: ToolEvent = { ...lastToolStart, result: event.result }; + pendingToolEvents.push(ev); + lastToolStart = null; + setMessages(prev => { + const copy = [...prev]; + const last = { ...copy[copy.length - 1] }; + last.toolEvents = [...pendingToolEvents]; + copy[copy.length - 1] = last; + return copy; + }); + } + } else if (event.type === "usage") { + const pt = event.prompt_tokens ?? 0; + const ct = event.completion_tokens ?? 0; + const cost = computeCost(pt, ct, selectedModelInfo); + setSessionUsage(prev => ({ + promptTokens: prev.promptTokens + pt, + completionTokens: prev.completionTokens + ct, + costUsd: prev.costUsd + cost, + })); + } else if (event.type === "error") { + setError(event.message); + } + } catch { /* ignore */ } + } + } + } catch (err) { + if ((err as Error).name !== "AbortError") { + setError(err instanceof Error ? err.message : "Erreur inconnue"); + } + } finally { + setLoading(false); + } + }, [input, loading, messages, selectedModel, selectedModelInfo]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }; + + const clearConversation = () => { + setMessages([]); + setSessionUsage({ promptTokens: 0, completionTokens: 0, costUsd: 0 }); + setError(null); + if (loading && abortRef.current) { + abortRef.current.abort(); + setLoading(false); + } + }; + + return ( +
+ {/* Header */} +
+
+

+ + Admin AI Chat +

+

+ Accès direct à la base de données · Admin uniquement +

+
+ + {/* Usage / Cost */} +
+
+ + {formatCost(sessionUsage.costUsd)} + session + {sessionUsage.promptTokens > 0 && ( + + ({sessionUsage.promptTokens + sessionUsage.completionTokens} tok) + + )} +
+ + +
+
+ + {/* Model selector */} +
+
+ Modèle + {loadingModels ? ( +
+ + Chargement des modèles… +
+ ) : ( +
+ + {selectedModelInfo && !selectedModelInfo.free && ( +
+ in: ${(selectedModelInfo.promptPrice * 1_000_000).toFixed(2)}/M + · + out: ${(selectedModelInfo.completionPrice * 1_000_000).toFixed(2)}/M +
+ )} + {selectedModelInfo?.free && ( + GRATUIT + )} +
+ )} +
+
+ + {/* Messages */} +
+ {messages.length === 0 && ( +
+
+ +
+
+

Assistant Admin CollectFlow

+

+ Posez des questions sur vos données, demandez des analyses, ou explorez la base de données. +

+
+
+ {[ + "Combien de produits par fournisseur ?", + "Quels sont les 10 produits avec le plus de CA ?", + "Montre-moi les snapshots récents", + "Répartition des gammes A/B/C/D/Z", + ].map(suggestion => ( + + ))} +
+
+ )} + + {messages.map((msg, i) => ( + + ))} + + {loading && messages[messages.length - 1]?.role !== "assistant" && ( +
+
+ +
+
+ + + +
+
+ )} + + {error && ( +
+ + {error} +
+ )} + +
+
+ + {/* Input */} +
+
+