feat(ui): lot 2 — socle visuel, menu regroupé et composants communs

Thème
- Clair par défaut ; les classes dark: suivent le bouton de thème (et non
  plus le réglage du système) ; animations tw-animate-css enfin importées.
- Couleurs shadcn branchées sur les variables (survols des menus visibles),
  libellés gris au contraste AA dans les deux thèmes, fond plein pour le
  menu et l'en-tête.

Composants partagés (src/components/ui)
- PageHeader, Card/Field, StatCard, Badge/DeltaBadge/StoreBadge/GammeBadge,
  Tabs/Segmented/useUrlTab, Select/SearchInput, EmptyState/ErrorState/
  Skeleton, Tooltip/Terme (glossaire), toast et confirmer, Pagination,
  DataTable (recherche, filtres, tri, pagination, export, totaux).
- Sources uniques : magasins, gammes (avec leur sens), glossaire, seuils de
  marge, export Excel.

Navigation
- Menu groupé (Au quotidien / Analyses / Suivi / Administration), déplié
  par défaut, libellés en français.
- En-tête : nom de la page, thème, menu du compte avec le vrai nom, la
  hauteur des lignes de la Grille et la déconnexion. La recherche globale,
  qui ne filtrait que la Grille, est retirée.
- Page Historique (sessions + exports) ; /snapshots et /exports y
  redirigent.
- Squelettes de chargement au lieu de la fenêtre plein écran, pages
  d'erreur et « introuvable » en français, arrivée sur l'Accueil après
  connexion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
Claude committed 2026-10-03 20:45:03 +00:00
1 parent 7da4c6d5fd
commit 0c890c84c5
47 files changed
+2004 -272

No files matched your search

