Initial commit: MSP Monitor Dashboard
- Next.js 14 frontend with dark cyan/navy theme - tRPC API with Prisma ORM - MeshCentral, LibreNMS, Headwind MDM integrations - Multi-tenant architecture - Alert system with email/SMS/webhook notifications - Docker Compose deployment - Complete documentation
This commit is contained in:
162
src/components/dashboard/AlertsFeed.tsx
Normal file
162
src/components/dashboard/AlertsFeed.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import { AlertTriangle, CheckCircle, Info, Clock } from 'lucide-react'
|
||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||
|
||||
interface Alert {
|
||||
id: string
|
||||
severidad: 'INFO' | 'WARNING' | 'CRITICAL'
|
||||
estado: 'ACTIVA' | 'RECONOCIDA' | 'RESUELTA'
|
||||
titulo: string
|
||||
mensaje: string
|
||||
createdAt: Date
|
||||
dispositivo?: { nombre: string } | null
|
||||
cliente: { nombre: string }
|
||||
}
|
||||
|
||||
interface AlertsFeedProps {
|
||||
alerts: Alert[]
|
||||
onAcknowledge?: (alertId: string) => void
|
||||
onResolve?: (alertId: string) => void
|
||||
maxItems?: number
|
||||
}
|
||||
|
||||
export default function AlertsFeed({
|
||||
alerts,
|
||||
onAcknowledge,
|
||||
onResolve,
|
||||
maxItems = 10,
|
||||
}: AlertsFeedProps) {
|
||||
const displayAlerts = alerts.slice(0, maxItems)
|
||||
|
||||
if (displayAlerts.length === 0) {
|
||||
return (
|
||||
<div className="card p-8 text-center">
|
||||
<CheckCircle className="w-12 h-12 text-success mx-auto mb-3" />
|
||||
<p className="text-gray-400">No hay alertas activas</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="font-medium">Alertas Recientes</h3>
|
||||
<a href="/alertas" className="text-sm text-primary-500 hover:underline">
|
||||
Ver todas
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-dark-100">
|
||||
{displayAlerts.map((alert) => (
|
||||
<AlertItem
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
onAcknowledge={onAcknowledge}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertItem({
|
||||
alert,
|
||||
onAcknowledge,
|
||||
onResolve,
|
||||
}: {
|
||||
alert: Alert
|
||||
onAcknowledge?: (alertId: string) => void
|
||||
onResolve?: (alertId: string) => void
|
||||
}) {
|
||||
const severityConfig = {
|
||||
CRITICAL: {
|
||||
icon: <AlertTriangle className="w-5 h-5" />,
|
||||
color: 'text-danger',
|
||||
bgColor: 'bg-danger/20',
|
||||
borderColor: 'border-l-danger',
|
||||
},
|
||||
WARNING: {
|
||||
icon: <AlertTriangle className="w-5 h-5" />,
|
||||
color: 'text-warning',
|
||||
bgColor: 'bg-warning/20',
|
||||
borderColor: 'border-l-warning',
|
||||
},
|
||||
INFO: {
|
||||
icon: <Info className="w-5 h-5" />,
|
||||
color: 'text-info',
|
||||
bgColor: 'bg-info/20',
|
||||
borderColor: 'border-l-info',
|
||||
},
|
||||
}
|
||||
|
||||
const config = severityConfig[alert.severidad]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 border-l-4 hover:bg-dark-300/30 transition-colors',
|
||||
config.borderColor,
|
||||
alert.severidad === 'CRITICAL' && 'animate-pulse-slow'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn('p-2 rounded-lg', config.bgColor)}>
|
||||
<span className={config.color}>{config.icon}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm">{alert.titulo}</h4>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{alert.mensaje}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'badge shrink-0',
|
||||
alert.estado === 'ACTIVA' && 'badge-danger',
|
||||
alert.estado === 'RECONOCIDA' && 'badge-warning',
|
||||
alert.estado === 'RESUELTA' && 'badge-success'
|
||||
)}
|
||||
>
|
||||
{alert.estado}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatRelativeTime(alert.createdAt)}
|
||||
</div>
|
||||
{alert.dispositivo && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{alert.dispositivo.nombre}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-600">
|
||||
{alert.cliente.nombre}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{alert.estado === 'ACTIVA' && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => onAcknowledge?.(alert.id)}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
Reconocer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onResolve?.(alert.id)}
|
||||
className="btn btn-ghost btn-sm text-success"
|
||||
>
|
||||
Resolver
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
341
src/components/dashboard/DeviceGrid.tsx
Normal file
341
src/components/dashboard/DeviceGrid.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Monitor,
|
||||
Laptop,
|
||||
Server,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
Router,
|
||||
Network,
|
||||
Shield,
|
||||
Wifi,
|
||||
Printer,
|
||||
HelpCircle,
|
||||
MoreVertical,
|
||||
ExternalLink,
|
||||
Power,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatRelativeTime, getStatusColor, getStatusBgColor } from '@/lib/utils'
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
nombre: string
|
||||
tipo: string
|
||||
estado: string
|
||||
ip?: string | null
|
||||
sistemaOperativo?: string | null
|
||||
lastSeen?: Date | null
|
||||
cpuUsage?: number | null
|
||||
ramUsage?: number | null
|
||||
cliente?: { nombre: string }
|
||||
}
|
||||
|
||||
interface DeviceGridProps {
|
||||
devices: Device[]
|
||||
viewMode?: 'grid' | 'list'
|
||||
onAction?: (deviceId: string, action: string) => void
|
||||
}
|
||||
|
||||
const deviceIcons: Record<string, React.ReactNode> = {
|
||||
PC: <Monitor className="w-8 h-8" />,
|
||||
LAPTOP: <Laptop className="w-8 h-8" />,
|
||||
SERVIDOR: <Server className="w-8 h-8" />,
|
||||
CELULAR: <Smartphone className="w-8 h-8" />,
|
||||
TABLET: <Tablet className="w-8 h-8" />,
|
||||
ROUTER: <Router className="w-8 h-8" />,
|
||||
SWITCH: <Network className="w-8 h-8" />,
|
||||
FIREWALL: <Shield className="w-8 h-8" />,
|
||||
AP: <Wifi className="w-8 h-8" />,
|
||||
IMPRESORA: <Printer className="w-8 h-8" />,
|
||||
OTRO: <HelpCircle className="w-8 h-8" />,
|
||||
}
|
||||
|
||||
export default function DeviceGrid({ devices, viewMode = 'grid', onAction }: DeviceGridProps) {
|
||||
if (viewMode === 'list') {
|
||||
return <DeviceList devices={devices} onAction={onAction} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{devices.map((device) => (
|
||||
<DeviceCard key={device.id} device={device} onAction={onAction} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceCard({
|
||||
device,
|
||||
onAction,
|
||||
}: {
|
||||
device: Device
|
||||
onAction?: (deviceId: string, action: string) => void
|
||||
}) {
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
const getDeviceUrl = () => {
|
||||
const type = device.tipo
|
||||
if (['PC', 'LAPTOP', 'SERVIDOR'].includes(type)) return `/equipos/${device.id}`
|
||||
if (['CELULAR', 'TABLET'].includes(type)) return `/celulares/${device.id}`
|
||||
return `/red/${device.id}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'card p-4 transition-all hover:border-primary-500/50 relative group',
|
||||
device.estado === 'ALERTA' && 'border-danger/50'
|
||||
)}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'status-dot',
|
||||
device.estado === 'ONLINE' && 'status-dot-online',
|
||||
device.estado === 'OFFLINE' && 'status-dot-offline',
|
||||
device.estado === 'ALERTA' && 'status-dot-alert',
|
||||
device.estado === 'MANTENIMIENTO' && 'status-dot-maintenance'
|
||||
)}
|
||||
/>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-1 rounded hover:bg-dark-100 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
|
||||
<div className="dropdown right-0 z-50">
|
||||
{['PC', 'LAPTOP', 'SERVIDOR'].includes(device.tipo) && device.estado === 'ONLINE' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'desktop')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Escritorio remoto
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'terminal')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Terminal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'files')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
Archivos
|
||||
</button>
|
||||
<div className="h-px bg-dark-100 my-1" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'restart')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2 text-warning"
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
Reiniciar
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon and name */}
|
||||
<Link href={getDeviceUrl()} className="block">
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div className={cn('p-3 rounded-lg', getStatusBgColor(device.estado))}>
|
||||
<span className={getStatusColor(device.estado)}>
|
||||
{deviceIcons[device.tipo] || deviceIcons.OTRO}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium truncate">{device.nombre}</h3>
|
||||
<p className="text-xs text-gray-500">{device.tipo}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-2 text-sm">
|
||||
{device.ip && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">IP</span>
|
||||
<span className="font-mono text-gray-300">{device.ip}</span>
|
||||
</div>
|
||||
)}
|
||||
{device.sistemaOperativo && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">OS</span>
|
||||
<span className="text-gray-300 truncate ml-2">{device.sistemaOperativo}</span>
|
||||
</div>
|
||||
)}
|
||||
{device.lastSeen && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Visto</span>
|
||||
<span className="text-gray-400">{formatRelativeTime(device.lastSeen)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metrics bar */}
|
||||
{device.estado === 'ONLINE' && (device.cpuUsage !== null || device.ramUsage !== null) && (
|
||||
<div className="mt-3 pt-3 border-t border-dark-100 grid grid-cols-2 gap-2">
|
||||
{device.cpuUsage !== null && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-gray-500">CPU</span>
|
||||
<span className={cn(device.cpuUsage > 80 ? 'text-danger' : 'text-gray-400')}>
|
||||
{Math.round(device.cpuUsage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-dark-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
device.cpuUsage > 80 ? 'bg-danger' : device.cpuUsage > 60 ? 'bg-warning' : 'bg-success'
|
||||
)}
|
||||
style={{ width: `${device.cpuUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{device.ramUsage !== null && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-gray-500">RAM</span>
|
||||
<span className={cn(device.ramUsage > 80 ? 'text-danger' : 'text-gray-400')}>
|
||||
{Math.round(device.ramUsage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-dark-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
device.ramUsage > 80 ? 'bg-danger' : device.ramUsage > 60 ? 'bg-warning' : 'bg-success'
|
||||
)}
|
||||
style={{ width: `${device.ramUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceList({
|
||||
devices,
|
||||
onAction,
|
||||
}: {
|
||||
devices: Device[]
|
||||
onAction?: (deviceId: string, action: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dispositivo</th>
|
||||
<th>Tipo</th>
|
||||
<th>IP</th>
|
||||
<th>Estado</th>
|
||||
<th>CPU</th>
|
||||
<th>RAM</th>
|
||||
<th>Ultimo contacto</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((device) => (
|
||||
<tr key={device.id}>
|
||||
<td>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={getStatusColor(device.estado)}>
|
||||
{deviceIcons[device.tipo] || deviceIcons.OTRO}
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-medium">{device.nombre}</div>
|
||||
{device.cliente && (
|
||||
<div className="text-xs text-gray-500">{device.cliente.nombre}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-gray-400">{device.tipo}</td>
|
||||
<td className="font-mono text-gray-400">{device.ip || '-'}</td>
|
||||
<td>
|
||||
<span
|
||||
className={cn(
|
||||
'badge',
|
||||
device.estado === 'ONLINE' && 'badge-success',
|
||||
device.estado === 'OFFLINE' && 'badge-gray',
|
||||
device.estado === 'ALERTA' && 'badge-danger',
|
||||
device.estado === 'MANTENIMIENTO' && 'badge-warning'
|
||||
)}
|
||||
>
|
||||
{device.estado}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{device.cpuUsage !== null ? (
|
||||
<span className={cn(device.cpuUsage > 80 ? 'text-danger' : 'text-gray-400')}>
|
||||
{Math.round(device.cpuUsage)}%
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{device.ramUsage !== null ? (
|
||||
<span className={cn(device.ramUsage > 80 ? 'text-danger' : 'text-gray-400')}>
|
||||
{Math.round(device.ramUsage)}%
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="text-gray-500">
|
||||
{device.lastSeen ? formatRelativeTime(device.lastSeen) : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
href={`/equipos/${device.id}`}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
Ver
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
src/components/dashboard/KPICards.tsx
Normal file
92
src/components/dashboard/KPICards.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { Monitor, Smartphone, Network, AlertTriangle, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface KPICardsProps {
|
||||
stats: {
|
||||
totalDispositivos: number
|
||||
dispositivosOnline: number
|
||||
dispositivosOffline: number
|
||||
dispositivosAlerta: number
|
||||
alertasActivas: number
|
||||
alertasCriticas: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function KPICards({ stats }: KPICardsProps) {
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total Dispositivos',
|
||||
value: stats.totalDispositivos,
|
||||
icon: <Monitor className="w-6 h-6" />,
|
||||
color: 'text-primary-400',
|
||||
bgColor: 'bg-primary-900/30',
|
||||
},
|
||||
{
|
||||
title: 'En Linea',
|
||||
value: stats.dispositivosOnline,
|
||||
icon: <CheckCircle className="w-6 h-6" />,
|
||||
color: 'text-success',
|
||||
bgColor: 'bg-success/20',
|
||||
percentage: stats.totalDispositivos > 0
|
||||
? Math.round((stats.dispositivosOnline / stats.totalDispositivos) * 100)
|
||||
: 0,
|
||||
},
|
||||
{
|
||||
title: 'Fuera de Linea',
|
||||
value: stats.dispositivosOffline,
|
||||
icon: <XCircle className="w-6 h-6" />,
|
||||
color: 'text-gray-400',
|
||||
bgColor: 'bg-gray-500/20',
|
||||
},
|
||||
{
|
||||
title: 'Con Alertas',
|
||||
value: stats.dispositivosAlerta,
|
||||
icon: <AlertTriangle className="w-6 h-6" />,
|
||||
color: 'text-warning',
|
||||
bgColor: 'bg-warning/20',
|
||||
},
|
||||
{
|
||||
title: 'Alertas Activas',
|
||||
value: stats.alertasActivas,
|
||||
icon: <AlertTriangle className="w-6 h-6" />,
|
||||
color: 'text-danger',
|
||||
bgColor: 'bg-danger/20',
|
||||
highlight: stats.alertasCriticas > 0,
|
||||
subtitle: stats.alertasCriticas > 0
|
||||
? `${stats.alertasCriticas} criticas`
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{cards.map((card, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'card p-4 transition-all hover:scale-[1.02]',
|
||||
card.highlight && 'border-danger glow-border animate-pulse'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{card.title}</p>
|
||||
<p className="text-3xl font-bold mt-1">{card.value}</p>
|
||||
{card.subtitle && (
|
||||
<p className="text-xs text-danger mt-1">{card.subtitle}</p>
|
||||
)}
|
||||
{card.percentage !== undefined && (
|
||||
<p className="text-xs text-gray-500 mt-1">{card.percentage}% del total</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn('p-3 rounded-lg', card.bgColor)}>
|
||||
<span className={card.color}>{card.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
135
src/components/layout/ClientSelector.tsx
Normal file
135
src/components/layout/ClientSelector.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Building2, ChevronDown, Check, Search } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Client {
|
||||
id: string
|
||||
nombre: string
|
||||
codigo: string
|
||||
}
|
||||
|
||||
interface ClientSelectorProps {
|
||||
clients?: Client[]
|
||||
selectedId?: string | null
|
||||
onChange?: (clientId: string | null) => void
|
||||
showAll?: boolean
|
||||
}
|
||||
|
||||
export default function ClientSelector({
|
||||
clients = [],
|
||||
selectedId = null,
|
||||
onChange,
|
||||
showAll = true,
|
||||
}: ClientSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const selectedClient = selectedId
|
||||
? clients.find((c) => c.id === selectedId)
|
||||
: null
|
||||
|
||||
const filteredClients = clients.filter(
|
||||
(c) =>
|
||||
c.nombre.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.codigo.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
const handleSelect = (id: string | null) => {
|
||||
onChange?.(id)
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-dark-300 border border-dark-100 rounded-lg hover:border-primary-500 transition-colors min-w-[200px]"
|
||||
>
|
||||
<Building2 className="w-4 h-4 text-gray-500" />
|
||||
<span className="flex-1 text-left text-sm">
|
||||
{selectedClient ? selectedClient.nombre : 'Todos los clientes'}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-4 h-4 text-gray-500 transition-transform',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
}}
|
||||
/>
|
||||
<div className="absolute left-0 mt-2 w-72 bg-dark-200 border border-dark-100 rounded-lg shadow-lg z-50">
|
||||
{/* Search */}
|
||||
<div className="p-2 border-b border-dark-100">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar cliente..."
|
||||
className="input py-1.5 pl-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="max-h-60 overflow-y-auto p-1">
|
||||
{showAll && (
|
||||
<button
|
||||
onClick={() => handleSelect(null)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-dark-100 transition-colors',
|
||||
!selectedId && 'bg-primary-900/50 text-primary-400'
|
||||
)}
|
||||
>
|
||||
<Building2 className="w-4 h-4" />
|
||||
<span className="flex-1 text-left">Todos los clientes</span>
|
||||
{!selectedId && <Check className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{filteredClients.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-gray-500 text-sm">
|
||||
No se encontraron clientes
|
||||
</div>
|
||||
) : (
|
||||
filteredClients.map((client) => (
|
||||
<button
|
||||
key={client.id}
|
||||
onClick={() => handleSelect(client.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-dark-100 transition-colors',
|
||||
selectedId === client.id && 'bg-primary-900/50 text-primary-400'
|
||||
)}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-dark-100 flex items-center justify-center text-xs font-medium text-gray-400">
|
||||
{client.codigo.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium">{client.nombre}</div>
|
||||
<div className="text-xs text-gray-500">{client.codigo}</div>
|
||||
</div>
|
||||
{selectedId === client.id && <Check className="w-4 h-4" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
173
src/components/layout/Header.tsx
Normal file
173
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Bell, Search, User, LogOut, Settings, ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import ClientSelector from './ClientSelector'
|
||||
|
||||
interface HeaderProps {
|
||||
user?: {
|
||||
nombre: string
|
||||
email: string
|
||||
avatar?: string
|
||||
rol: string
|
||||
}
|
||||
onLogout?: () => void
|
||||
}
|
||||
|
||||
export default function Header({ user, onLogout }: HeaderProps) {
|
||||
const [showUserMenu, setShowUserMenu] = useState(false)
|
||||
const [showNotifications, setShowNotifications] = useState(false)
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-dark-400 border-b border-dark-100 flex items-center justify-between px-6">
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="relative w-96">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar dispositivos, clientes..."
|
||||
className="input pl-10 bg-dark-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Client Selector */}
|
||||
<ClientSelector />
|
||||
</div>
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Notifications */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
className="relative p-2 rounded-lg hover:bg-dark-100 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-danger rounded-full" />
|
||||
</button>
|
||||
|
||||
{showNotifications && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setShowNotifications(false)}
|
||||
/>
|
||||
<div className="dropdown w-80 right-0 z-50">
|
||||
<div className="px-4 py-3 border-b border-dark-100">
|
||||
<h3 className="font-medium">Notificaciones</h3>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<NotificationItem
|
||||
type="critical"
|
||||
title="Servidor principal offline"
|
||||
message="El servidor SRV-01 no responde"
|
||||
time="hace 5 min"
|
||||
/>
|
||||
<NotificationItem
|
||||
type="warning"
|
||||
title="CPU alta en PC-ADMIN"
|
||||
message="Uso de CPU al 95%"
|
||||
time="hace 15 min"
|
||||
/>
|
||||
<NotificationItem
|
||||
type="info"
|
||||
title="Backup completado"
|
||||
message="Backup diario finalizado"
|
||||
time="hace 1 hora"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t border-dark-100">
|
||||
<a href="/alertas" className="text-primary-500 text-sm hover:underline">
|
||||
Ver todas las alertas
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-dark-100 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white font-medium">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt="" className="w-full h-full rounded-full object-cover" />
|
||||
) : (
|
||||
user?.nombre?.charAt(0).toUpperCase() || 'U'
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left hidden sm:block">
|
||||
<div className="text-sm font-medium text-gray-200">{user?.nombre || 'Usuario'}</div>
|
||||
<div className="text-xs text-gray-500">{user?.rol || 'Rol'}</div>
|
||||
</div>
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
/>
|
||||
<div className="dropdown z-50">
|
||||
<div className="px-4 py-3 border-b border-dark-100">
|
||||
<div className="text-sm font-medium">{user?.nombre}</div>
|
||||
<div className="text-xs text-gray-500">{user?.email}</div>
|
||||
</div>
|
||||
<a href="/perfil" className="dropdown-item flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
Mi perfil
|
||||
</a>
|
||||
<a href="/configuracion" className="dropdown-item flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Configuracion
|
||||
</a>
|
||||
<div className="h-px bg-dark-100 my-1" />
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="dropdown-item flex items-center gap-2 w-full text-left text-danger"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Cerrar sesion
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
interface NotificationItemProps {
|
||||
type: 'critical' | 'warning' | 'info'
|
||||
title: string
|
||||
message: string
|
||||
time: string
|
||||
}
|
||||
|
||||
function NotificationItem({ type, title, message, time }: NotificationItemProps) {
|
||||
const colors = {
|
||||
critical: 'bg-danger/20 border-danger',
|
||||
warning: 'bg-warning/20 border-warning',
|
||||
info: 'bg-info/20 border-info',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'px-4 py-3 border-l-4 hover:bg-dark-100 cursor-pointer transition-colors',
|
||||
colors[type]
|
||||
)}
|
||||
>
|
||||
<div className="font-medium text-sm">{title}</div>
|
||||
<div className="text-xs text-gray-400">{message}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{time}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
182
src/components/layout/Sidebar.tsx
Normal file
182
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Network,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Settings,
|
||||
Users,
|
||||
Building2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Activity,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
badge?: number
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
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[] = [
|
||||
{
|
||||
label: 'Clientes',
|
||||
href: '/clientes',
|
||||
icon: <Building2 className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Usuarios',
|
||||
href: '/usuarios',
|
||||
icon: <Users className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Configuracion',
|
||||
href: '/configuracion',
|
||||
icon: <Settings className="w-5 h-5" />,
|
||||
},
|
||||
]
|
||||
|
||||
interface SidebarProps {
|
||||
alertasActivas?: number
|
||||
}
|
||||
|
||||
export default function Sidebar({ alertasActivas = 0 }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const pathname = usePathname()
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/') return pathname === '/'
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
|
||||
const items = navItems.map((item) => ({
|
||||
...item,
|
||||
badge: item.href === '/alertas' ? alertasActivas : undefined,
|
||||
}))
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'h-screen bg-dark-400 border-r border-dark-100 flex flex-col transition-all duration-300',
|
||||
collapsed ? 'w-16' : 'w-64'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center justify-between px-4 border-b border-dark-100">
|
||||
{!collapsed && (
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Activity className="w-8 h-8 text-primary-500" />
|
||||
<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 */}
|
||||
<nav className="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
|
||||
{items.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}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<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>
|
||||
))}
|
||||
|
||||
{/* Separador */}
|
||||
<div className="h-px bg-dark-100 my-4" />
|
||||
|
||||
{/* 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}
|
||||
{!collapsed && <span className="flex-1">{item.label}</span>}
|
||||
</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>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user