feat: add no-sales-6-months tab to stock management page

Adds a second tab "Sans vente 6 mois" on the grid page listing all
products with no sale in the last 6 months, sorted by oldest last
sale first. Resolves merge conflicts from upstream refactor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-04-14 12:49:13 +02:00
commit dc16beeda4
51 files changed
+4889 -863

No files matched your search

@@ -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 (
<button
onClick={handleAnalyze}
+7 -9
View File
@@ -2,8 +2,8 @@
import { getSitesFromApi } from "@/lib/api-ff-client";
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
import { getProductRows, type GetProductRowsResult } from "./api/get-product-rows";
import type { GridFilters } from "@/types/grid";
import { getProductRows } from "./api/get-product-rows";
import type { ProductRow, GridFilters } from "@/types/grid";
/**
* Get the list of all suppliers from PostgreSQL (fouadr1).
@@ -21,21 +21,19 @@ export async function getMagasins() {
/**
* Nomenclature filter is disabled — code3 is not available from the API.
* Returns an empty hierarchy so the sidebar filter is hidden gracefully.
*/
export async function getAvailableNomenclature() {
return {};
}
/**
* Retourne tous les produits d'un fournisseur.
* La charge est paginée (renvoie la première page en SSR) pour empêcher le figeage du thread principal UI.
* Get product data for a specific supplier and store.
*/
export async function getGridData(
codeFournisseur: string,
magasin: string = "TOTAL",
filters?: Partial<GridFilters>,
page: number = 0,
pageSize: number = 200,
): Promise<GetProductRowsResult> {
return getProductRows({ codeFournisseur, magasin, filters, page, pageSize });
filters?: Partial<GridFilters>
): Promise<ProductRow[]> {
return getProductRows({ codeFournisseur, magasin, filters });
}
+8 -97
View File
@@ -16,42 +16,16 @@ import {
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { unstable_cache } from "next/cache";
// ─── Interfaces ───────────────────────────────────────────────────────────────
export interface GetProductRowsInput {
interface GetProductRowsInput {
codeFournisseur: string;
magasin?: string;
filters?: Partial<GridFilters>;
page?: number;
pageSize?: number;
offset?: number;
search?: string;
gamme?: string | null;
code3?: string | null;
}
export interface GetProductRowsResult {
rows: ProductRow[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
// Fonction utilitaire cachée pour mémoiser le chargement par fournisseur
const getCachedSupplierRows = (codeFournisseur: string) => {
return unstable_cache(
async () => _fetchAllProductRows(codeFournisseur),
[`grid-rows-${codeFournisseur}`],
{ revalidate: 300, tags: [`grid-rows-${codeFournisseur}`] }
)();
};
// ─── Fonction interne : fetch complet non-paginé (cachée) ───────────────────
async function _fetchAllProductRows(codeFournisseur: string): Promise<ProductRow[]> {
const magasin = "TOTAL"; // Le cache est toujours TOTAL ; le switch magasin est client-side
console.log(`\n>>> [_fetchAllProductRows] (NO CACHE) supplier: ${codeFournisseur}, magasin: ${magasin}`);
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
const { codeFournisseur, magasin = "TOTAL" } = input;
console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`);
try {
const { dateDebut, dateFin } = buildLast12MonthsRange();
@@ -96,6 +70,8 @@ async function _fetchAllProductRows(codeFournisseur: string): Promise<ProductRow
codein: art.codein,
codeFournisseur: art.codefou ?? codeFournisseur,
nomFournisseur: art.nomfou ?? "",
fournisseurPrincipalCode: art.codefou_principal ?? undefined,
fournisseurPrincipalNom: art.nomfou_principal ?? undefined,
libelle1: art.libelle1 ?? "",
gtin: art.gtin ?? "",
reference: art.reference ?? "",
@@ -318,79 +294,14 @@ async function _fetchAllProductRows(codeFournisseur: string): Promise<ProductRow
// ─── Phase 10 : Filtrer gamme Y sans ventes + compute scores ─────────
const allRows = Array.from(productMap.values());
const rows = allRows.filter(p => p.codeGamme?.trim().toUpperCase() !== "Y" || p.totalQuantite > 0);
const rows = allRows.filter(p => p.codeGamme !== "Y" || p.totalQuantite > 0);
const excludedY = allRows.length - rows.length;
console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`);
return computeProductScores(rows);
} catch (error) {
console.error(`[_fetchAllProductRows] Error for ${codeFournisseur}:`, error);
console.error(`[getProductRows] Error for ${codeFournisseur}:`, error);
return [];
}
}
// ─── Fonction publique ─────────────
/**
* Point d'entrée unique pour la grille.
* - Retourne les lignes paginées pour préserver le client.
*/
export async function getProductRows(input: GetProductRowsInput): Promise<GetProductRowsResult> {
const {
codeFournisseur,
page,
pageSize = 200,
offset,
search,
gamme,
code3,
} = input;
// Récupérer toutes les lignes mises en cache pour éviter les 650 requêtes SQL
const allRows = await getCachedSupplierRows(codeFournisseur);
let filtered = allRows;
if (search && search.trim() !== "") {
const q = search.trim().toLowerCase();
filtered = filtered.filter(r =>
(r.libelle1 ?? "").toLowerCase().includes(q) ||
(r.codein ?? "").toLowerCase().includes(q) ||
(r.reference ?? "").toLowerCase().includes(q) ||
(r.gtin ?? "").toLowerCase().includes(q)
);
}
if (gamme !== undefined && gamme !== null && gamme !== "") {
filtered = filtered.filter(r => {
const g = r.codeGamme ?? "";
const norm = g.trim() === "" ? "Aucune" : g;
return norm === gamme;
});
}
if (code3) {
filtered = filtered.filter(r => r.code3 === code3);
}
const total = filtered.length;
if (page === undefined) {
return { rows: filtered, total, page: 0, pageSize: total, totalPages: 1 };
}
const safePageSize = Math.max(1, Math.min(pageSize, 20000));
const totalPages = Math.ceil(total / safePageSize);
const safePage = Math.max(0, Math.min(page ?? 0, Math.max(0, totalPages - 1)));
// Priorité à l'offset s'il est fourni (pour la pagination côté client qui ne respecte pas les blocs de 10k au départ)
const startIdx = offset !== undefined ? offset : safePage * safePageSize;
if (startIdx >= total) {
return { rows: [], total, page: safePage, pageSize: safePageSize, totalPages };
}
const rows = filtered.slice(startIdx, startIdx + safePageSize);
return { rows, total, page: safePage, pageSize: safePageSize, totalPages };
}
@@ -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;
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Download, FileSpreadsheet, FileText, ChevronDown } from "lucide-react";
import { Download, FileSpreadsheet, FileText, ChevronDown, Table2 } from "lucide-react";
import * as ExcelJS from "exceljs";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
@@ -11,6 +11,52 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
const [isOpen, setIsOpen] = useState(false);
const { rows, draftChanges } = useGridStore();
const handleExportAllProducts = async () => {
setIsOpen(false);
if (rows.length === 0) {
alert("Aucune donnée à exporter.");
return;
}
const supplier = nomFournisseur || rows[0]?.nomFournisseur || "Fournisseur";
const payload = {
nomFournisseur: supplier,
rows: rows.map(r => ({
codein: r.codein,
libelle1: r.libelle1 || "",
code3: r.code3 || "",
libelle3: r.libelle3 || "",
codeGamme: (draftChanges[r.codein] ?? r.codeGamme) as string | null,
totalQuantite: r.totalQuantite,
totalCa: r.totalCa,
tauxMarge: r.tauxMarge,
gammeAvant: r.codeGammeInit ?? null,
})),
};
try {
const res = await fetch("/api/export/excel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Erreur lors de l'export");
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Assortiment_Complet_${supplier.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error(error);
alert("Une erreur s'est produite lors de l'export Excel.");
}
};
const handleExportExcel = async () => {
setIsOpen(false);
const workbook = new ExcelJS.Workbook();
@@ -157,6 +203,19 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
<div className="my-1 h-px bg-[var(--border)] mx-2" />
<button
onClick={handleExportAllProducts}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-lg transition-colors hover:bg-white/5"
>
<Table2 className="w-4 h-4 text-blue-400" />
<div>
<div className="font-semibold text-[var(--text-primary)]">Tous les produits (Excel)</div>
<div className="text-[11px] text-[var(--text-muted)] mt-0.5">Gamme avant/après, delta, stats 12m</div>
</div>
</button>
<div className="my-1 h-px bg-[var(--border)] mx-2" />
<button
onClick={handlePrintPDF}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-lg transition-colors hover:bg-white/5"
@@ -1,5 +1,6 @@
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
import { useSession } from "next-auth/react";
import { BulkAiAnalyzer } from "./bulk-ai-analyzer";
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
@@ -27,6 +28,8 @@ function Stat({ label, value, sub }: { label: string; value: string; sub?: strin
}
export function FloatingSummaryBar() {
const { data: session } = useSession();
const isAdmin = (session?.user as any)?.role === "admin";
const { summary, resetDrafts, rows, filters, draftChanges } = useGridStore();
const { resetInsights } = useAiCopilotStore();
const [isPending, startTransition] = useTransition();
@@ -175,7 +178,7 @@ export function FloatingSummaryBar() {
</div>
<div className="flex space-x-3 items-center">
<BulkAiAnalyzer />
{isAdmin && <BulkAiAnalyzer />}
<DropdownMenu>
<DropdownMenuTrigger asChild>
+11 -59
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState, useTransition, useCallback, useMemo } from "react";
import { useEffect, useRef, useState, useTransition, useMemo } from "react";
import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
@@ -9,15 +9,15 @@ import { ExportDropdown } from "@/features/grid/components/export-dropdown";
import { NoSalesTab, countNoSalesIn6Months } from "@/features/grid/components/no-sales-tab";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
import { useInfiniteGrid } from "@/features/grid/hooks/use-infinite-grid";
import type { ProductRow } from "@/types/grid";
import { CheckCircle, AlertCircle, Loader2, StopCircle } from "lucide-react";
import { CheckCircle, AlertCircle, Loader2 } from "lucide-react";
import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { computeProductScores } from "@/lib/score-engine";
interface GridClientProps {
initialRows: ProductRow[];
initialTotal: number;
codeFournisseur: string;
nomFournisseur: string;
fournisseurs: { code: string; nom: string }[];
@@ -25,11 +25,10 @@ interface GridClientProps {
magasin: string;
}
export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
const { data: session } = useSession();
const isAdmin = (session?.user as { role?: string })?.role === "admin";
const isAdmin = (session?.user as any)?.role === "admin";
const setRows = useGridStore((s) => s.setRows);
const appendRows = useGridStore((s) => s.appendRows);
const rows = useGridStore((s) => s.rows);
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
const setFilter = useGridStore((s) => s.setFilter);
@@ -48,20 +47,6 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
const [isMounted, setIsMounted] = useState(false);
// ─── Chargement progressif des pages suivantes ─────────────────────────────
const handlePageLoaded = useCallback((newRows: ProductRow[]) => {
// Ajout O(n_nouveaux_items) au lieu de O(N_total)
appendRows(newRows);
}, [appendRows]);
const { total, isLoadingMore, hasMore, loadNextPage, stopLoading, progress } = useInfiniteGrid({
codeFournisseur,
magasin: magasin || "TOTAL",
initialTotal,
pageSize: 10000,
onPageLoaded: handlePageLoaded
});
useEffect(() => {
setIsMounted(true);
}, []);
@@ -70,6 +55,7 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
useEffect(() => {
if (!isMounted) return;
const currentQueryString = searchParams.toString();
// Set the active query, ensuring it starts with ? if not empty
setActiveGridQuery(currentQueryString ? `?${currentQueryString}` : "");
}, [searchParams, setActiveGridQuery, isMounted]);
@@ -82,24 +68,16 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
setFilter("code3", null);
prevFournisseurRef.current = codeFournisseur;
}
// Reset du store pour ce fournisseur
setRows(initialRows);
const scoredRows = computeProductScores([...initialRows]);
setRows(scoredRows);
}, [codeFournisseur, initialRows, setRows, setFilter, isMounted]);
// Synchroniser le magasin actif
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
useEffect(() => {
if (!isMounted) return;
setActiveMagasin(magasin || "TOTAL");
}, [magasin, setActiveMagasin, isMounted]);
// Chargement automatique des pages suivantes en arrière-plan
useEffect(() => {
if (!isMounted || !hasMore || isLoadingMore) return;
// Délai court pour ne pas bloquer le rendu
const timer = setTimeout(loadNextPage, 300);
return () => clearTimeout(timer);
}, [isMounted, hasMore, isLoadingMore, loadNextPage]);
const handleSave = () => {
startTransition(async () => {
const result = await save();
@@ -123,7 +101,7 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
<p className="text-[13px] mt-0.5" style={{ color: "var(--text-secondary)" }}>
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>{nomFournisseur}</span>
{" "}• <span className="font-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span>
{" "}• {total.toLocaleString("fr-FR")} références
{" "}• {initialRows.length} références
</p>
</div>
<div className="flex items-center gap-3">
@@ -154,32 +132,6 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
</div>
</div>
{/* Barre de progression chargement en arrière-plan */}
{(hasMore || isLoadingMore) && (
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 rounded-lg text-[11px] font-medium"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
>
<Loader2 className="w-3 h-3 animate-spin shrink-0" style={{ color: "var(--accent)" }} />
<span>
Téléchargement&nbsp;: {rows.length.toLocaleString("fr-FR")} / {total.toLocaleString("fr-FR")} références
</span>
<div className="flex-1 h-1 w-[100px] rounded-full overflow-hidden" style={{ background: "var(--border)" }}>
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${progress}%`, background: "var(--accent)" }}
/>
</div>
<button
onClick={stopLoading}
className="ml-2 flex items-center gap-1 px-2 py-0.5 rounded-full hover:bg-red-500/10 text-red-600 transition-colors"
title="Arrêter le téléchargement du reste des références"
>
<StopCircle className="w-3.5 h-3.5" />
<span className="text-[10px] uppercase font-bold">Stop</span>
</button>
</div>
)}
{/* Filters */}
<div className="shrink-0 print:hidden">
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
+45 -45
View File
@@ -13,7 +13,7 @@ import {
RowSelectionState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse } from "lucide-react";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -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<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
// 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<HTMLElement | null>(null);
@@ -442,33 +430,45 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
accessorKey: "libelle1",
header: "Désignation",
size: 280,
cell: ({ row }) => (
<div className="flex items-center gap-2 overflow-hidden w-full pr-1">
<span className="text-[13px] font-bold truncate flex-1" title={row.original.libelle1} style={{ color: "var(--text-primary)" }}>
{row.original.libelle1}
</span>
<div className="flex gap-1.5 shrink-0">
{row.original.workingStores.map((magasin) => {
const config = getStoreConfig(magasin);
return (
<div
key={magasin}
title={`Travaillé par : ${SITE_LABELS[magasin]?.nom ?? magasin}`}
className="px-1.5 py-0.5 rounded-md flex items-center gap-1 text-[10px] font-black border shadow-sm transition-transform hover:scale-110"
style={{
background: config.bg,
borderColor: config.border,
color: config.text,
}}
>
<Store className="w-2.5 h-2.5" strokeWidth={3} />
<span>{config.label}</span>
</div>
);
})}
cell: ({ row }) => {
const r = row.original;
const isDifferentSupplier = r.fournisseurPrincipalCode && r.fournisseurPrincipalCode !== r.codeFournisseur;
const warnTitle = isDifferentSupplier
? `Dernier fournisseur enregistré : ${r.fournisseurPrincipalNom ?? r.fournisseurPrincipalCode} (${r.fournisseurPrincipalCode}) — les ventes sont à attribuer à ce fournisseur`
: undefined;
return (
<div className="flex items-center gap-2 overflow-hidden w-full pr-1">
{isDifferentSupplier && (
<span title={warnTitle} className="shrink-0 flex items-center">
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
</span>
)}
<span className="text-[13px] font-bold truncate flex-1" title={r.libelle1} style={{ color: "var(--text-primary)" }}>
{r.libelle1}
</span>
<div className="flex gap-1.5 shrink-0">
{r.workingStores.map((magasin) => {
const config = getStoreConfig(magasin);
return (
<div
key={magasin}
title={`Travaillé par : ${SITE_LABELS[magasin]?.nom ?? magasin}`}
className="px-1.5 py-0.5 rounded-md flex items-center gap-1 text-[10px] font-black border shadow-sm transition-transform hover:scale-110"
style={{
background: config.bg,
borderColor: config.border,
color: config.text,
}}
>
<Store className="w-2.5 h-2.5" strokeWidth={3} />
<span>{config.label}</span>
</div>
);
})}
</div>
</div>
</div>
),
);
},
},
{
accessorKey: "libelle3",
@@ -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<number>([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,
};
}
+5 -25
View File
@@ -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<codein, ProductRow>) */
rowsIndex: Map<string, ProductRow>;
/** Draft edits: codein → new GammeCode */
draftChanges: Record<string, GammeCode>;
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<GridState>()(
persist(
(set, get) => ({
rows: [],
rowsIndex: new Map(),
activeMagasin: "TOTAL",
draftChanges: {},
filters: {
@@ -98,27 +94,12 @@ export const useGridStore = create<GridState>()(
},
setRows: (rows) => {
// Construire l'index en O(n) une seule fois au lieu de O(n) à chaque lookup.
const rowsIndex = new Map<string, ProductRow>(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<GridState>()(
},
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) {
+12 -2
View File
@@ -11,6 +11,9 @@ export interface DbConfig {
url: string;
openRouterKey?: string;
openRouterModel?: string;
aiProvider?: "openrouter" | "google";
googleAiKey?: string;
googleAiModel?: string;
}
export async function testDatabaseConnection(url: string) {
@@ -34,9 +37,16 @@ export async function testDatabaseConnection(url: string) {
}
}
export async function saveDatabaseSettings(url: string, openRouterKey?: string, openRouterModel?: string) {
export async function saveDatabaseSettings(
url: string,
openRouterKey?: string,
openRouterModel?: string,
aiProvider?: "openrouter" | "google",
googleAiKey?: string,
googleAiModel?: string,
) {
try {
const config: DbConfig = { url, openRouterKey, openRouterModel };
const config: DbConfig = { url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
// S'assurer que le dossier data existe
await fs.mkdir(DATA_DIR, { recursive: true });