mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-24 07:28:10 +02:00
feat: implement collapsible sidebar, PostgreSQL client for bulk data fetching, and stock-negatif page
This commit is contained in:
1 parent
d96995fce9
commit
ac1c9a5522
4 files changed
+290
-132
No files matched your search
@@ -3,9 +3,8 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Download } from "lucide-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";
|
type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
function fmtDate(raw: string | null) {
|
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" });
|
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" />;
|
if (col !== sortKey) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
||||||
return sortDir === "asc"
|
return sortDir === "asc"
|
||||||
? <ChevronUp className="inline w-3 h-3 ml-1" />
|
? <ChevronUp className="inline w-3 h-3 ml-1" />
|
||||||
: <ChevronDown className="inline w-3 h-3 ml-1" />;
|
: <ChevronDown className="inline w-3 h-3 ml-1" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
function useSortedRows<T extends Record<string, unknown>>(rows: T[], numericKeys: string[]) {
|
||||||
rows: PgStockNegatifRow[];
|
const [sortKey, setSortKey] = useState<keyof T>(Object.keys(rows[0] ?? { codein: "" })[0] as keyof T);
|
||||||
magasin: string;
|
|
||||||
sites: { code: string; label: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StockNegatifClient({ rows, magasin, sites }: Props) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("stockdispo");
|
|
||||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||||
|
|
||||||
function handleMagasinChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
function handleSort(key: keyof T) {
|
||||||
const val = e.target.value;
|
if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||||
if (val) {
|
else { setSortKey(key); setSortDir("asc"); }
|
||||||
router.push(`/stock-negatif?magasin=${val}`);
|
|
||||||
} else {
|
|
||||||
router.push("/stock-negatif");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSort(key: SortKey) {
|
const sorted = useMemo(() => [...rows].sort((a, b) => {
|
||||||
if (sortKey === key) {
|
const av = a[sortKey];
|
||||||
setSortDir(d => d === "asc" ? "desc" : "asc");
|
const bv = b[sortKey];
|
||||||
} else {
|
if (numericKeys.includes(sortKey as string)) {
|
||||||
setSortKey(key);
|
return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av);
|
||||||
setSortDir("asc");
|
|
||||||
}
|
}
|
||||||
}
|
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 { sorted, sortKey, sortDir, handleSort };
|
||||||
return [...rows].sort((a, b) => {
|
}
|
||||||
let av: string | number = a[sortKey] ?? "";
|
|
||||||
let bv: string | number = b[sortKey] ?? "";
|
// ---------------------------------------------------------------------------
|
||||||
if (sortKey === "stockdispo") {
|
// Onglet 1 — Stock Négatif
|
||||||
av = Number(av);
|
// ---------------------------------------------------------------------------
|
||||||
bv = Number(bv);
|
|
||||||
return sortDir === "asc" ? av - bv : bv - av;
|
function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin: string }) {
|
||||||
}
|
type SK = keyof PgStockNegatifRow;
|
||||||
av = String(av).toLowerCase();
|
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgStockNegatifRow>(
|
||||||
bv = String(bv).toLowerCase();
|
rows.length ? rows : [] as PgStockNegatifRow[],
|
||||||
return sortDir === "asc" ? av.localeCompare(bv) : bv.localeCompare(av);
|
["stockdispo"]
|
||||||
});
|
);
|
||||||
}, [rows, sortKey, sortDir]);
|
|
||||||
|
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() {
|
async function handleExport() {
|
||||||
const ExcelJS = (await import("exceljs")).default;
|
const ExcelJS = (await import("exceljs")).default;
|
||||||
const wb = new ExcelJS.Workbook();
|
const wb = new ExcelJS.Workbook();
|
||||||
const ws = wb.addWorksheet("Stock Négatif");
|
const ws = wb.addWorksheet("Stock Négatif");
|
||||||
|
ws.addRow(["Code article", "Libellé", "Fournisseur", "Stock négatif"]);
|
||||||
const headers = ["Code article", "Libellé", "Fournisseur", "Stock négatif"];
|
ws.getRow(1).eachCell(cell => {
|
||||||
ws.addRow(headers);
|
|
||||||
const headerRow = ws.getRow(1);
|
|
||||||
headerRow.eachCell(cell => {
|
|
||||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
|
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
|
||||||
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
|
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
|
||||||
cell.alignment = { vertical: "middle", horizontal: "center" };
|
cell.alignment = { vertical: "middle", horizontal: "center" };
|
||||||
});
|
});
|
||||||
ws.getRow(1).height = 22;
|
ws.getRow(1).height = 22;
|
||||||
|
for (const r of sorted) ws.addRow([r.codein, r.libelle1, r.fournisseur, r.stockdispo]);
|
||||||
for (const r of sorted) {
|
ws.columns = [{ width: 16 }, { width: 40 }, { width: 30 }, { width: 14 }];
|
||||||
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 buffer = await wb.xlsx.writeBuffer();
|
||||||
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = filename;
|
a.download = `Stock_Negatif_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
const th = (key: SortKey, label: string) => (
|
return (
|
||||||
<th
|
<div className="space-y-4">
|
||||||
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"
|
<div className="flex justify-end items-center gap-3">
|
||||||
onClick={() => handleSort(key)}
|
<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">
|
||||||
{label}
|
<Download className="w-4 h-4" />Exporter Excel
|
||||||
<SortIcon col={key} sortKey={sortKey} sortDir={sortDir} />
|
</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>
|
</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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
@@ -133,63 +267,37 @@ export function StockNegatifClient({ rows, magasin, sites }: Props) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Tabs */}
|
||||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
<div className="border-b border-gray-200">
|
||||||
<table className="min-w-full text-sm">
|
<nav className="-mb-px flex gap-1">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
{TABS.map(t => (
|
||||||
<tr>
|
<button
|
||||||
{th("codein", "Code article")}
|
key={t.key}
|
||||||
{th("libelle1", "Libellé")}
|
onClick={() => handleTabChange(t.key)}
|
||||||
{th("fournisseur", "Fournisseur")}
|
className={[
|
||||||
{th("site", "Magasin")}
|
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap",
|
||||||
{th("stockdispo", "Stock négatif")}
|
activeTab === t.key
|
||||||
{th("dernierevente", "Dernière vente")}
|
? "border-blue-600 text-blue-600"
|
||||||
</tr>
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
|
||||||
</thead>
|
].join(" ")}
|
||||||
<tbody className="divide-y divide-gray-100">
|
>
|
||||||
{sorted.length === 0 && (
|
{t.label}
|
||||||
<tr>
|
<span className={[
|
||||||
<td colSpan={6} className="px-3 py-8 text-center text-gray-400">
|
"ml-2 rounded-full px-2 py-0.5 text-xs font-semibold",
|
||||||
Aucun article en stock négatif
|
activeTab === t.key ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500",
|
||||||
</td>
|
].join(" ")}>
|
||||||
</tr>
|
{t.key === "negatif" ? rowsNegatif.length : rowsSansVente.length}
|
||||||
)}
|
</span>
|
||||||
{sorted.map((row, i) => (
|
</button>
|
||||||
<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>
|
</nav>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{activeTab === "negatif" && <TabStockNegatif rows={rowsNegatif} magasin={magasin} />}
|
||||||
|
{activeTab === "sans-vente" && <TabEntreesSansVente rows={rowsSansVente} magasin={magasin} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,34 @@
|
|||||||
import { pgGetStockNegatif, PgStockNegatifRow } from "@/lib/pg-ff-client";
|
import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow } from "@/lib/pg-ff-client";
|
||||||
import { StockNegatifClient } from "./client";
|
import { GestionStockClient } from "./client";
|
||||||
|
|
||||||
export type { PgStockNegatifRow };
|
export type { PgStockNegatifRow, PgStockSansVenteRow };
|
||||||
|
|
||||||
const SITES = [
|
const SITES = [
|
||||||
{ code: "292", label: "292 — Frouard / Nancy" },
|
{ code: "292", label: "292 — Frouard / Nancy" },
|
||||||
{ code: "579", label: "579 — Houdemont" },
|
{ code: "579", label: "579 — Houdemont" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default async function StockNegatifPage(props: {
|
export default async function GestionStockPage(props: {
|
||||||
searchParams: Promise<Record<string, string | string[]>>;
|
searchParams: Promise<Record<string, string | string[]>>;
|
||||||
}) {
|
}) {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const magasin = (searchParams.magasin as string) || "";
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-6">
|
<div className="min-h-screen bg-gray-50 p-6">
|
||||||
<div className="mx-auto max-w-screen-2xl">
|
<div className="mx-auto max-w-screen-2xl">
|
||||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Stock Négatif</h1>
|
<h1 className="mb-6 text-3xl font-bold text-gray-900">Gestion de Stock</h1>
|
||||||
<StockNegatifClient
|
<GestionStockClient
|
||||||
rows={rows}
|
rowsNegatif={rowsNegatif}
|
||||||
|
rowsSansVente={rowsSansVente}
|
||||||
magasin={magasin}
|
magasin={magasin}
|
||||||
|
tab={tab}
|
||||||
sites={SITES}
|
sites={SITES}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const NAV_ITEMS = [
|
|||||||
{ icon: TrendingUp, label: "Analytics", href: "/analytics" },
|
{ icon: TrendingUp, label: "Analytics", href: "/analytics" },
|
||||||
{ icon: Trophy, label: "Hit Parade", href: "/hit-parade" },
|
{ icon: Trophy, label: "Hit Parade", href: "/hit-parade" },
|
||||||
{ icon: Megaphone, label: "Publicités", href: "/publicites" },
|
{ 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: Camera, label: "Snapshots", href: "/snapshots" },
|
||||||
{ icon: FileDown, label: "Exports", href: "/exports", adminOnly: true },
|
{ icon: FileDown, label: "Exports", href: "/exports", adminOnly: true },
|
||||||
{ icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
|
{ icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
|
||||||
|
|||||||
@@ -846,6 +846,50 @@ export interface PgStockNegatifRow {
|
|||||||
* Retourne tous les articles avec un stock disponible négatif.
|
* Retourne tous les articles avec un stock disponible négatif.
|
||||||
* Si `site` est fourni, filtre sur ce magasin uniquement.
|
* 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[]> {
|
export async function pgGetStockNegatif(site?: string): Promise<PgStockNegatifRow[]> {
|
||||||
const siteFilter = site ? sql`AND cs.site = ${site}` : sql``;
|
const siteFilter = site ? sql`AND cs.site = ${site}` : sql``;
|
||||||
const result = await pgNoParallel(sql`
|
const result = await pgNoParallel(sql`
|
||||||
|
|||||||
Reference in new issue
Block a user