feat: responsive sidebar, client selector and layout with alert/device counts
This commit is contained in:
@@ -1,37 +1,132 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import Sidebar from '@/components/layout/Sidebar'
|
import Sidebar from '@/components/layout/Sidebar'
|
||||||
import Header from '@/components/layout/Header'
|
import Header from '@/components/layout/Header'
|
||||||
|
import { SelectedClientProvider, useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||||
|
import { trpc } from '@/lib/trpc-client'
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [alertasActivas, setAlertasActivas] = useState(0)
|
const router = useRouter()
|
||||||
const [user, setUser] = useState({
|
|
||||||
nombre: 'Admin',
|
const meQuery = trpc.auth.me.useQuery(undefined, {
|
||||||
email: 'admin@example.com',
|
retry: false,
|
||||||
rol: 'SUPER_ADMIN',
|
staleTime: 60 * 1000,
|
||||||
|
})
|
||||||
|
const logoutMutation = trpc.auth.logout.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
window.location.href = '/login'
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// TODO: Cargar alertas activas desde API
|
if (meQuery.isError) {
|
||||||
// TODO: Cargar usuario desde sesion
|
router.push('/login')
|
||||||
}, [])
|
}
|
||||||
|
}, [meQuery.isError, router])
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = () => {
|
||||||
// TODO: Implementar logout
|
logoutMutation.mutate()
|
||||||
window.location.href = '/login'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (meQuery.isLoading || meQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-dark-500 items-center justify-center">
|
||||||
|
<div className="text-gray-400">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = meQuery.data
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent user={user} onLogout={handleLogout}>
|
||||||
|
{children}
|
||||||
|
</DashboardContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardContent({
|
||||||
|
user,
|
||||||
|
onLogout,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
user: { nombre: string; email: string; rol: string }
|
||||||
|
onLogout: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectedClientProvider>
|
||||||
|
<DashboardContentInner user={user} onLogout={onLogout}>
|
||||||
|
{children}
|
||||||
|
</DashboardContentInner>
|
||||||
|
</SelectedClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardContentInner({
|
||||||
|
user,
|
||||||
|
onLogout,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
user: { nombre: string; email: string; rol: string }
|
||||||
|
onLogout: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { selectedClientId } = useSelectedClient()
|
||||||
|
const clienteId = selectedClientId ?? undefined
|
||||||
|
|
||||||
|
const activeAlertsCountQuery = trpc.alertas.conteoActivas.useQuery(
|
||||||
|
{ clienteId },
|
||||||
|
{ refetchOnWindowFocus: true, staleTime: 30 * 1000 }
|
||||||
|
)
|
||||||
|
const activeAlertsCount = activeAlertsCountQuery.data?.total ?? 0
|
||||||
|
|
||||||
|
const devicesCountQuery = trpc.equipos.list.useQuery(
|
||||||
|
{ clienteId, page: 1, limit: 1 },
|
||||||
|
{ refetchOnWindowFocus: true, staleTime: 30 * 1000 }
|
||||||
|
)
|
||||||
|
const devicesCount = devicesCountQuery.data?.pagination?.total ?? 0
|
||||||
|
|
||||||
|
const clientsQuery = trpc.clientes.list.useQuery(
|
||||||
|
{ limit: 100 },
|
||||||
|
{ staleTime: 60 * 1000 }
|
||||||
|
)
|
||||||
|
const clients = (clientsQuery.data?.clientes ?? []).map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
nombre: c.nombre,
|
||||||
|
codigo: c.codigo,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-dark-500">
|
<div className="flex h-screen bg-dark-500">
|
||||||
<Sidebar alertasActivas={alertasActivas} />
|
<Sidebar
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
activeAlertsCount={activeAlertsCount}
|
||||||
<Header user={user} onLogout={handleLogout} />
|
devicesCount={devicesCount}
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
open={sidebarOpen}
|
||||||
|
onClose={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="ml-0 md:ml-[260px] flex min-w-0 flex-1 flex-col overflow-hidden transition-[margin] duration-200">
|
||||||
|
<Header
|
||||||
|
user={{
|
||||||
|
nombre: user.nombre,
|
||||||
|
email: user.email,
|
||||||
|
rol: user.rol,
|
||||||
|
}}
|
||||||
|
onLogout={onLogout}
|
||||||
|
clients={clients}
|
||||||
|
showAllClientsOption={user.rol === 'SUPER_ADMIN'}
|
||||||
|
onOpenSidebar={() => setSidebarOpen(true)}
|
||||||
|
/>
|
||||||
|
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Bell, Search, User, LogOut, Settings, ChevronDown } from 'lucide-react'
|
import { Bell, Search, User, LogOut, Settings, ChevronDown, Menu } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import ClientSelector from './ClientSelector'
|
import ClientSelector from './ClientSelector'
|
||||||
|
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||||
|
|
||||||
|
export type HeaderClient = { id: string; nombre: string; codigo: string }
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
user?: {
|
user?: {
|
||||||
@@ -13,17 +16,36 @@ interface HeaderProps {
|
|||||||
rol: string
|
rol: string
|
||||||
}
|
}
|
||||||
onLogout?: () => void
|
onLogout?: () => void
|
||||||
|
clients?: HeaderClient[]
|
||||||
|
showAllClientsOption?: boolean
|
||||||
|
onOpenSidebar?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({ user, onLogout }: HeaderProps) {
|
export default function Header({
|
||||||
|
user,
|
||||||
|
onLogout,
|
||||||
|
clients = [],
|
||||||
|
showAllClientsOption = false,
|
||||||
|
onOpenSidebar,
|
||||||
|
}: HeaderProps) {
|
||||||
const [showUserMenu, setShowUserMenu] = useState(false)
|
const [showUserMenu, setShowUserMenu] = useState(false)
|
||||||
const [showNotifications, setShowNotifications] = useState(false)
|
const [showNotifications, setShowNotifications] = useState(false)
|
||||||
|
const { selectedClientId, setSelectedClientId } = useSelectedClient()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-16 bg-dark-400 border-b border-dark-100 flex items-center justify-between px-6">
|
<header className="h-16 bg-dark-400 border-b border-dark-100 flex items-center justify-between gap-2 px-4 sm:px-6">
|
||||||
{/* Search */}
|
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-4 flex-1">
|
{onOpenSidebar != null && (
|
||||||
<div className="relative w-96">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenSidebar}
|
||||||
|
aria-label="Abrir menú"
|
||||||
|
className="md:hidden shrink-0 p-2 rounded-lg hover:bg-dark-100 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Menu className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="relative w-full max-w-xs sm:max-w-none sm:w-96">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -31,9 +53,13 @@ export default function Header({ user, onLogout }: HeaderProps) {
|
|||||||
className="input pl-10 bg-dark-300"
|
className="input pl-10 bg-dark-300"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Client Selector */}
|
{/* Client Selector */}
|
||||||
<ClientSelector />
|
<ClientSelector
|
||||||
|
clients={clients}
|
||||||
|
selectedId={selectedClientId}
|
||||||
|
onChange={setSelectedClientId}
|
||||||
|
showAll={showAllClientsOption}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right section */}
|
{/* Right section */}
|
||||||
@@ -79,7 +105,7 @@ export default function Header({ user, onLogout }: HeaderProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-3 border-t border-dark-100">
|
<div className="px-4 py-3 border-t border-dark-100">
|
||||||
<a href="/alertas" className="text-primary-500 text-sm hover:underline">
|
<a href="/alerts" className="text-primary-500 text-sm hover:underline">
|
||||||
Ver todas las alertas
|
Ver todas las alertas
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,88 +1,112 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Monitor,
|
Monitor,
|
||||||
Smartphone,
|
Video,
|
||||||
|
Terminal,
|
||||||
|
FolderOpen,
|
||||||
|
Gauge,
|
||||||
|
Package,
|
||||||
Network,
|
Network,
|
||||||
|
Smartphone,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
FileText,
|
FileText,
|
||||||
Settings,
|
Settings,
|
||||||
Users,
|
|
||||||
Building2,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Activity,
|
Activity,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import SidebarItem, { type BadgeType } from './SidebarItem'
|
||||||
|
import SidebarSection from './SidebarSection'
|
||||||
|
|
||||||
interface NavItem {
|
export interface SidebarMenuItem {
|
||||||
label: string
|
label: string
|
||||||
href: string
|
href: string
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
badge?: number
|
badge?: { type: BadgeType; value: string | number }
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
export interface SidebarMenuSection {
|
||||||
{
|
label: string
|
||||||
label: 'Dashboard',
|
items: SidebarMenuItem[]
|
||||||
href: '/',
|
}
|
||||||
icon: <LayoutDashboard className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Equipos',
|
|
||||||
href: '/equipos',
|
|
||||||
icon: <Monitor className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Celulares',
|
|
||||||
href: '/celulares',
|
|
||||||
icon: <Smartphone className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Red',
|
|
||||||
href: '/red',
|
|
||||||
icon: <Network className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Alertas',
|
|
||||||
href: '/alertas',
|
|
||||||
icon: <AlertTriangle className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Reportes',
|
|
||||||
href: '/reportes',
|
|
||||||
icon: <FileText className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const adminItems: NavItem[] = [
|
const menuConfig: SidebarMenuSection[] = [
|
||||||
{
|
{
|
||||||
label: 'Clientes',
|
label: 'PRINCIPAL',
|
||||||
href: '/clientes',
|
items: [
|
||||||
icon: <Building2 className="w-5 h-5" />,
|
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard className="w-5 h-5" /> },
|
||||||
|
{
|
||||||
|
label: 'Dispositivos',
|
||||||
|
href: '/devices',
|
||||||
|
icon: <Monitor className="w-5 h-5" />,
|
||||||
|
badge: { type: 'red', value: 10 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sesiones',
|
||||||
|
href: '/sesiones',
|
||||||
|
icon: <Video className="w-5 h-5" />,
|
||||||
|
badge: { type: 'red', value: 4 },
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Usuarios',
|
label: 'HERRAMIENTAS',
|
||||||
href: '/usuarios',
|
items: [
|
||||||
icon: <Users className="w-5 h-5" />,
|
{ label: 'Terminal', href: '/terminal', icon: <Terminal className="w-5 h-5" /> },
|
||||||
|
{ label: 'Archivos', href: '/archivos', icon: <FolderOpen className="w-5 h-5" /> },
|
||||||
|
{ label: 'Rendimiento', href: '/rendimiento', icon: <Gauge className="w-5 h-5" /> },
|
||||||
|
{ label: 'Software', href: '/software', icon: <Package className="w-5 h-5" /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Configuracion',
|
label: 'INTEGRACIONES',
|
||||||
href: '/configuracion',
|
items: [
|
||||||
icon: <Settings className="w-5 h-5" />,
|
{
|
||||||
|
label: 'LibreNMS',
|
||||||
|
href: '/configuracion',
|
||||||
|
icon: <Network className="w-5 h-5" />,
|
||||||
|
badge: { type: 'green', value: 'OK' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Headwind MDM',
|
||||||
|
href: '/configuracion',
|
||||||
|
icon: <Smartphone className="w-5 h-5" />,
|
||||||
|
badge: { type: 'blue', value: 12 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'MONITOREO',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Alertas',
|
||||||
|
href: '/alerts',
|
||||||
|
icon: <AlertTriangle className="w-5 h-5" />,
|
||||||
|
badge: { type: 'red', value: 5 },
|
||||||
|
},
|
||||||
|
{ label: 'Reportes', href: '/reportes', icon: <FileText className="w-5 h-5" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'SISTEMA',
|
||||||
|
items: [
|
||||||
|
{ label: 'Configuracion', href: '/configuracion', icon: <Settings className="w-5 h-5" /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
alertasActivas?: number
|
activeAlertsCount?: number
|
||||||
|
devicesCount?: number
|
||||||
|
open?: boolean
|
||||||
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({ alertasActivas = 0 }: SidebarProps) {
|
export default function Sidebar({ activeAlertsCount, devicesCount, open = false, onClose }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
const isActive = (href: string) => {
|
const isActive = (href: string) => {
|
||||||
@@ -90,93 +114,83 @@ export default function Sidebar({ alertasActivas = 0 }: SidebarProps) {
|
|||||||
return pathname.startsWith(href)
|
return pathname.startsWith(href)
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = navItems.map((item) => ({
|
const getBadgeValue = (item: SidebarMenuItem): SidebarMenuItem['badge'] => {
|
||||||
...item,
|
if (item.href === '/alerts' && activeAlertsCount !== undefined) {
|
||||||
badge: item.href === '/alertas' ? alertasActivas : undefined,
|
if (activeAlertsCount === 0) return undefined
|
||||||
}))
|
return { type: 'red', value: activeAlertsCount }
|
||||||
|
}
|
||||||
|
if (item.href === '/devices' && devicesCount !== undefined) {
|
||||||
|
return { type: 'red', value: devicesCount }
|
||||||
|
}
|
||||||
|
return item.badge
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<>
|
||||||
className={cn(
|
{/* Mobile overlay */}
|
||||||
'h-screen bg-dark-400 border-r border-dark-100 flex flex-col transition-all duration-300',
|
<div
|
||||||
collapsed ? 'w-16' : 'w-64'
|
role="button"
|
||||||
)}
|
tabIndex={0}
|
||||||
>
|
aria-label="Cerrar menú"
|
||||||
{/* Logo */}
|
onClick={onClose}
|
||||||
<div className="h-16 flex items-center justify-between px-4 border-b border-dark-100">
|
onKeyDown={(e) => e.key === 'Escape' && onClose?.()}
|
||||||
{!collapsed && (
|
className={cn(
|
||||||
<Link href="/" className="flex items-center gap-2">
|
'fixed inset-0 z-30 bg-black/60 transition-opacity duration-200 md:hidden',
|
||||||
<Activity className="w-8 h-8 text-primary-500" />
|
open ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||||
<span className="font-bold text-lg gradient-text">MSP Monitor</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
)}
|
||||||
<button
|
/>
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
|
||||||
className="p-1.5 rounded-lg hover:bg-dark-100 text-gray-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
{collapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronLeft className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
<aside
|
||||||
<nav className="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
|
className={cn(
|
||||||
{items.map((item) => (
|
'fixed left-0 top-0 z-40 h-screen w-[260px] flex flex-col overflow-hidden',
|
||||||
|
'bg-gradient-to-b from-[#0f172a] to-[#111827]',
|
||||||
|
'border-r border-slate-800/80',
|
||||||
|
'transition-transform duration-200 ease-out',
|
||||||
|
'md:translate-x-0',
|
||||||
|
open ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-16 shrink-0 items-center justify-between gap-2 border-b border-slate-800/80 px-4">
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
href="/"
|
||||||
href={item.href}
|
onClick={onClose}
|
||||||
className={cn(
|
className="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-slate-700/30"
|
||||||
'sidebar-link',
|
|
||||||
isActive(item.href) && 'active',
|
|
||||||
collapsed && 'justify-center px-2'
|
|
||||||
)}
|
|
||||||
title={collapsed ? item.label : undefined}
|
|
||||||
>
|
>
|
||||||
{item.icon}
|
<Activity className="h-8 w-8 text-cyan-400" />
|
||||||
{!collapsed && (
|
<span className="text-lg font-semibold text-white">MSP Monitor</span>
|
||||||
<>
|
|
||||||
<span className="flex-1">{item.label}</span>
|
|
||||||
{item.badge !== undefined && item.badge > 0 && (
|
|
||||||
<span className="badge badge-danger">{item.badge}</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{collapsed && item.badge !== undefined && item.badge > 0 && (
|
|
||||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-danger rounded-full text-xs flex items-center justify-center">
|
|
||||||
{item.badge > 9 ? '9+' : item.badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
<button
|
||||||
|
type="button"
|
||||||
{/* Separador */}
|
onClick={onClose}
|
||||||
<div className="h-px bg-dark-100 my-4" />
|
aria-label="Cerrar menú"
|
||||||
|
className="md:hidden p-2 rounded-lg text-slate-400 hover:bg-slate-700/30 hover:text-white transition-colors"
|
||||||
{/* Admin items */}
|
|
||||||
{adminItems.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
'sidebar-link',
|
|
||||||
isActive(item.href) && 'active',
|
|
||||||
collapsed && 'justify-center px-2'
|
|
||||||
)}
|
|
||||||
title={collapsed ? item.label : undefined}
|
|
||||||
>
|
>
|
||||||
{item.icon}
|
<X className="w-6 h-6" />
|
||||||
{!collapsed && <span className="flex-1">{item.label}</span>}
|
</button>
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
{!collapsed && (
|
|
||||||
<div className="p-4 border-t border-dark-100">
|
|
||||||
<div className="text-xs text-gray-500 text-center">
|
|
||||||
MSP Monitor v1.0.0
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</aside>
|
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||||
|
{menuConfig.map((section) => (
|
||||||
|
<SidebarSection key={section.label} label={section.label}>
|
||||||
|
{section.items.map((item) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={item.href + item.label}
|
||||||
|
label={item.label}
|
||||||
|
href={item.href}
|
||||||
|
icon={item.icon}
|
||||||
|
active={isActive(item.href)}
|
||||||
|
badge={getBadgeValue(item)}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SidebarSection>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800/80 px-4 py-3 shrink-0">
|
||||||
|
<p className="text-center text-xs text-slate-500">MSP Monitor v1.0.0</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
69
src/components/layout/SidebarItem.tsx
Normal file
69
src/components/layout/SidebarItem.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type BadgeType = 'red' | 'blue' | 'green'
|
||||||
|
|
||||||
|
export interface SidebarItemProps {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
active?: boolean
|
||||||
|
badge?: {
|
||||||
|
type: BadgeType
|
||||||
|
value: string | number
|
||||||
|
}
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeStyles: Record<BadgeType, string> = {
|
||||||
|
red: 'bg-red-500/90 text-white',
|
||||||
|
blue: 'bg-blue-500/90 text-white',
|
||||||
|
green: 'bg-emerald-500/90 text-white',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SidebarItem({
|
||||||
|
label,
|
||||||
|
href,
|
||||||
|
icon,
|
||||||
|
active,
|
||||||
|
badge,
|
||||||
|
onClick,
|
||||||
|
}: SidebarItemProps) {
|
||||||
|
const isPill = badge?.type === 'green' && typeof badge.value === 'string'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-4 py-2.5 transition-all duration-200',
|
||||||
|
active
|
||||||
|
? 'bg-gradient-to-r from-cyan-600/30 to-blue-600/20 text-white shadow-[0_0_20px_-5px_rgba(6,182,212,0.25)]'
|
||||||
|
: 'text-slate-400 hover:bg-slate-700/50 hover:text-slate-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0',
|
||||||
|
active ? 'text-cyan-300' : 'text-slate-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||||
|
{badge != null && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 items-center justify-center text-xs font-bold',
|
||||||
|
isPill ? 'rounded-full px-2 py-0.5' : 'h-5 min-w-[1.25rem] rounded-full px-1.5',
|
||||||
|
badgeStyles[badge.type]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{badge.value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/components/layout/SidebarSection.tsx
Normal file
24
src/components/layout/SidebarSection.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface SidebarSectionProps {
|
||||||
|
label: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SidebarSection({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: SidebarSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('mt-6 first:mt-4', className)}>
|
||||||
|
<p className="mb-2 px-4 text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-0.5">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user