From 2c84cfda534bae3b095714d2c3427e1b545c4e7d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 10:29:00 +0000 Subject: [PATCH] =?UTF-8?q?perf(stock):=20onglets=20instantan=C3=A9s,=20pa?= =?UTF-8?q?gination=20et=20fournisseur=20de=20derni=C3=A8re=20entr=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le changement d'onglet déclenchait un router.push : la page serveur était rejouée et les 3 requêtes SQL relancées à chaque clic, alors que les données étaient déjà côté client. Sans aucun retour visuel, l'onglet semblait bloqué. Interface (stock-negatif/client.tsx) - Changement d'onglet purement client (useTransition + history.replaceState) : plus aucune requête SQL rejouée, l'URL reste partageable. - Modal de chargement pendant le changement de magasin (seule action qui interroge vraiment le serveur), pendant un changement d'onglet lent et pendant la génération de l'export Excel. Affichage différé de 120 ms pour éviter tout clignotement. - Pagination 50 lignes : l'onglet « Sans vente 6 mois » rendait jusqu'à 17 000 lignes d'un coup, ce qui figeait le navigateur. L'export Excel continue de reprendre l'intégralité des lignes filtrées. - Tri : ordre de la base conservé tant qu'aucune colonne n'est cliquée (le tri alphabétique sur codein écrasait le classement métier), collateur Intl réutilisé au lieu d'un localeCompare par comparaison, tri numérique robuste aux valeurs nulles. - Les 3 onglets partagent désormais un composant de tableau unique piloté par une configuration de colonnes, au lieu de 3 copies quasi identiques. - Select magasin et onglets désactivés pendant le chargement, aria-busy posé. Données (pg-ff-client.ts) - Un article n'est plus rattaché à son fournisseur principal mais au fournisseur de sa dernière entrée en stock : s'il est rentré chez un autre fournisseur, il n'apparaît plus sous le précédent. La colonne de mvtart qui porte ce lien est détectée une fois par process parmi une liste blanche (même principe que getNomenclatureParentCol), avec repli documenté sur artfou1.preference = 1 si la base ne porte pas l'information. - La jointure latérale sur la dernière entrée remplace la sous-requête corrélée MAX(datmvt) : même coût qu'avant pour une information de plus. - pgGetStockSansVente dédoublonne artfou1 via DISTINCT ON comme ses deux requêtes sœurs, et exclut les stocks nuls en SQL (HAVING) au lieu de les filtrer côté client : le compteur d'onglet correspond enfin aux lignes réellement transmises. Diagnostic - GET /api/diag/stock-fournisseur : colonne détectée, colonnes réelles de mvtart et échantillon comparant fournisseur principal et fournisseur de dernière entrée. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0174nm1nWPaypWBNHioSTJ8X --- src/app/(dashboard)/stock-negatif/client.tsx | 860 ++++++++++--------- src/app/api/diag/stock-fournisseur/route.ts | 21 + src/lib/pg-ff-client.ts | 180 +++- 3 files changed, 638 insertions(+), 423 deletions(-) create mode 100644 src/app/api/diag/stock-fournisseur/route.ts diff --git a/src/app/(dashboard)/stock-negatif/client.tsx b/src/app/(dashboard)/stock-negatif/client.tsx index d603715..e8a5082 100644 --- a/src/app/(dashboard)/stock-negatif/client.tsx +++ b/src/app/(dashboard)/stock-negatif/client.tsx @@ -1,12 +1,29 @@ "use client"; import { useRouter } from "next/navigation"; -import { useState, useMemo } from "react"; -import { ChevronUp, ChevronDown, ChevronsUpDown, Download, Search } from "lucide-react"; +import { useState, useMemo, useEffect, useTransition, type ReactNode } from "react"; +import { + ChevronUp, + ChevronDown, + ChevronsUpDown, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + Download, + Search, +} from "lucide-react"; +import LoadingModal from "@/components/shared/loading-modal"; import type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow } from "./page"; type SortDir = "asc" | "desc"; +/** Nombre de lignes rendues simultanément : au-delà, le navigateur fige. */ +const PAGE_SIZE = 50; + +/** Un seul collateur réutilisé : `localeCompare` en crée un par appel (très coûteux sur 17 000 lignes). */ +const collator = new Intl.Collator("fr", { numeric: true, sensitivity: "base" }); + function fmtDate(raw: string | null) { if (!raw) return "—"; const d = new Date(raw); @@ -21,9 +38,66 @@ function getYear(raw: string | null): string { return String(d.getFullYear()); } -function SortIcon({ col, sortKey, sortDir }: { col: T; sortKey: T; sortDir: SortDir }) { - if (col !== sortKey) return ; - return sortDir === "asc" +/** + * Passe à `true` seulement si `active` reste vrai plus de `delay` ms. + * Évite le clignotement du modal sur les transitions instantanées. + */ +function useDelayedFlag(active: boolean, delay = 120) { + const [elapsed, setElapsed] = useState(false); + const [wasActive, setWasActive] = useState(active); + + // Réinitialisation pendant le rendu (et non dans un effet) : dès que + // l'attente cesse, le prochain délai repart de zéro. + if (wasActive !== active) { + setWasActive(active); + if (!active) setElapsed(false); + } + + useEffect(() => { + if (!active) return; + const id = setTimeout(() => setElapsed(true), delay); + return () => clearTimeout(id); + }, [active, delay]); + + return active && elapsed; +} + +// --------------------------------------------------------------------------- +// Tableau générique (les 3 onglets partagent filtres, tri, pagination, export) +// --------------------------------------------------------------------------- + +interface Column { + key: Extract; + label: string; + /** Tri numérique au lieu du tri alphabétique. */ + numeric?: boolean; + render?: (row: T) => ReactNode; +} + +interface ExcelSpec { + sheet: string; + filePrefix: string; + header: string[]; + widths: number[]; + row: (r: T) => (string | number)[]; +} + +interface StockTableProps { + rows: T[]; + magasin: string; + columns: Column[]; + fournisseurOf: (r: T) => string; + /** Source du filtre « Année » — omis, le filtre n'est pas affiché. */ + anneeOf?: (r: T) => string; + anneeLabel?: string; + searchIn: (r: T) => (string | null | undefined)[]; + emptyLabel: string; + excel: ExcelSpec; +} + +function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) { + if (!active) return ; + return dir === "asc" ? : ; } @@ -49,95 +123,174 @@ function FilterSelect({ label, value, onChange, options }: { ); } -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 Pagination({ page, totalPages, total, from, to, onChange }: { + page: number; + totalPages: number; + total: number; + from: number; + to: number; + onChange: (p: number) => void; +}) { + if (total === 0) return null; - function handleSort(key: keyof T) { - if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc"); + const btn = "rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"; + + return ( +
+ + Lignes {from.toLocaleString("fr-FR")} + {" à "} + {to.toLocaleString("fr-FR")} + {" sur "} + {total.toLocaleString("fr-FR")} + +
+ + + + Page {page.toLocaleString("fr-FR")} / {totalPages.toLocaleString("fr-FR")} + + + +
+
+ ); +} + +function StockTable({ + rows, + magasin, + columns, + fournisseurOf, + anneeOf, + anneeLabel = "Année entrée", + searchIn, + emptyLabel, + excel, +}: StockTableProps) { + const [filterFournisseur, setFilterFournisseur] = useState(""); + const [filterAnnee, setFilterAnnee] = useState(""); + const [search, setSearch] = useState(""); + // `null` = ordre renvoyé par la base (le plus pertinent : stock le plus négatif, + // entrée la plus récente…). Aucun tri client tant que l'utilisateur n'en demande pas. + const [sortKey, setSortKey] = useState | null>(null); + const [sortDir, setSortDir] = useState("asc"); + const [page, setPage] = useState(1); + const [exporting, setExporting] = useState(false); + + const fournisseurs = useMemo( + () => [...new Set(rows.map(fournisseurOf).filter(Boolean))].sort((a, b) => collator.compare(a, b)), + [rows, fournisseurOf] + ); + const annees = useMemo( + () => anneeOf ? [...new Set(rows.map(anneeOf).filter(Boolean))].sort((a, b) => b.localeCompare(a)) : [], + [rows, anneeOf] + ); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!filterFournisseur && !filterAnnee && !q) return rows; + return rows.filter(r => + (!filterFournisseur || fournisseurOf(r) === filterFournisseur) && + (!filterAnnee || (anneeOf ? anneeOf(r) === filterAnnee : true)) && + (!q || searchIn(r).some(v => v?.toLowerCase().includes(q))) + ); + }, [rows, filterFournisseur, filterAnnee, search, fournisseurOf, anneeOf, searchIn]); + + const numericKeys = useMemo( + () => new Set(columns.filter(c => c.numeric).map(c => c.key as string)), + [columns] + ); + + const sorted = useMemo(() => { + if (!sortKey) return filtered; + const numeric = numericKeys.has(sortKey); + const dir = sortDir === "asc" ? 1 : -1; + return [...filtered].sort((a, b) => { + const av = (a as Record)[sortKey]; + const bv = (b as Record)[sortKey]; + if (numeric) { + const an = Number(av); + const bn = Number(bv); + if (Number.isNaN(an) && Number.isNaN(bn)) return 0; + if (Number.isNaN(an)) return 1; + if (Number.isNaN(bn)) return -1; + return (an - bn) * dir; + } + return collator.compare(String(av ?? ""), String(bv ?? "")) * dir; + }); + }, [filtered, sortKey, sortDir, numericKeys]); + + // Toute modification des filtres / du tri ramène à la première page. + // Ajustement pendant le rendu : pas d'effet, donc pas de rendu en cascade. + const [pagedSet, setPagedSet] = useState(sorted); + if (pagedSet !== sorted) { + setPagedSet(sorted); + setPage(1); + } + + const total = sorted.length; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const currentPage = Math.min(page, totalPages); + const pageRows = useMemo( + () => sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE), + [sorted, currentPage] + ); + + function handleSort(key: Extract) { + 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 hasFilters = Boolean(filterFournisseur || filterAnnee || search); + + async function handleExport() { + if (exporting) return; + setExporting(true); + try { + const ExcelJS = (await import("exceljs")).default; + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet(excel.sheet); + ws.addRow(excel.header); + 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; + // L'export reprend l'intégralité des lignes filtrées, pas seulement la page affichée. + for (const r of sorted) ws.addRow(excel.row(r)); + ws.columns = excel.widths.map(width => ({ width })); + 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 = `${excel.filePrefix}_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + } finally { + setExporting(false); } - const as = String(av ?? "").toLowerCase(); - const bs = String(bv ?? "").toLowerCase(); - return sortDir === "asc" ? as.localeCompare(bs) : bs.localeCompare(as); - }), [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 [filterFournisseur, setFilterFournisseur] = useState(""); - const [filterAnnee, setFilterAnnee] = useState(""); - const [search, setSearch] = 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(() => { - const q = search.trim().toLowerCase(); - return rows.filter(r => - (!filterFournisseur || r.fournisseur === filterFournisseur) && - (!filterAnnee || getYear(r.derniereentree) === filterAnnee) && - (!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q)) - ); - }, [rows, filterFournisseur, filterAnnee, search]); - - const { sorted, sortKey, sortDir, handleSort } = useSortedRows( - filtered, - ["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"); - ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "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, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)]); - ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { 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); - const a = document.createElement("a"); - a.href = url; - a.download = `Stock_Negatif_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`; - a.click(); - URL.revokeObjectURL(url); } return (
+ {exporting && ( + + )} +
@@ -151,8 +304,10 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin />
- - {(filterFournisseur || filterAnnee || search) && ( + {anneeOf && ( + + )} + {hasFilters && (
- {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")} - {th("derniereentree", "Dernière entrée")} + {columns.map(c => ( + + ))} - {sorted.length === 0 && ( - + {pageRows.length === 0 && ( + + + )} - {sorted.map((row, i) => ( - - - - - - - - + {pageRows.map((row, i) => ( + ).codein)}-${String((row as Record).site)}-${i}`} + className="hover:bg-gray-50 transition-colors"> + {columns.map(c => ( + + ))} ))}
handleSort(c.key)} + className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900" + > + {c.label} + +
Aucun article en stock négatif
{emptyLabel}
{row.codein}{row.libelle1}{row.fournisseur} - {row.site} - {row.stockdispo}{fmtDate(row.dernierevente)}{fmtDate(row.derniereentree)}
+ {c.render + ? c.render(row) + : String((row as Record)[c.key] ?? "")} +
+ + setPage(Math.min(Math.max(1, p), totalPages))} + />
); } // --------------------------------------------------------------------------- -// Onglet 2 — Entrées sans vente +// Cellules partagées // --------------------------------------------------------------------------- -function TabEntreesSansVente({ rows, magasin }: { rows: PgStockSansVenteRow[]; magasin: string }) { - type SK = keyof PgStockSansVenteRow; - - const [filterFournisseur, setFilterFournisseur] = useState(""); - const [filterAnnee, setFilterAnnee] = useState(""); - const [search, setSearch] = 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(() => { - const q = search.trim().toLowerCase(); - return baseRows.filter(r => - (!filterFournisseur || r.fournisseur === filterFournisseur) && - (!filterAnnee || getYear(r.derniere_entree) === filterAnnee) && - (!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q)) - ); - }, [baseRows, filterFournisseur, filterAnnee, search]); - - const { sorted, sortKey, sortDir, handleSort } = useSortedRows( - filtered, - ["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(["CODEIN", "GENCODE", "QTE", "CODMV", "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.stock_actuel, "412", fmtDate(r.derniere_entree)]); - ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { 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); - 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 ( -
-
-
-
- - setSearch(e.target.value)} - placeholder="Code article ou libellé…" - className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]" - /> -
- - - {(filterFournisseur || filterAnnee || search) && ( - - )} -
-
- {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)}
-
-
- ); -} +const cellCode = (v: string) => {v}; +const cellSite = (v: string) => ( + {v} +); +const cellDate = (v: string | null) => {fmtDate(v)}; // --------------------------------------------------------------------------- -// Onglet 3 — Sans vente depuis 6 mois +// Configuration des 3 onglets +// +// Définies au niveau module : leur identité doit rester stable d'un rendu à +// l'autre, sinon les `useMemo` de StockTable se recalculent en boucle. // --------------------------------------------------------------------------- -function TabSansVente6Mois({ rows, magasin }: { rows: PgSansVente6MoisRow[]; magasin: string }) { - type SK = keyof PgSansVente6MoisRow; +type TabConfig = Omit, "rows" | "magasin">; - const [filterFournisseur, setFilterFournisseur] = useState(""); - const [search, setSearch] = useState(""); +const NEGATIF_CONFIG: TabConfig = { + fournisseurOf: r => r.fournisseur, + anneeOf: r => getYear(r.derniereentree), + searchIn: r => [r.codein, r.libelle1], + emptyLabel: "Aucun article en stock négatif", + columns: [ + { key: "codein", label: "Code article", render: r => cellCode(r.codein) }, + { key: "libelle1", label: "Libellé", render: r => {r.libelle1} }, + { key: "fournisseur", label: "Fournisseur", render: r => {r.fournisseur} }, + { key: "site", label: "Magasin", render: r => cellSite(r.site) }, + { key: "stockdispo", label: "Stock", numeric: true, render: r => {r.stockdispo} }, + { key: "dernierevente", label: "Dernière vente", render: r => cellDate(r.dernierevente) }, + { key: "derniereentree", label: "Dernière entrée", render: r => cellDate(r.derniereentree) }, + ], + excel: { + sheet: "Stock Négatif", + filePrefix: "Stock_Negatif", + header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "Dernière entrée"], + widths: [16, 16, 10, 10, 16, 22], + row: r => [r.codein, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)], + }, +}; - const fournisseurs = useMemo(() => - [...new Set(rows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")), - [rows] - ); +const SANS_VENTE_CONFIG: TabConfig = { + fournisseurOf: r => r.fournisseur, + anneeOf: r => getYear(r.derniere_entree), + searchIn: r => [r.codein, r.libelle1], + emptyLabel: "Aucun article trouvé", + columns: [ + { key: "codein", label: "Code article", render: r => cellCode(r.codein) }, + { key: "libelle1", label: "Libellé", render: r => {r.libelle1} }, + { key: "fournisseur", label: "Fournisseur", render: r => {r.fournisseur} }, + { key: "site", label: "Magasin", render: r => cellSite(r.site) }, + { key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => {r.stock_actuel} }, + { key: "derniere_entree", label: "Dernière entrée", render: r => cellDate(r.derniere_entree) }, + ], + excel: { + sheet: "Entrées sans vente", + filePrefix: "Entrees_Sans_Vente", + header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière entrée"], + widths: [16, 16, 10, 10, 22], + row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_entree)], + }, +}; - const filtered = useMemo(() => { - const q = search.trim().toLowerCase(); - return rows.filter(r => - (!filterFournisseur || r.fournisseur === filterFournisseur) && - (!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q)) - ); - }, [rows, filterFournisseur, search]); - - const { sorted, sortKey, sortDir, handleSort } = useSortedRows>( - filtered as (PgSansVente6MoisRow & Record)[], - ["stock_actuel", "jours_sans_vente"] - ); - - const th = (key: SK, label: string) => ( - handleSort(key as string & keyof (PgSansVente6MoisRow & Record))}> - {label} - - ); - - async function handleExport() { - const ExcelJS = (await import("exceljs")).default; - const wb = new ExcelJS.Workbook(); - const ws = wb.addWorksheet("Sans vente 6 mois"); - ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente"]); - 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.stock_actuel, "412", fmtDate(r.derniere_vente)]); - ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { 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); - const a = document.createElement("a"); - a.href = url; - a.download = `Sans_Vente_6Mois_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`; - a.click(); - URL.revokeObjectURL(url); - } - - return ( -
-
-
-
- - setSearch(e.target.value)} - placeholder="Code article ou libellé…" - className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]" - /> -
- - {(filterFournisseur || search) && ( - - )} -
-
- {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_vente", "Dernière vente")} - {th("jours_sans_vente", "Jours sans vente")} - {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_vente)} 365 ? "#ef4444" : "#6b7280" }}> - {row.jours_sans_vente != null ? row.jours_sans_vente.toLocaleString("fr-FR") : "—"} - {fmtDate(row.derniere_entree)}
-
-
- ); -} +const SANS_VENTE_6MOIS_CONFIG: TabConfig = { + fournisseurOf: r => r.fournisseur, + searchIn: r => [r.codein, r.libelle1], + emptyLabel: "Aucun article trouvé", + columns: [ + { key: "codein", label: "Code article", render: r => cellCode(r.codein) }, + { key: "libelle1", label: "Libellé", render: r => {r.libelle1} }, + { key: "fournisseur", label: "Fournisseur", render: r => {r.fournisseur} }, + { key: "site", label: "Magasin", render: r => cellSite(r.site) }, + { key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => {r.stock_actuel} }, + { key: "derniere_vente", label: "Dernière vente", render: r => cellDate(r.derniere_vente) }, + { + key: "jours_sans_vente", + label: "Jours sans vente", + numeric: true, + render: r => ( + 365 ? "#ef4444" : "#6b7280" }} + > + {r.jours_sans_vente != null ? r.jours_sans_vente.toLocaleString("fr-FR") : "—"} + + ), + }, + { key: "derniere_entree", label: "Dernière entrée", render: r => cellDate(r.derniere_entree) }, + ], + excel: { + sheet: "Sans vente 6 mois", + filePrefix: "Sans_Vente_6Mois", + header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente"], + widths: [16, 16, 10, 10, 22], + row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_vente)], + }, +}; // --------------------------------------------------------------------------- // Composant principal avec onglets @@ -481,6 +494,10 @@ const TABS = [ type TabKey = typeof TABS[number]["key"]; +function isTabKey(v: string): v is TabKey { + return TABS.some(t => t.key === v); +} + interface Props { rowsNegatif: PgStockNegatifRow[]; rowsSansVente: PgStockSansVenteRow[]; @@ -492,25 +509,59 @@ interface Props { export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6Mois, magasin, tab, sites }: Props) { const router = useRouter(); - const activeTab: TabKey = (TABS.some(t => t.key === tab) ? tab : "negatif") as TabKey; + const initialTab: TabKey = isTabKey(tab) ? tab : "negatif"; + + const [activeTab, setActiveTab] = useState(initialTab); + // Le changement d'onglet est purement client : les 3 jeux de données sont déjà chargés. + const [switching, startSwitch] = useTransition(); + // Le changement de magasin, lui, relance les requêtes serveur. + const [navigating, startNavigation] = useTransition(); + + // Resynchronise l'onglet si l'URL change côté serveur (retour arrière, lien direct). + const [urlTab, setUrlTab] = useState(initialTab); + if (urlTab !== initialTab) { + setUrlTab(initialTab); + setActiveTab(initialTab); + } + + function buildUrl(nextTab: TabKey, nextMagasin: string) { + const params = new URLSearchParams(); + if (nextMagasin) params.set("magasin", nextMagasin); + params.set("tab", nextTab); + return `/stock-negatif?${params.toString()}`; + } 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()}`); + startNavigation(() => { + router.push(buildUrl(activeTab, val)); + }); } function handleTabChange(key: TabKey) { - const params = new URLSearchParams(); - if (magasin) params.set("magasin", magasin); - params.set("tab", key); - router.push(`/stock-negatif?${params.toString()}`); + if (key === activeTab || switching) return; + // Mise à jour de l'URL sans navigation serveur : aucune requête SQL n'est rejouée. + window.history.replaceState(null, "", buildUrl(key, magasin)); + startSwitch(() => setActiveTab(key)); } + const counts: Record = useMemo(() => ({ + "negatif": rowsNegatif.length, + "sans-vente": rowsSansVente.length, + "sans-vente-6mois": rowsSansVente6Mois.length, + }), [rowsNegatif, rowsSansVente, rowsSansVente6Mois]); + + const busy = switching || navigating; + const showLoader = useDelayedFlag(busy); + const loaderMessage = navigating ? "Chargement des données" : "Préparation de l'onglet"; + const loaderSubMessage = navigating + ? "Interrogation du stock pour le magasin sélectionné…" + : "Application des filtres et du tri…"; + return ( -
+
+ {showLoader && } + {/* Toolbar */}
@@ -518,7 +569,8 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M