mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-24 07:28:10 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c84cfda53 | ||
|
|
585e171708 | ||
|
|
2404b22628 | ||
|
|
7f5ef93469 | ||
|
|
60ce3b8d21 |
No files matched your search
@@ -1,12 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Download, Search } from "lucide-react";
|
||||
import { useState, useMemo, useEffect, useTransition, type ReactNode } from "react";
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Download,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
import type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow } from "./page";
|
||||
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
/** Nombre de lignes rendues simultanément : au-delà, le navigateur fige. */
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/** Un seul collateur réutilisé : `localeCompare` en crée un par appel (très coûteux sur 17 000 lignes). */
|
||||
const collator = new Intl.Collator("fr", { numeric: true, sensitivity: "base" });
|
||||
|
||||
function fmtDate(raw: string | null) {
|
||||
if (!raw) return "—";
|
||||
const d = new Date(raw);
|
||||
@@ -21,9 +38,66 @@ function getYear(raw: string | null): string {
|
||||
return String(d.getFullYear());
|
||||
}
|
||||
|
||||
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"
|
||||
/**
|
||||
* Passe à `true` seulement si `active` reste vrai plus de `delay` ms.
|
||||
* Évite le clignotement du modal sur les transitions instantanées.
|
||||
*/
|
||||
function useDelayedFlag(active: boolean, delay = 120) {
|
||||
const [elapsed, setElapsed] = useState(false);
|
||||
const [wasActive, setWasActive] = useState(active);
|
||||
|
||||
// Réinitialisation pendant le rendu (et non dans un effet) : dès que
|
||||
// l'attente cesse, le prochain délai repart de zéro.
|
||||
if (wasActive !== active) {
|
||||
setWasActive(active);
|
||||
if (!active) setElapsed(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const id = setTimeout(() => setElapsed(true), delay);
|
||||
return () => clearTimeout(id);
|
||||
}, [active, delay]);
|
||||
|
||||
return active && elapsed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tableau générique (les 3 onglets partagent filtres, tri, pagination, export)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Column<T> {
|
||||
key: Extract<keyof T, string>;
|
||||
label: string;
|
||||
/** Tri numérique au lieu du tri alphabétique. */
|
||||
numeric?: boolean;
|
||||
render?: (row: T) => ReactNode;
|
||||
}
|
||||
|
||||
interface ExcelSpec<T> {
|
||||
sheet: string;
|
||||
filePrefix: string;
|
||||
header: string[];
|
||||
widths: number[];
|
||||
row: (r: T) => (string | number)[];
|
||||
}
|
||||
|
||||
interface StockTableProps<T> {
|
||||
rows: T[];
|
||||
magasin: string;
|
||||
columns: Column<T>[];
|
||||
fournisseurOf: (r: T) => string;
|
||||
/** Source du filtre « Année » — omis, le filtre n'est pas affiché. */
|
||||
anneeOf?: (r: T) => string;
|
||||
anneeLabel?: string;
|
||||
searchIn: (r: T) => (string | null | undefined)[];
|
||||
emptyLabel: string;
|
||||
excel: ExcelSpec<T>;
|
||||
}
|
||||
|
||||
function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) {
|
||||
if (!active) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
||||
return dir === "asc"
|
||||
? <ChevronUp className="inline w-3 h-3 ml-1" />
|
||||
: <ChevronDown className="inline w-3 h-3 ml-1" />;
|
||||
}
|
||||
@@ -49,95 +123,174 @@ function FilterSelect({ label, value, onChange, options }: {
|
||||
);
|
||||
}
|
||||
|
||||
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 Pagination({ page, totalPages, total, from, to, onChange }: {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
total: number;
|
||||
from: number;
|
||||
to: number;
|
||||
onChange: (p: number) => void;
|
||||
}) {
|
||||
if (total === 0) return null;
|
||||
|
||||
function handleSort(key: keyof T) {
|
||||
if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||
const btn = "rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-1">
|
||||
<span className="text-xs text-gray-500">
|
||||
Lignes <span className="font-medium text-gray-700">{from.toLocaleString("fr-FR")}</span>
|
||||
{" à "}
|
||||
<span className="font-medium text-gray-700">{to.toLocaleString("fr-FR")}</span>
|
||||
{" sur "}
|
||||
<span className="font-medium text-gray-700">{total.toLocaleString("fr-FR")}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button className={btn} onClick={() => onChange(1)} disabled={page <= 1} aria-label="Première page">
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button className={btn} onClick={() => onChange(page - 1)} disabled={page <= 1} aria-label="Page précédente">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="px-2 text-xs text-gray-600 tabular-nums">
|
||||
Page {page.toLocaleString("fr-FR")} / {totalPages.toLocaleString("fr-FR")}
|
||||
</span>
|
||||
<button className={btn} onClick={() => onChange(page + 1)} disabled={page >= totalPages} aria-label="Page suivante">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button className={btn} onClick={() => onChange(totalPages)} disabled={page >= totalPages} aria-label="Dernière page">
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StockTable<T extends object>({
|
||||
rows,
|
||||
magasin,
|
||||
columns,
|
||||
fournisseurOf,
|
||||
anneeOf,
|
||||
anneeLabel = "Année entrée",
|
||||
searchIn,
|
||||
emptyLabel,
|
||||
excel,
|
||||
}: StockTableProps<T>) {
|
||||
const [filterFournisseur, setFilterFournisseur] = useState("");
|
||||
const [filterAnnee, setFilterAnnee] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
// `null` = ordre renvoyé par la base (le plus pertinent : stock le plus négatif,
|
||||
// entrée la plus récente…). Aucun tri client tant que l'utilisateur n'en demande pas.
|
||||
const [sortKey, setSortKey] = useState<Extract<keyof T, string> | null>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
const [page, setPage] = useState(1);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const fournisseurs = useMemo(
|
||||
() => [...new Set(rows.map(fournisseurOf).filter(Boolean))].sort((a, b) => collator.compare(a, b)),
|
||||
[rows, fournisseurOf]
|
||||
);
|
||||
const annees = useMemo(
|
||||
() => anneeOf ? [...new Set(rows.map(anneeOf).filter(Boolean))].sort((a, b) => b.localeCompare(a)) : [],
|
||||
[rows, anneeOf]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!filterFournisseur && !filterAnnee && !q) return rows;
|
||||
return rows.filter(r =>
|
||||
(!filterFournisseur || fournisseurOf(r) === filterFournisseur) &&
|
||||
(!filterAnnee || (anneeOf ? anneeOf(r) === filterAnnee : true)) &&
|
||||
(!q || searchIn(r).some(v => v?.toLowerCase().includes(q)))
|
||||
);
|
||||
}, [rows, filterFournisseur, filterAnnee, search, fournisseurOf, anneeOf, searchIn]);
|
||||
|
||||
const numericKeys = useMemo(
|
||||
() => new Set(columns.filter(c => c.numeric).map(c => c.key as string)),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sortKey) return filtered;
|
||||
const numeric = numericKeys.has(sortKey);
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
return [...filtered].sort((a, b) => {
|
||||
const av = (a as Record<string, unknown>)[sortKey];
|
||||
const bv = (b as Record<string, unknown>)[sortKey];
|
||||
if (numeric) {
|
||||
const an = Number(av);
|
||||
const bn = Number(bv);
|
||||
if (Number.isNaN(an) && Number.isNaN(bn)) return 0;
|
||||
if (Number.isNaN(an)) return 1;
|
||||
if (Number.isNaN(bn)) return -1;
|
||||
return (an - bn) * dir;
|
||||
}
|
||||
return collator.compare(String(av ?? ""), String(bv ?? "")) * dir;
|
||||
});
|
||||
}, [filtered, sortKey, sortDir, numericKeys]);
|
||||
|
||||
// Toute modification des filtres / du tri ramène à la première page.
|
||||
// Ajustement pendant le rendu : pas d'effet, donc pas de rendu en cascade.
|
||||
const [pagedSet, setPagedSet] = useState(sorted);
|
||||
if (pagedSet !== sorted) {
|
||||
setPagedSet(sorted);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const total = sorted.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageRows = useMemo(
|
||||
() => sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
|
||||
[sorted, currentPage]
|
||||
);
|
||||
|
||||
function handleSort(key: Extract<keyof T, string>) {
|
||||
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 hasFilters = Boolean(filterFournisseur || filterAnnee || search);
|
||||
|
||||
async function handleExport() {
|
||||
if (exporting) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const ExcelJS = (await import("exceljs")).default;
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet(excel.sheet);
|
||||
ws.addRow(excel.header);
|
||||
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;
|
||||
// L'export reprend l'intégralité des lignes filtrées, pas seulement la page affichée.
|
||||
for (const r of sorted) ws.addRow(excel.row(r));
|
||||
ws.columns = excel.widths.map(width => ({ width }));
|
||||
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 = `${excel.filePrefix}_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
const as = String(av ?? "").toLowerCase();
|
||||
const bs = String(bv ?? "").toLowerCase();
|
||||
return sortDir === "asc" ? as.localeCompare(bs) : bs.localeCompare(as);
|
||||
}), [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 [filterFournisseur, setFilterFournisseur] = useState("");
|
||||
const [filterAnnee, setFilterAnnee] = 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 annees = useMemo(() =>
|
||||
[...new Set(rows.map(r => getYear(r.derniereentree)).filter(Boolean))].sort((a, b) => b.localeCompare(a)),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows.filter(r =>
|
||||
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
|
||||
(!filterAnnee || getYear(r.derniereentree) === filterAnnee) &&
|
||||
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
|
||||
);
|
||||
}, [rows, filterFournisseur, filterAnnee, search]);
|
||||
|
||||
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgStockNegatifRow>(
|
||||
filtered,
|
||||
["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");
|
||||
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "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, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)]);
|
||||
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 16 }, { width: 22 }];
|
||||
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 = `Stock_Negatif_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{exporting && (
|
||||
<LoadingModal
|
||||
message="Génération du fichier Excel"
|
||||
subMessage={`${total.toLocaleString("fr-FR")} ligne${total !== 1 ? "s" : ""} en cours d'écriture…`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
@@ -151,8 +304,10 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin
|
||||
/>
|
||||
</div>
|
||||
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
|
||||
<FilterSelect label="Année entrée" value={filterAnnee} onChange={setFilterAnnee} options={annees} />
|
||||
{(filterFournisseur || filterAnnee || search) && (
|
||||
{anneeOf && (
|
||||
<FilterSelect label={anneeLabel} value={filterAnnee} onChange={setFilterAnnee} options={annees} />
|
||||
)}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }}
|
||||
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
|
||||
@@ -162,312 +317,170 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin
|
||||
)}
|
||||
</div>
|
||||
<div className="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">
|
||||
<span className="text-sm text-gray-500">
|
||||
{total.toLocaleString("fr-FR")} article{total !== 1 ? "s" : ""}
|
||||
{hasFilters && total !== rows.length && (
|
||||
<span className="text-gray-400"> / {rows.length.toLocaleString("fr-FR")}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || total === 0}
|
||||
className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-4 h-4" />Exporter Excel
|
||||
</button>
|
||||
</div>
|
||||
</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")}
|
||||
{th("derniereentree", "Dernière entrée")}
|
||||
{columns.map(c => (
|
||||
<th
|
||||
key={c.key}
|
||||
onClick={() => handleSort(c.key)}
|
||||
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"
|
||||
>
|
||||
{c.label}
|
||||
<SortIcon active={sortKey === c.key} dir={sortDir} />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.length === 0 && (
|
||||
<tr><td colSpan={7} className="px-3 py-8 text-center text-gray-400">Aucun article en stock négatif</td></tr>
|
||||
{pageRows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-8 text-center text-gray-400">{emptyLabel}</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>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniereentree)}</td>
|
||||
{pageRows.map((row, i) => (
|
||||
<tr key={`${String((row as Record<string, unknown>).codein)}-${String((row as Record<string, unknown>).site)}-${i}`}
|
||||
className="hover:bg-gray-50 transition-colors">
|
||||
{columns.map(c => (
|
||||
<td key={c.key} className="px-3 py-2">
|
||||
{c.render
|
||||
? c.render(row)
|
||||
: String((row as Record<string, unknown>)[c.key] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
from={total === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1}
|
||||
to={Math.min(currentPage * PAGE_SIZE, total)}
|
||||
onChange={p => setPage(Math.min(Math.max(1, p), totalPages))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onglet 2 — Entrées sans vente
|
||||
// Cellules partagées
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabEntreesSansVente({ rows, magasin }: { rows: PgStockSansVenteRow[]; magasin: string }) {
|
||||
type SK = keyof PgStockSansVenteRow;
|
||||
|
||||
const [filterFournisseur, setFilterFournisseur] = useState("");
|
||||
const [filterAnnee, setFilterAnnee] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const baseRows = useMemo(() => rows.filter(r => r.stock_actuel !== 0), [rows]);
|
||||
|
||||
const fournisseurs = useMemo(() =>
|
||||
[...new Set(baseRows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
|
||||
[baseRows]
|
||||
);
|
||||
const annees = useMemo(() =>
|
||||
[...new Set(baseRows.map(r => getYear(r.derniere_entree)).filter(Boolean))].sort((a, b) => b.localeCompare(a)),
|
||||
[baseRows]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return baseRows.filter(r =>
|
||||
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
|
||||
(!filterAnnee || getYear(r.derniere_entree) === filterAnnee) &&
|
||||
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
|
||||
);
|
||||
}, [baseRows, filterFournisseur, filterAnnee, search]);
|
||||
|
||||
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgStockSansVenteRow>(
|
||||
filtered,
|
||||
["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(["CODEIN", "GENCODE", "QTE", "CODMV", "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.stock_actuel, "412", fmtDate(r.derniere_entree)]);
|
||||
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 22 }];
|
||||
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 flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => 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]"
|
||||
/>
|
||||
</div>
|
||||
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
|
||||
<FilterSelect label="Année entrée" value={filterAnnee} onChange={setFilterAnnee} options={annees} />
|
||||
{(filterFournisseur || filterAnnee || search) && (
|
||||
<button
|
||||
onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }}
|
||||
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
|
||||
>
|
||||
✕ Réinitialiser
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="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 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>
|
||||
);
|
||||
}
|
||||
const cellCode = (v: string) => <span className="font-mono text-xs text-gray-700">{v}</span>;
|
||||
const cellSite = (v: string) => (
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{v}</span>
|
||||
);
|
||||
const cellDate = (v: string | null) => <span className="text-gray-600 text-xs">{fmtDate(v)}</span>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onglet 3 — Sans vente depuis 6 mois
|
||||
// Configuration des 3 onglets
|
||||
//
|
||||
// Définies au niveau module : leur identité doit rester stable d'un rendu à
|
||||
// l'autre, sinon les `useMemo` de StockTable se recalculent en boucle.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabSansVente6Mois({ rows, magasin }: { rows: PgSansVente6MoisRow[]; magasin: string }) {
|
||||
type SK = keyof PgSansVente6MoisRow;
|
||||
type TabConfig<T> = Omit<StockTableProps<T>, "rows" | "magasin">;
|
||||
|
||||
const [filterFournisseur, setFilterFournisseur] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const NEGATIF_CONFIG: TabConfig<PgStockNegatifRow> = {
|
||||
fournisseurOf: r => r.fournisseur,
|
||||
anneeOf: r => getYear(r.derniereentree),
|
||||
searchIn: r => [r.codein, r.libelle1],
|
||||
emptyLabel: "Aucun article en stock négatif",
|
||||
columns: [
|
||||
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
|
||||
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
|
||||
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
|
||||
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
|
||||
{ key: "stockdispo", label: "Stock", numeric: true, render: r => <span className="font-semibold text-red-600">{r.stockdispo}</span> },
|
||||
{ key: "dernierevente", label: "Dernière vente", render: r => cellDate(r.dernierevente) },
|
||||
{ key: "derniereentree", label: "Dernière entrée", render: r => cellDate(r.derniereentree) },
|
||||
],
|
||||
excel: {
|
||||
sheet: "Stock Négatif",
|
||||
filePrefix: "Stock_Negatif",
|
||||
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "Dernière entrée"],
|
||||
widths: [16, 16, 10, 10, 16, 22],
|
||||
row: r => [r.codein, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)],
|
||||
},
|
||||
};
|
||||
|
||||
const fournisseurs = useMemo(() =>
|
||||
[...new Set(rows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
|
||||
[rows]
|
||||
);
|
||||
const SANS_VENTE_CONFIG: TabConfig<PgStockSansVenteRow> = {
|
||||
fournisseurOf: r => r.fournisseur,
|
||||
anneeOf: r => getYear(r.derniere_entree),
|
||||
searchIn: r => [r.codein, r.libelle1],
|
||||
emptyLabel: "Aucun article trouvé",
|
||||
columns: [
|
||||
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
|
||||
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
|
||||
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
|
||||
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
|
||||
{ key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => <span className="font-semibold text-amber-600">{r.stock_actuel}</span> },
|
||||
{ key: "derniere_entree", label: "Dernière entrée", render: r => cellDate(r.derniere_entree) },
|
||||
],
|
||||
excel: {
|
||||
sheet: "Entrées sans vente",
|
||||
filePrefix: "Entrees_Sans_Vente",
|
||||
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière entrée"],
|
||||
widths: [16, 16, 10, 10, 22],
|
||||
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_entree)],
|
||||
},
|
||||
};
|
||||
|
||||
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<PgSansVente6MoisRow & Record<string, unknown>>(
|
||||
filtered as (PgSansVente6MoisRow & Record<string, unknown>)[],
|
||||
["stock_actuel", "jours_sans_vente"]
|
||||
);
|
||||
|
||||
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 as string & keyof (PgSansVente6MoisRow & Record<string, unknown>))}>
|
||||
{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("Sans vente 6 mois");
|
||||
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière 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.stock_actuel, "412", fmtDate(r.derniere_vente)]);
|
||||
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 22 }];
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => 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]"
|
||||
/>
|
||||
</div>
|
||||
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
|
||||
{(filterFournisseur || search) && (
|
||||
<button
|
||||
onClick={() => { setFilterFournisseur(""); setSearch(""); }}
|
||||
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
|
||||
>
|
||||
✕ Réinitialiser
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="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 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_vente", "Dernière vente")}
|
||||
{th("jours_sans_vente", "Jours sans vente")}
|
||||
{th("derniere_entree", "Dernière entrée")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.length === 0 && (
|
||||
<tr><td colSpan={8} 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_vente)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-medium"
|
||||
style={{ color: row.jours_sans_vente && row.jours_sans_vente > 365 ? "#ef4444" : "#6b7280" }}>
|
||||
{row.jours_sans_vente != null ? row.jours_sans_vente.toLocaleString("fr-FR") : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_entree)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const SANS_VENTE_6MOIS_CONFIG: TabConfig<PgSansVente6MoisRow> = {
|
||||
fournisseurOf: r => r.fournisseur,
|
||||
searchIn: r => [r.codein, r.libelle1],
|
||||
emptyLabel: "Aucun article trouvé",
|
||||
columns: [
|
||||
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
|
||||
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
|
||||
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
|
||||
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
|
||||
{ key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => <span className="font-semibold text-amber-600">{r.stock_actuel}</span> },
|
||||
{ key: "derniere_vente", label: "Dernière vente", render: r => cellDate(r.derniere_vente) },
|
||||
{
|
||||
key: "jours_sans_vente",
|
||||
label: "Jours sans vente",
|
||||
numeric: true,
|
||||
render: r => (
|
||||
<span
|
||||
className="tabular-nums font-medium"
|
||||
style={{ color: r.jours_sans_vente && r.jours_sans_vente > 365 ? "#ef4444" : "#6b7280" }}
|
||||
>
|
||||
{r.jours_sans_vente != null ? r.jours_sans_vente.toLocaleString("fr-FR") : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "derniere_entree", label: "Dernière entrée", render: r => cellDate(r.derniere_entree) },
|
||||
],
|
||||
excel: {
|
||||
sheet: "Sans vente 6 mois",
|
||||
filePrefix: "Sans_Vente_6Mois",
|
||||
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente"],
|
||||
widths: [16, 16, 10, 10, 22],
|
||||
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_vente)],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composant principal avec onglets
|
||||
@@ -481,6 +494,10 @@ const TABS = [
|
||||
|
||||
type TabKey = typeof TABS[number]["key"];
|
||||
|
||||
function isTabKey(v: string): v is TabKey {
|
||||
return TABS.some(t => t.key === v);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rowsNegatif: PgStockNegatifRow[];
|
||||
rowsSansVente: PgStockSansVenteRow[];
|
||||
@@ -492,25 +509,59 @@ interface 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;
|
||||
const initialTab: TabKey = isTabKey(tab) ? tab : "negatif";
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
|
||||
// Le changement d'onglet est purement client : les 3 jeux de données sont déjà chargés.
|
||||
const [switching, startSwitch] = useTransition();
|
||||
// Le changement de magasin, lui, relance les requêtes serveur.
|
||||
const [navigating, startNavigation] = useTransition();
|
||||
|
||||
// Resynchronise l'onglet si l'URL change côté serveur (retour arrière, lien direct).
|
||||
const [urlTab, setUrlTab] = useState(initialTab);
|
||||
if (urlTab !== initialTab) {
|
||||
setUrlTab(initialTab);
|
||||
setActiveTab(initialTab);
|
||||
}
|
||||
|
||||
function buildUrl(nextTab: TabKey, nextMagasin: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (nextMagasin) params.set("magasin", nextMagasin);
|
||||
params.set("tab", nextTab);
|
||||
return `/stock-negatif?${params.toString()}`;
|
||||
}
|
||||
|
||||
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()}`);
|
||||
startNavigation(() => {
|
||||
router.push(buildUrl(activeTab, val));
|
||||
});
|
||||
}
|
||||
|
||||
function handleTabChange(key: TabKey) {
|
||||
const params = new URLSearchParams();
|
||||
if (magasin) params.set("magasin", magasin);
|
||||
params.set("tab", key);
|
||||
router.push(`/stock-negatif?${params.toString()}`);
|
||||
if (key === activeTab || switching) return;
|
||||
// Mise à jour de l'URL sans navigation serveur : aucune requête SQL n'est rejouée.
|
||||
window.history.replaceState(null, "", buildUrl(key, magasin));
|
||||
startSwitch(() => setActiveTab(key));
|
||||
}
|
||||
|
||||
const counts: Record<TabKey, number> = useMemo(() => ({
|
||||
"negatif": rowsNegatif.length,
|
||||
"sans-vente": rowsSansVente.length,
|
||||
"sans-vente-6mois": rowsSansVente6Mois.length,
|
||||
}), [rowsNegatif, rowsSansVente, rowsSansVente6Mois]);
|
||||
|
||||
const busy = switching || navigating;
|
||||
const showLoader = useDelayedFlag(busy);
|
||||
const loaderMessage = navigating ? "Chargement des données" : "Préparation de l'onglet";
|
||||
const loaderSubMessage = navigating
|
||||
? "Interrogation du stock pour le magasin sélectionné…"
|
||||
: "Application des filtres et du tri…";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4" aria-busy={busy}>
|
||||
{showLoader && <LoadingModal message={loaderMessage} subMessage={loaderSubMessage} />}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -518,7 +569,8 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M
|
||||
<select
|
||||
value={magasin}
|
||||
onChange={handleMagasinChange}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={navigating}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<option value="">Tous les magasins</option>
|
||||
{sites.map(s => (
|
||||
@@ -531,37 +583,45 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M
|
||||
{/* 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
|
||||
: t.key === "sans-vente"
|
||||
? rowsSansVente.filter(r => r.stock_actuel !== 0).length
|
||||
: rowsSansVente6Mois.length}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{TABS.map(t => {
|
||||
const isActive = activeTab === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => handleTabChange(t.key)}
|
||||
disabled={busy}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={[
|
||||
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap",
|
||||
isActive
|
||||
? "border-blue-600 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
|
||||
busy ? "cursor-wait" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{t.label}
|
||||
<span className={[
|
||||
"ml-2 rounded-full px-2 py-0.5 text-xs font-semibold",
|
||||
isActive ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500",
|
||||
].join(" ")}>
|
||||
{counts[t.key].toLocaleString("fr-FR")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === "negatif" && <TabStockNegatif rows={rowsNegatif} magasin={magasin} />}
|
||||
{activeTab === "sans-vente" && <TabEntreesSansVente rows={rowsSansVente} magasin={magasin} />}
|
||||
{activeTab === "sans-vente-6mois" && <TabSansVente6Mois rows={rowsSansVente6Mois} magasin={magasin} />}
|
||||
{activeTab === "negatif" && (
|
||||
<StockTable<PgStockNegatifRow> rows={rowsNegatif} magasin={magasin} {...NEGATIF_CONFIG} />
|
||||
)}
|
||||
{activeTab === "sans-vente" && (
|
||||
<StockTable<PgStockSansVenteRow> rows={rowsSansVente} magasin={magasin} {...SANS_VENTE_CONFIG} />
|
||||
)}
|
||||
{activeTab === "sans-vente-6mois" && (
|
||||
<StockTable<PgSansVente6MoisRow> rows={rowsSansVente6Mois} magasin={magasin} {...SANS_VENTE_6MOIS_CONFIG} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { pgDiagFournisseurDerniereEntree } from "@/lib/pg-ff-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Diagnostic de l'attribution « fournisseur de la dernière entrée » utilisée par
|
||||
* la page Gestion de Stock.
|
||||
*
|
||||
* `colonneDetectee: null` signifie que `mvtart` ne porte aucune des colonnes
|
||||
* fournisseur connues : la page retombe alors sur `artfou1.preference = 1`.
|
||||
* `colonnesMvtart` liste ce qui existe réellement, pour compléter la liste
|
||||
* blanche `MVTART_FOU_CANDIDATES` si le nom diffère.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json(await pgDiagFournisseurDerniereEntree());
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,47 @@
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* ── Défilement de la Grille ──
|
||||
La règle ci-dessus (8 px, rail transparent, pouce très pâle) convient aux
|
||||
panneaux où l'ascenseur est accessoire. Dans la Grille, le tableau fait
|
||||
~2600 px de large : l'ascenseur HORIZONTAL est l'outil de navigation
|
||||
principal, et un rail invisible ne dit pas qu'il reste des colonnes à
|
||||
droite. On lui donne donc un rail dessiné et un pouce saisissable.
|
||||
`scrollbar-*` couvre Firefox, que les sélecteurs -webkit- ignorent. */
|
||||
.grid-scroll {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: var(--text-muted) var(--bg-elevated);
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar-track:horizontal {
|
||||
background: var(--bg-elevated);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
border-radius: 99px;
|
||||
/* Bordure transparente + background-clip : le pouce garde de l'air dans son
|
||||
rail sans qu'on ait à jouer sur des marges, impossibles ici. */
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Le coin entre les deux ascenseurs, sinon laissé en blanc par le navigateur. */
|
||||
.grid-scroll::-webkit-scrollbar-corner {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
/* ── Heatmap cells ── */
|
||||
.heatmap-zero {
|
||||
background: var(--bg-elevated);
|
||||
|
||||
@@ -32,7 +32,7 @@ export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkAct
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-slate-900 border border-slate-700/50 rounded-xl backdrop-blur-md shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2 bg-slate-900 border border-slate-700/50 rounded-xl backdrop-blur-md shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<span className="text-[11px] font-black uppercase tracking-wider text-slate-200">
|
||||
{selectedCodeins.length} sélectionné{selectedCodeins.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
|
||||
@@ -19,9 +19,12 @@ function Stat({ label, value, sub }: { label: string; value: string; sub?: strin
|
||||
// Determine color based on label to match the prototype
|
||||
const valColor = label.includes("CA") || label.includes("Marge") ? "text-emerald-600 dark:text-emerald-400" : "text-slate-900 dark:text-white";
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-slate-500 text-[10px] uppercase font-black mb-1 tracking-tighter">{label}</span>
|
||||
<span className={`font-mono-nums font-black text-xl ${valColor}`}>{value}</span>
|
||||
// Barre maintenant dans le flux : chaque pixel de hauteur est pris au
|
||||
// tableau. Ces chiffres sont déjà repris en gros dans l'en-tête de page,
|
||||
// ils n'ont pas besoin d'y être une seconde fois en 20 px.
|
||||
<div className="flex flex-col items-center leading-tight">
|
||||
<span className="text-slate-500 text-[10px] uppercase font-black tracking-tighter">{label}</span>
|
||||
<span className={`font-mono-nums font-black text-base ${valColor}`}>{value}</span>
|
||||
{sub && <span className="text-emerald-600 dark:text-emerald-500 text-[11px] font-bold">{sub}</span>}
|
||||
</div>
|
||||
);
|
||||
@@ -153,7 +156,20 @@ export function FloatingSummaryBar() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-6 left-[264px] right-6 p-4 flex justify-between items-center shadow-lg border rounded-xl z-50 transition-colors bg-slate-100 dark:bg-slate-800"
|
||||
/*
|
||||
* Dans le flux, plus en `fixed`.
|
||||
*
|
||||
* En `fixed` elle ne réservait aucune hauteur : seul un `pb-12` posé sur
|
||||
* la page compensait, et il était trop court d'une quarantaine de pixels.
|
||||
* Elle recouvrait donc la dernière ligne du tableau ET la barre de
|
||||
* défilement horizontale — d'où l'impression qu'aucun ascenseur n'existait.
|
||||
*
|
||||
* Le `left-[264px]` était en prime calé sur une barre latérale dépliée,
|
||||
* alors qu'elle est repliée par défaut (64 px) : 200 px de vide à gauche.
|
||||
* En flux, la grille cède exactement la hauteur nécessaire et il n'y a
|
||||
* plus aucune valeur magique à tenir en phase.
|
||||
*/
|
||||
className="shrink-0 px-4 py-2.5 flex flex-wrap justify-between items-center gap-x-4 gap-y-2 shadow-sm border rounded-xl transition-colors bg-slate-100 dark:bg-slate-800"
|
||||
style={{
|
||||
borderColor: hasDrafts ? "rgba(234, 179, 8, 0.5)" : "var(--border-strong)",
|
||||
}}
|
||||
|
||||
@@ -209,7 +209,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const activeStoreNom = magasins.find(m => m.code === magasin)?.nom || "National (Total)";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-3 min-h-0 pb-12">
|
||||
<div className="flex flex-col h-full space-y-3 min-h-0">
|
||||
{/* Header — chrome raffiné (eyebrow teal + fournisseur héro + pastilles métriques) */}
|
||||
<header className="shrink-0 flex items-center justify-between gap-4 pb-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
|
||||
@@ -134,7 +134,7 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
fournisseurs={fournisseurs}
|
||||
selectedCode={searchParams.get("fournisseur")}
|
||||
onSelect={handleSupplierSelect}
|
||||
className="w-[280px]"
|
||||
className="w-[200px] xl:w-[280px]"
|
||||
/>
|
||||
|
||||
{/* Store Selector */}
|
||||
@@ -142,7 +142,7 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
magasins={magasins}
|
||||
selectedCode={activeMagasin === "TOTAL" ? null : activeMagasin}
|
||||
onSelect={handleStoreSelect}
|
||||
className="w-[220px]"
|
||||
className="w-[170px] xl:w-[220px]"
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
@@ -156,12 +156,19 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
placeholder="Rechercher (code, désignation...)"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
className="apple-input w-72 pr-8"
|
||||
// La croix native de `type="search"` (WebKit/Blink) est masquée :
|
||||
// ce champ a déjà son propre bouton d'effacement juste à droite,
|
||||
// et les deux apparaissaient côte à côte. On garde `type="search"`
|
||||
// pour la sémantique ; le masquage est local, la recherche de
|
||||
// l'en-tête n'ayant pas de bouton maison, elle garde le sien.
|
||||
className="apple-input w-52 xl:w-72 pr-8 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-cancel-button]:hidden"
|
||||
style={{ paddingLeft: "32px" }}
|
||||
/>
|
||||
{searchValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchValue("")}
|
||||
aria-label="Effacer la recherche"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 hover:opacity-100 opacity-60 transition-opacity" style={{ color: "var(--text-secondary)" }} />
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { ProductRow, GammeCode } from "@/types/grid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getLast12Months,
|
||||
getMonthsFromRows,
|
||||
formatMonthLabel,
|
||||
formatDate,
|
||||
SITE_LABELS,
|
||||
@@ -257,6 +258,37 @@ interface VirtualRowProps {
|
||||
columnSizing: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes figées à gauche : celles qui disent QUELLE ligne on lit.
|
||||
*
|
||||
* Sans elles, dès qu'on défile vers les totaux ou les mois, la Désignation
|
||||
* sort de l'écran et plus rien n'identifie la ligne — sur 2600 px de tableau
|
||||
* pour ~1150 px visibles, c'est la moitié du temps.
|
||||
*
|
||||
* `libelle1` a été rapprochée de `codein` dans l'ordre des colonnes : le
|
||||
* figeage exige des colonnes contiguës depuis la gauche, et geler en plus
|
||||
* Référence et GTIN aurait immobilisé 636 px au lieu de 406.
|
||||
*/
|
||||
const COLONNES_FIGEES = new Set(["select", "codein", "libelle1"]);
|
||||
|
||||
/**
|
||||
* Décalage `left` de chaque colonne figée, par cumul des largeurs.
|
||||
*
|
||||
* On s'arrête à la première colonne non figée : une colonne figée qui ne
|
||||
* toucherait pas le bord flotterait au milieu du tableau. Si l'utilisateur
|
||||
* masque « Code interne », le calcul se réajuste tout seul.
|
||||
*/
|
||||
function decalagesFiges(colonnes: { id: string; taille: number }[]): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
let cumul = 0;
|
||||
for (const c of colonnes) {
|
||||
if (!COLONNES_FIGEES.has(c.id)) break;
|
||||
out.set(c.id, cumul);
|
||||
cumul += c.taille;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey, columnSizing }: VirtualRowProps) => {
|
||||
// Uniquement la ligne concernée écoute son propre changement pour l'effet visuel
|
||||
const effectiveGamme = useGridStore((s) => s.draftChanges[row.original.codein] ?? row.original.codeGamme);
|
||||
@@ -274,6 +306,10 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey
|
||||
void columnsKey;
|
||||
void columnSizing; // idem, pour le redimensionnement des colonnes
|
||||
|
||||
const cellules = row.getVisibleCells();
|
||||
const figes = decalagesFiges(cellules.map((c) => ({ id: c.column.id, taille: c.column.getSize() })));
|
||||
const dernierFige = [...figes.keys()].at(-1);
|
||||
|
||||
return (
|
||||
<tr
|
||||
data-index={virtualRow.index}
|
||||
@@ -291,6 +327,9 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<ProductRow, unknown>) => {
|
||||
const gauche = figes.get(cell.column.id);
|
||||
const estFige = gauche !== undefined;
|
||||
const derniereFigee = estFige && cell.column.id === dernierFige;
|
||||
const isFlexible = cell.column.id === "libelle1" || cell.column.id === "libelle3";
|
||||
const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(cell.column.id) || cell.column.id === "prixVente" || cell.column.id === "gamme";
|
||||
const size = cell.column.getSize();
|
||||
@@ -298,8 +337,15 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
"px-2 overflow-hidden flex items-center transition-colors group-hover/row:bg-white/5",
|
||||
isSelected && "bg-transparent"
|
||||
"px-2 overflow-hidden flex items-center transition-colors",
|
||||
// Une cellule figée doit être OPAQUE : le reste de la ligne
|
||||
// glisse dessous. Le survol passe donc par une teinte pleine,
|
||||
// pas par le voile `bg-white/5` des cellules ordinaires.
|
||||
estFige
|
||||
? (isSelected
|
||||
? "bg-[var(--accent-bg)]"
|
||||
: "bg-[var(--bg-surface)] group-hover/row:bg-[var(--bg-elevated)]")
|
||||
: cn("group-hover/row:bg-white/5", isSelected && "bg-transparent"),
|
||||
)}
|
||||
style={{
|
||||
width: isFlexible ? "100%" : size,
|
||||
@@ -307,7 +353,16 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey
|
||||
minWidth: size,
|
||||
maxWidth: isFlexible ? "none" : size,
|
||||
height: `${rowHeight}px`,
|
||||
justifyContent: isCenter ? "center" : "flex-start"
|
||||
justifyContent: isCenter ? "center" : "flex-start",
|
||||
...(estFige ? {
|
||||
position: "sticky" as const,
|
||||
left: gauche,
|
||||
// Au-dessus des cellules voisines, sous l'en-tête (z-10).
|
||||
zIndex: 2,
|
||||
// Trait de séparation : sans lui, rien ne dit où finit la
|
||||
// zone figée et où commence ce qui défile.
|
||||
borderRight: derniereFigee ? "1px solid var(--border-strong)" : undefined,
|
||||
} : {}),
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
@@ -566,8 +621,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
const [gapsOpen, setGapsOpen] = useState(false);
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client
|
||||
const MONTHS_12 = useMemo(() => getLast12Months(), []);
|
||||
// Les 12 mois viennent des données, pas de l'horloge du navigateur : les
|
||||
// totaux sont figés sur la fenêtre du serveur, les colonnes doivent l'être
|
||||
// aussi, sinon un mois manque aux cases mais pas au total (cf. months.ts).
|
||||
// Le calcul local ne sert que tant qu'aucune ligne n'est chargée.
|
||||
const MONTHS_12 = useMemo(() => getMonthsFromRows(rows) ?? getLast12Months(), [rows]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
@@ -647,32 +705,6 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "reference",
|
||||
header: "Référence",
|
||||
size: 110,
|
||||
cell: ({ getValue }) => (
|
||||
<CopiableCell
|
||||
value={getValue<string>()}
|
||||
titre="Copier la référence"
|
||||
className="text-[12px] font-mono opacity-80 group-hover:opacity-100"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "gtin",
|
||||
header: "EAN / GTIN",
|
||||
size: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<CopiableCell
|
||||
value={getValue<string>()}
|
||||
titre="Copier l'EAN / GTIN"
|
||||
className="text-[12px] font-mono tracking-tight opacity-70 group-hover:opacity-100"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "libelle1",
|
||||
header: "Désignation",
|
||||
@@ -724,6 +756,32 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "reference",
|
||||
header: "Référence",
|
||||
size: 110,
|
||||
cell: ({ getValue }) => (
|
||||
<CopiableCell
|
||||
value={getValue<string>()}
|
||||
titre="Copier la référence"
|
||||
className="text-[12px] font-mono opacity-80 group-hover:opacity-100"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "gtin",
|
||||
header: "EAN / GTIN",
|
||||
size: 120,
|
||||
cell: ({ getValue }) => (
|
||||
<CopiableCell
|
||||
value={getValue<string>()}
|
||||
titre="Copier l'EAN / GTIN"
|
||||
className="text-[12px] font-mono tracking-tight opacity-70 group-hover:opacity-100"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "libelle3",
|
||||
header: "Famille",
|
||||
@@ -1061,7 +1119,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
});
|
||||
|
||||
const visibleColumns = table.getVisibleLeafColumns();
|
||||
const totalWidth = visibleColumns.reduce((s, c) => s + ((c.columnDef as { size?: number }).size ?? 150), 0);
|
||||
// `getSize()` et non `columnDef.size` : le redimensionnement de colonnes est
|
||||
// actif et persisté. En lisant la taille déclarée, la largeur plancher de la
|
||||
// table restait figée sur l'ancienne somme dès qu'on élargissait une colonne,
|
||||
// ce qui rognait les dernières et raccourcissait la course de défilement.
|
||||
const totalWidth = visibleColumns.reduce((s, c) => s + c.getSize(), 0);
|
||||
// Signature du jeu de colonnes affiché : elle sert de signal de re-rendu aux
|
||||
// lignes virtuelles (cf. `GridRow`). Calculée une fois par rendu de grille,
|
||||
// pas une fois par ligne.
|
||||
@@ -1214,7 +1276,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className="h-full w-full overflow-auto rounded-[14px] relative"
|
||||
// `tabIndex` : sans lui, les flèches, Origine et Fin ne défilent pas
|
||||
// la grille. Il ne restait que Maj+molette, que personne ne devine.
|
||||
tabIndex={0}
|
||||
aria-label="Tableau des articles — défilement horizontal sur 12 mois"
|
||||
className="h-full w-full overflow-auto rounded-[14px] relative grid-scroll"
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
@@ -1230,21 +1296,44 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
}}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="flex w-full">
|
||||
{headerGroup.headers.map((header) => {
|
||||
{(() => {
|
||||
const figesEntete = decalagesFiges(
|
||||
headerGroup.headers.map((h) => ({ id: h.column.id, taille: h.getSize() })),
|
||||
);
|
||||
const dernierFigeEntete = [...figesEntete.keys()].at(-1);
|
||||
return headerGroup.headers.map((header) => {
|
||||
const gaucheEntete = figesEntete.get(header.column.id);
|
||||
const enteteFigee = gaucheEntete !== undefined;
|
||||
const isFlexible = header.column.id === "libelle1" || header.column.id === "libelle3";
|
||||
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(header.column.id) || header.column.id === "prixVente" || header.column.id === "gamme";
|
||||
const size = header.getSize();
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
className="px-2 py-3 text-[10px] font-black uppercase tracking-[0.05em] whitespace-nowrap select-none flex items-center transition-colors hover:bg-white/5 relative group/header"
|
||||
className={cn(
|
||||
"px-2 py-3 text-[10px] font-black uppercase tracking-[0.05em] whitespace-nowrap select-none flex items-center transition-colors relative group/header",
|
||||
!enteteFigee && "hover:bg-white/5",
|
||||
)}
|
||||
style={{
|
||||
width: isFlexible ? "100%" : size,
|
||||
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
|
||||
minWidth: size,
|
||||
maxWidth: isFlexible ? "none" : size,
|
||||
color: "var(--text-muted)",
|
||||
justifyContent: isCenter ? "center" : "flex-start"
|
||||
justifyContent: isCenter ? "center" : "flex-start",
|
||||
...(enteteFigee ? {
|
||||
position: "sticky" as const,
|
||||
left: gaucheEntete,
|
||||
zIndex: 2,
|
||||
// Le fond du <thead> est un dégradé translucide : sans
|
||||
// fond propre, les autres intitulés défileraient à
|
||||
// travers l'en-tête figé. On reprend le même dégradé
|
||||
// pour que la bande reste d'un seul tenant.
|
||||
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
|
||||
borderRight: header.column.id === dernierFigeEntete
|
||||
? "1px solid var(--border-strong)"
|
||||
: undefined,
|
||||
} : {}),
|
||||
}}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
@@ -1270,7 +1359,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
});
|
||||
})()}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
|
||||
@@ -129,15 +129,27 @@ function EnteteSection({ icone: Icone, titre, detail, couleur }: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock de fin de mois, un magasin par ligne.
|
||||
* Une série mensuelle ventilée par magasin, un magasin par ligne.
|
||||
*
|
||||
* Sert aux ventes (flux : une colonne « 12 m » cumule la ligne) comme au stock
|
||||
* (niveau : additionner douze fins de mois n'aurait aucun sens, pas de cumul).
|
||||
*
|
||||
* La ligne de total est recalculée depuis les lignes affichées : un total qui ne
|
||||
* correspond pas à la somme visible fait douter du tableau entier.
|
||||
*/
|
||||
function TableauStockMagasins({ row, mois, activeMagasin }: {
|
||||
function TableauParMagasin({ row, mois, activeMagasin, parSite, agrege, sousLigne, cumul, aria }: {
|
||||
row: ProductRow;
|
||||
mois: string[];
|
||||
activeMagasin: string;
|
||||
/** Série par magasin (site → mois → valeur). */
|
||||
parSite: Record<string, Record<string, number>> | undefined;
|
||||
/** Série agrégée réseau, affichée seule quand aucune ventilation n'existe. */
|
||||
agrege: Record<string, number>;
|
||||
/** Texte secondaire sous le nom du magasin (`null` pour la ligne réseau). */
|
||||
sousLigne: (site: string | null, valeurs: number[]) => string;
|
||||
/** Ajoute une colonne de cumul sur 12 mois (flux uniquement). */
|
||||
cumul?: boolean;
|
||||
aria: string;
|
||||
}) {
|
||||
const sites = [...new Set([
|
||||
...Object.keys(row.stock12mByStore ?? {}),
|
||||
@@ -147,26 +159,31 @@ function TableauStockMagasins({ row, mois, activeMagasin }: {
|
||||
// Sans ventilation par site (produit jamais mouvementé, données partielles),
|
||||
// on montre au moins la série agrégée plutôt qu'un tableau vide.
|
||||
const lignes = sites.length > 0
|
||||
? sites.map((site) => ({
|
||||
cle: site,
|
||||
nom: SITE_LABELS[site]?.nom ?? site,
|
||||
actif: activeMagasin === site,
|
||||
derniereEntree: row.derniereLivraisonByStore?.[site],
|
||||
valeurs: mois.map((m) => row.stock12mByStore?.[site]?.[m] ?? 0),
|
||||
}))
|
||||
: [{
|
||||
cle: "TOTAL",
|
||||
nom: "Total réseau",
|
||||
actif: true,
|
||||
derniereEntree: row.derniereLivraison,
|
||||
valeurs: mois.map((m) => row.stock12m[m] ?? 0),
|
||||
}];
|
||||
? sites.map((site) => {
|
||||
const valeurs = mois.map((m) => parSite?.[site]?.[m] ?? 0);
|
||||
return { cle: site, nom: SITE_LABELS[site]?.nom ?? site, actif: activeMagasin === site, valeurs, sous: sousLigne(site, valeurs) };
|
||||
})
|
||||
: [(() => {
|
||||
const valeurs = mois.map((m) => agrege[m] ?? 0);
|
||||
return { cle: "TOTAL", nom: "Total réseau", actif: true, valeurs, sous: sousLigne(null, valeurs) };
|
||||
})()];
|
||||
|
||||
const totaux = mois.map((_, i) => lignes.reduce((t, l) => t + l.valeurs[i], 0));
|
||||
const somme = (v: number[]) => v.reduce((t, x) => t + x, 0);
|
||||
|
||||
const cellule = (v: number, key: string, gras = false) => (
|
||||
<td
|
||||
key={key}
|
||||
className={`px-1.5 py-1.5 text-right tabular-nums${gras ? " font-bold" : ""}`}
|
||||
style={{ color: v === 0 && !gras ? "var(--text-muted)" : "var(--text-primary)" }}
|
||||
>
|
||||
{fmt(v)}
|
||||
</td>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[11.5px]" style={{ minWidth: 600 }}>
|
||||
<table className="w-full text-[11.5px]" style={{ minWidth: 600 }} aria-label={aria}>
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>
|
||||
@@ -177,6 +194,11 @@ function TableauStockMagasins({ row, mois, activeMagasin }: {
|
||||
{formatMonthLabel(m)}
|
||||
</th>
|
||||
))}
|
||||
{cumul && (
|
||||
<th className="text-right px-2 py-1.5 font-bold whitespace-nowrap" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
|
||||
12 m
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -186,21 +208,16 @@ function TableauStockMagasins({ row, mois, activeMagasin }: {
|
||||
<tr key={l.cle} style={{ borderTop: "1px solid var(--border)", background: surligne ? "var(--accent-bg)" : undefined }}>
|
||||
<td className="px-2.5 py-1.5 whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
|
||||
<div className="font-semibold">{l.nom}</div>
|
||||
{/* La date de dernière entrée explique un stock qui ne bouge
|
||||
plus : elle est écrite, pas réservée au survol. */}
|
||||
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>
|
||||
{l.derniereEntree ? `dern. entrée ${formatDate(l.derniereEntree)}` : "aucune entrée"}
|
||||
</div>
|
||||
{/* L'information secondaire (dernière entrée, mois avec
|
||||
vente) est écrite, pas réservée au survol. */}
|
||||
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>{l.sous}</div>
|
||||
</td>
|
||||
{l.valeurs.map((v, i) => (
|
||||
<td
|
||||
key={mois[i]}
|
||||
className="px-1.5 py-1.5 text-right tabular-nums"
|
||||
style={{ color: v === 0 ? "var(--text-muted)" : "var(--text-primary)" }}
|
||||
>
|
||||
{fmt(v)}
|
||||
{l.valeurs.map((v, i) => cellule(v, mois[i]))}
|
||||
{cumul && (
|
||||
<td className="px-2 py-1.5 text-right tabular-nums font-bold" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
|
||||
{fmt(somme(l.valeurs))}
|
||||
</td>
|
||||
))}
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -209,11 +226,12 @@ function TableauStockMagasins({ row, mois, activeMagasin }: {
|
||||
<td className="px-2.5 py-1.5 font-bold" style={{ color: "var(--text-primary)" }}>
|
||||
Total
|
||||
</td>
|
||||
{totaux.map((v, i) => (
|
||||
<td key={mois[i]} className="px-1.5 py-1.5 text-right tabular-nums font-bold" style={{ color: "var(--text-primary)" }}>
|
||||
{fmt(v)}
|
||||
{totaux.map((v, i) => cellule(v, mois[i], true))}
|
||||
{cumul && (
|
||||
<td className="px-2 py-1.5 text-right tabular-nums font-bold" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
|
||||
{fmt(somme(totaux))}
|
||||
</td>
|
||||
))}
|
||||
)}
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
@@ -225,8 +243,13 @@ function TableauStockMagasins({ row, mois, activeMagasin }: {
|
||||
/**
|
||||
* Détail 12 mois d'un produit : ventes, entrées en stock, stock par magasin.
|
||||
*
|
||||
* Les deux premières séries suivent le magasin actif de la Grille (cohérence
|
||||
* avec la case cliquée) ; la troisième reste toujours ventilée, c'est son objet.
|
||||
* Les graphiques suivent le magasin actif de la Grille (cohérence avec la case
|
||||
* cliquée) ; les tableaux de ventes et de stock restent toujours ventilés par
|
||||
* magasin, c'est leur objet.
|
||||
*
|
||||
* `mois` est la fenêtre du serveur (lue dans les données, cf. `months.ts`) :
|
||||
* c'est ce qui garantit que la tuile « Ventes 12 m » retombe sur le total de la
|
||||
* Grille.
|
||||
*/
|
||||
export function ProductMonthlyModal({ row, mois, activeMagasin, onClose }: {
|
||||
row: ProductRow;
|
||||
@@ -308,7 +331,7 @@ export function ProductMonthlyModal({ row, mois, activeMagasin, onClose }: {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="space-y-1">
|
||||
<section className="space-y-1.5">
|
||||
<EnteteSection
|
||||
icone={ShoppingCart}
|
||||
titre="Ventes mensuelles"
|
||||
@@ -321,6 +344,22 @@ export function ProductMonthlyModal({ row, mois, activeMagasin, onClose }: {
|
||||
couleur={COULEUR_VENTES}
|
||||
aria={`Ventes mensuelles de ${row.libelle1} sur 12 mois`}
|
||||
/>
|
||||
{/* Le graphique donne la forme, le tableau donne les chiffres par
|
||||
magasin — et son cumul « 12 m » est le même total que la tuile
|
||||
et que la case cliquée dans la Grille. */}
|
||||
<TableauParMagasin
|
||||
row={row}
|
||||
mois={mois}
|
||||
activeMagasin={activeMagasin}
|
||||
parSite={row.sales12mByStore}
|
||||
agrege={row.sales12m}
|
||||
sousLigne={(_site, valeurs) => {
|
||||
const n = valeurs.filter((v) => v !== 0).length;
|
||||
return n === 0 ? "aucune vente" : `${n} mois avec vente`;
|
||||
}}
|
||||
cumul
|
||||
aria={`Ventes mensuelles de ${row.libelle1} par magasin`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-1">
|
||||
@@ -345,7 +384,19 @@ export function ProductMonthlyModal({ row, mois, activeMagasin, onClose }: {
|
||||
detail="stock de fin de mois"
|
||||
couleur="var(--border-strong)"
|
||||
/>
|
||||
<TableauStockMagasins row={row} mois={mois} activeMagasin={activeMagasin} />
|
||||
<TableauParMagasin
|
||||
row={row}
|
||||
mois={mois}
|
||||
activeMagasin={activeMagasin}
|
||||
parSite={row.stock12mByStore}
|
||||
agrege={row.stock12m}
|
||||
sousLigne={(site) => {
|
||||
// La date de dernière entrée explique un stock qui ne bouge plus.
|
||||
const d = site ? row.derniereLivraisonByStore?.[site] : row.derniereLivraison;
|
||||
return d ? `dern. entrée ${formatDate(d)}` : "aucune entrée";
|
||||
}}
|
||||
aria={`Stock de fin de mois de ${row.libelle1} par magasin`}
|
||||
/>
|
||||
</section>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,32 @@ export function getLast12Months(): string[] {
|
||||
return months;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fenêtre de 12 mois **telle que le serveur l'a calculée**, lue dans les clés des
|
||||
* séries mensuelles des lignes reçues.
|
||||
*
|
||||
* `getLast12Months()` recalcule la fenêtre depuis l'horloge du navigateur ; or
|
||||
* les totaux (`totalQuantite`, `quantiteByStore`…) sont figés côté serveur, sur
|
||||
* SA fenêtre. Dès que les deux divergent — onglet resté ouvert au changement de
|
||||
* mois, lignes servies depuis le cache, horloges décalées — un mois de ventes
|
||||
* disparaît des colonnes tout en restant compté dans le total : la case
|
||||
* « Tot. 12m » affiche 28 quand les douze cases visibles ne font que 20.
|
||||
* Les clés des données sont la seule référence commune : on les prend.
|
||||
*
|
||||
* Renvoie `null` si aucune ligne ne porte de série (fournisseur sans vente,
|
||||
* chargement en cours) — l'appelant retombe alors sur `getLast12Months()`.
|
||||
*/
|
||||
export function getMonthsFromRows(rows: ReadonlyArray<{ sales12m?: Record<string, number> }>): string[] | null {
|
||||
for (const r of rows) {
|
||||
const keys = Object.keys(r.sales12m ?? {}).filter((k) => /^\d{6}$/.test(k));
|
||||
if (keys.length < 12) continue;
|
||||
// Toutes les lignes d'un même calcul partagent la même fenêtre : la
|
||||
// première ligne complète suffit.
|
||||
return keys.sort().slice(-12);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `"202601"` → `"Jan 26"`. */
|
||||
export function formatMonthLabel(key: string): string {
|
||||
const m = parseInt(key.slice(4, 6), 10);
|
||||
|
||||
+157
-23
@@ -1030,6 +1030,143 @@ export async function pgGetStockForCodeins(codeins: string[]): Promise<Map<strin
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stock — fournisseur de la dernière entrée
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Colonnes candidates de `mvtart` reliant un mouvement de stock à un fournisseur.
|
||||
* `mvtart` provient d'une réplication FF : le nom exact n'est pas garanti, on le
|
||||
* détecte une fois par process (même principe que `getNomenclatureParentCol`).
|
||||
* - target "artfou1" : la colonne est une FK vers `artfou1.no_id`
|
||||
* - target "code" : la colonne contient directement le code fournisseur (`fouident.code`)
|
||||
*/
|
||||
const MVTART_FOU_CANDIDATES: { column: string; target: "artfou1" | "code" }[] = [
|
||||
{ column: "artfou1_no_id", target: "artfou1" },
|
||||
{ column: "artfou_no_id", target: "artfou1" },
|
||||
{ column: "afou_no_id", target: "artfou1" },
|
||||
{ column: "codefou", target: "code" },
|
||||
{ column: "code_fou", target: "code" },
|
||||
{ column: "fou_code", target: "code" },
|
||||
{ column: "fournisseur", target: "code" },
|
||||
];
|
||||
|
||||
let _mvtartFouLink: { column: string; target: "artfou1" | "code" } | null | undefined = undefined;
|
||||
|
||||
async function getMvtartFouLink() {
|
||||
if (_mvtartFouLink !== undefined) return _mvtartFouLink;
|
||||
try {
|
||||
const meta = await db.execute(sql`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'mvtart'
|
||||
`);
|
||||
const cols = new Set(
|
||||
(meta.rows as { column_name: string }[]).map(r => r.column_name.toLowerCase())
|
||||
);
|
||||
// Le nom vient d'information_schema ET doit figurer dans la liste blanche
|
||||
// ci-dessus : aucune valeur utilisateur n'atteint le SQL.
|
||||
_mvtartFouLink = MVTART_FOU_CANDIDATES.find(c => cols.has(c.column)) ?? null;
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] Détection mvtart.fournisseur impossible:", e);
|
||||
_mvtartFouLink = null;
|
||||
}
|
||||
console.log("[pg-ff] mvtart → fournisseur:", _mvtartFouLink ?? "aucune colonne (repli artfou1.preference=1)");
|
||||
return _mvtartFouLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jointure latérale sur la DERNIÈRE entrée en stock (`genremvt IN (1,2)`) du
|
||||
* couple article/site : elle expose `lastentry.datmvt` (date) et
|
||||
* `lastentry.code` (code fournisseur, `NULL` si la base ne porte pas
|
||||
* l'information).
|
||||
*
|
||||
* Règle métier : un article n'est rattaché qu'au fournisseur de sa dernière
|
||||
* entrée. S'il est rentré en stock chez un autre fournisseur, il ne doit plus
|
||||
* apparaître sous le précédent.
|
||||
*
|
||||
* La `LEFT JOIN artfou1` (et non `JOIN`) garantit qu'on retient bien la vraie
|
||||
* dernière entrée, même si son fournisseur est inconnu.
|
||||
*/
|
||||
function lastEntryJoin(
|
||||
link: { column: string; target: "artfou1" | "code" } | null,
|
||||
artnoidExpr: SQL,
|
||||
siteExpr: SQL,
|
||||
): SQL {
|
||||
const codeExpr = link
|
||||
? (link.target === "artfou1" ? sql`afe.code` : sql.raw(`me.${link.column}`))
|
||||
: sql`NULL::text`;
|
||||
const joinArtfou = link && link.target === "artfou1"
|
||||
? sql`LEFT JOIN artfou1 afe ON afe.no_id = ${sql.raw(`me.${link.column}`)}`
|
||||
: sql``;
|
||||
return sql`
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT me.datmvt, ${codeExpr} AS code
|
||||
FROM mvtart me
|
||||
${joinArtfou}
|
||||
WHERE me.artnoid = ${artnoidExpr}
|
||||
AND me.site = ${siteExpr}
|
||||
AND me.genremvt IN (1, 2)
|
||||
ORDER BY me.datmvt DESC, me.no_id DESC
|
||||
LIMIT 1
|
||||
) lastentry ON TRUE
|
||||
${link ? sql`LEFT JOIN fouident flast ON flast.code = lastentry.code` : sql``}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Libellé fournisseur affiché : celui de la dernière entrée quand la base le
|
||||
* permet, sinon repli documenté sur `artfou1.preference = 1`.
|
||||
*/
|
||||
function fournisseurExpr(link: { column: string; target: "artfou1" | "code" } | null): SQL {
|
||||
return link
|
||||
? sql`COALESCE(flast.nom, lastentry.code, f.nom, af.code)`
|
||||
: sql`COALESCE(f.nom, af.code)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic (`/api/diag/stock-fournisseur`) : expose la colonne détectée, les
|
||||
* colonnes réellement présentes sur `mvtart` et un échantillon comparant le
|
||||
* fournisseur principal au fournisseur de la dernière entrée.
|
||||
*/
|
||||
export async function pgDiagFournisseurDerniereEntree() {
|
||||
const link = await getMvtartFouLink();
|
||||
const meta = await db.execute(sql`
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_name = 'mvtart' ORDER BY ordinal_position
|
||||
`);
|
||||
let echantillon: unknown[] = [];
|
||||
if (link) {
|
||||
const r = await pgNoParallel(sql`
|
||||
SELECT
|
||||
a.codein,
|
||||
cs.site,
|
||||
COALESCE(f.nom, af.code) AS fournisseur_preference,
|
||||
COALESCE(flast.nom, lastentry.code) AS fournisseur_derniere_entree,
|
||||
lastentry.datmvt::text AS derniere_entree
|
||||
FROM cube_stock cs
|
||||
JOIN articles a ON a.no_id = cs.artnoid
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (art_no_id) art_no_id, code
|
||||
FROM artfou1
|
||||
WHERE preference = 1
|
||||
ORDER BY art_no_id, code
|
||||
) af ON af.art_no_id = a.no_id
|
||||
LEFT JOIN fouident f ON f.code = af.code
|
||||
${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)}
|
||||
WHERE cs.qte < 0
|
||||
AND cs.site IN ('292', '579')
|
||||
LIMIT 20
|
||||
`);
|
||||
echantillon = r.rows;
|
||||
}
|
||||
return {
|
||||
colonneDetectee: link,
|
||||
candidatsTestes: MVTART_FOU_CANDIDATES.map(c => c.column),
|
||||
colonnesMvtart: meta.rows,
|
||||
echantillon,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stock négatif — tous magasins ou par site
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1044,10 +1181,6 @@ export interface PgStockNegatifRow {
|
||||
derniereentree: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -1060,22 +1193,30 @@ export interface PgStockSansVenteRow {
|
||||
/**
|
||||
* Retourne les articles ayant des entrées en stock (hors mois courant)
|
||||
* mais aucune vente enregistrée sur ce site.
|
||||
* Les articles dont le stock actuel est nul sont exclus (rien à régulariser).
|
||||
*/
|
||||
export async function pgGetStockSansVente(site?: string): Promise<PgStockSansVenteRow[]> {
|
||||
const link = await getMvtartFouLink();
|
||||
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,
|
||||
${fournisseurExpr(link)} 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 (
|
||||
SELECT DISTINCT ON (art_no_id) art_no_id, code
|
||||
FROM artfou1
|
||||
WHERE preference = 1
|
||||
ORDER BY art_no_id, code
|
||||
) af ON af.art_no_id = a.no_id
|
||||
JOIN fouident f ON f.code = af.code
|
||||
LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site
|
||||
${lastEntryJoin(link, sql`a.no_id`, sql`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)
|
||||
@@ -1086,29 +1227,25 @@ export async function pgGetStockSansVente(site?: string): Promise<PgStockSansVen
|
||||
AND mv2.site = m.site
|
||||
AND mv2.genremvt = 3
|
||||
)
|
||||
GROUP BY a.codein, a.libelle1, f.nom, af.code, m.site, cs.qte
|
||||
GROUP BY a.codein, a.libelle1, ${fournisseurExpr(link)}, m.site, cs.qte
|
||||
HAVING COALESCE(cs.qte::float, 0) <> 0
|
||||
ORDER BY MAX(m.datmvt) DESC
|
||||
`);
|
||||
return result.rows as PgStockSansVenteRow[];
|
||||
}
|
||||
|
||||
export async function pgGetStockNegatif(site?: string): Promise<PgStockNegatifRow[]> {
|
||||
const link = await getMvtartFouLink();
|
||||
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,
|
||||
${fournisseurExpr(link)} AS fournisseur,
|
||||
cs.site,
|
||||
cs.qte::float AS stockdispo,
|
||||
cs.dernierevente::text,
|
||||
(
|
||||
SELECT MAX(m.datmvt)::text
|
||||
FROM mvtart m
|
||||
WHERE m.artnoid = cs.artnoid
|
||||
AND m.site = cs.site
|
||||
AND m.genremvt IN (1, 2)
|
||||
) AS derniereentree
|
||||
lastentry.datmvt::text AS derniereentree
|
||||
FROM cube_stock cs
|
||||
JOIN articles a ON a.no_id = cs.artnoid
|
||||
JOIN (
|
||||
@@ -1118,6 +1255,7 @@ export async function pgGetStockNegatif(site?: string): Promise<PgStockNegatifRo
|
||||
ORDER BY art_no_id, code
|
||||
) af ON af.art_no_id = a.no_id
|
||||
JOIN fouident f ON f.code = af.code
|
||||
${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)}
|
||||
WHERE cs.qte < 0
|
||||
AND cs.site IN ('292', '579')
|
||||
${siteFilter}
|
||||
@@ -1147,12 +1285,13 @@ export interface PgSansVente6MoisRow {
|
||||
* ET dont la dernière entrée en stock remonte aussi à plus de 6 mois.
|
||||
*/
|
||||
export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6MoisRow[]> {
|
||||
const link = await getMvtartFouLink();
|
||||
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,
|
||||
${fournisseurExpr(link)} AS fournisseur,
|
||||
cs.site,
|
||||
cs.qte::float AS stock_actuel,
|
||||
cs.dernierevente::text AS derniere_vente,
|
||||
@@ -1160,13 +1299,7 @@ export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6Mo
|
||||
WHEN cs.dernierevente IS NULL THEN NULL
|
||||
ELSE (CURRENT_DATE - cs.dernierevente::date)
|
||||
END AS jours_sans_vente,
|
||||
(
|
||||
SELECT MAX(m.datmvt)::text
|
||||
FROM mvtart m
|
||||
WHERE m.artnoid = cs.artnoid
|
||||
AND m.site = cs.site
|
||||
AND m.genremvt IN (1, 2)
|
||||
) AS derniere_entree
|
||||
lastentry.datmvt::text AS derniere_entree
|
||||
FROM cube_stock cs
|
||||
JOIN articles a ON a.no_id = cs.artnoid
|
||||
JOIN (
|
||||
@@ -1176,6 +1309,7 @@ export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6Mo
|
||||
ORDER BY art_no_id, code
|
||||
) af ON af.art_no_id = a.no_id
|
||||
JOIN fouident f ON f.code = af.code
|
||||
${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)}
|
||||
WHERE cs.site IN ('292', '579')
|
||||
AND cs.qte > 0
|
||||
AND (
|
||||
|
||||
Reference in new issue
Block a user