From 7bf42dfc0152b28d60316520ed1742dba4a23f29 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 7 Apr 2026 15:33:01 +0200 Subject: [PATCH] feat: implement direct PostgreSQL bulk querying for FF Nancy data to replace per-article HTTP requests --- .claude/settings.json | 3 +- src/app/(dashboard)/stock-negatif/client.tsx | 195 +++++++++++++++++++ src/app/(dashboard)/stock-negatif/page.tsx | 31 +++ src/components/shared/sidebar.tsx | 3 +- src/lib/pg-ff-client.ts | 38 ++++ 5 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 src/app/(dashboard)/stock-negatif/client.tsx create mode 100644 src/app/(dashboard)/stock-negatif/page.tsx diff --git a/.claude/settings.json b/.claude/settings.json index 52a30b9..6458876 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -30,7 +30,8 @@ "Bash(curl -s \"https://collect.ffnancy.fr/api/diag-mvtart\")", "Read(//c/Users/Michael/VSCODE/CollectFlow/**)", "Bash(xargs wc:*)", - "Bash(ls /c/Users/Michael/VSCODE/CollectFlow/.next/static/chunks/*.css)" + "Bash(ls /c/Users/Michael/VSCODE/CollectFlow/.next/static/chunks/*.css)", + "Bash(find /c/Users/Michael/VSCODE/CollectFlow/src/app/api -type f \\\\\\( -name \"*.ts\" -o -name \"*.tsx\" \\\\\\) -exec ls -lh {} \\\\;)" ] } } diff --git a/src/app/(dashboard)/stock-negatif/client.tsx b/src/app/(dashboard)/stock-negatif/client.tsx new file mode 100644 index 0000000..0005c9b --- /dev/null +++ b/src/app/(dashboard)/stock-negatif/client.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, useMemo } from "react"; +import { ChevronUp, ChevronDown, ChevronsUpDown, Download } from "lucide-react"; +import type { PgStockNegatifRow } from "./page"; + +type SortKey = "codein" | "libelle1" | "fournisseur" | "site" | "stockdispo" | "dernierevente"; +type SortDir = "asc" | "desc"; + +function fmtDate(raw: string | null) { + if (!raw) return "—"; + const d = new Date(raw); + if (isNaN(d.getTime())) return raw; + return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }); +} + +function SortIcon({ col, sortKey, sortDir }: { col: SortKey; sortKey: SortKey; 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"); + 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: SortKey) { + if (sortKey === key) { + setSortDir(d => d === "asc" ? "desc" : "asc"); + } else { + setSortKey(key); + setSortDir("asc"); + } + } + + 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]); + + 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 => { + 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`; + + 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.click(); + URL.revokeObjectURL(url); + } + + const th = (key: SortKey, label: string) => ( + handleSort(key)} + > + {label} + + + ); + + return ( +
+ {/* Toolbar */} +
+
+ + +
+ +
+ + {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)}
+
+
+ ); +} diff --git a/src/app/(dashboard)/stock-negatif/page.tsx b/src/app/(dashboard)/stock-negatif/page.tsx new file mode 100644 index 0000000..414b8f2 --- /dev/null +++ b/src/app/(dashboard)/stock-negatif/page.tsx @@ -0,0 +1,31 @@ +import { pgGetStockNegatif, PgStockNegatifRow } from "@/lib/pg-ff-client"; +import { StockNegatifClient } from "./client"; + +export type { PgStockNegatifRow }; + +const SITES = [ + { code: "292", label: "292 — Frouard / Nancy" }, + { code: "579", label: "579 — Houdemont" }, +]; + +export default async function StockNegatifPage(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const magasin = (searchParams.magasin as string) || ""; + + const rows = await pgGetStockNegatif(magasin || undefined); + + return ( +
+
+

Stock Négatif

+ +
+
+ ); +} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index 620e3e3..54f7650 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react"; +import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; @@ -14,6 +14,7 @@ const NAV_ITEMS = [ { icon: TrendingUp, label: "Analytics", href: "/analytics" }, { icon: Trophy, label: "Hit Parade", href: "/hit-parade" }, { icon: Megaphone, label: "Publicités", href: "/publicites" }, + { icon: PackageMinus, label: "Stock Négatif", href: "/stock-negatif" }, { icon: Camera, label: "Snapshots", href: "/snapshots" }, { icon: FileDown, label: "Exports", href: "/exports", adminOnly: true }, { icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true }, diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 2558625..f2070da 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -828,3 +828,41 @@ export async function pgGetStockForCodeins(codeins: string[]): Promise { + const siteFilter = site ? sql`AND cs.site = ${site}` : sql``; + const result = await pgNoParallel(sql` + SELECT + a.codein, + COALESCE(a.libelle1, '') AS libelle1, + COALESCE(f.nom, af.code) AS fournisseur, + cs.site, + cs.stockdispo::float, + cs.dernierevente::text + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 + JOIN fouident f ON f.code = af.code + WHERE cs.stockdispo < 0 + ${siteFilter} + ORDER BY cs.stockdispo ASC + `); + return result.rows as PgStockNegatifRow[]; +}