mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-21 05:58:10 +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"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user