+95 -52
View File
@@ -1,73 +1,116 @@
"use client";
import { Search, X, Sun, Moon } from "lucide-react";
import { ChevronDown, LogOut, Moon, Rows3, Sun, User as UserIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import { signOut, useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { pageCourante } from "@/components/shared/navigation";
type Densite = "compact" | "normal" | "comfortable";
/**
* En-tête : nom de la page en cours, choix du thème et menu du compte.
*
* La recherche qui s'y trouvait ne filtrait que la Grille (même depuis les autres
* pages, et en silence) : elle est retirée, la Grille garde la sienne. Le nom
* affiché est celui de l'utilisateur connecté (il était écrit en dur).
*/
export function Header() {
const pathname = usePathname();
const { resolvedTheme, setTheme } = useTheme();
const setFilter = useGridStore((s) => s.setFilter);
const search = useGridStore((s) => s.filters.search);
const isDark = resolvedTheme === "dark";
const { data: session } = useSession();
const displayDensity = useGridStore((s) => s.displayDensity);
const setDisplayDensity = useGridStore((s) => s.setDisplayDensity);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
// Le thème n'est connu qu'au montage (lu dans le navigateur).
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsMounted(true);
}, []);
const isDark = isMounted && resolvedTheme === "dark";
const page = pageCourante(pathname);
const nom = session?.user?.name ?? "";
const role = (session?.user as { role?: string } | undefined)?.role === "admin" ? "Administrateur" : "Utilisateur";
const initiale = nom.trim().charAt(0).toUpperCase() || "?";
return (
<header
className="h-12 flex items-center justify-between px-5 sticky top-0 z-20 glass"
style={{ borderBottom: "1px solid var(--border)" }}
>
{/* Search */}
<div className="relative w-72">
<Search
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5"
style={{ color: "var(--text-muted)" }}
strokeWidth={2}
/>
<input
type="search"
placeholder="Rechercher…"
value={search}
onChange={(e) => setFilter("search", e.target.value)}
className="apple-input pr-8 h-8 text-[13px]"
style={{ paddingLeft: "32px" }}
/>
{search && (
<button
onClick={() => setFilter("search", "")}
className="absolute right-2.5 top-1/2 -translate-y-1/2"
>
<X className="w-3 h-3" style={{ color: "var(--text-muted)" }} />
</button>
<header className="h-14 shrink-0 flex items-center justify-between gap-4 px-6 glass border-b border-[var(--border)]">
<div className="min-w-0">
{page && (
<p className="truncate text-sm font-semibold text-[var(--text-primary)]" title={page.description}>
{page.label}
</p>
)}
</div>
{/* Right controls */}
<div className="flex items-center gap-2">
{/* Theme toggle */}
<button
onClick={() => 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
? <Sun className="w-[15px] h-[15px]" strokeWidth={1.8} />
: <Moon className="w-[15px] h-[15px]" strokeWidth={1.8} />}
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
<span className="hidden md:inline">{isDark ? "Mode clair" : "Mode sombre"}</span>
</button>
{/* Divider */}
<div className="w-px h-5 mx-1" style={{ background: "var(--border)" }} />
{/* User */}
<div className="flex items-center gap-2">
<div className="w-[26px] h-[26px] rounded-full bg-emerald-500 dark:bg-emerald-600 flex items-center justify-center text-[11px] font-bold text-white shadow-sm border border-white/20">
M
</div>
<span className="text-[13px] font-medium" style={{ color: "var(--text-secondary)" }}>
Michael
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex h-9 items-center gap-2 rounded-lg pl-1.5 pr-2 hover:bg-[var(--bg-elevated)]"
aria-label="Menu du compte"
>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-[var(--accent)] text-xs font-bold text-white">
{session ? initiale : <UserIcon className="h-4 w-4" />}
</span>
<span className="hidden sm:block max-w-[160px] truncate text-[13px] font-medium text-[var(--text-primary)]">
{nom}
</span>
<ChevronDown className="h-4 w-4 text-[var(--text-muted)]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>
<span className="block text-sm font-semibold text-[var(--text-primary)]">{nom || "Compte"}</span>
<span className="block text-xs font-normal text-[var(--text-muted)]">{role}</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-center gap-2 text-xs font-medium text-[var(--text-secondary)]">
<Rows3 className="h-3.5 w-3.5" /> Hauteur des lignes de la Grille
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={displayDensity}
onValueChange={(v) => setDisplayDensity(v as Densite)}
>
<DropdownMenuRadioItem value="compact">Compacte</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="normal">Normale</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="comfortable">Aérée</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={async () => {
await signOut({ redirect: false });
window.location.href = "/login";
}}
>
<LogOut /> Se déconnecter
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
+77
View File
@@ -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;
}
+96 -118
View File
@@ -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 (
<aside
className={cn(
"flex-shrink-0 flex flex-col h-screen z-30 glass transition-all duration-300 relative",
isExpanded ? "w-60" : "w-16 items-center"
"flex-shrink-0 flex flex-col h-screen z-30 glass transition-[width] duration-200 relative border-r border-[var(--border)]",
isExpanded ? "w-[272px]" : "w-16",
)}
style={{ borderRight: "1px solid var(--border)" }}
aria-label="Menu principal"
>
{/* Toggle Button */}
<button
onClick={toggleSidebar}
className="absolute -right-3 top-6 w-6 h-6 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-full flex items-center justify-center shadow-sm text-slate-500 hover:text-slate-800 dark:hover:text-slate-300 transition-colors z-40"
aria-label={isExpanded ? "Replier le menu" : "Déplier le menu"}
title={isExpanded ? "Replier le menu" : "Déplier le menu"}
className="absolute -right-3 top-5 z-40 flex h-6 w-6 items-center justify-center rounded-full border shadow-sm bg-[var(--bg-surface)] border-[var(--border-strong)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
{isExpanded ? <ChevronLeft className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
{isExpanded ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
{/* Logo */}
<div className={cn("py-[18px] w-full flex", isExpanded ? "px-5" : "justify-center")} style={{ borderBottom: "1px solid var(--border)" }}>
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-[var(--accent)] flex items-center justify-center shrink-0 shadow-sm border border-white/10" title="CollectFlow">
<Package className="w-[18px] h-[18px] text-white" strokeWidth={2.2} />
<div className={cn("flex h-14 items-center border-b border-[var(--border)]", isExpanded ? "px-5" : "justify-center")}>
<Link href="/dashboard" className="flex items-center gap-2.5" title="CollectFlow — accueil">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--accent)] shadow-sm">
<Package className="h-[18px] w-[18px] text-white" strokeWidth={2.2} />
</div>
{isExpanded && (
<span className="text-[15px] font-semibold tracking-[-0.3px] text-[var(--text-primary)] whitespace-nowrap overflow-hidden">
CollectFlow
</span>
)}
</div>
{isExpanded && <span className="text-base font-semibold text-[var(--text-primary)]">CollectFlow</span>}
</Link>
</div>
{/* Navigation */}
<nav className={cn("w-full flex-1 flex flex-col py-4 space-y-2", isExpanded ? "px-2" : "items-center px-0")}>
{filteredItems.map(({ icon: Icon, label, href }) => {
const isActive = pathname.startsWith(href);
let resolvedHref = href;
if (href === "/grid" && isMounted && activeGridQuery) {
resolvedHref = `/grid${activeGridQuery}`;
}
return (
<Link
key={href}
href={resolvedHref}
title={!isExpanded ? label : undefined}
style={{
color: isActive ? "var(--text-primary)" : "var(--text-secondary)",
background: isActive ? "var(--bg-elevated)" : "transparent",
}}
className={cn(
"flex items-center rounded-xl transition-all duration-150 overflow-hidden",
isExpanded ? "px-3 py-[7px] gap-2.5 w-full" : "justify-center w-10 h-10",
!isActive && "hover:bg-[var(--bg-elevated)]"
)}
>
<Icon className={cn("shrink-0", isExpanded ? "w-[15px] h-[15px]" : "w-[18px] h-[18px]")} strokeWidth={1.8} />
{isExpanded && <span className="text-[13px] font-medium whitespace-nowrap">{label}</span>}
</Link>
);
})}
</nav>
{/* User Profile & Logout */}
<div className={cn("w-full pb-4 flex flex-col gap-3", isExpanded ? "px-3" : "items-center")}>
<div className={cn(
"rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)] flex items-center shadow-sm overflow-hidden",
isExpanded ? "px-3 py-3 gap-3 w-full" : "justify-center w-10 h-10"
)}
title={!isExpanded ? (session?.user?.name || "Profil") : undefined}
>
<div className={cn(
"rounded-lg bg-[var(--accent-bg)] flex items-center justify-center border border-[var(--accent-border)] shrink-0",
isExpanded ? "w-8 h-8" : "w-6 h-6 border-transparent bg-transparent"
)}>
<UserIcon className={cn("text-[var(--accent)]", isExpanded ? "w-4 h-4" : "w-5 h-5")} />
<nav className={cn("flex-1 overflow-y-auto py-3", isExpanded ? "px-3" : "px-2")}>
{groupes.map((groupe, gi) => (
<div key={groupe.title} className={cn(gi > 0 && "mt-4")}>
{isExpanded ? (
<p className="mb-1 px-3 text-xs font-semibold text-[var(--text-muted)]">{groupe.title}</p>
) : (
gi > 0 && <div className="mx-2 mb-3 border-t border-[var(--border)]" aria-hidden />
)}
<ul className="space-y-0.5">
{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 = (
<Link
href={resolvedHref}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center rounded-lg transition-colors",
isExpanded ? "gap-3 px-3 py-2" : "h-10 w-10 justify-center mx-auto",
isActive
? "bg-[var(--accent-bg)] text-[var(--text-primary)] font-semibold"
: "text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]",
)}
>
<Icon
className={cn("h-[18px] w-[18px] shrink-0", isActive && "text-[var(--accent)]")}
strokeWidth={1.9}
aria-hidden
/>
{isExpanded ? (
<span className="truncate text-sm">{label}</span>
) : (
<span className="sr-only">{label}</span>
)}
</Link>
);
return (
<li key={href}>
{isExpanded ? lien : (
<Tooltip
side="right"
content={<><span className="font-semibold">{label}</span><span className="block text-[var(--text-secondary)]">{description}</span></>}
>
{lien}
</Tooltip>
)}
</li>
);
})}
</ul>
</div>
{isExpanded && (
<div className="flex-1 min-w-0">
{session?.user ? (
<>
<p className="text-[12px] font-bold text-[var(--text-primary)] truncate">{session.user.name}</p>
<p className="text-[10px] uppercase font-black text-[var(--text-muted)] tracking-wider">
{userRole === "admin" ? "Administrateur" : "Utilisateur"}
</p>
</>
) : (
<div className="flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin text-[var(--accent)]" />
<p className="text-[10px] text-[var(--text-muted)] animate-pulse">Session...</p>
</div>
)}
</div>
)}
</div>
<button
onClick={async () => {
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"
)}
>
<LogOut className={cn("shrink-0", isExpanded ? "w-[15px] h-[15px]" : "w-[18px] h-[18px]")} strokeWidth={1.8} />
{isExpanded && "Déconnexion"}
</button>
</div>
))}
</nav>
</aside>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="dark"
defaultTheme="light"
enableSystem={false}
storageKey="collectflow-theme"
>
+77
View File
@@ -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<Ton, string> = {
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 (
<span
title={title}
className={cn(
"inline-flex items-center gap-1 whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-semibold",
TONS[ton],
className,
)}
>
{children}
</span>
);
}
/**
* É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 <Badge className={className} title="Pas de comparaison possible">—</Badge>;
}
const ton: Ton = pct > 0.05 ? "succes" : pct < -0.05 ? "erreur" : "neutre";
const Icone = pct > 0.05 ? TrendingUp : pct < -0.05 ? TrendingDown : Minus;
return (
<Badge ton={ton} className={cn("tabular-nums", className)}>
<Icone className="h-3.5 w-3.5" aria-hidden />
{pct > 0 ? "+" : ""}{fmtDecimal1(pct)} %
</Badge>
);
}
/** Nom du magasin, jamais son code brut. */
export function StoreBadge({ code, court = false }: { code: string | null | undefined; court?: boolean }) {
return <Badge ton="accent">{nomMagasin(code, { court })}</Badge>;
}
/** 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 (
<span
title={g ? `${g.code} — ${g.nom} : ${g.description}` : "Aucune gamme"}
className={cn(
"inline-flex items-center gap-1 whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-bold",
g ? g.classes : CLASSES_SANS_GAMME,
)}
>
{g ? g.code : "—"}
{avecNom && <span className="font-medium">{g ? g.nom : "Sans gamme"}</span>}
</span>
);
}
+45
View File
@@ -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<HTMLDivElement>) {
return (
<div
className={cn("rounded-xl border bg-[var(--bg-surface)] border-[var(--border)] shadow-[var(--shadow-sm)]", className)}
{...props}
/>
);
}
interface CardHeaderProps {
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
className?: string;
}
export function CardHeader({ title, description, actions, className }: CardHeaderProps) {
return (
<div className={cn("flex flex-wrap items-start justify-between gap-3 px-5 pt-4 pb-3 border-b border-[var(--border)]", className)}>
<div className="min-w-0">
<h2 className="text-base font-semibold text-[var(--text-primary)]">{title}</h2>
{description && <p className="mt-0.5 text-[13px] text-[var(--text-secondary)]">{description}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-5", className)} {...props} />;
}
/** Paire libellé / valeur dans une fiche (identité produit, réglages…). */
export function Field({ label, children, className }: { label: ReactNode; children: ReactNode; className?: string }) {
return (
<div className={cn("min-w-0", className)}>
<div className="text-xs font-medium text-[var(--text-muted)]">{label}</div>
<div className="mt-0.5 text-sm text-[var(--text-primary)] break-words">{children}</div>
</div>
);
}
+359
View File
@@ -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<T> {
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<T> {
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<T> {
rows: readonly T[];
columns: readonly DataColumn<T>[];
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<T>[];
/** 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> | 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<T>({
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<T>) {
const [recherche, setRecherche] = useState("");
const rechercheDifferee = useDeferredValue(recherche);
const [valeursFiltres, setValeursFiltres] = useState<Record<string, string>>({});
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<string, { value: string; label: string }[]> = {};
for (const f of filters) {
const valeurs = new Set<string>();
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 (
<div className={cn("space-y-3", className)}>
{/* Barre d'outils */}
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-wrap items-end gap-3">
{toolbar}
{searchIn && (
<SearchInput value={recherche} onChange={setRecherche} placeholder={searchPlaceholder} aria-label="Rechercher dans le tableau" />
)}
{filters.map((f) => (
<Select
key={f.id}
aria-label={f.label}
label={f.label}
value={valeursFiltres[f.id] ?? ""}
onChange={(v) => setValeursFiltres((s) => ({ ...s, [f.id]: v }))}
placeholder={f.allLabel ?? "Tous"}
options={optionsFiltres[f.id] ?? []}
/>
))}
{filtreActif && (
<Button
variant="ghost"
onClick={() => { setRecherche(""); setValeursFiltres({}); }}
>
<RotateCcw /> Effacer les filtres
</Button>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-[13px] text-[var(--text-secondary)] tabular-nums">
<span className="font-semibold text-[var(--text-primary)]">{total.toLocaleString("fr-FR")}</span> {unite}
{filtreActif && total !== rows.length && <> sur {rows.length.toLocaleString("fr-FR")}</>}
</span>
{onExport && (
<Button variant="outline" onClick={exporter} disabled={exportEnCours || total === 0}>
{exportEnCours ? <Loader2 className="animate-spin" /> : <Download />}
{exportEnCours ? "Export en cours…" : exportLabel}
</Button>
)}
</div>
</div>
{/* Tableau */}
{total === 0 ? (
<EmptyState
title={filtreActif ? "Aucune ligne ne correspond aux filtres" : emptyTitle}
description={filtreActif ? "Modifiez la recherche ou effacez les filtres." : emptyDescription}
/>
) : (
<div
className="overflow-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]"
style={{ maxHeight }}
>
<table className="min-w-full text-sm">
<thead className="sticky top-0 z-10 bg-[var(--bg-elevated)]">
<tr>
{columns.map((c) => {
const triable = Boolean(c.sortValue);
const actif = tri?.id === c.id;
const contenu = (
<span className={cn("inline-flex items-center gap-1", alignement(c.align))}>
{c.header}
{triable && (
actif
? (tri!.dir === "asc" ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />)
: <ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
)}
</span>
);
return (
<th
key={c.id}
scope="col"
aria-sort={actif ? (tri!.dir === "asc" ? "ascending" : "descending") : undefined}
className={cn(
"whitespace-nowrap border-b border-[var(--border-strong)] px-3 py-2.5 text-[13px] font-semibold text-[var(--text-secondary)]",
alignement(c.align),
c.headerClassName,
)}
>
{triable ? (
<button
type="button"
onClick={() => basculerTri(c.id)}
className="hover:text-[var(--text-primary)]"
title={typeof c.hint === "string" ? c.hint : undefined}
>
{contenu}
</button>
) : c.hint ? (
<Tooltip content={c.hint}><span className="cursor-help">{contenu}</span></Tooltip>
) : contenu}
</th>
);
})}
</tr>
</thead>
<tbody>
{lignesAffichees.map((r, i) => (
<tr
key={rowKey(r, i)}
onClick={onRowClick ? () => 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) => (
<td
key={c.id}
className={cn(
"px-3 py-2 text-[var(--text-primary)]",
c.align === "right" && "text-right tabular-nums",
c.align === "center" && "text-center",
c.className,
)}
>
{c.cell(r)}
</td>
))}
</tr>
))}
</tbody>
{showFooter && (
<tfoot className="sticky bottom-0 bg-[var(--bg-elevated)] font-semibold">
<tr>
{columns.map((c) => (
<td
key={c.id}
className={cn(
"border-t border-[var(--border-strong)] px-3 py-2.5 text-[var(--text-primary)]",
c.align === "right" && "text-right tabular-nums",
c.align === "center" && "text-center",
)}
>
{c.footer ? c.footer(triees) : null}
</td>
))}
</tr>
</tfoot>
)}
</table>
</div>
)}
{pagine && (
<Pagination
page={pageCourante}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onChange={setPage}
unite={unite}
/>
)}
</div>
);
}
+2 -2
View File
@@ -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"
>
<XIcon />
<span className="sr-only">Close</span>
<span className="sr-only">Fermer</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
@@ -111,7 +111,7 @@ function DialogFooter({
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">Fermer</Button>
</DialogPrimitive.Close>
)}
</div>
+2 -2
View File
@@ -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({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-elevated)] p-1 text-[var(--text-primary)] shadow-lg 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-[var(--border-strong)] bg-[var(--bg-surface)] p-1 text-[var(--text-primary)] shadow-lg 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
+162
View File
@@ -0,0 +1,162 @@
"use client";
/**
* Retours à l'utilisateur, communs à toute l'application :
* - `toast.succes / toast.erreur / toast.info` : message discret en bas à droite,
* qui disparaît seul (remplace les fenêtres de succès et les `alert()`).
* - `confirmer({...})` : demande de confirmation, renvoie une promesse de booléen
* (remplace `window.confirm`, au rendu non maîtrisé et en anglais selon le poste).
*
* Le rendu est assuré par un unique `<FeedbackHost />` (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<FeedbackState>(() => ({ 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<boolean> {
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<TonToast, string> = {
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 (
<>
<div
aria-live="polite"
className="pointer-events-none fixed bottom-4 right-4 z-[300] flex w-[min(380px,calc(100vw-2rem))] flex-col gap-2"
>
{toasts.map((t) => {
const Icone = ICONES[t.ton];
return (
<div
key={t.id}
role={t.ton === "erreur" ? "alert" : "status"}
className="pointer-events-auto flex items-start gap-3 rounded-xl border px-4 py-3 shadow-[var(--shadow-lg)] animate-in fade-in-0 slide-in-from-bottom-2"
style={{ background: "var(--bg-surface)", borderColor: "var(--border-strong)" }}
>
<Icone className="mt-0.5 h-5 w-5 shrink-0" style={{ color: COULEURS[t.ton] }} aria-hidden />
<p className="flex-1 text-sm text-[var(--text-primary)]">{t.message}</p>
<button
type="button"
onClick={() => retirerToast(t.id)}
aria-label="Fermer le message"
className="rounded p-0.5 text-[var(--text-muted)] hover:text-[var(--text-primary)]"
>
<X className="h-4 w-4" />
</button>
</div>
);
})}
</div>
<Dialog open={confirmation !== null} onOpenChange={(open) => { if (!open) repondre(false); }}>
<DialogContent showCloseButton={false} className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{confirmation?.titre}</DialogTitle>
{confirmation?.message && (
<DialogDescription className="text-[var(--text-secondary)]">{confirmation.message}</DialogDescription>
)}
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => repondre(false)}>Annuler</Button>
<Button
variant={confirmation?.danger ? "destructive" : "default"}
onClick={() => repondre(true)}
className={cn(confirmation?.danger && "dark:bg-destructive")}
>
{confirmation?.libelleConfirmer}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+92
View File
@@ -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 (
<label htmlFor={htmlFor} className={cn("block mb-1 text-[13px] font-medium text-[var(--text-secondary)]", className)}>
{children}
</label>
);
}
export interface SelectOption {
value: string;
label: string;
}
interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "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 = (
<select
id={id}
onChange={(e) => onChange(e.target.value)}
className={cn(CHAMP, "pr-8 min-w-[160px] cursor-pointer", className)}
{...props}
>
{placeholder !== undefined && <option value="">{placeholder}</option>}
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
if (!label) return select;
return (
<div>
<Label htmlFor={id}>{label}</Label>
{select}
</div>
);
}
interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "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 (
<div className={cn("relative", className)}>
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" aria-hidden />
<input
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={cn(CHAMP, "w-full min-w-[240px] pl-9 pr-8")}
{...props}
/>
{value && (
<button
type="button"
onClick={() => 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)]"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
return <input className={cn(CHAMP, className)} {...props} />;
}
+45
View File
@@ -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 (
<header className={cn("flex flex-wrap items-start justify-between gap-4 mb-6", className)}>
<div className="flex items-start gap-3 min-w-0">
{Icon && (
<div
className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border"
style={{ background: "var(--accent-bg)", borderColor: "var(--accent-border)", color: "var(--accent)" }}
>
<Icon className="h-5 w-5" strokeWidth={2} />
</div>
)}
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>
{title}
</h1>
{description && (
<p className="mt-1 text-sm max-w-3xl" style={{ color: "var(--text-secondary)" }}>
{description}
</p>
)}
</div>
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</header>
);
}
+50
View File
@@ -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 (
<nav aria-label="Pagination" className="flex flex-wrap items-center justify-between gap-3">
<span className="text-[13px] text-[var(--text-secondary)] tabular-nums">
{debut.toLocaleString("fr-FR")} à {fin.toLocaleString("fr-FR")} sur{" "}
<span className="font-semibold text-[var(--text-primary)]">{total.toLocaleString("fr-FR")}</span> {unite}
</span>
<div className="flex items-center gap-1.5">
<button type="button" className={bouton} onClick={() => aller(1)} disabled={page <= 1} aria-label="Première page">
<ChevronsLeft className="h-4 w-4" />
</button>
<button type="button" className={bouton} onClick={() => aller(page - 1)} disabled={page <= 1} aria-label="Page précédente">
<ChevronLeft className="h-4 w-4" />
</button>
<span className="px-2 text-[13px] text-[var(--text-secondary)] tabular-nums">
Page {page.toLocaleString("fr-FR")} / {totalPages.toLocaleString("fr-FR")}
</span>
<button type="button" className={bouton} onClick={() => aller(page + 1)} disabled={page >= totalPages} aria-label="Page suivante">
<ChevronRight className="h-4 w-4" />
</button>
<button type="button" className={bouton} onClick={() => aller(totalPages)} disabled={page >= totalPages} aria-label="Dernière page">
<ChevronsRight className="h-4 w-4" />
</button>
</div>
</nav>
);
}
+45
View File
@@ -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 (
<div
className={cn(
"rounded-xl border p-4 flex flex-col gap-2 bg-[var(--bg-surface)] border-[var(--border)] shadow-[var(--shadow-sm)]",
className,
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-[13px] font-medium text-[var(--text-secondary)]">{label}</span>
{Icon && <Icon className="h-4 w-4 shrink-0 text-[var(--text-muted)]" aria-hidden />}
</div>
<div className="text-2xl font-bold tabular-nums tracking-tight text-[var(--text-primary)]">{value}</div>
{(delta !== undefined || hint) && (
<div className="flex flex-wrap items-center gap-2 text-[13px] text-[var(--text-muted)]">
{delta !== undefined && (
<>
<DeltaBadge pct={delta} />
<span>{deltaLabel}</span>
</>
)}
{hint && <span>{hint}</span>}
</div>
)}
</div>
);
}
+101
View File
@@ -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 (
<div
className={cn(
"flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed px-6 py-12 text-center",
"border-[var(--border-strong)] bg-[var(--bg-surface)]",
className,
)}
>
<Icon className="h-8 w-8 text-[var(--text-muted)]" aria-hidden />
<p className="text-base font-semibold text-[var(--text-primary)]">{title}</p>
{description && <p className="max-w-md text-sm text-[var(--text-secondary)]">{description}</p>}
{action && <div className="mt-2">{action}</div>}
</div>
);
}
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 (
<div
role="alert"
className={cn(
"flex flex-col items-center gap-2 rounded-xl border px-6 py-10 text-center",
"border-[var(--accent-error)]/40 bg-[var(--accent-error-bg)]",
className,
)}
>
<AlertTriangle className="h-8 w-8 text-[var(--accent-error)]" aria-hidden />
<p className="text-base font-semibold text-[var(--text-primary)]">{title}</p>
<p className="max-w-md text-sm text-[var(--text-secondary)]">{description}</p>
{detail && (
<details className="mt-1 max-w-xl text-left text-xs text-[var(--text-muted)]">
<summary className="cursor-pointer">Détail technique</summary>
<pre className="mt-1 whitespace-pre-wrap break-words font-mono">{detail}</pre>
</details>
)}
{action && <div className="mt-2">{action}</div>}
</div>
);
}
export function Skeleton({ className }: { className?: string }) {
return <div className={cn("animate-pulse rounded-md bg-[var(--bg-elevated)]", className)} aria-hidden />;
}
/**
* 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 (
<div className="space-y-6" aria-busy="true" aria-label="Chargement de la page">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-xl" />
<div className="space-y-2">
<Skeleton className="h-6 w-56" />
<Skeleton className="h-4 w-80" />
</div>
</div>
{cards > 0 && (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: cards }, (_, i) => <Skeleton key={i} className="h-28 rounded-xl" />)}
</div>
)}
<div className="rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] p-4 space-y-3">
<Skeleton className="h-9 w-full" />
{Array.from({ length: rows }, (_, i) => <Skeleton key={i} className="h-7 w-full" />)}
</div>
</div>
);
}
+126
View File
@@ -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<V extends string> {
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<V extends string> {
items: readonly TabItem<V>[];
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<V extends string>({ items, value, onChange, disabled, className }: TabsProps<V>) {
return (
<div role="tablist" className={cn("flex shrink-0 gap-1 overflow-x-auto border-b border-[var(--border)]", className)}>
{items.map((t) => {
const actif = t.value === value;
const Icone = t.icon;
return (
<button
key={t.value}
role="tab"
type="button"
aria-selected={actif}
disabled={disabled}
onClick={() => !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 && <Icone className="h-4 w-4" aria-hidden />}
{t.label}
{t.count !== undefined && (
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums",
t.alert && t.count > 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")}
</span>
)}
</button>
);
})}
</div>
);
}
/**
* Segment de choix court (2 à 4 options), pour les bascules du type
* « Fournisseur / Nomenclature » ou « Tous / Frouard / Houdemont ».
*/
export function Segmented<V extends string>({ items, value, onChange, disabled, className }: TabsProps<V>) {
return (
<div
role="radiogroup"
className={cn("inline-flex w-fit shrink-0 gap-0.5 rounded-lg border p-0.5 bg-[var(--bg-elevated)] border-[var(--border)]", className)}
>
{items.map((t) => {
const actif = t.value === value;
return (
<button
key={t.value}
type="button"
role="radio"
aria-checked={actif}
disabled={disabled}
onClick={() => !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 && (
<span className="ml-1.5 text-xs tabular-nums text-[var(--text-muted)]">{t.count.toLocaleString("fr-FR")}</span>
)}
</button>
);
})}
</div>
);
}
/**
* 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<V extends string>(param: string, initial: V): [V, (value: V) => void] {
const [value, setValue] = useState<V>(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];
}
+76
View File
@@ -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 (
<TooltipPrimitive.Provider delayDuration={250} skipDelayDuration={100}>
{children}
</TooltipPrimitive.Provider>
);
}
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 (
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side={side}
sideOffset={6}
collisionPadding={8}
className="z-[200] max-w-xs rounded-lg border px-3 py-2 text-[13px] leading-snug shadow-[var(--shadow-md)] animate-in fade-in-0 zoom-in-95"
style={{ background: "var(--bg-surface)", borderColor: "var(--border-strong)", color: "var(--text-primary)" }}
>
{content}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
}
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 (
<Tooltip
content={
<>
<span className="font-semibold">{entree.terme}</span>
<span className="block mt-0.5" style={{ color: "var(--text-secondary)" }}>{entree.definition}</span>
</>
}
>
<span
tabIndex={0}
className={cn("inline-flex items-center gap-1 cursor-help outline-none", className)}
>
{children ?? entree.terme}
<Info className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden />
</span>
</Tooltip>
);
}