Compare commits

..
Author SHA1 Message Date
Claude 2c84cfda53 perf(stock): onglets instantanés, pagination et fournisseur de dernière entrée
Le changement d'onglet déclenchait un router.push : la page serveur était
rejouée et les 3 requêtes SQL relancées à chaque clic, alors que les données
étaient déjà côté client. Sans aucun retour visuel, l'onglet semblait bloqué.

Interface (stock-negatif/client.tsx)
- Changement d'onglet purement client (useTransition + history.replaceState) :
  plus aucune requête SQL rejouée, l'URL reste partageable.
- Modal de chargement pendant le changement de magasin (seule action qui
  interroge vraiment le serveur), pendant un changement d'onglet lent et
  pendant la génération de l'export Excel. Affichage différé de 120 ms pour
  éviter tout clignotement.
- Pagination 50 lignes : l'onglet « Sans vente 6 mois » rendait jusqu'à 17 000
  lignes d'un coup, ce qui figeait le navigateur. L'export Excel continue de
  reprendre l'intégralité des lignes filtrées.
- Tri : ordre de la base conservé tant qu'aucune colonne n'est cliquée (le tri
  alphabétique sur codein écrasait le classement métier), collateur Intl
  réutilisé au lieu d'un localeCompare par comparaison, tri numérique robuste
  aux valeurs nulles.
- Les 3 onglets partagent désormais un composant de tableau unique piloté par
  une configuration de colonnes, au lieu de 3 copies quasi identiques.
- Select magasin et onglets désactivés pendant le chargement, aria-busy posé.

Données (pg-ff-client.ts)
- Un article n'est plus rattaché à son fournisseur principal mais au
  fournisseur de sa dernière entrée en stock : s'il est rentré chez un autre
  fournisseur, il n'apparaît plus sous le précédent. La colonne de mvtart qui
  porte ce lien est détectée une fois par process parmi une liste blanche
  (même principe que getNomenclatureParentCol), avec repli documenté sur
  artfou1.preference = 1 si la base ne porte pas l'information.
- La jointure latérale sur la dernière entrée remplace la sous-requête
  corrélée MAX(datmvt) : même coût qu'avant pour une information de plus.
- pgGetStockSansVente dédoublonne artfou1 via DISTINCT ON comme ses deux
  requêtes sœurs, et exclut les stocks nuls en SQL (HAVING) au lieu de les
  filtrer côté client : le compteur d'onglet correspond enfin aux lignes
  réellement transmises.

Diagnostic
- GET /api/diag/stock-fournisseur : colonne détectée, colonnes réelles de
  mvtart et échantillon comparant fournisseur principal et fournisseur de
  dernière entrée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174nm1nWPaypWBNHioSTJ8X
2026-09-22 10:29:00 +00:00
Claude 585e171708 fix(grid): aligner la fenêtre 12 mois sur les données et tabuler les ventes du modal
Le total « Tot. 12m » est figé côté serveur sur SA fenêtre de 12 mois, tandis
que la Grille et le modal recalculaient les 12 mois depuis l'horloge du
navigateur. Dès que les deux divergeaient (onglet ouvert au changement de
mois, lignes servies depuis le cache), un mois de ventes disparaissait des
colonnes tout en restant compté dans le total : 28 sur la ligne, 20 dans le
modal.

- months.ts : `getMonthsFromRows()` lit la fenêtre dans les clés des séries
  reçues ; `getLast12Months()` ne sert plus que de repli avant chargement.
- heatmap-grid : MONTHS_12 dérivé des lignes chargées.
- product-monthly-modal : le tableau par magasin devient générique et sert
  aussi aux ventes, sous le graphique, avec une colonne de cumul « 12 m »
  qui retombe sur le total de la Grille.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177HLQz6w2hgoXpx6t1Rkn9
