mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
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:
47 files changed
+2004
-272
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
defaultTheme="light"
|
||||
enableSystem={false}
|
||||
storageKey="collectflow-theme"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user