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>
);