feat: implement collapsible sidebar, PostgreSQL client for bulk data fetching, and stock-negatif page

This commit is contained in:
Michael committed 2026-04-07 16:51:52 +02:00
1 parent d96995fce9
commit ac1c9a5522
4 files changed
+290 -132

No files matched your search

+231 -123
View File
@@ -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<T extends string>({ col, sortKey, sortDir }: { col: T; sortKey: T; sortDir: SortDir }) {
if (col !== sortKey) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
return sortDir === "asc"
? <ChevronUp className="inline w-3 h-3 ml-1" />
: <ChevronDown className="inline w-3 h-3 ml-1" />;
}
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<SortKey>("stockdispo");
function useSortedRows<T extends Record<string, unknown>>(rows: T[], numericKeys: string[]) {
const [sortKey, setSortKey] = useState<keyof T>(Object.keys(rows[0] ?? { codein: "" })[0] as keyof T);
const [sortDir, setSortDir] = useState<SortDir>("asc");
function handleMagasinChange(e: React.ChangeEvent<HTMLSelectElement>) {
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<PgStockNegatifRow>(
rows.length ? rows : [] as PgStockNegatifRow[],
["stockdispo"]
);
const th = (key: SK, label: string) => (
<th 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"
onClick={() => handleSort(key)}>
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
</th>
);
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) => (
<th
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"
onClick={() => handleSort(key)}
>
{label}
<SortIcon col={key} sortKey={sortKey} sortDir={sortDir} />
return (
<div className="space-y-4">
<div className="flex justify-end items-center gap-3">
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
<Download className="w-4 h-4" />Exporter Excel
</button>
</div>
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{th("codein", "Code article")}
{th("libelle1", "Libellé")}
{th("fournisseur", "Fournisseur")}
{th("site", "Magasin")}
{th("stockdispo", "Stock")}
{th("dernierevente", "Dernière vente")}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.length === 0 && (
<tr><td colSpan={6} className="px-3 py-8 text-center text-gray-400">Aucun article en stock négatif</td></tr>
)}
{sorted.map((row, i) => (
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
<td className="px-3 py-2">
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
</td>
<td className="px-3 py-2 font-semibold text-red-600">{row.stockdispo}</td>
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.dernierevente)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Onglet 2 — Entrées sans vente
// ---------------------------------------------------------------------------
function TabEntreesSansVente({ rows, magasin }: { rows: PgStockSansVenteRow[]; magasin: string }) {
type SK = keyof PgStockSansVenteRow;
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgStockSansVenteRow>(
rows.length ? rows : [] as PgStockSansVenteRow[],
["stock_actuel"]
);
const th = (key: SK, label: string) => (
<th 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"
onClick={() => handleSort(key)}>
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
</th>
);
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 (
<div className="space-y-4">
<div className="flex justify-end items-center gap-3">
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
<Download className="w-4 h-4" />Exporter Excel
</button>
</div>
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{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")}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.length === 0 && (
<tr><td colSpan={6} className="px-3 py-8 text-center text-gray-400">Aucun article trouvé</td></tr>
)}
{sorted.map((row, i) => (
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
<td className="px-3 py-2">
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
</td>
<td className="px-3 py-2 font-semibold text-amber-600">{row.stock_actuel}</td>
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_entree)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<HTMLSelectElement>) {
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 (
<div className="space-y-4">
{/* Toolbar */}
@@ -133,63 +267,37 @@ export function StockNegatifClient({ rows, magasin, sites }: Props) {
))}
</select>
</div>
<div className="ml-auto flex items-center gap-3">
<span className="text-sm text-gray-500">
{sorted.length} article{sorted.length !== 1 ? "s" : ""}
</span>
<button
onClick={handleExport}
className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors"
>
<Download className="w-4 h-4" />
Exporter Excel
</button>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{th("codein", "Code article")}
{th("libelle1", "Libellé")}
{th("fournisseur", "Fournisseur")}
{th("site", "Magasin")}
{th("stockdispo", "Stock négatif")}
{th("dernierevente", "Dernière vente")}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-gray-400">
Aucun article en stock négatif
</td>
</tr>
)}
{sorted.map((row, i) => (
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
<td className="px-3 py-2">
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">
{row.site}
</span>
</td>
<td className="px-3 py-2">
<span className="font-semibold text-red-600">
{row.stockdispo}
</span>
</td>
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.dernierevente)}</td>
</tr>
))}
</tbody>
</table>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex gap-1">
{TABS.map(t => (
<button
key={t.key}
onClick={() => handleTabChange(t.key)}
className={[
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap",
activeTab === t.key
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
].join(" ")}
>
{t.label}
<span className={[
"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.length}
</span>
</button>
))}
</nav>
</div>
{/* Tab content */}
{activeTab === "negatif" && <TabStockNegatif rows={rowsNegatif} magasin={magasin} />}
{activeTab === "sans-vente" && <TabEntreesSansVente rows={rowsSansVente} magasin={magasin} />}
</div>
);
}
+14 -8
View File
@@ -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<Record<string, string | string[]>>;
}) {
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 (
<div className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-screen-2xl">
<h1 className="mb-6 text-3xl font-bold text-gray-900">Stock Négatif</h1>
<StockNegatifClient
rows={rows}
<h1 className="mb-6 text-3xl font-bold text-gray-900">Gestion de Stock</h1>
<GestionStockClient
rowsNegatif={rowsNegatif}
rowsSansVente={rowsSansVente}
magasin={magasin}
tab={tab}
sites={SITES}
/>
</div>
+1 -1
View File
@@ -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 },
+44
View File
@@ -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<PgStockSansVenteRow[]> {
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<PgStockNegatifRow[]> {
const siteFilter = site ? sql`AND cs.site = ${site}` : sql``;
const result = await pgNoParallel(sql`