From 71241fe0e65bff74d2a7132349d0e5b2a722f0f8 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 14 Apr 2026 13:17:56 +0200 Subject: [PATCH] feat: add "Sans vente 6 mois" tab to Gestion de Stock page New SQL query pgGetSansVente6Mois returns products with available stock (qte > 0) whose last sale was over 6 months ago or never. Third tab added with filtering by supplier, search, sort, and Excel export. Co-Authored-By: Claude Sonnet 4.6 --- src/app/(dashboard)/stock-negatif/client.tsx | 142 ++++++++++++++++++- src/app/(dashboard)/stock-negatif/page.tsx | 8 +- src/lib/pg-ff-client.ts | 48 +++++++ 3 files changed, 192 insertions(+), 6 deletions(-) diff --git a/src/app/(dashboard)/stock-negatif/client.tsx b/src/app/(dashboard)/stock-negatif/client.tsx index 0c2ef82..c68b9dd 100644 --- a/src/app/(dashboard)/stock-negatif/client.tsx +++ b/src/app/(dashboard)/stock-negatif/client.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/navigation"; import { useState, useMemo } from "react"; import { ChevronUp, ChevronDown, ChevronsUpDown, Download, Search } from "lucide-react"; -import type { PgStockNegatifRow, PgStockSansVenteRow } from "./page"; +import type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow } from "./page"; type SortDir = "asc" | "desc"; @@ -338,6 +338,135 @@ function TabEntreesSansVente({ rows, magasin }: { rows: PgStockSansVenteRow[]; m ); } +// --------------------------------------------------------------------------- +// Onglet 3 — Sans vente depuis 6 mois +// --------------------------------------------------------------------------- + +function TabSansVente6Mois({ rows, magasin }: { rows: PgSansVente6MoisRow[]; magasin: string }) { + type SK = keyof PgSansVente6MoisRow; + + const [filterFournisseur, setFilterFournisseur] = 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 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", "Libellé", "Fournisseur", "Magasin", "Stock actuel", "Dernière vente", "Jours sans 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.libelle1, r.fournisseur, r.site, r.stock_actuel, fmtDate(r.derniere_vente), r.jours_sans_vente ?? "—"]); + ws.columns = [{ width: 16 }, { width: 30 }, { width: 24 }, { width: 10 }, { width: 12 }, { width: 18 }, { 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 = `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")} + + + + {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") : "—"} +
+
+
+ ); +} + // --------------------------------------------------------------------------- // Composant principal avec onglets // --------------------------------------------------------------------------- @@ -345,6 +474,7 @@ function TabEntreesSansVente({ rows, magasin }: { rows: PgStockSansVenteRow[]; m const TABS = [ { key: "negatif", label: "Stock négatif" }, { key: "sans-vente", label: "Entrées sans vente" }, + { key: "sans-vente-6mois", label: "Sans vente 6 mois" }, ] as const; type TabKey = typeof TABS[number]["key"]; @@ -352,12 +482,13 @@ type TabKey = typeof TABS[number]["key"]; interface Props { rowsNegatif: PgStockNegatifRow[]; rowsSansVente: PgStockSansVenteRow[]; + rowsSansVente6Mois: PgSansVente6MoisRow[]; magasin: string; tab: string; sites: { code: string; label: string }[]; } -export function GestionStockClient({ rowsNegatif, rowsSansVente, magasin, tab, sites }: 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; @@ -414,7 +545,11 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, magasin, tab, s "ml-2 rounded-full px-2 py-0.5 text-xs font-semibold", activeTab === t.key ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500", ].join(" ")}> - {t.key === "negatif" ? rowsNegatif.length : rowsSansVente.filter(r => r.stock_actuel !== 0).length} + {t.key === "negatif" + ? rowsNegatif.length + : t.key === "sans-vente" + ? rowsSansVente.filter(r => r.stock_actuel !== 0).length + : rowsSansVente6Mois.length} ))} @@ -424,6 +559,7 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, magasin, tab, s {/* Tab content */} {activeTab === "negatif" && } {activeTab === "sans-vente" && } + {activeTab === "sans-vente-6mois" && } ); } diff --git a/src/app/(dashboard)/stock-negatif/page.tsx b/src/app/(dashboard)/stock-negatif/page.tsx index a059760..d80bd88 100644 --- a/src/app/(dashboard)/stock-negatif/page.tsx +++ b/src/app/(dashboard)/stock-negatif/page.tsx @@ -1,7 +1,7 @@ -import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow } from "@/lib/pg-ff-client"; +import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow, pgGetSansVente6Mois, PgSansVente6MoisRow } from "@/lib/pg-ff-client"; import { GestionStockClient } from "./client"; -export type { PgStockNegatifRow, PgStockSansVenteRow }; +export type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow }; const SITES = [ { code: "292", label: "292 — Frouard / Nancy" }, @@ -15,9 +15,10 @@ export default async function GestionStockPage(props: { const magasin = (searchParams.magasin as string) || ""; const tab = (searchParams.tab as string) || "negatif"; - const [rowsNegatif, rowsSansVente] = await Promise.all([ + const [rowsNegatif, rowsSansVente, rowsSansVente6Mois] = await Promise.all([ pgGetStockNegatif(magasin || undefined), pgGetStockSansVente(magasin || undefined), + pgGetSansVente6Mois(magasin || undefined), ]); return ( @@ -27,6 +28,7 @@ export default async function GestionStockPage(props: { 0) + * mais dont la dernière vente remonte à plus de 6 mois (ou n'ont jamais été vendus). + */ +export async function pgGetSansVente6Mois(site?: 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.qte::float AS stock_actuel, + cs.dernierevente::text AS derniere_vente, + CASE + WHEN cs.dernierevente IS NULL THEN NULL + ELSE (CURRENT_DATE - cs.dernierevente::date) + END AS jours_sans_vente + 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.site IN ('292', '579') + AND cs.qte > 0 + AND ( + cs.dernierevente IS NULL + OR cs.dernierevente < CURRENT_DATE - INTERVAL '6 months' + ) + ${siteFilter} + ORDER BY cs.dernierevente ASC NULLS FIRST + `); + return result.rows as PgSansVente6MoisRow[]; +} + // --------------------------------------------------------------------------- // Commandes automatiques — par site + fournisseur avec franco // ---------------------------------------------------------------------------