From ac1c9a552205f1f7d0c9dfb22e762d55514be481 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 7 Apr 2026 16:51:52 +0200 Subject: [PATCH] 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`