406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
import { useAuth } from "@/_core/hooks/useAuth";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarHeader,
|
|
SidebarInset,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarMenuSub,
|
|
SidebarMenuSubItem,
|
|
SidebarMenuSubButton,
|
|
SidebarProvider,
|
|
SidebarTrigger,
|
|
useSidebar,
|
|
} from "@/components/ui/sidebar";
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
import { getLoginUrl } from "@/const";
|
|
import { useIsMobile } from "@/hooks/useMobile";
|
|
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare, Brain, BarChart2, BarChart3 } from "lucide-react";
|
|
import { CSSProperties, useEffect, useRef, useState } from "react";
|
|
import { useLocation } from "wouter";
|
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
|
import { Button } from "./ui/button";
|
|
|
|
type MenuItem = {
|
|
icon: any;
|
|
label: string;
|
|
path?: string;
|
|
adminOnly?: boolean;
|
|
children?: MenuItem[];
|
|
color?: string; // Couleur de la section
|
|
};
|
|
|
|
const menuStructure: MenuItem[] = [
|
|
{
|
|
icon: LayoutDashboard,
|
|
label: "Tableau de bord",
|
|
path: "/dashboard",
|
|
color: "from-blue-500 to-cyan-500",
|
|
},
|
|
{
|
|
icon: Receipt,
|
|
label: "Facturation",
|
|
color: "from-purple-500 to-pink-500",
|
|
children: [
|
|
{ icon: Upload, label: "Import", path: "/upload" },
|
|
{ icon: FileText, label: "Factures", path: "/invoices" },
|
|
{ icon: FileText, label: "Factures BAP", path: "/invoices-bap" },
|
|
],
|
|
},
|
|
{
|
|
icon: BarChart3,
|
|
label: "Ventilations",
|
|
color: "from-teal-500 to-cyan-500",
|
|
children: [
|
|
{ icon: BarChart3, label: "FreePro", path: "/ventilation-freepro" },
|
|
],
|
|
},
|
|
{
|
|
icon: Cog,
|
|
label: "Configuration",
|
|
color: "from-orange-500 to-amber-500",
|
|
children: [
|
|
{ icon: Settings, label: "Paramètres IA et Signatures", path: "/settings" },
|
|
{ icon: Download, label: "Paramètres import / export", path: "/import-settings" },
|
|
{ icon: List, label: "Administration des listes", path: "/lists-admin" },
|
|
{ icon: Zap, label: "Automatismes", path: "/automation-rules" },
|
|
{ icon: Brain, label: "Apprentissages IA", path: "/learning-settings" },
|
|
{ icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true },
|
|
],
|
|
},
|
|
{
|
|
icon: ClipboardList,
|
|
label: "Traçabilité",
|
|
color: "from-green-500 to-emerald-500",
|
|
children: [
|
|
{ icon: History, label: "Historiques", path: "/history" },
|
|
{ icon: BarChart2, label: "Rapport imports", path: "/import-report" },
|
|
{ icon: CheckSquare, label: "Historique BAP", path: "/bap-history" },
|
|
],
|
|
},
|
|
];
|
|
|
|
const SIDEBAR_WIDTH_KEY = "sidebar-width";
|
|
const DEFAULT_WIDTH = 280;
|
|
const MIN_WIDTH = 200;
|
|
const MAX_WIDTH = 480;
|
|
|
|
export default function DashboardLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
|
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
|
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
|
|
});
|
|
const { loading, user } = useAuth();
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
|
|
}, [sidebarWidth]);
|
|
|
|
if (loading) {
|
|
return <DashboardLayoutSkeleton />
|
|
}
|
|
|
|
if (!user) {
|
|
window.location.href = "/login";
|
|
return <DashboardLayoutSkeleton />;
|
|
}
|
|
|
|
return (
|
|
<SidebarProvider
|
|
style={
|
|
{
|
|
"--sidebar-width": `${sidebarWidth}px`,
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
|
|
{children}
|
|
</DashboardLayoutContent>
|
|
</SidebarProvider>
|
|
);
|
|
}
|
|
|
|
type DashboardLayoutContentProps = {
|
|
children: React.ReactNode;
|
|
setSidebarWidth: (width: number) => void;
|
|
};
|
|
|
|
function DashboardLayoutContent({
|
|
children,
|
|
setSidebarWidth,
|
|
}: DashboardLayoutContentProps) {
|
|
const { user, logout } = useAuth();
|
|
const [location, setLocation] = useLocation();
|
|
const { state, toggleSidebar } = useSidebar();
|
|
const isCollapsed = state === "collapsed";
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
|
const isMobile = useIsMobile();
|
|
|
|
// État pour la section ouverte (une seule à la fois, Tableau de bord par défaut)
|
|
const [openSection, setOpenSection] = useState<string | null>("Tableau de bord");
|
|
|
|
// Trouver le label actif pour le header mobile
|
|
const findActiveLabel = (): string => {
|
|
for (const section of menuStructure) {
|
|
if (section.path === location) return section.label;
|
|
if (section.children) {
|
|
const child = section.children.find(c => c.path === location);
|
|
if (child) return child.label;
|
|
}
|
|
}
|
|
return "Menu";
|
|
};
|
|
|
|
const toggleSection = (label: string) => {
|
|
// Si on clique sur la section déjà ouverte, on la ferme
|
|
// Sinon, on ouvre la nouvelle section (et ferme automatiquement l'ancienne)
|
|
setOpenSection(prev => prev === label ? null : label);
|
|
};
|
|
|
|
// Ouvrir automatiquement la section parent quand on navigue vers une page enfant
|
|
useEffect(() => {
|
|
for (const section of menuStructure) {
|
|
if (section.children) {
|
|
const hasActiveChild = section.children.some(child => child.path === location);
|
|
if (hasActiveChild && openSection !== section.label) {
|
|
setOpenSection(section.label);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}, [location]);
|
|
|
|
useEffect(() => {
|
|
if (isCollapsed) {
|
|
setIsResizing(false);
|
|
}
|
|
}, [isCollapsed]);
|
|
|
|
useEffect(() => {
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!isResizing) return;
|
|
|
|
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
|
|
const newWidth = e.clientX - sidebarLeft;
|
|
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
|
setSidebarWidth(newWidth);
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsResizing(false);
|
|
};
|
|
|
|
if (isResizing) {
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
document.body.style.cursor = "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
};
|
|
}, [isResizing, setSidebarWidth]);
|
|
|
|
const renderMenuItem = (item: MenuItem) => {
|
|
// Filtrer les items admin
|
|
if (item.adminOnly && user?.role !== "admin") return null;
|
|
|
|
// Item simple sans enfants
|
|
if (!item.children) {
|
|
const isActive = location === item.path;
|
|
return (
|
|
<SidebarMenuItem key={item.path} className="mb-2">
|
|
<SidebarMenuButton
|
|
isActive={isActive}
|
|
onClick={() => item.path && setLocation(item.path)}
|
|
tooltip={item.label}
|
|
className={`h-12 transition-all font-medium rounded-xl ${
|
|
isActive
|
|
? `bg-gradient-to-r ${item.color} text-white shadow-lg`
|
|
: "hover:bg-accent/50"
|
|
}`}
|
|
>
|
|
<div className={`p-2 rounded-lg ${isActive ? "bg-white/20" : "bg-accent"}`}>
|
|
<item.icon className="h-5 w-5" />
|
|
</div>
|
|
<span className="text-base">{item.label}</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
}
|
|
|
|
// Item avec enfants (accordéon)
|
|
const isOpen = openSection === item.label;
|
|
const hasActiveChild = item.children.some(child => child.path === location);
|
|
|
|
return (
|
|
<Collapsible
|
|
key={item.label}
|
|
open={isOpen}
|
|
onOpenChange={() => toggleSection(item.label)}
|
|
className="group/collapsible mb-2"
|
|
>
|
|
<SidebarMenuItem>
|
|
<CollapsibleTrigger asChild>
|
|
<SidebarMenuButton
|
|
tooltip={item.label}
|
|
className={`h-12 transition-all font-medium rounded-xl ${
|
|
hasActiveChild || isOpen
|
|
? `bg-gradient-to-r ${item.color} text-white shadow-lg`
|
|
: "hover:bg-accent/50"
|
|
}`}
|
|
>
|
|
<div className={`p-2 rounded-lg ${hasActiveChild || isOpen ? "bg-white/20" : "bg-accent"}`}>
|
|
<item.icon className="h-5 w-5" />
|
|
</div>
|
|
<span className="text-base">{item.label}</span>
|
|
<ChevronDown
|
|
className={`ml-auto h-5 w-5 transition-transform duration-300 ${isOpen ? "rotate-180" : ""}`}
|
|
/>
|
|
</SidebarMenuButton>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="transition-all duration-300 ease-in-out">
|
|
<SidebarMenuSub className="ml-2 mt-2 space-y-1 border-l-2 border-border pl-4">
|
|
{item.children
|
|
.filter(child => !child.adminOnly || user?.role === "admin")
|
|
.map(child => {
|
|
const isActive = location === child.path;
|
|
return (
|
|
<SidebarMenuSubItem key={child.path}>
|
|
<SidebarMenuSubButton
|
|
isActive={isActive}
|
|
onClick={() => child.path && setLocation(child.path)}
|
|
className={`h-10 rounded-lg transition-all ${
|
|
isActive
|
|
? "bg-accent text-accent-foreground font-medium shadow-sm"
|
|
: "hover:bg-accent/50"
|
|
}`}
|
|
>
|
|
<child.icon className={`h-4 w-4 ${isActive ? "text-primary" : ""}`} />
|
|
<span>{child.label}</span>
|
|
</SidebarMenuSubButton>
|
|
</SidebarMenuSubItem>
|
|
);
|
|
})}
|
|
</SidebarMenuSub>
|
|
</CollapsibleContent>
|
|
</SidebarMenuItem>
|
|
</Collapsible>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="relative" ref={sidebarRef}>
|
|
<Sidebar
|
|
collapsible="icon"
|
|
className="border-r-0"
|
|
disableTransition={isResizing}
|
|
>
|
|
<SidebarHeader className="h-16 justify-center border-b">
|
|
<div className="flex items-center gap-3 px-2 transition-all w-full">
|
|
<button
|
|
onClick={toggleSidebar}
|
|
className="h-9 w-9 flex items-center justify-center hover:bg-accent rounded-xl transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
|
|
aria-label="Toggle navigation"
|
|
>
|
|
<PanelLeft className="h-5 w-5 text-muted-foreground" />
|
|
</button>
|
|
{!isCollapsed ? (
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="font-bold text-lg tracking-tight truncate bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
|
Navigation
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</SidebarHeader>
|
|
|
|
<SidebarContent className="gap-0 p-3">
|
|
<SidebarMenu>
|
|
{menuStructure.map(renderMenuItem)}
|
|
</SidebarMenu>
|
|
</SidebarContent>
|
|
|
|
<SidebarFooter className="p-3 border-t">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-accent/50 transition-all w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
<Avatar className="h-10 w-10 border-2 shrink-0 shadow-sm">
|
|
<AvatarFallback className="text-sm font-semibold bg-gradient-to-br from-primary to-primary/60">
|
|
{user?.name?.charAt(0).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
|
|
<p className="text-sm font-semibold truncate leading-none">
|
|
{user?.name || "-"}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground truncate mt-1.5">
|
|
{user?.email || "-"}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-48">
|
|
<DropdownMenuItem
|
|
onClick={logout}
|
|
className="cursor-pointer text-destructive focus:text-destructive"
|
|
>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
<span>Sign out</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarFooter>
|
|
</Sidebar>
|
|
<div
|
|
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
|
|
onMouseDown={() => {
|
|
if (isCollapsed) return;
|
|
setIsResizing(true);
|
|
}}
|
|
style={{ zIndex: 50 }}
|
|
/>
|
|
</div>
|
|
|
|
<SidebarInset>
|
|
{isMobile && (
|
|
<div className="flex border-b h-14 items-center justify-between bg-background/95 px-2 backdrop-blur supports-[backdrop-filter]:backdrop-blur sticky top-0 z-40">
|
|
<div className="flex items-center gap-2">
|
|
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="tracking-tight text-foreground font-semibold">
|
|
{findActiveLabel()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<main className="flex-1 p-4">{children}</main>
|
|
</SidebarInset>
|
|
</>
|
|
);
|
|
}
|