-
-
setFilter("search", e.target.value)}
- className="apple-input pr-8 h-8 text-[13px]"
- style={{ paddingLeft: "32px" }}
- />
- {search && (
-
setFilter("search", "")}
- className="absolute right-2.5 top-1/2 -translate-y-1/2"
- >
-
-
+
+
+ {page && (
+
+ {page.label}
+
)}
- {/* Right controls */}
- {/* Theme toggle */}
setTheme(isDark ? "light" : "dark")}
- title={isDark ? "Mode clair" : "Mode sombre"}
- className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all duration-150"
- style={{ color: "var(--text-secondary)" }}
- onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--bg-elevated)"; }}
- onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
+ className="flex h-9 items-center gap-2 rounded-lg px-3 text-[13px] font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]"
+ title={isDark ? "Passer en mode clair" : "Passer en mode sombre"}
>
- {isDark
- ?
- : }
+ {isDark ? : }
+ {isDark ? "Mode clair" : "Mode sombre"}
- {/* Divider */}
-
-
- {/* User */}
-
+
+
+
+
+ {session ? initiale : }
+
+
+ {nom}
+
+
+
+
+
+
+ {nom || "Compte"}
+ {role}
+
+
+
+ Hauteur des lignes de la Grille
+
+ setDisplayDensity(v as Densite)}
+ >
+ Compacte
+ Normale
+ Aérée
+
+
+ {
+ await signOut({ redirect: false });
+ window.location.href = "/login";
+ }}
+ >
+ Se déconnecter
+
+
+
);
diff --git a/src/components/shared/navigation.ts b/src/components/shared/navigation.ts
new file mode 100644
index 0000000..425d918
--- /dev/null
+++ b/src/components/shared/navigation.ts
@@ -0,0 +1,77 @@
+import {
+ CalendarClock,
+ CalendarRange,
+ History,
+ House,
+ LayoutGrid,
+ Megaphone,
+ PackageMinus,
+ PackageSearch,
+ Settings,
+ ShoppingCart,
+ Trophy,
+ type LucideIcon,
+} from "lucide-react";
+
+export interface NavItem {
+ label: string;
+ href: string;
+ icon: LucideIcon;
+ /** Phrase d'aide affichée au survol du menu replié. */
+ description: string;
+ adminOnly?: boolean;
+}
+
+export interface NavGroup {
+ title: string;
+ items: NavItem[];
+}
+
+/**
+ * Menu principal, regroupé par usage. Les libellés disent ce que fait la page,
+ * en français (« Dashboard », « Analytics », « Snapshots » ont disparu).
+ * Les adresses ne changent pas : favoris et liens existants restent valables.
+ */
+export const NAV_GROUPS: NavGroup[] = [
+ {
+ title: "Au quotidien",
+ items: [
+ { label: "Accueil", href: "/dashboard", icon: House, description: "Les ventes d'hier et les meilleurs produits" },
+ { label: "Révision d'assortiment", href: "/grid", icon: LayoutGrid, description: "Choisir la gamme de chaque produit d'un fournisseur" },
+ { label: "Recherche produit", href: "/produits", icon: PackageSearch, description: "Trouver un produit et voir ses ventes dans le réseau" },
+ { label: "Stocks à surveiller", href: "/stock-negatif", icon: PackageMinus, description: "Stocks négatifs et produits qui ne se vendent pas" },
+ { label: "Commandes fournisseurs", href: "/commandes-auto", icon: ShoppingCart, description: "Commandes automatiques et rythme de commande" },
+ ],
+ },
+ {
+ title: "Analyses",
+ items: [
+ { label: "Meilleures ventes", href: "/hit-parade", icon: Trophy, description: "Classement des produits les plus vendus sur une période" },
+ { label: "Ventes par mois", href: "/analytics", icon: CalendarRange, description: "Chiffre d'affaires d'un mois comparé à l'an dernier" },
+ { label: "Publicités", href: "/publicites", icon: Megaphone, description: "Résultats des opérations publicitaires" },
+ ],
+ },
+ {
+ title: "Suivi",
+ items: [
+ { label: "Historique", href: "/historique", icon: History, description: "Sessions de révision enregistrées et exports" },
+ ],
+ },
+ {
+ title: "Administration",
+ items: [
+ { label: "Synchronisation", href: "/admin/synchronisation", icon: CalendarClock, description: "Mise à jour nocturne des données", adminOnly: true },
+ { label: "Paramètres", href: "/settings", icon: Settings, description: "Connexions, utilisateurs et clés d'accès", adminOnly: true },
+ ],
+ },
+];
+
+/** Entrée du menu correspondant à l'adresse courante (pour le titre de l'en-tête). */
+export function pageCourante(pathname: string): NavItem | undefined {
+ for (const g of NAV_GROUPS) {
+ for (const item of g.items) {
+ if (pathname === item.href || pathname.startsWith(`${item.href}/`)) return item;
+ }
+ }
+ return undefined;
+}
diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx
index 613da03..976ab12 100644
--- a/src/components/shared/sidebar.tsx
+++ b/src/components/shared/sidebar.tsx
@@ -1,161 +1,139 @@
"use client";
-import { LayoutGrid, Camera, FileDown, Settings, Package, PackageSearch, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, CalendarClock } from "lucide-react";
+import { ChevronLeft, ChevronRight, Package } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
+import { useEffect, useState } from "react";
+import { useSession } from "next-auth/react";
import { cn } from "@/lib/utils";
import { useGridStore } from "@/features/grid/store/use-grid-store";
-import { useEffect, useState } from "react";
-import { useSession, signOut } from "next-auth/react";
+import { NAV_GROUPS } from "@/components/shared/navigation";
+import { Tooltip } from "@/components/ui/tooltip";
-const NAV_ITEMS = [
- { icon: BarChart2, label: "Dashboard", href: "/dashboard" },
- { icon: LayoutGrid, label: "Grille", href: "/grid" },
- { icon: PackageSearch, label: "Produits", href: "/produits" },
- { icon: TrendingUp, label: "Analytics", href: "/analytics" },
- { icon: Trophy, label: "Hit Parade", href: "/hit-parade" },
- { icon: Megaphone, label: "Publicités", href: "/publicites" },
- { icon: PackageMinus, label: "Gestion de Stock", href: "/stock-negatif" },
- { icon: ShoppingCart, label: "Commandes auto", href: "/commandes-auto" },
- { icon: Camera, label: "Snapshots", href: "/snapshots" },
- { icon: FileDown, label: "Exports", href: "/exports", adminOnly: true },
- { icon: CalendarClock, label: "Synchronisation", href: "/admin/synchronisation", adminOnly: true },
- { icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
-];
+const CLE_STOCKAGE = "sidebar_expanded";
+/**
+ * Menu principal : groupé par usage, déplié par défaut (libellés visibles).
+ * Le compte utilisateur et la déconnexion sont dans l'en-tête.
+ */
export function Sidebar() {
const pathname = usePathname();
const { data: session } = useSession();
const activeGridQuery = useGridStore((s) => s.activeGridQuery);
const [isMounted, setIsMounted] = useState(false);
- const [isExpanded, setIsExpanded] = useState(false);
+ const [isExpanded, setIsExpanded] = useState(true);
useEffect(() => {
+ // `isMounted` évite un écart d'hydratation (préférence lue dans le navigateur).
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setIsMounted(true);
- // Load expanded state from local storage
- const stored = localStorage.getItem("sidebar_expanded");
- if (stored === "true") setIsExpanded(true);
+ try {
+ // Seul un repli explicite est mémorisé : par défaut, le menu est déplié.
+ if (localStorage.getItem(CLE_STOCKAGE) === "false") setIsExpanded(false);
+ } catch {
+ /* stockage indisponible : on garde le menu déplié */
+ }
}, []);
const toggleSidebar = () => {
const next = !isExpanded;
setIsExpanded(next);
- localStorage.setItem("sidebar_expanded", String(next));
+ try {
+ localStorage.setItem(CLE_STOCKAGE, String(next));
+ } catch {
+ /* préférence non mémorisée, sans conséquence */
+ }
};
- const userRole = (session?.user as any)?.role;
- const filteredItems = NAV_ITEMS.filter(item => !item.adminOnly || userRole === "admin");
+ const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
+ const groupes = NAV_GROUPS
+ .map((g) => ({ ...g, items: g.items.filter((i) => !i.adminOnly || isAdmin) }))
+ .filter((g) => g.items.length > 0);
return (
- {/* Toggle Button */}
- {isExpanded ? : }
+ {isExpanded ? : }
{/* Logo */}
-
-
-
-
+
+
+
- {isExpanded && (
-
- CollectFlow
-
- )}
-
+ {isExpanded &&
CollectFlow }
+
{/* Navigation */}
-
- {filteredItems.map(({ icon: Icon, label, href }) => {
- const isActive = pathname.startsWith(href);
-
- let resolvedHref = href;
- if (href === "/grid" && isMounted && activeGridQuery) {
- resolvedHref = `/grid${activeGridQuery}`;
- }
-
- return (
-
-
- {isExpanded && {label} }
-
- );
- })}
-
-
- {/* User Profile & Logout */}
-
-
-
-
+
+ {groupes.map((groupe, gi) => (
+ 0 && "mt-4")}>
+ {isExpanded ? (
+
{groupe.title}
+ ) : (
+ gi > 0 &&
+ )}
+
+ {groupe.items.map(({ icon: Icon, label, href, description }) => {
+ const isActive = pathname === href || pathname.startsWith(`${href}/`);
+ // Retour à la Grille : on rouvre le dernier fournisseur consulté.
+ const resolvedHref = href === "/grid" && isMounted && activeGridQuery
+ ? `/grid${activeGridQuery}`
+ : href;
+ const lien = (
+
+
+ {isExpanded ? (
+ {label}
+ ) : (
+ {label}
+ )}
+
+ );
+ return (
+
+ {isExpanded ? lien : (
+ {label} {description} >}
+ >
+ {lien}
+
+ )}
+
+ );
+ })}
+
- {isExpanded && (
-
- {session?.user ? (
- <>
-
{session.user.name}
-
- {userRole === "admin" ? "Administrateur" : "Utilisateur"}
-
- >
- ) : (
-
- )}
-
- )}
-
-
-
{
- await signOut({ redirect: false });
- window.location.href = "/login";
- }}
- title={!isExpanded ? "Déconnexion" : undefined}
- className={cn(
- "flex items-center text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] transition-colors rounded-xl overflow-hidden",
- isExpanded ? "w-full gap-2.5 px-3 py-2 text-[13px] font-medium" : "justify-center w-10 h-10"
- )}
- >
-
- {isExpanded && "Déconnexion"}
-
-
+ ))}
+
);
}
diff --git a/src/components/shared/theme-provider.tsx b/src/components/shared/theme-provider.tsx
index a1eb978..b7556ec 100644
--- a/src/components/shared/theme-provider.tsx
+++ b/src/components/shared/theme-provider.tsx
@@ -6,7 +6,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..8355752
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,77 @@
+import type { ReactNode } from "react";
+import { TrendingDown, TrendingUp, Minus } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { nomMagasin } from "@/lib/magasins";
+import { CLASSES_SANS_GAMME, trouverGamme } from "@/lib/gammes";
+import { fmtDecimal1 } from "@/lib/format";
+
+export type Ton = "neutre" | "accent" | "succes" | "alerte" | "erreur" | "info";
+
+const TONS: Record = {
+ neutre: "bg-[var(--bg-elevated)] text-[var(--text-secondary)] border-[var(--border)]",
+ accent: "bg-[var(--accent-bg)] text-[var(--accent)] border-[var(--accent-border)]",
+ succes: "bg-[var(--accent-success-bg)] text-[var(--accent-success)] border-[var(--accent-success)]/30",
+ alerte: "bg-[var(--accent-warning-bg)] text-[var(--accent-warning)] border-[var(--accent-warning)]/30",
+ erreur: "bg-[var(--accent-error-bg)] text-[var(--accent-error)] border-[var(--accent-error)]/30",
+ info: "bg-[var(--accent-bg)] text-[var(--viz-1)] border-[var(--border)]",
+};
+
+export function Badge({ ton = "neutre", children, className, title }: {
+ ton?: Ton;
+ children: ReactNode;
+ className?: string;
+ title?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Évolution en pourcentage (vs N-1) : flèche, signe et couleur. `null` (pas de
+ * base de comparaison) s'affiche « — » plutôt qu'un faux 0 %.
+ */
+export function DeltaBadge({ pct, className }: { pct: number | null | undefined; className?: string }) {
+ if (pct == null || !Number.isFinite(pct)) {
+ return — ;
+ }
+ const ton: Ton = pct > 0.05 ? "succes" : pct < -0.05 ? "erreur" : "neutre";
+ const Icone = pct > 0.05 ? TrendingUp : pct < -0.05 ? TrendingDown : Minus;
+ return (
+
+
+ {pct > 0 ? "+" : ""}{fmtDecimal1(pct)} %
+
+ );
+}
+
+/** Nom du magasin, jamais son code brut. */
+export function StoreBadge({ code, court = false }: { code: string | null | undefined; court?: boolean }) {
+ return {nomMagasin(code, { court })} ;
+}
+
+/** Pastille de gamme avec, au besoin, son nom (« A — Cœur »). */
+export function GammeBadge({ code, avecNom = false }: { code: string | null | undefined; avecNom?: boolean }) {
+ const g = trouverGamme(code);
+ return (
+
+ {g ? g.code : "—"}
+ {avecNom && {g ? g.nom : "Sans gamme"} }
+
+ );
+}
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..17e9869
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,45 @@
+import type { HTMLAttributes, ReactNode } from "react";
+import { cn } from "@/lib/utils";
+
+/** Conteneur de base : fond, bordure et ombre du thème. */
+export function Card({ className, ...props }: HTMLAttributes) {
+ return (
+
+ );
+}
+
+interface CardHeaderProps {
+ title: ReactNode;
+ description?: ReactNode;
+ actions?: ReactNode;
+ className?: string;
+}
+
+export function CardHeader({ title, description, actions, className }: CardHeaderProps) {
+ return (
+
+
+
{title}
+ {description &&
{description}
}
+
+ {actions &&
{actions}
}
+
+ );
+}
+
+export function CardContent({ className, ...props }: HTMLAttributes) {
+ return
;
+}
+
+/** Paire libellé / valeur dans une fiche (identité produit, réglages…). */
+export function Field({ label, children, className }: { label: ReactNode; children: ReactNode; className?: string }) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx
new file mode 100644
index 0000000..cac422f
--- /dev/null
+++ b/src/components/ui/data-table.tsx
@@ -0,0 +1,359 @@
+"use client";
+
+/**
+ * Tableau de données commun : recherche, filtres, tri par colonne, pagination,
+ * export, état vide et ligne de totaux — identiques sur toutes les pages.
+ *
+ * Né du tableau de la page Stocks, qui avait déjà tout cela ; les autres pages
+ * en réimplémentaient chacune une partie (13 tableaux faits à la main).
+ */
+
+import { useDeferredValue, useMemo, useState, type ReactNode } from "react";
+import { ChevronDown, ChevronUp, ChevronsUpDown, Download, Loader2, RotateCcw } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { SearchInput, Select } from "@/components/ui/form-controls";
+import { Pagination } from "@/components/ui/pagination";
+import { EmptyState } from "@/components/ui/states";
+import { Tooltip } from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+import { toast } from "@/components/ui/feedback";
+
+type Sens = "asc" | "desc";
+
+export interface DataColumn {
+ id: string;
+ header: ReactNode;
+ /** Explication de la colonne, en infobulle sur l'en-tête. */
+ hint?: ReactNode;
+ align?: "left" | "right" | "center";
+ /** Valeur de tri ; sans elle, la colonne n'est pas triable. */
+ sortValue?: (row: T) => string | number | null | undefined;
+ cell: (row: T) => ReactNode;
+ /** Cellule de la ligne de totaux (cf. `showFooter`). */
+ footer?: (rows: readonly T[]) => ReactNode;
+ className?: string;
+ headerClassName?: string;
+}
+
+export interface DataFilter {
+ id: string;
+ label: string;
+ valueOf: (row: T) => string | null | undefined;
+ /** Libellé affiché pour une valeur (ex. code magasin → nom). */
+ optionLabel?: (value: string) => string;
+ /** Libellé de l'option « tout » (par défaut « Tous »). */
+ allLabel?: string;
+ /** Ordre des options : alphabétique (défaut) ou décroissant (années…). */
+ order?: Sens;
+}
+
+interface DataTableProps {
+ rows: readonly T[];
+ columns: readonly DataColumn[];
+ rowKey: (row: T, index: number) => string;
+ /** Champs parcourus par la recherche ; sans eux, pas de champ de recherche. */
+ searchIn?: (row: T) => (string | number | null | undefined)[];
+ searchPlaceholder?: string;
+ filters?: readonly DataFilter[];
+ /** Lignes par page ; 0 = tout afficher. */
+ pageSize?: number;
+ /** Nom des lignes comptées : « articles », « fournisseurs »… */
+ unite?: string;
+ emptyTitle?: ReactNode;
+ emptyDescription?: ReactNode;
+ /** Export des lignes filtrées et triées (toutes, pas seulement la page affichée). */
+ onExport?: (rows: readonly T[]) => Promise | void;
+ exportLabel?: string;
+ /** Contrôles supplémentaires, placés avant la recherche. */
+ toolbar?: ReactNode;
+ /** Affiche la ligne de totaux (colonnes avec `footer`). */
+ showFooter?: boolean;
+ initialSort?: { id: string; dir: Sens };
+ onRowClick?: (row: T) => void;
+ /** Hauteur maximale de la zone défilante (en-tête collant). */
+ maxHeight?: string;
+ className?: string;
+}
+
+const collator = new Intl.Collator("fr", { numeric: true, sensitivity: "base" });
+
+function comparer(a: string | number | null | undefined, b: string | number | null | undefined): number {
+ const videA = a === null || a === undefined || a === "";
+ const videB = b === null || b === undefined || b === "";
+ if (videA && videB) return 0;
+ if (videA) return 1; // valeurs absentes toujours en fin de liste
+ if (videB) return -1;
+ if (typeof a === "number" && typeof b === "number") return a - b;
+ return collator.compare(String(a), String(b));
+}
+
+export function DataTable({
+ rows,
+ columns,
+ rowKey,
+ searchIn,
+ searchPlaceholder = "Rechercher…",
+ filters = [],
+ pageSize = 50,
+ unite = "lignes",
+ emptyTitle = "Aucun résultat",
+ emptyDescription,
+ onExport,
+ exportLabel = "Exporter en Excel",
+ toolbar,
+ showFooter = false,
+ initialSort,
+ onRowClick,
+ maxHeight = "70vh",
+ className,
+}: DataTableProps) {
+ const [recherche, setRecherche] = useState("");
+ const rechercheDifferee = useDeferredValue(recherche);
+ const [valeursFiltres, setValeursFiltres] = useState>({});
+ const [tri, setTri] = useState<{ id: string; dir: Sens } | null>(initialSort ?? null);
+ const [page, setPage] = useState(1);
+ const [exportEnCours, setExportEnCours] = useState(false);
+
+ const optionsFiltres = useMemo(() => {
+ const out: Record = {};
+ for (const f of filters) {
+ const valeurs = new Set();
+ for (const r of rows) {
+ const v = f.valueOf(r);
+ if (v) valeurs.add(v);
+ }
+ const triees = [...valeurs].sort((a, b) => (f.order === "desc" ? -1 : 1) * collator.compare(a, b));
+ out[f.id] = triees.map((v) => ({ value: v, label: f.optionLabel ? f.optionLabel(v) : v }));
+ }
+ return out;
+ }, [filters, rows]);
+
+ const filtrees = useMemo(() => {
+ const q = rechercheDifferee.trim().toLowerCase();
+ const actifs = filters.filter((f) => valeursFiltres[f.id]);
+ if (!q && actifs.length === 0) return rows;
+ return rows.filter((r) => {
+ for (const f of actifs) if (f.valueOf(r) !== valeursFiltres[f.id]) return false;
+ if (q && searchIn) {
+ return searchIn(r).some((v) => v !== null && v !== undefined && String(v).toLowerCase().includes(q));
+ }
+ return true;
+ });
+ }, [rows, filters, valeursFiltres, rechercheDifferee, searchIn]);
+
+ const triees = useMemo(() => {
+ if (!tri) return filtrees;
+ const col = columns.find((c) => c.id === tri.id);
+ if (!col?.sortValue) return filtrees;
+ const valeur = col.sortValue;
+ const sens = tri.dir === "asc" ? 1 : -1;
+ return [...filtrees].sort((a, b) => {
+ const va = valeur(a);
+ const vb = valeur(b);
+ const vide = va === null || va === undefined || va === "" || vb === null || vb === undefined || vb === "";
+ // Les valeurs absentes restent en fin de liste dans les deux sens.
+ return vide ? comparer(va, vb) : comparer(va, vb) * sens;
+ });
+ }, [filtrees, tri, columns]);
+
+ // Tout changement de filtre, de recherche ou de tri ramène à la page 1
+ // (ajusté pendant le rendu, sans effet ni rendu supplémentaire).
+ const [jeuPagine, setJeuPagine] = useState(triees);
+ if (jeuPagine !== triees) {
+ setJeuPagine(triees);
+ setPage(1);
+ }
+
+ const total = triees.length;
+ const pagine = pageSize > 0;
+ const totalPages = pagine ? Math.max(1, Math.ceil(total / pageSize)) : 1;
+ const pageCourante = Math.min(page, totalPages);
+ const lignesAffichees = useMemo(
+ () => (pagine ? triees.slice((pageCourante - 1) * pageSize, pageCourante * pageSize) : triees),
+ [triees, pagine, pageCourante, pageSize],
+ );
+
+ const filtreActif = recherche !== "" || Object.values(valeursFiltres).some(Boolean);
+
+ function basculerTri(id: string) {
+ // Premier clic : les plus grands chiffres d'abord, mais l'ordre A→Z pour du texte.
+ const col = columns.find((c) => c.id === id);
+ const exemple = rows.length > 0 ? col?.sortValue?.(rows[0]) : undefined;
+ const sensInitial: Sens = typeof exemple === "number" ? "desc" : "asc";
+ setTri((t) => (t?.id === id ? { id, dir: t.dir === "asc" ? "desc" : "asc" } : { id, dir: sensInitial }));
+ }
+
+ async function exporter() {
+ if (!onExport || exportEnCours) return;
+ setExportEnCours(true);
+ try {
+ await onExport(triees);
+ } catch (e) {
+ toast.erreur(`L'export a échoué : ${e instanceof Error ? e.message : String(e)}`);
+ } finally {
+ setExportEnCours(false);
+ }
+ }
+
+ const alignement = (a?: "left" | "right" | "center") =>
+ a === "right" ? "text-right justify-end" : a === "center" ? "text-center justify-center" : "text-left";
+
+ return (
+
+ {/* Barre d'outils */}
+
+
+ {toolbar}
+ {searchIn && (
+
+ )}
+ {filters.map((f) => (
+ setValeursFiltres((s) => ({ ...s, [f.id]: v }))}
+ placeholder={f.allLabel ?? "Tous"}
+ options={optionsFiltres[f.id] ?? []}
+ />
+ ))}
+ {filtreActif && (
+ { setRecherche(""); setValeursFiltres({}); }}
+ >
+ Effacer les filtres
+
+ )}
+
+
+
+ {total.toLocaleString("fr-FR")} {unite}
+ {filtreActif && total !== rows.length && <> sur {rows.length.toLocaleString("fr-FR")}>}
+
+ {onExport && (
+
+ {exportEnCours ? : }
+ {exportEnCours ? "Export en cours…" : exportLabel}
+
+ )}
+
+
+
+ {/* Tableau */}
+ {total === 0 ? (
+
+ ) : (
+
+
+
+
+ {columns.map((c) => {
+ const triable = Boolean(c.sortValue);
+ const actif = tri?.id === c.id;
+ const contenu = (
+
+ {c.header}
+ {triable && (
+ actif
+ ? (tri!.dir === "asc" ? : )
+ :
+ )}
+
+ );
+ return (
+
+ {triable ? (
+ basculerTri(c.id)}
+ className="hover:text-[var(--text-primary)]"
+ title={typeof c.hint === "string" ? c.hint : undefined}
+ >
+ {contenu}
+
+ ) : c.hint ? (
+ {contenu}
+ ) : contenu}
+
+ );
+ })}
+
+
+
+ {lignesAffichees.map((r, i) => (
+ onRowClick(r) : undefined}
+ className={cn(
+ "border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--bg-elevated)]",
+ onRowClick && "cursor-pointer",
+ )}
+ >
+ {columns.map((c) => (
+
+ {c.cell(r)}
+
+ ))}
+
+ ))}
+
+ {showFooter && (
+
+
+ {columns.map((c) => (
+
+ {c.footer ? c.footer(triees) : null}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {pagine && (
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
index 80d7ad6..bf9459c 100644
--- a/src/components/ui/dialog.tsx
+++ b/src/components/ui/dialog.tsx
@@ -73,7 +73,7 @@ function DialogContent({
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
- Close
+ Fermer
)}
@@ -111,7 +111,7 @@ function DialogFooter({
{children}
{showCloseButton && (
- Close
+ Fermer
)}
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
index bbc7d47..1c9880c 100644
--- a/src/components/ui/dropdown-menu.tsx
+++ b/src/components/ui/dropdown-menu.tsx
@@ -42,7 +42,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
- "z-50 max-h-[80vh] min-w-[12rem] overflow-x-hidden overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-elevated)] p-2 text-[var(--text-primary)] shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ "z-50 max-h-[80vh] min-w-[12rem] overflow-x-hidden overflow-y-auto rounded-lg border border-[var(--border-strong)] bg-[var(--bg-surface)] p-1.5 text-[var(--text-primary)] shadow-[var(--shadow-md)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
@@ -230,7 +230,7 @@ function DropdownMenuSubContent({
` (cf. app/providers.tsx).
+ */
+
+import { create } from "zustand";
+import { CheckCircle2, Info, X, XCircle } from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+type TonToast = "succes" | "erreur" | "info";
+
+interface ToastItem {
+ id: number;
+ ton: TonToast;
+ message: string;
+}
+
+interface DemandeConfirmation {
+ titre: string;
+ message?: string;
+ libelleConfirmer: string;
+ danger: boolean;
+ resoudre: (ok: boolean) => void;
+}
+
+interface FeedbackState {
+ toasts: ToastItem[];
+ confirmation: DemandeConfirmation | null;
+}
+
+const useFeedback = create
(() => ({ toasts: [], confirmation: null }));
+
+let prochainId = 1;
+
+function ajouterToast(ton: TonToast, message: string, dureeMs: number) {
+ const id = prochainId++;
+ useFeedback.setState((s) => ({ toasts: [...s.toasts.slice(-3), { id, ton, message }] }));
+ window.setTimeout(() => retirerToast(id), dureeMs);
+}
+
+function retirerToast(id: number) {
+ useFeedback.setState((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
+}
+
+export const toast = {
+ succes: (message: string) => ajouterToast("succes", message, 3500),
+ erreur: (message: string) => ajouterToast("erreur", message, 7000),
+ info: (message: string) => ajouterToast("info", message, 4500),
+};
+
+interface OptionsConfirmation {
+ titre: string;
+ message?: string;
+ /** Libellé du bouton de validation : un verbe (« Supprimer »), jamais « OK ». */
+ libelleConfirmer?: string;
+ /** Action irréversible : bouton rouge. */
+ danger?: boolean;
+}
+
+export function confirmer(options: OptionsConfirmation): Promise {
+ return new Promise((resolve) => {
+ // Une demande encore ouverte est considérée comme refusée.
+ useFeedback.getState().confirmation?.resoudre(false);
+ useFeedback.setState({
+ confirmation: {
+ titre: options.titre,
+ message: options.message,
+ libelleConfirmer: options.libelleConfirmer ?? "Confirmer",
+ danger: options.danger ?? false,
+ resoudre: resolve,
+ },
+ });
+ });
+}
+
+function repondre(ok: boolean) {
+ const demande = useFeedback.getState().confirmation;
+ if (!demande) return;
+ useFeedback.setState({ confirmation: null });
+ demande.resoudre(ok);
+}
+
+const ICONES = { succes: CheckCircle2, erreur: XCircle, info: Info } as const;
+const COULEURS: Record = {
+ succes: "var(--accent-success)",
+ erreur: "var(--accent-error)",
+ info: "var(--accent)",
+};
+
+export function FeedbackHost() {
+ const toasts = useFeedback((s) => s.toasts);
+ const confirmation = useFeedback((s) => s.confirmation);
+
+ return (
+ <>
+
+ {toasts.map((t) => {
+ const Icone = ICONES[t.ton];
+ return (
+
+
+
{t.message}
+
retirerToast(t.id)}
+ aria-label="Fermer le message"
+ className="rounded p-0.5 text-[var(--text-muted)] hover:text-[var(--text-primary)]"
+ >
+
+
+
+ );
+ })}
+
+
+ { if (!open) repondre(false); }}>
+
+
+ {confirmation?.titre}
+ {confirmation?.message && (
+ {confirmation.message}
+ )}
+
+
+ repondre(false)}>Annuler
+ repondre(true)}
+ className={cn(confirmation?.danger && "dark:bg-destructive")}
+ >
+ {confirmation?.libelleConfirmer}
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/ui/form-controls.tsx b/src/components/ui/form-controls.tsx
new file mode 100644
index 0000000..8e9b6f8
--- /dev/null
+++ b/src/components/ui/form-controls.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
+import { Search, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const CHAMP =
+ "h-9 rounded-lg border px-3 text-sm bg-[var(--bg-surface)] border-[var(--border-strong)] text-[var(--text-primary)] " +
+ "placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent-bg)] " +
+ "disabled:opacity-60 disabled:cursor-not-allowed";
+
+/** Libellé au-dessus d'un champ (et non à côté) : plus lisible en colonne étroite. */
+export function Label({ children, htmlFor, className }: { children: ReactNode; htmlFor?: string; className?: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export interface SelectOption {
+ value: string;
+ label: string;
+}
+
+interface SelectProps extends Omit, "onChange"> {
+ label?: ReactNode;
+ options: readonly SelectOption[];
+ onChange: (value: string) => void;
+ /** Option vide en tête (« Tous », « Tous les fournisseurs »…). */
+ placeholder?: string;
+}
+
+/** Liste déroulante native, au style du thème. */
+export function Select({ label, options, onChange, placeholder, className, id, ...props }: SelectProps) {
+ const select = (
+ onChange(e.target.value)}
+ className={cn(CHAMP, "pr-8 min-w-[160px] cursor-pointer", className)}
+ {...props}
+ >
+ {placeholder !== undefined && {placeholder} }
+ {options.map((o) => (
+ {o.label}
+ ))}
+
+ );
+ if (!label) return select;
+ return (
+
+ {label}
+ {select}
+
+ );
+}
+
+interface SearchInputProps extends Omit, "onChange" | "value"> {
+ value: string;
+ onChange: (value: string) => void;
+}
+
+/** Champ de recherche avec loupe et bouton d'effacement. */
+export function SearchInput({ value, onChange, className, placeholder = "Rechercher…", ...props }: SearchInputProps) {
+ return (
+
+
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ className={cn(CHAMP, "w-full min-w-[240px] pl-9 pr-8")}
+ {...props}
+ />
+ {value && (
+ onChange("")}
+ aria-label="Effacer la recherche"
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)]"
+ >
+
+
+ )}
+
+ );
+}
+
+export function Input({ className, ...props }: InputHTMLAttributes) {
+ return ;
+}
diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx
new file mode 100644
index 0000000..9003833
--- /dev/null
+++ b/src/components/ui/page-header.tsx
@@ -0,0 +1,45 @@
+import type { ReactNode } from "react";
+import type { LucideIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface PageHeaderProps {
+ title: ReactNode;
+ /** Une phrase : à quoi sert la page, en langage courant. */
+ description?: ReactNode;
+ icon?: LucideIcon;
+ /** Boutons ou filtres alignés à droite du titre. */
+ actions?: ReactNode;
+ className?: string;
+}
+
+/**
+ * En-tête commun à toutes les pages : même taille de titre, même place pour
+ * l'explication et pour les actions. Il y en avait sept variantes.
+ */
+export function PageHeader({ title, description, icon: Icon, actions, className }: PageHeaderProps) {
+ return (
+
+
+ {Icon && (
+
+
+
+ )}
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+ {actions && {actions}
}
+
+ );
+}
diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx
new file mode 100644
index 0000000..9ed0b15
--- /dev/null
+++ b/src/components/ui/pagination.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
+
+interface PaginationProps {
+ page: number;
+ totalPages: number;
+ total: number;
+ pageSize: number;
+ onChange: (page: number) => void;
+ /** Nom des éléments comptés (« articles », « publicités »…). */
+ unite?: string;
+}
+
+/** Pagination commune : « 51 à 100 sur 1 234 articles » et quatre boutons. */
+export function Pagination({ page, totalPages, total, pageSize, onChange, unite = "lignes" }: PaginationProps) {
+ if (total === 0 || totalPages <= 1) return null;
+ const debut = (page - 1) * pageSize + 1;
+ const fin = Math.min(page * pageSize, total);
+ const aller = (p: number) => onChange(Math.min(Math.max(1, p), totalPages));
+ const bouton =
+ "rounded-lg border p-1.5 border-[var(--border-strong)] bg-[var(--bg-surface)] text-[var(--text-secondary)] " +
+ "hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40";
+
+ return (
+
+
+ {debut.toLocaleString("fr-FR")} à {fin.toLocaleString("fr-FR")} sur{" "}
+ {total.toLocaleString("fr-FR")} {unite}
+
+
+ aller(1)} disabled={page <= 1} aria-label="Première page">
+
+
+ aller(page - 1)} disabled={page <= 1} aria-label="Page précédente">
+
+
+
+ Page {page.toLocaleString("fr-FR")} / {totalPages.toLocaleString("fr-FR")}
+
+ aller(page + 1)} disabled={page >= totalPages} aria-label="Page suivante">
+
+
+ aller(totalPages)} disabled={page >= totalPages} aria-label="Dernière page">
+
+
+
+
+ );
+}
diff --git a/src/components/ui/stat-card.tsx b/src/components/ui/stat-card.tsx
new file mode 100644
index 0000000..d3c6424
--- /dev/null
+++ b/src/components/ui/stat-card.tsx
@@ -0,0 +1,45 @@
+import type { ReactNode } from "react";
+import type { LucideIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { DeltaBadge } from "@/components/ui/badge";
+
+interface StatCardProps {
+ label: ReactNode;
+ value: ReactNode;
+ /** Précision sous la valeur (« dont 12 en promo », « hier »…). */
+ hint?: ReactNode;
+ /** Évolution en % à afficher en pastille (vs N-1). */
+ delta?: number | null;
+ deltaLabel?: ReactNode;
+ icon?: LucideIcon;
+ className?: string;
+}
+
+/** Indicateur chiffré : libellé lisible, valeur en grand, évolution. */
+export function StatCard({ label, value, hint, delta, deltaLabel = "vs N-1", icon: Icon, className }: StatCardProps) {
+ return (
+
+
+ {label}
+ {Icon && }
+
+
{value}
+ {(delta !== undefined || hint) && (
+
+ {delta !== undefined && (
+ <>
+
+ {deltaLabel}
+ >
+ )}
+ {hint && {hint} }
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/states.tsx b/src/components/ui/states.tsx
new file mode 100644
index 0000000..29f1688
--- /dev/null
+++ b/src/components/ui/states.tsx
@@ -0,0 +1,101 @@
+import type { ReactNode } from "react";
+import { AlertTriangle, Inbox, type LucideIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface EmptyStateProps {
+ title: ReactNode;
+ description?: ReactNode;
+ icon?: LucideIcon;
+ action?: ReactNode;
+ className?: string;
+}
+
+/** Rien à afficher : dire pourquoi, et quoi faire. */
+export function EmptyState({ title, description, icon: Icon = Inbox, action, className }: EmptyStateProps) {
+ return (
+
+
+
{title}
+ {description &&
{description}
}
+ {action &&
{action}
}
+
+ );
+}
+
+interface ErrorStateProps {
+ title?: ReactNode;
+ /** Explication pour l'utilisateur ; le détail technique va dans `detail`. */
+ description?: ReactNode;
+ /** Message technique, replié par défaut. */
+ detail?: string;
+ action?: ReactNode;
+ className?: string;
+}
+
+/** Un chargement a échoué : message clair, détail technique replié, action de reprise. */
+export function ErrorState({
+ title = "Les données n'ont pas pu être chargées",
+ description = "Réessayez dans un instant. Si le problème persiste, prévenez un administrateur.",
+ detail,
+ action,
+ className,
+}: ErrorStateProps) {
+ return (
+
+
+
{title}
+
{description}
+ {detail && (
+
+ Détail technique
+ {detail}
+
+ )}
+ {action &&
{action}
}
+
+ );
+}
+
+export function Skeleton({ className }: { className?: string }) {
+ return
;
+}
+
+/**
+ * Silhouette d'une page pendant son chargement : la mise en page reste visible
+ * et rien ne bloque l'écran (contrairement à l'ancienne fenêtre plein écran).
+ */
+export function PageSkeleton({ cards = 0, rows = 8 }: { cards?: number; rows?: number }) {
+ return (
+
+
+ {cards > 0 && (
+
+ {Array.from({ length: cards }, (_, i) => )}
+
+ )}
+
+
+ {Array.from({ length: rows }, (_, i) => )}
+
+
+ );
+}
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..a74d3b3
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { useState, type ReactNode } from "react";
+import type { LucideIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export interface TabItem {
+ value: V;
+ label: ReactNode;
+ /** Compteur affiché à droite du libellé. */
+ count?: number;
+ /** Pastille d'alerte (rouge) au lieu d'un compteur neutre. */
+ alert?: boolean;
+ icon?: LucideIcon;
+}
+
+interface TabsProps {
+ items: readonly TabItem[];
+ value: V;
+ onChange: (value: V) => void;
+ disabled?: boolean;
+ className?: string;
+}
+
+/**
+ * Onglets soulignés, identiques sur toutes les pages. L'appelant décide de la
+ * synchronisation avec l'URL (cf. `useUrlTab`) : un onglet doit pouvoir être
+ * partagé par lien et survivre au rechargement.
+ */
+export function Tabs({ items, value, onChange, disabled, className }: TabsProps) {
+ return (
+
+ {items.map((t) => {
+ const actif = t.value === value;
+ const Icone = t.icon;
+ return (
+ !actif && onChange(t.value)}
+ className={cn(
+ "-mb-px inline-flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors disabled:cursor-wait",
+ actif
+ ? "border-[var(--accent)] text-[var(--text-primary)]"
+ : "border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-strong)]",
+ )}
+ >
+ {Icone && }
+ {t.label}
+ {t.count !== undefined && (
+ 0
+ ? "bg-[var(--accent-error)] text-white"
+ : actif
+ ? "bg-[var(--accent-bg)] text-[var(--accent)]"
+ : "bg-[var(--bg-elevated)] text-[var(--text-secondary)]",
+ )}
+ >
+ {t.count.toLocaleString("fr-FR")}
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * Segment de choix court (2 à 4 options), pour les bascules du type
+ * « Fournisseur / Nomenclature » ou « Tous / Frouard / Houdemont ».
+ */
+export function Segmented({ items, value, onChange, disabled, className }: TabsProps) {
+ return (
+
+ {items.map((t) => {
+ const actif = t.value === value;
+ return (
+ !actif && onChange(t.value)}
+ className={cn(
+ "rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors whitespace-nowrap disabled:cursor-wait",
+ actif
+ ? "bg-[var(--bg-surface)] text-[var(--text-primary)] shadow-[var(--shadow-sm)]"
+ : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
+ )}
+ >
+ {t.label}
+ {t.count !== undefined && (
+ {t.count.toLocaleString("fr-FR")}
+ )}
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * Onglet actif reflété dans l'URL (`?param=valeur`) sans navigation serveur :
+ * le lien se partage et survit au rechargement, et changer d'onglet ne relance
+ * aucune requête. `initial` vient de la page serveur (searchParams).
+ */
+export function useUrlTab(param: string, initial: V): [V, (value: V) => void] {
+ const [value, setValue] = useState(initial);
+ const change = (next: V) => {
+ setValue(next);
+ const url = new URL(window.location.href);
+ url.searchParams.set(param, next);
+ window.history.replaceState(window.history.state, "", url.toString());
+ };
+ return [value, change];
+}
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..c8ddbdd
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { Tooltip as TooltipPrimitive } from "radix-ui";
+import { Info } from "lucide-react";
+import { GLOSSAIRE, type CleGlossaire } from "@/lib/glossaire";
+import { cn } from "@/lib/utils";
+
+/** À placer une fois en haut de l'arbre (cf. app/providers.tsx). */
+export function TooltipProvider({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface TooltipProps {
+ content: ReactNode;
+ children: ReactNode;
+ side?: "top" | "right" | "bottom" | "left";
+}
+
+/** Infobulle accessible (survol et focus clavier), lisible dans les deux thèmes. */
+export function Tooltip({ content, children, side = "top" }: TooltipProps) {
+ return (
+
+ {children}
+
+
+ {content}
+
+
+
+ );
+}
+
+interface TermeProps {
+ /** Entrée du glossaire à expliquer. */
+ id: CleGlossaire;
+ /** Texte affiché ; par défaut le libellé du glossaire. */
+ children?: ReactNode;
+ className?: string;
+}
+
+/**
+ * Terme métier avec sa définition au survol : un petit « i » signale qu'une
+ * explication existe, sans encombrer la lecture.
+ */
+export function Terme({ id, children, className }: TermeProps) {
+ const entree = GLOSSAIRE[id];
+ return (
+
+ {entree.terme}
+ {entree.definition}
+ >
+ }
+ >
+
+ {children ?? entree.terme}
+
+
+
+ );
+}
diff --git a/src/features/auth/api/login-action.ts b/src/features/auth/api/login-action.ts
index df29b2f..9b514f9 100644
--- a/src/features/auth/api/login-action.ts
+++ b/src/features/auth/api/login-action.ts
@@ -11,7 +11,7 @@ export async function loginAction(prevState: string | undefined, formData: FormD
await signIn("credentials", {
username: formData.get("username"),
password: formData.get("password"),
- redirectTo: "/grid"
+ redirectTo: "/dashboard"
});
} catch (error) {
if (error instanceof AuthError) {
diff --git a/src/lib/export-excel.ts b/src/lib/export-excel.ts
new file mode 100644
index 0000000..9f32259
--- /dev/null
+++ b/src/lib/export-excel.ts
@@ -0,0 +1,53 @@
+/**
+ * Export Excel côté navigateur, commun aux pages (Stocks, Meilleures ventes,
+ * Ventes par mois…). La bibliothèque n'est chargée qu'au moment de l'export.
+ *
+ * Il y avait trois mécanismes et deux bibliothèques (xlsx, exceljs) : une seule
+ * subsiste, avec la même mise en forme d'en-tête partout.
+ */
+
+export type CelluleExcel = string | number | null | undefined;
+
+interface ExportExcel {
+ /** Nom de l'onglet du classeur. */
+ feuille: string;
+ /** Nom du fichier, sans extension ni date (ajoutées automatiquement). */
+ fichier: string;
+ entetes: string[];
+ /** Largeurs de colonnes, en caractères. */
+ largeurs?: number[];
+ lignes: CelluleExcel[][];
+ /** Ligne de totaux, mise en gras. */
+ totaux?: CelluleExcel[];
+}
+
+export async function telechargerExcel({ feuille, fichier, entetes, largeurs, lignes, totaux }: ExportExcel): Promise {
+ const { Workbook } = await import("exceljs");
+ const classeur = new Workbook();
+ classeur.creator = "CollectFlow";
+ classeur.created = new Date();
+ const ws = classeur.addWorksheet(feuille.slice(0, 31)); // limite Excel
+
+ ws.addRow(entetes);
+ const entete = ws.getRow(1);
+ entete.height = 22;
+ entete.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.views = [{ state: "frozen", ySplit: 1 }];
+
+ for (const l of lignes) ws.addRow(l.map((v) => v ?? ""));
+ if (totaux) ws.addRow(totaux.map((v) => v ?? "")).font = { bold: true };
+ if (largeurs) ws.columns = largeurs.map((width) => ({ width }));
+
+ const buffer = await classeur.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 = `${fichier}_${new Date().toISOString().slice(0, 10)}.xlsx`;
+ a.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/src/lib/gammes.ts b/src/lib/gammes.ts
new file mode 100644
index 0000000..81e1fb3
--- /dev/null
+++ b/src/lib/gammes.ts
@@ -0,0 +1,66 @@
+/**
+ * Les gammes d'assortiment : code, nom et couleurs — source unique.
+ *
+ * Les couleurs étaient définies trois fois (liste déroulante, filtre, actions
+ * groupées) et le sens des lettres n'apparaissait que dans la barre d'actions
+ * groupées : ailleurs, l'utilisateur ne voyait que « A », « B »…
+ */
+
+export type CodeGamme = "A" | "B" | "C" | "Y" | "Z";
+
+export interface Gamme {
+ code: CodeGamme;
+ /** Nom court : « Cœur », « Sortie »… */
+ nom: string;
+ /** Phrase d'explication pour les infobulles. */
+ description: string;
+ /** Classes de pastille (fond, bordure, texte) lisibles en clair comme en sombre. */
+ classes: string;
+}
+
+export const GAMMES: readonly Gamme[] = [
+ {
+ code: "A",
+ nom: "Cœur",
+ description: "Produit permanent, au cœur de l'assortiment.",
+ classes: "border-emerald-500/40 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
+ },
+ {
+ code: "B",
+ nom: "Complémentaire",
+ description: "Produit qui complète l'offre du rayon.",
+ classes: "border-blue-500/40 bg-blue-500/10 text-blue-800 dark:text-blue-300",
+ },
+ {
+ code: "C",
+ nom: "Saisonnier",
+ description: "Produit présent selon la saison.",
+ classes: "border-amber-500/40 bg-amber-500/10 text-amber-800 dark:text-amber-300",
+ },
+ {
+ code: "Y",
+ nom: "En veille",
+ description: "Produit mis en attente : il n'est plus commandé pour l'instant.",
+ classes: "border-violet-500/40 bg-violet-500/10 text-violet-800 dark:text-violet-300",
+ },
+ {
+ code: "Z",
+ nom: "Sortie",
+ description: "Produit à retirer de l'assortiment.",
+ classes: "border-rose-500/40 bg-rose-500/10 text-rose-800 dark:text-rose-300",
+ },
+];
+
+/** Pastille d'un produit sans gamme. */
+export const CLASSES_SANS_GAMME = "border-slate-400/40 bg-slate-500/10 text-slate-700 dark:text-slate-300";
+
+export function trouverGamme(code: string | null | undefined): Gamme | undefined {
+ if (!code) return undefined;
+ return GAMMES.find((g) => g.code === code.trim());
+}
+
+/** « A — Cœur », ou « Sans gamme ». */
+export function libelleGamme(code: string | null | undefined): string {
+ const g = trouverGamme(code);
+ return g ? `${g.code} — ${g.nom}` : "Sans gamme";
+}
diff --git a/src/lib/glossaire.ts b/src/lib/glossaire.ts
new file mode 100644
index 0000000..2e62ecb
--- /dev/null
+++ b/src/lib/glossaire.ts
@@ -0,0 +1,97 @@
+/**
+ * Glossaire des termes métier affichés dans l'application.
+ *
+ * Une seule définition par terme, reprise par les infobulles (``) : un
+ * utilisateur occasionnel ne doit pas avoir à deviner ce que signifient
+ * « Franco », « PRMP » ou « Réseau ».
+ */
+
+import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
+
+export interface EntreeGlossaire {
+ /** Libellé affiché. */
+ terme: string;
+ /** Explication en une ou deux phrases. */
+ definition: string;
+}
+
+export const GLOSSAIRE = {
+ gamme: {
+ terme: "Gamme",
+ definition: "Place du produit dans l'assortiment du magasin, de A (cœur de gamme) à Z (sortie).",
+ },
+ gammeInit: {
+ terme: "Gamme actuelle",
+ definition: "Gamme enregistrée aujourd'hui dans FF, avant vos modifications.",
+ },
+ reseau: {
+ terme: "Réseau",
+ definition: `Les ${NB_MAGASINS_RESEAU} magasins du réseau La Foir'Fouille (données Qlik).`,
+ },
+ nosMagasins: {
+ terme: "Nos magasins",
+ definition: "Frouard (Nancy) et Houdemont.",
+ },
+ presenceReseau: {
+ terme: "Magasins vendeurs",
+ definition: `Nombre de magasins du réseau (sur ${NB_MAGASINS_RESEAU}) qui ont vendu ce produit sur la période.`,
+ },
+ caParMagasin: {
+ terme: "CA par magasin",
+ definition: "Chiffre d'affaires moyen d'un magasin du réseau qui vend ce produit.",
+ },
+ franco: {
+ terme: "Franco",
+ definition: "Montant de commande à atteindre pour que le fournisseur livre sans frais de port.",
+ },
+ commandeAuto: {
+ terme: "Commande automatique",
+ definition: "Proposition de commande calculée par FF à partir des ventes et du stock.",
+ },
+ cadencier: {
+ terme: "Cadencier",
+ definition: "Rythme de commande choisi pour un fournisseur (toutes les X semaines) : l'alerte se déclenche quand l'échéance approche.",
+ },
+ pcb: {
+ terme: "PCB",
+ definition: "« Par combien » : nombre d'unités dans un colis du fournisseur.",
+ },
+ prmp: {
+ terme: "PRMP",
+ definition: "Prix de revient moyen pondéré : coût d'achat moyen d'une unité en stock.",
+ },
+ gtin: {
+ terme: "Code-barres",
+ definition: "Code EAN / GTIN imprimé sur l'emballage du produit.",
+ },
+ codeInterne: {
+ terme: "Code article",
+ definition: "Code interne de l'article dans FF.",
+ },
+ codeCentrale: {
+ terme: "Code centrale",
+ definition: "Code de l'article à la centrale d'achat, commun à tous les magasins du réseau.",
+ },
+ n1: {
+ terme: "N-1",
+ definition: "Même période l'année précédente.",
+ },
+ marge: {
+ terme: "Marge",
+ definition: "Chiffre d'affaires moins le coût d'achat. Le taux de marge est la marge divisée par le chiffre d'affaires.",
+ },
+ stockNegatif: {
+ terme: "Stock négatif",
+ definition: "Le stock calculé est inférieur à zéro : des ventes ont été enregistrées sans l'entrée de marchandise correspondante.",
+ },
+ tauxSortie: {
+ terme: "Taux de sortie",
+ definition: "Part du stock de l'opération vendue pendant la publicité.",
+ },
+ partCaTotal: {
+ terme: "Part du CA total",
+ definition: "Poids des ventes de l'opération dans le chiffre d'affaires total du magasin sur la même période.",
+ },
+} satisfies Record;
+
+export type CleGlossaire = keyof typeof GLOSSAIRE;
diff --git a/src/lib/magasins.ts b/src/lib/magasins.ts
new file mode 100644
index 0000000..04f5385
--- /dev/null
+++ b/src/lib/magasins.ts
@@ -0,0 +1,48 @@
+/**
+ * Les magasins suivis par l'application — source unique des noms affichés.
+ *
+ * Chaque page avait sa propre version (« 292 — Frouard / Nancy », « Frouard »,
+ * « Nancy », « F », « Site 292 »…) : un même magasin portait jusqu'à huit noms.
+ * Toute page qui affiche un magasin passe désormais par `nomMagasin()`.
+ */
+
+export interface Magasin {
+ /** Code FF du site (clé de toutes les requêtes). */
+ code: string;
+ /** Nom complet, celui affiché par défaut. */
+ nom: string;
+ /** Nom court, pour les en-têtes de colonnes étroites. */
+ court: string;
+ /** Initiale, pour les pastilles. */
+ initiale: string;
+}
+
+export const MAGASINS: readonly Magasin[] = [
+ { code: "292", nom: "Frouard (Nancy)", court: "Frouard", initiale: "F" },
+ { code: "579", nom: "Houdemont", court: "Houdemont", initiale: "H" },
+];
+
+/** Code utilisé par la Grille et l'API pour « tous nos magasins ». */
+export const CODE_TOUS_MAGASINS = "TOTAL";
+/** Libellé de l'ensemble de nos magasins (à ne pas confondre avec le réseau). */
+export const LIBELLE_TOUS_MAGASINS = "Nos 2 magasins";
+
+/** Anciens codes encore présents dans certaines réponses de l'API FF. */
+const ALIAS: Record = { F01: "292", F02: "579" };
+
+export function trouverMagasin(code: string | null | undefined): Magasin | undefined {
+ if (!code) return undefined;
+ const c = String(code).trim();
+ return MAGASINS.find((m) => m.code === (ALIAS[c] ?? c));
+}
+
+/**
+ * Nom lisible d'un magasin. `TOTAL` (ou vide) donne « Nos 2 magasins » ; un code
+ * inconnu est renvoyé tel quel plutôt que masqué.
+ */
+export function nomMagasin(code: string | null | undefined, options: { court?: boolean } = {}): string {
+ if (!code || code === CODE_TOUS_MAGASINS) return LIBELLE_TOUS_MAGASINS;
+ const m = trouverMagasin(code);
+ if (!m) return String(code);
+ return options.court ? m.court : m.nom;
+}
diff --git a/src/lib/marge.ts b/src/lib/marge.ts
new file mode 100644
index 0000000..3eb5ca2
--- /dev/null
+++ b/src/lib/marge.ts
@@ -0,0 +1,33 @@
+/**
+ * Seuils de couleur des taux de marge — une seule règle pour toute l'application.
+ *
+ * Le tableau de bord colorait à 25 / 15 %, la fiche produit à 40 / 25 %, la
+ * Grille à 30 / 15 % pour le réseau : un même taux changeait de couleur d'une
+ * page à l'autre. On retient la règle de la Grille (colonne Marge), l'écran
+ * le plus utilisé : 40 % et 25 %.
+ */
+
+export const SEUILS_MARGE = {
+ /** À partir de ce taux : vert. */
+ bon: 40,
+ /** À partir de ce taux : orange ; en dessous : rouge. */
+ moyen: 25,
+} as const;
+
+export type NiveauMarge = "bon" | "moyen" | "faible";
+
+export function niveauMarge(tauxPct: number | null | undefined): NiveauMarge | null {
+ if (tauxPct == null || !Number.isFinite(tauxPct)) return null;
+ if (tauxPct >= SEUILS_MARGE.bon) return "bon";
+ if (tauxPct >= SEUILS_MARGE.moyen) return "moyen";
+ return "faible";
+}
+
+/** Couleur CSS (variable du thème) associée au niveau de marge. */
+export function couleurMarge(tauxPct: number | null | undefined): string {
+ const niveau = niveauMarge(tauxPct);
+ if (niveau === "bon") return "var(--accent-success)";
+ if (niveau === "moyen") return "var(--accent-warning)";
+ if (niveau === "faible") return "var(--accent-error)";
+ return "var(--text-muted)";
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index f7c6ca7..6f4a357 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -26,9 +26,9 @@ export default auth((req) => {
return NextResponse.redirect(new URL("/login", nextUrl));
}
- // 3. Rediriger vers le dashboard si déjà connecté et sur /login
+ // 3. Déjà connecté et sur /login : page d'accueil (la même que « / »).
if (isLoggedIn && isPublicRoute) {
- return NextResponse.redirect(new URL("/grid", nextUrl));
+ return NextResponse.redirect(new URL("/dashboard", nextUrl));
}
// 4. Protection par rôle (Admin seulement pour les paramètres)
@@ -36,7 +36,7 @@ export default auth((req) => {
const userRole = (req.auth?.user as any)?.role;
if (isAdminRoute && userRole !== "admin") {
- return NextResponse.redirect(new URL("/grid", nextUrl));
+ return NextResponse.redirect(new URL("/dashboard", nextUrl));
}
return NextResponse.next();