2026-09-02 10:43:26 +00:00
Claude 2404b22628 feat(grille): fige les colonnes d'identité à gauche
Sur 2600 px de tableau pour ~1150 px visibles, on voit 8 colonnes sur 18. Dès
qu'on défilait vers les totaux ou les mois, la Désignation sortait de l'écran
et plus rien ne disait quelle ligne on lisait.

Case à cocher, Code interne et Désignation restent désormais collés au bord
gauche pendant le défilement horizontal, en-tête compris.

Deux conséquences de conception :

- La Désignation a été rapprochée du Code interne dans l'ordre des colonnes.
  Le figeage exige des colonnes contiguës depuis la gauche ; en la laissant
  après Référence et GTIN, il aurait fallu geler ces deux-là aussi, soit 636 px
  immobilisés au lieu de 406. L'ordre « code, désignation, puis codes
  secondaires » est de toute façon celui qu'on lit.

- Les cellules figées doivent être OPAQUES, le reste de la ligne glissant
  dessous. Le survol passe donc par une teinte pleine et non par le voile
  translucide des cellules ordinaires, et l'en-tête figé reprend le dégradé du
  thead pour que la bande reste d'un seul tenant. Un filet marque la limite de
  la zone figée.

Les décalages sont calculés par cumul des largeurs réelles et s'arrêtent à la
première colonne non figée : masquer le Code interne réajuste tout seul, et une
colonne figée ne peut jamais se retrouver à flotter au milieu du tableau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-21 09:45:17 +00:00
Claude 7f5ef93469 fix(grille): défilement horizontal visible et barre du bas dans le flux
Les deux symptômes n'en faisaient qu'un. L'ascenseur horizontal EXISTAIT déjà
(conteneur en overflow-auto, table en min-width ~2600 px), mais il était caché
sous la carte de synthèse.

Celle-ci était en `position: fixed` et ne réservait donc aucune hauteur : seul
un `pb-12` posé sur la page compensait, trop court d'une quarantaine de pixels
— exactement la bande où se trouvent la barre de défilement et la dernière
ligne. Son `left-[264px]` était en prime calé sur une barre latérale dépliée,
alors qu'elle est repliée par défaut : 200 px de vide à gauche, qui écrasaient
ses statistiques en hauteur. Elle est remise dans le flux : la grille lui cède
exactement la place nécessaire, et il n'y a plus de valeur magique à tenir en
phase avec la sidebar.

L'ascenseur devient visible : la règle générale (8 px, rail transparent, pouce
à 12 % d'opacité) convient aux panneaux où le défilement est accessoire, pas à
la Grille où il est l'outil de navigation principal. Classe `.grid-scroll`
dédiée, avec rail dessiné, pouce saisissable, et prise en charge de Firefox que
les sélecteurs -webkit- ignorent.

Corrigé aussi :
- totalWidth lisait `columnDef.size` au lieu de `getSize()`. Le redimensionnement
  de colonnes étant actif et persisté, la largeur plancher restait figée sur
  l'ancienne somme : dernières colonnes rognées, course de défilement trop courte.
- Conteneur non focusable : flèches, Origine et Fin ne défilaient pas la grille.
- Barre de filtres qui passait sur deux lignes sous ~1200 px, volant 48 px au
  tableau : largeurs réduites en dessous de xl.
- Barre d'actions groupées sans flex-wrap, qui s'écrasait sur petit écran.
- Double croix dans la recherche : `type="search"` ajoutait la croix native du
  navigateur par-dessus le bouton maison. Masquage local — la recherche de
  l'en-tête n'a pas de bouton propre et garde donc le sien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-21 08:52:28 +00:00
LogiFlow 60ce3b8d21 Merge pull request #13 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Claude/grille modal ventes stock qlxg9i
2026-08-14 10:45:43 +02:00
11 changed files with 953 additions and 507 deletions

No files matched your search

+460 -400
View File
@@ -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 });
}
}
+41
View File
@@ -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)",
}}
+1 -1
View File
@@ -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)" }} />
+127 -37
View File
@@ -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>
);
+26
View File
@@ -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
View File
@@ -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 (