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