Compare commits

...

3 Commits

Author SHA1 Message Date
d999cf6298 Devices functions 2026-02-19 13:12:02 -06:00
bd9bffb57c Dashboard design improved 2026-02-18 10:01:24 -06:00
4235f640d9 Almost all sections with mock data 2026-02-16 14:41:01 -06:00
60 changed files with 4633 additions and 345 deletions

View File

@@ -0,0 +1,147 @@
'use client'
import { useState, useMemo, useCallback, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import { trpc } from '@/lib/trpc-client'
import FileExplorerContainer from '@/components/files/FileExplorerContainer'
import type { FileItem } from '@/components/files/FileRow'
const MOCK_FILES_BY_PATH: Record<string, FileItem[]> = {
'C:\\': [
{ id: '1', name: 'Documents', type: 'folder', size: null, modifiedAt: '2026-02-15' },
{ id: '2', name: 'Windows', type: 'folder', size: null, modifiedAt: '2026-02-10' },
{ id: '3', name: 'Users', type: 'folder', size: null, modifiedAt: '2026-02-14' },
{ id: '4', name: 'archivo.txt', type: 'file', size: '2.4 KB', modifiedAt: '2026-02-12' },
{ id: '5', name: 'config.ini', type: 'file', size: '1.1 KB', modifiedAt: '2026-02-11' },
],
'C:\\Documents': [
{ id: 'd1', name: '..', type: 'folder', size: null, modifiedAt: '—' },
{ id: 'd2', name: 'Subcarpeta', type: 'folder', size: null, modifiedAt: '2026-02-14' },
{ id: 'd3', name: 'readme.txt', type: 'file', size: '512 B', modifiedAt: '2026-02-13' },
{ id: 'd4', name: 'reporte.pdf', type: 'file', size: '245 KB', modifiedAt: '2026-02-15' },
],
'C:\\Windows': [
{ id: 'w1', name: '..', type: 'folder', size: null, modifiedAt: '—' },
{ id: 'w2', name: 'System32', type: 'folder', size: null, modifiedAt: '2026-02-10' },
{ id: 'w3', name: 'Temp', type: 'folder', size: null, modifiedAt: '2026-02-15' },
],
'C:\\Users': [
{ id: 'u1', name: '..', type: 'folder', size: null, modifiedAt: '—' },
{ id: 'u2', name: 'Public', type: 'folder', size: null, modifiedAt: '2026-02-14' },
{ id: 'u3', name: 'Admin', type: 'folder', size: null, modifiedAt: '2026-02-15' },
],
'C:\\Documents\\Subcarpeta': [
{ id: 's1', name: '..', type: 'folder', size: null, modifiedAt: '—' },
{ id: 's2', name: 'datos.xlsx', type: 'file', size: '18 KB', modifiedAt: '2026-02-15' },
],
}
function getFilesForPath(path: string): FileItem[] {
const normalized = path.replace(/\/$/, '').replace(/\//g, '\\')
return MOCK_FILES_BY_PATH[normalized] ?? [{
id: 'back',
name: '..',
type: 'folder',
size: null,
modifiedAt: '—',
}]
}
export default function FileExplorerPage() {
const { selectedClientId } = useSelectedClient()
const clienteId = selectedClientId ?? undefined
const searchParams = useSearchParams()
const deviceIdFromUrl = searchParams.get('deviceId')
const listQuery = trpc.equipos.list.useQuery(
{ clienteId, limit: 100 },
{ refetchOnWindowFocus: false }
)
const devices = useMemo(
() => (listQuery.data?.dispositivos ?? []).map((d) => ({ id: d.id, nombre: d.nombre })),
[listQuery.data]
)
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('')
const [currentPath, setCurrentPath] = useState('C:\\')
useEffect(() => {
if (!deviceIdFromUrl || !listQuery.data?.dispositivos?.length) return
const exists = listQuery.data.dispositivos.some((d) => d.id === deviceIdFromUrl)
if (exists) {
setSelectedDeviceId(deviceIdFromUrl)
setCurrentPath('C:\\')
}
}, [deviceIdFromUrl, listQuery.data?.dispositivos])
const selectedDevice = selectedDeviceId
? devices.find((d) => d.id === selectedDeviceId)
: null
const selectedDeviceName = selectedDevice?.nombre ?? null
const files = useMemo(() => getFilesForPath(currentPath), [currentPath])
const handleFolderClick = useCallback((name: string) => {
setCurrentPath((prev) => {
if (name === '..') {
const parts = prev.replace(/\\$/, '').split('\\')
parts.pop()
return parts.length > 1 ? parts.join('\\') + '\\' : 'C:\\'
}
const sep = prev.endsWith('\\') ? '' : '\\'
return `${prev}${sep}${name}`
})
}, [])
const handleRefresh = useCallback(() => {
// Mock: no-op; in real impl would refetch file list
}, [])
const handleUpload = useCallback(() => {
// Mock: no-op; in real impl would open file picker / upload
}, [])
return (
<div className="space-y-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">
Explorador de Archivos
</h1>
<p className="mt-1 text-gray-400">
Navega y transfiere archivos remotamente
</p>
</div>
<div className="shrink-0">
<select
value={selectedDeviceId}
onChange={(e) => {
setSelectedDeviceId(e.target.value)
setCurrentPath('C:\\')
}}
className="w-64 rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
>
<option value="">-- Seleccionar dispositivo --</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.nombre}
</option>
))}
</select>
</div>
</header>
<FileExplorerContainer
selectedDeviceName={selectedDeviceName}
currentPath={currentPath}
files={files}
onFolderClick={handleFolderClick}
onRefresh={handleRefresh}
onUpload={handleUpload}
/>
</div>
)
}

View File

@@ -1,11 +1,14 @@
'use client'
import { useState, useMemo } from 'react'
import { Search } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Search, Plus } from 'lucide-react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import { trpc } from '@/lib/trpc-client'
import { formatRelativeTime } from '@/lib/utils'
import DeviceCard, { type DeviceCardStatus } from '@/components/devices/DeviceCard'
import AddDeviceModal from '@/components/devices/AddDeviceModal'
import DeviceDetailModal from '@/components/devices/device-detail/DeviceDetailModal'
type StateFilter = '' | 'ONLINE' | 'OFFLINE' | 'ALERTA' | 'MANTENIMIENTO' | 'DESCONOCIDO'
@@ -61,6 +64,12 @@ export default function DevicesPage() {
const [search, setSearch] = useState('')
const [stateFilter, setStateFilter] = useState<StateFilter>('')
const [osFilter, setOsFilter] = useState('')
const [addModalOpen, setAddModalOpen] = useState(false)
const [detailDeviceId, setDetailDeviceId] = useState<string | null>(null)
const [detailDeviceName, setDetailDeviceName] = useState<string>('')
const [connectError, setConnectError] = useState<string | null>(null)
const router = useRouter()
const utils = trpc.useUtils()
const listQuery = trpc.equipos.list.useQuery(
{
@@ -86,23 +95,81 @@ export default function DevicesPage() {
}))
}, [listQuery.data])
const openDetail = (id: string, name: string) => {
setDetailDeviceId(id)
setDetailDeviceName(name)
}
const [connectingId, setConnectingId] = useState<string | null>(null)
const iniciarSesionMutation = trpc.equipos.iniciarSesion.useMutation({
onSuccess: (data) => {
setConnectError(null)
setConnectingId(null)
utils.sesiones.list.invalidate()
utils.clientes.dashboardStats.invalidate()
if (data.url) window.open(data.url, '_blank', 'noopener,noreferrer')
},
onError: (err) => {
setConnectError(err.message)
setConnectingId(null)
},
})
const handleConnect = (id: string) => {
console.log('Conectar', id)
setConnectError(null)
setConnectingId(id)
iniciarSesionMutation.mutate({ dispositivoId: id, tipo: 'desktop' })
}
const handleFiles = (id: string) => {
console.log('Archivos', id)
router.push(`/archivos?deviceId=${encodeURIComponent(id)}`)
}
const handleTerminal = (id: string) => {
console.log('Terminal', id)
router.push(`/terminal?deviceId=${encodeURIComponent(id)}`)
}
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white sm:text-3xl">Dispositivos</h1>
<p className="mt-1 text-gray-400">Administración de equipos conectados</p>
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">Dispositivos</h1>
<p className="mt-1 text-gray-400">Administración de equipos conectados</p>
</div>
<button
type="button"
onClick={() => setAddModalOpen(true)}
className="btn btn-primary inline-flex items-center gap-2 shrink-0"
>
<Plus className="w-4 h-4" />
Agregar Dispositivo
</button>
</header>
{connectError && (
<div className="rounded-lg bg-red-500/20 border border-red-500/40 px-4 py-3 text-sm text-red-400 flex items-center justify-between gap-2">
<span>{connectError}</span>
<button type="button" onClick={() => setConnectError(null)} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
<AddDeviceModal
open={addModalOpen}
onClose={() => setAddModalOpen(false)}
clienteId={clienteId}
onSuccess={() => utils.equipos.list.invalidate()}
/>
<DeviceDetailModal
open={!!detailDeviceId}
onClose={() => setDetailDeviceId(null)}
deviceId={detailDeviceId}
deviceName={detailDeviceName}
onConnect={handleConnect}
onTerminal={handleTerminal}
onFiles={handleFiles}
/>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
@@ -166,6 +233,8 @@ export default function DevicesPage() {
onConectar={handleConnect}
onArchivos={handleFiles}
onTerminal={handleTerminal}
onInfo={(id, name) => openDetail(id, name ?? device.name)}
isConnecting={connectingId === device.id}
/>
))}
</div>

View File

@@ -0,0 +1,73 @@
'use client'
import { useState, useMemo } from 'react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import HeadwindMetricCard from '@/components/headwind/HeadwindMetricCard'
import MobileDeviceList from '@/components/headwind/MobileDeviceList'
import CorporateAppsList from '@/components/headwind/CorporateAppsList'
import {
getMdmDashboardData,
type Device,
type AppDeployment,
type DashboardStats,
} from '@/mocks/mdmDashboardData'
export default function HeadwindPage() {
useSelectedClient()
const initial = useMemo(() => getMdmDashboardData(), [])
const [stats] = useState<DashboardStats>(initial.stats)
const [devices] = useState<Device[]>(initial.devices)
const [appDeployments] = useState<AppDeployment[]>(initial.appDeployments)
return (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-bold text-white">
Headwind MDM
</h1>
<p className="mt-1 text-gray-400">
Gestión de dispositivos móviles Android
</p>
</header>
<section
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
aria-label="Resumen"
>
<HeadwindMetricCard
label="Dispositivos Android"
value={stats.totalAndroidDevices}
subtitle="Total registrados"
accent="green"
/>
<HeadwindMetricCard
label="Apps desplegadas"
value={stats.deployedApps}
subtitle="En catálogo"
accent="blue"
/>
<HeadwindMetricCard
label="Políticas activas"
value={stats.activePolicies}
subtitle="Configuraciones aplicadas"
accent="cyan"
/>
<HeadwindMetricCard
label="Batería promedio"
value={`${stats.averageBatteryPercent}%`}
subtitle="Estado actual"
accent="amber"
/>
</section>
<section
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
aria-label="Dispositivos y apps"
>
<MobileDeviceList devices={devices} />
<CorporateAppsList apps={appDeployments} />
</section>
</div>
)
}

View File

@@ -94,6 +94,12 @@ function DashboardContentInner({
)
const devicesCount = devicesCountQuery.data?.pagination?.total ?? 0
const sessionsCountQuery = trpc.sesiones.count.useQuery(
{ clienteId },
{ refetchOnWindowFocus: true, staleTime: 15 * 1000 }
)
const sessionsCount = sessionsCountQuery.data ?? 0
const clientsQuery = trpc.clientes.list.useQuery(
{ limit: 100 },
{ staleTime: 60 * 1000 }
@@ -111,6 +117,7 @@ function DashboardContentInner({
<Sidebar
activeAlertsCount={activeAlertsCount}
devicesCount={devicesCount}
sessionsCount={sessionsCount}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>

View File

@@ -0,0 +1,105 @@
'use client'
import { useState } from 'react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import LibrenmsMetricCard from '@/components/librenms/LibrenmsMetricCard'
import DeviceList from '@/components/librenms/DeviceList'
import AlertList from '@/components/librenms/AlertList'
import type { NetworkDevice } from '@/components/librenms/DeviceRow'
import type { SnmpAlert } from '@/components/librenms/AlertItem'
const MOCK_DEVICES: NetworkDevice[] = [
{ id: '1', name: 'Core Switch', model: 'Cisco 3850', status: 'online' },
{ id: '2', name: 'Router Principal', model: 'MikroTik CCR', status: 'online' },
{ id: '3', name: 'AP-Oficina-01', model: 'Ubiquiti UAP', status: 'online' },
{ id: '4', name: 'Switch-Piso2', model: 'HP ProCurve', status: 'warning' },
{ id: '5', name: 'Firewall', model: 'pfSense', status: 'online' },
]
const MOCK_ALERTS: SnmpAlert[] = [
{
id: '1',
title: 'CPU Alto Core Switch',
detail: 'Hace 15 min 92% utilización',
severity: 'critical',
},
{
id: '2',
title: 'Puerto Down Switch-Piso2',
detail: 'Hace 1h GigabitEthernet0/12',
severity: 'warning',
},
{
id: '3',
title: 'Alto tráfico WAN',
detail: 'Hace 2h 95% capacidad',
severity: 'critical',
},
]
export default function LibrenmsPage() {
useSelectedClient()
const [devices] = useState<NetworkDevice[]>(MOCK_DEVICES)
const [alerts] = useState<SnmpAlert[]>(MOCK_ALERTS)
const [metrics] = useState({
totalDevices: 28,
uptime: 99.7,
activeAlerts: 3,
trafficToday: 847,
})
return (
<div className="space-y-6">
{/* Page header */}
<header>
<h1 className="text-3xl font-bold text-white">
LibreNMS - Monitoreo de Red
</h1>
<p className="mt-1 text-gray-400">
SNMP, NetFlow y alertas de infraestructura
</p>
</header>
{/* KPI metric cards */}
<section
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
aria-label="Métricas principales"
>
<LibrenmsMetricCard
label="Dispositivos Red"
value={metrics.totalDevices}
subtitle="Switches, routers, APs"
accent="green"
/>
<LibrenmsMetricCard
label="Uptime Promedio"
value={`${metrics.uptime}%`}
subtitle="Últimos 30 días"
accent="cyan"
/>
<LibrenmsMetricCard
label="Alertas Activas"
value={metrics.activeAlerts}
subtitle="2 críticas, 1 warning"
accent="yellow"
/>
<LibrenmsMetricCard
label="Tráfico Total"
value={`${metrics.trafficToday} GB`}
subtitle="NetFlow hoy"
accent="blue"
/>
</section>
{/* Main content grid */}
<section
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
aria-label="Dispositivos y alertas"
>
<DeviceList devices={devices} />
<AlertList alerts={alerts} />
</section>
</div>
)
}

View File

@@ -1,178 +1,29 @@
'use client'
import { useState, useMemo } from 'react'
import { RefreshCw, Grid, List, Filter } from 'lucide-react'
import KPICards from '@/components/dashboard/KPICards'
import DeviceGrid from '@/components/dashboard/DeviceGrid'
import AlertsFeed from '@/components/dashboard/AlertsFeed'
import { useMemo } from 'react'
import Link from 'next/link'
import { Monitor, CheckCircle, XCircle, AlertTriangle, Plus } from 'lucide-react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import { cn } from '@/lib/utils'
import { trpc } from '@/lib/trpc-client'
import SummaryCard from '@/components/dashboard/SummaryCard'
import DeviceStatusChart from '@/components/dashboard/DeviceStatusChart'
import RecentActivityList from '@/components/dashboard/RecentActivityList'
import SystemHealthCard from '@/components/dashboard/SystemHealthCard'
import QuickConnectionCard from '@/components/dashboard/QuickConnectionCard'
import {
MOCK_DASHBOARD_SECONDARY,
MOCK_SYSTEM_HEALTH,
MOCK_QUICK_CONNECTIONS,
} from '@/mocks/dashboardData'
import type {
QuickConnectionItem,
QuickConnectionStatus,
RecentActivityItem,
} from '@/mocks/dashboardData'
type DeviceForGrid = {
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 }
}
type DashboardAlert = {
id: string
severidad: 'INFO' | 'WARNING' | 'CRITICAL'
estado: 'ACTIVA' | 'RECONOCIDA' | 'RESUELTA'
titulo: string
mensaje: string
createdAt: Date
dispositivo: { nombre: string }
cliente: { nombre: string }
}
// Mock data - en produccion vendria de la API
const mockStats = {
totalDispositivos: 127,
dispositivosOnline: 98,
dispositivosOffline: 24,
dispositivosAlerta: 5,
alertasActivas: 8,
alertasCriticas: 2,
sesionesActivas: 3,
}
const mockDevices = [
{
id: '1',
nombre: 'SRV-PRINCIPAL',
tipo: 'SERVIDOR',
estado: 'ONLINE',
ip: '192.168.1.10',
sistemaOperativo: 'Windows Server 2022',
lastSeen: new Date(),
cpuUsage: 45,
ramUsage: 72,
},
{
id: '2',
nombre: 'PC-ADMIN-01',
tipo: 'PC',
estado: 'ONLINE',
ip: '192.168.1.101',
sistemaOperativo: 'Windows 11 Pro',
lastSeen: new Date(),
cpuUsage: 23,
ramUsage: 56,
},
{
id: '3',
nombre: 'LAPTOP-VENTAS',
tipo: 'LAPTOP',
estado: 'ALERTA',
ip: '192.168.1.105',
sistemaOperativo: 'Windows 11 Pro',
lastSeen: new Date(Date.now() - 1000 * 60 * 5),
cpuUsage: 95,
ramUsage: 88,
},
{
id: '4',
nombre: 'ROUTER-PRINCIPAL',
tipo: 'ROUTER',
estado: 'ONLINE',
ip: '192.168.1.1',
sistemaOperativo: 'RouterOS 7.12',
lastSeen: new Date(),
cpuUsage: null,
ramUsage: null,
},
{
id: '5',
nombre: 'SW-CORE-01',
tipo: 'SWITCH',
estado: 'ONLINE',
ip: '192.168.1.2',
sistemaOperativo: 'Cisco IOS',
lastSeen: new Date(),
cpuUsage: null,
ramUsage: null,
},
{
id: '6',
nombre: 'CELULAR-GERENTE',
tipo: 'CELULAR',
estado: 'ONLINE',
ip: null,
sistemaOperativo: 'Android 14',
lastSeen: new Date(),
cpuUsage: null,
ramUsage: null,
},
{
id: '7',
nombre: 'SRV-BACKUP',
tipo: 'SERVIDOR',
estado: 'OFFLINE',
ip: '192.168.1.11',
sistemaOperativo: 'Ubuntu 22.04',
lastSeen: new Date(Date.now() - 1000 * 60 * 60 * 2),
cpuUsage: null,
ramUsage: null,
},
{
id: '8',
nombre: 'AP-OFICINA-01',
tipo: 'AP',
estado: 'ONLINE',
ip: '192.168.1.50',
sistemaOperativo: 'UniFi AP',
lastSeen: new Date(),
cpuUsage: null,
ramUsage: null,
},
]
const mockAlerts = [
{
id: '1',
severidad: 'CRITICAL' as const,
estado: 'ACTIVA' as const,
titulo: 'Servidor de backup offline',
mensaje: 'El servidor SRV-BACKUP no responde desde hace 2 horas',
createdAt: new Date(Date.now() - 1000 * 60 * 120),
dispositivo: { nombre: 'SRV-BACKUP' },
cliente: { nombre: 'Cliente A' },
},
{
id: '2',
severidad: 'WARNING' as const,
estado: 'ACTIVA' as const,
titulo: 'CPU alta',
mensaje: 'Uso de CPU al 95% en LAPTOP-VENTAS',
createdAt: new Date(Date.now() - 1000 * 60 * 15),
dispositivo: { nombre: 'LAPTOP-VENTAS' },
cliente: { nombre: 'Cliente A' },
},
{
id: '3',
severidad: 'INFO' as const,
estado: 'RECONOCIDA' as const,
titulo: 'Actualizacion disponible',
mensaje: 'Windows Update pendiente en PC-ADMIN-01',
createdAt: new Date(Date.now() - 1000 * 60 * 60),
dispositivo: { nombre: 'PC-ADMIN-01' },
cliente: { nombre: 'Cliente A' },
},
]
const DEVICES_LIMIT = 12
const DEVICES_LIMIT = 8
export default function DashboardPage() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const utils = trpc.useUtils()
const { selectedClientId } = useSelectedClient()
const clienteId = selectedClientId ?? undefined
@@ -180,38 +31,6 @@ export default function DashboardPage() {
{ clienteId },
{ refetchOnWindowFocus: false }
)
const stats = statsQuery.data ?? mockStats
const alertsQuery = trpc.alertas.list.useQuery(
{ page: 1, limit: 25, clienteId },
{ refetchOnWindowFocus: false }
)
const alerts: DashboardAlert[] = useMemo(() => {
const list = alertsQuery.data?.alertas ?? []
return list.map((a) => ({
id: a.id,
severidad: a.severidad,
estado: a.estado,
titulo: a.titulo,
mensaje: a.mensaje,
createdAt: a.createdAt instanceof Date ? a.createdAt : new Date(a.createdAt),
dispositivo: a.dispositivo ? { nombre: a.dispositivo.nombre } : { nombre: '—' },
cliente: { nombre: a.cliente.nombre },
}))
}, [alertsQuery.data])
const acknowledgeMutation = trpc.alertas.reconocer.useMutation({
onSuccess: () => {
utils.alertas.list.invalidate()
utils.clientes.dashboardStats.invalidate()
},
})
const resolveMutation = trpc.alertas.resolver.useMutation({
onSuccess: () => {
utils.alertas.list.invalidate()
utils.clientes.dashboardStats.invalidate()
},
})
const equiposQuery = trpc.equipos.list.useQuery(
{ page: 1, limit: DEVICES_LIMIT, clienteId },
@@ -226,147 +45,155 @@ export default function DashboardPage() {
{ refetchOnWindowFocus: false }
)
const devices: DeviceForGrid[] = useMemo(() => {
const eq = equiposQuery.data?.dispositivos ?? []
const rd = redQuery.data?.dispositivos ?? []
const cel = celularesQuery.data?.dispositivos ?? []
const all = [...eq, ...rd, ...cel]
return all.map((d) => ({
id: d.id,
nombre: d.nombre,
tipo: d.tipo,
estado: d.estado,
ip: d.ip ?? null,
sistemaOperativo: d.sistemaOperativo ?? null,
lastSeen: d.lastSeen ?? null,
cpuUsage: d.cpuUsage ?? null,
ramUsage: d.ramUsage ?? null,
cliente: d.cliente ? { nombre: d.cliente.nombre } : undefined,
const alertsQuery = trpc.alertas.list.useQuery(
{ page: 1, limit: 15, clienteId },
{ refetchOnWindowFocus: false }
)
const recentActivity: RecentActivityItem[] = useMemo(() => {
const list = alertsQuery.data?.alertas ?? []
return list.map((a) => ({
id: a.id,
type: 'alert' as const,
description: a.titulo,
deviceName: a.dispositivo?.nombre ?? '—',
timestamp: a.createdAt instanceof Date ? a.createdAt : new Date(a.createdAt),
severity: a.severidad,
}))
}, [equiposQuery.data, redQuery.data, celularesQuery.data])
}, [alertsQuery.data])
const devicesLoading =
equiposQuery.isLoading || redQuery.isLoading || celularesQuery.isLoading
const isRefreshing =
statsQuery.isFetching ||
alertsQuery.isFetching ||
equiposQuery.isFetching ||
redQuery.isFetching ||
celularesQuery.isFetching
const handleRefresh = async () => {
await Promise.all([
statsQuery.refetch(),
alertsQuery.refetch(),
equiposQuery.refetch(),
redQuery.refetch(),
celularesQuery.refetch(),
])
const stats = statsQuery.data ?? {
totalDispositivos: 0,
dispositivosOnline: 0,
dispositivosOffline: 0,
dispositivosAlerta: 0,
alertasActivas: 0,
alertasCriticas: 0,
sesionesActivas: 0,
}
const handleDeviceAction = (deviceId: string, action: string) => {
console.log(`Action ${action} on device ${deviceId}`)
// TODO: Implementar acciones
const deviceStatusBreakdown = {
online: stats.dispositivosOnline,
offline: stats.dispositivosOffline,
advertencia: stats.dispositivosAlerta,
}
const handleAcknowledgeAlert = (alertId: string) => {
acknowledgeMutation.mutate({ id: alertId })
}
const allDevices = [
...(equiposQuery.data?.dispositivos ?? []),
...(redQuery.data?.dispositivos ?? []),
...(celularesQuery.data?.dispositivos ?? []),
].slice(0, DEVICES_LIMIT)
const handleResolveAlert = (alertId: string) => {
resolveMutation.mutate({ id: alertId })
}
const quickConnections: QuickConnectionItem[] =
allDevices.length > 0
? allDevices.map((d) => ({
id: d.id,
name: d.nombre,
status: mapEstadoToQuickStatus(d.estado),
}))
: MOCK_QUICK_CONNECTIONS
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-gray-500">Vision general del sistema</p>
<h1 className="text-3xl font-bold text-white">MSP-CAS Dashboard</h1>
<p className="mt-1 text-gray-400">
MeshCentral + LibreNMS + Headwind MDM unificados
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefresh}
className="btn btn-secondary"
disabled={isRefreshing}
<div className="flex shrink-0">
<Link
href="/devices"
className="btn btn-primary inline-flex items-center gap-2"
>
<RefreshCw className={cn('w-4 h-4 mr-2', isRefreshing && 'animate-spin')} />
Actualizar
</button>
<Plus className="w-4 h-4" />
Agregar Dispositivo
</Link>
</div>
</div>
</header>
{/* KPI Cards */}
<KPICards stats={stats} />
<section
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"
aria-label="Resumen"
>
<SummaryCard
title="Total Dispositivos"
value={stats.totalDispositivos}
secondary={MOCK_DASHBOARD_SECONDARY.total}
icon={<Monitor className="w-6 h-6" />}
iconBgClass="bg-primary-900/30"
iconColorClass="text-primary-400"
/>
<SummaryCard
title="En Línea"
value={stats.dispositivosOnline}
secondary={
stats.totalDispositivos > 0
? `${Math.round((stats.dispositivosOnline / stats.totalDispositivos) * 100)}% disponibilidad`
: MOCK_DASHBOARD_SECONDARY.online
}
icon={<CheckCircle className="w-6 h-6" />}
iconBgClass="bg-success/20"
iconColorClass="text-success"
/>
<SummaryCard
title="Fuera de Línea"
value={stats.dispositivosOffline}
secondary={MOCK_DASHBOARD_SECONDARY.offline}
icon={<XCircle className="w-6 h-6" />}
iconBgClass="bg-gray-500/20"
iconColorClass="text-gray-400"
/>
<SummaryCard
title="Advertencias"
value={stats.dispositivosAlerta}
secondary={MOCK_DASHBOARD_SECONDARY.alerta}
icon={<AlertTriangle className="w-6 h-6" />}
iconBgClass="bg-warning/20"
iconColorClass="text-warning"
/>
</section>
{/* Main content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Devices */}
<div className="lg:col-span-2 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Dispositivos</h2>
<div className="flex items-center gap-2">
<button className="btn btn-ghost btn-sm">
<Filter className="w-4 h-4 mr-1" />
Filtrar
</button>
<div className="flex border border-dark-100 rounded-lg overflow-hidden">
<button
onClick={() => setViewMode('grid')}
className={cn(
'p-2 transition-colors',
viewMode === 'grid' ? 'bg-dark-100 text-primary-400' : 'text-gray-500'
)}
>
<Grid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={cn(
'p-2 transition-colors',
viewMode === 'list' ? 'bg-dark-100 text-primary-400' : 'text-gray-500'
)}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
{devicesLoading ? (
<div className="rounded-lg border border-dark-100 bg-dark-400 p-8 text-center text-gray-400">
Cargando dispositivos...
</div>
) : devices.length === 0 ? (
<div className="rounded-lg border border-dark-100 bg-dark-400 p-8 text-center text-gray-400">
No hay dispositivos. Agregue clientes y sincronice con MeshCentral, LibreNMS o Headwind.
</div>
) : (
<DeviceGrid
devices={devices}
viewMode={viewMode}
onAction={handleDeviceAction}
/>
)}
</div>
{/* Alerts */}
<div>
{alertsQuery.isLoading ? (
<div className="card p-8 text-center text-gray-400">
Cargando alertas...
</div>
) : (
<AlertsFeed
alerts={alerts}
onAcknowledge={handleAcknowledgeAlert}
onResolve={handleResolveAlert}
maxItems={10}
/>
)}
</div>
<section className="lg:col-span-2">
<DeviceStatusChart data={deviceStatusBreakdown} />
</section>
<section className="min-h-0">
<RecentActivityList items={recentActivity} isLoading={alertsQuery.isLoading} />
</section>
</div>
<section>
<h2 className="text-lg font-medium text-gray-200 mb-4">Salud del Sistema</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.cpu} />
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.ram} />
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.network} />
</div>
</section>
<section>
<h2 className="text-lg font-medium text-gray-200 mb-4">Conexión Rápida</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{quickConnections.map((item) => (
<QuickConnectionCard
key={item.id}
item={item}
onClick={(id) => {
// TODO: router.push(`/devices?id=${id}`) or open device detail modal
}}
/>
))}
</div>
</section>
</div>
)
}
function mapEstadoToQuickStatus(estado: string): QuickConnectionStatus {
const u = estado?.toUpperCase()
if (u === 'ONLINE') return 'online'
if (u === 'ALERTA') return 'advertencia'
return 'offline'
}

View File

@@ -0,0 +1,221 @@
'use client'
import { useState, useMemo, useEffect, useCallback } from 'react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import { trpc } from '@/lib/trpc-client'
import MetricCard from '@/components/performance/MetricCard'
import ProcessTable from '@/components/performance/ProcessTable'
import type { ProcessItem } from '@/components/performance/ProcessRow'
const CHART_POINTS = 20
const POLL_INTERVAL_MS = 2000
function randomIn(min: number, max: number) {
return Math.round(min + Math.random() * (max - min))
}
function generateMockProcesses(): ProcessItem[] {
const names = [
'chrome.exe',
'Code.exe',
'node.exe',
'System',
'svchost.exe',
'explorer.exe',
'MsMpEng.exe',
'SearchHost.exe',
'RuntimeBroker.exe',
'dllhost.exe',
]
return names.slice(0, 10).map((name, i) => ({
id: `p-${i}`,
name,
pid: 1000 + i * 100 + randomIn(0, 99),
cpu: randomIn(0, 45),
memory: `${randomIn(50, 800)} MB`,
state: i % 3 === 0 ? 'En ejecución' : 'Activo',
}))
}
export default function RendimientoPage() {
const { selectedClientId } = useSelectedClient()
const clienteId = selectedClientId ?? undefined
const listQuery = trpc.equipos.list.useQuery(
{ clienteId, limit: 100 },
{ refetchOnWindowFocus: false }
)
const devices = useMemo(
() => (listQuery.data?.dispositivos ?? []).map((d) => ({ id: d.id, nombre: d.nombre })),
[listQuery.data]
)
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('')
const [metrics, setMetrics] = useState({
cpu: 0,
memory: 0,
disk: 0,
network: 0,
})
const [chartHistory, setChartHistory] = useState<{
cpu: number[]
memory: number[]
disk: number[]
network: number[]
}>({
cpu: [],
memory: [],
disk: [],
network: [],
})
const [processes, setProcesses] = useState<ProcessItem[]>([])
const [loading, setLoading] = useState(false)
const hasDevice = !!selectedDeviceId
const tick = useCallback(() => {
const cpu = randomIn(15, 85)
const memory = randomIn(40, 90)
const disk = randomIn(25, 65)
const network = randomIn(5, 120)
setMetrics({ cpu, memory, disk, network })
setChartHistory((prev) => ({
cpu: [...prev.cpu, cpu].slice(-CHART_POINTS),
memory: [...prev.memory, memory].slice(-CHART_POINTS),
disk: [...prev.disk, disk].slice(-CHART_POINTS),
network: [...prev.network, network].slice(-CHART_POINTS),
}))
setProcesses(generateMockProcesses())
}, [])
useEffect(() => {
if (!hasDevice) {
setMetrics({ cpu: 0, memory: 0, disk: 0, network: 0 })
setChartHistory({ cpu: [], memory: [], disk: [], network: [] })
setProcesses([])
return
}
setLoading(true)
tick()
const t = setTimeout(() => setLoading(false), 400)
const interval = setInterval(tick, POLL_INTERVAL_MS)
return () => {
clearTimeout(t)
clearInterval(interval)
}
}, [hasDevice, tick])
const cpuFooter = hasDevice
? [
{ label: 'Procesos:', value: '142' },
{ label: 'Hilos:', value: '2,840' },
{ label: 'Velocidad:', value: '2.90 GHz' },
]
: [
{ label: 'Procesos:', value: '—' },
{ label: 'Hilos:', value: '—' },
{ label: 'Velocidad:', value: '—' },
]
const ramFooter = hasDevice
? [
{ label: 'En uso:', value: `${Math.round((metrics.memory / 100) * 16)} GB` },
{ label: 'Disponible:', value: `${Math.round(((100 - metrics.memory) / 100) * 16)} GB` },
{ label: 'Total:', value: '16 GB' },
]
: [
{ label: 'En uso:', value: '—' },
{ label: 'Disponible:', value: '—' },
{ label: 'Total:', value: '—' },
]
const diskFooter = hasDevice
? [
{ label: 'Lectura:', value: '12.5 MB/s' },
{ label: 'Escritura:', value: '3.2 MB/s' },
{ label: 'Activo:', value: 'Sí' },
]
: [
{ label: 'Lectura:', value: '—' },
{ label: 'Escritura:', value: '—' },
{ label: 'Activo:', value: '—' },
]
const networkFooter = hasDevice
? [
{ label: 'Enviado:', value: '1.2 GB' },
{ label: 'Recibido:', value: '4.8 GB' },
{ label: 'Adaptador:', value: 'Ethernet' },
]
: [
{ label: 'Enviado:', value: '—' },
{ label: 'Recibido:', value: '—' },
{ label: 'Adaptador:', value: '—' },
]
return (
<div className="space-y-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">
Rendimiento en Tiempo Real
</h1>
<p className="mt-1 text-gray-400">
Monitorea recursos del sistema
</p>
</div>
<div className="shrink-0">
<select
value={selectedDeviceId}
onChange={(e) => setSelectedDeviceId(e.target.value)}
className="w-64 rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">-- Seleccionar dispositivo --</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.nombre}
</option>
))}
</select>
</div>
</header>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<MetricCard
title="CPU"
value={hasDevice ? `${metrics.cpu}` : '—'}
valueSuffix="%"
footerStats={cpuFooter}
chartData={chartHistory.cpu}
highUsage={metrics.cpu > 80}
/>
<MetricCard
title="Memoria RAM"
value={hasDevice ? `${metrics.memory}` : '—'}
valueSuffix="%"
footerStats={ramFooter}
chartData={chartHistory.memory}
highUsage={metrics.memory > 80}
/>
<MetricCard
title="Disco"
value={hasDevice ? `${metrics.disk}` : '—'}
valueSuffix="%"
footerStats={diskFooter}
chartData={chartHistory.disk}
highUsage={metrics.disk > 80}
/>
<MetricCard
title="Red"
value={hasDevice ? `${metrics.network}` : '—'}
valueSuffix="Mbps"
footerStats={networkFooter}
chartData={chartHistory.network}
/>
</div>
<ProcessTable processes={processes} noDevice={!hasDevice} />
</div>
)
}

View File

@@ -0,0 +1,185 @@
'use client'
// TODO: replace mock reportService with trpc.reportes.inventario / reportes.alertas / reportes.exportarCSV and real PDF generation
import { useState, useCallback } from 'react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import DateRangeFilter, { type DateRangeValue } from '@/components/reportes/DateRangeFilter'
import ReportCard from '@/components/reportes/ReportCard'
import {
fetchInventoryReport,
fetchResourceUsageReport,
fetchAlertsReport,
} from '@/mocks/reportService'
function defaultDateRange(): DateRangeValue {
const today = new Date()
const monthAgo = new Date(today)
monthAgo.setMonth(monthAgo.getMonth() - 1)
return {
desde: monthAgo.toISOString().split('T')[0],
hasta: today.toISOString().split('T')[0],
}
}
export default function ReportesPage() {
useSelectedClient()
const [filters, setFilters] = useState<DateRangeValue>(defaultDateRange)
const [loading, setLoading] = useState<string | null>(null)
const getDates = useCallback((): { start: Date; end: Date } | null => {
if (!filters.desde || !filters.hasta || filters.hasta < filters.desde)
return null
return {
start: new Date(filters.desde),
end: new Date(filters.hasta),
}
}, [filters])
const mockExportPdf = useCallback(
(reportName: string) => {
const dates = getDates()
console.log(`[Mock] Export PDF: ${reportName}`, dates || filters)
},
[getDates, filters]
)
const mockExportExcel = useCallback(
(reportName: string) => {
const dates = getDates()
const csv = `Reporte,${reportName}\nDesde,${filters.desde || '-'}\nHasta,${filters.hasta || '-'}\n`
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `reporte-${reportName.toLowerCase().replace(/\s+/g, '-')}-${filters.desde || 'export'}.csv`
a.click()
URL.revokeObjectURL(url)
},
[getDates, filters]
)
const handleInventoryPdf = () => {
setLoading('inventario-pdf')
const dates = getDates()
if (dates) {
fetchInventoryReport(dates.start, dates.end).then(() => {
mockExportPdf('Inventario de Equipos')
setLoading(null)
})
} else {
mockExportPdf('Inventario de Equipos')
setLoading(null)
}
}
const handleInventoryExcel = () => {
setLoading('inventario-excel')
const dates = getDates()
if (dates) {
fetchInventoryReport(dates.start, dates.end).then(() => {
mockExportExcel('Inventario de Equipos')
setLoading(null)
})
} else {
mockExportExcel('Inventario de Equipos')
setLoading(null)
}
}
const handleResourcePdf = () => {
setLoading('recursos-pdf')
const dates = getDates()
if (dates) {
fetchResourceUsageReport(dates.start, dates.end).then(() => {
mockExportPdf('Uso de Recursos')
setLoading(null)
})
} else {
mockExportPdf('Uso de Recursos')
setLoading(null)
}
}
const handleResourceExcel = () => {
setLoading('recursos-excel')
const dates = getDates()
if (dates) {
fetchResourceUsageReport(dates.start, dates.end).then(() => {
mockExportExcel('Uso de Recursos')
setLoading(null)
})
} else {
mockExportExcel('Uso de Recursos')
setLoading(null)
}
}
const handleAlertsPdf = () => {
setLoading('alertas-pdf')
const dates = getDates()
if (dates) {
fetchAlertsReport(dates.start, dates.end).then(() => {
mockExportPdf('Historial de Alertas')
setLoading(null)
})
} else {
mockExportPdf('Historial de Alertas')
setLoading(null)
}
}
const handleAlertsExcel = () => {
setLoading('alertas-excel')
const dates = getDates()
if (dates) {
fetchAlertsReport(dates.start, dates.end).then(() => {
mockExportExcel('Historial de Alertas')
setLoading(null)
})
} else {
mockExportExcel('Historial de Alertas')
setLoading(null)
}
}
return (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-bold text-white">Reportes</h1>
<p className="mt-1 text-gray-400">Generación de informes del sistema</p>
</header>
<section className="py-4">
<DateRangeFilter value={filters} onChange={setFilters} />
</section>
<section
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"
aria-label="Reportes disponibles"
>
<ReportCard
title="Inventario de Equipos"
description="Listado completo de todos los dispositivos registrados con especificaciones técnicas y estado actual."
onExportPdf={handleInventoryPdf}
onExportExcel={handleInventoryExcel}
loading={loading?.startsWith('inventario') ?? false}
/>
<ReportCard
title="Uso de Recursos"
description="Estadísticas de consumo de CPU, memoria y red de todos los dispositivos en el periodo seleccionado."
onExportPdf={handleResourcePdf}
onExportExcel={handleResourceExcel}
loading={loading?.startsWith('recursos') ?? false}
/>
<ReportCard
title="Historial de Alertas"
description="Registro completo de todas las alertas generadas, incluyendo resolución y tiempo de respuesta."
onExportPdf={handleAlertsPdf}
onExportExcel={handleAlertsExcel}
loading={loading?.startsWith('alertas') ?? false}
/>
</section>
</div>
)
}

View File

@@ -0,0 +1,141 @@
'use client'
import { useState, useMemo, useEffect } from 'react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import { trpc } from '@/lib/trpc-client'
import { formatDurationSeconds } from '@/lib/utils'
import SessionCard, { type SessionTypeLabel } from '@/components/sessions/SessionCard'
const TIPO_TO_LABEL: Record<string, SessionTypeLabel> = {
desktop: 'Escritorio Remoto',
terminal: 'Terminal',
files: 'Archivos',
}
function getSessionTypeLabel(tipo: string): SessionTypeLabel {
return TIPO_TO_LABEL[tipo] ?? 'Terminal'
}
function computeDurationSeconds(startedAt: Date | string): number {
const start = new Date(startedAt).getTime()
return Math.floor((Date.now() - start) / 1000)
}
export default function SesionesPage() {
const { selectedClientId } = useSelectedClient()
const clienteId = selectedClientId ?? undefined
const utils = trpc.useUtils()
const listQuery = trpc.sesiones.list.useQuery(
{ clienteId, limit: 100 },
{ refetchOnWindowFocus: true, staleTime: 10 * 1000 }
)
const [liveDurations, setLiveDurations] = useState<Record<string, number>>({})
const sessions = useMemo(() => {
const list = listQuery.data?.sessions ?? []
return list.map((s) => {
const startedAt = s.iniciadaEn instanceof Date ? s.iniciadaEn : new Date(s.iniciadaEn)
const seconds = computeDurationSeconds(startedAt)
return {
id: s.id,
deviceName: s.dispositivo.nombre,
userEmail: s.usuario.email,
sessionType: getSessionTypeLabel(s.tipo),
startedAt,
initialSeconds: seconds,
}
})
}, [listQuery.data])
useEffect(() => {
if (sessions.length === 0) return
const interval = setInterval(() => {
const next: Record<string, number> = {}
sessions.forEach((s) => {
next[s.id] = computeDurationSeconds(s.startedAt)
})
setLiveDurations((prev) => ({ ...prev, ...next }))
}, 1000)
return () => clearInterval(interval)
}, [sessions])
const endSessionMutation = trpc.equipos.finalizarSesion.useMutation({
onSuccess: () => {
utils.sesiones.list.invalidate()
utils.sesiones.count.invalidate()
utils.clientes.dashboardStats.invalidate()
},
})
const handleEndSession = (sessionId: string) => {
if (typeof window !== 'undefined' && !window.confirm('¿Terminar esta sesión?')) return
endSessionMutation.mutate({ sesionId: sessionId })
}
if (listQuery.isLoading) {
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white sm:text-3xl">Sesiones Activas</h1>
<p className="mt-1 text-gray-400">Conexiones remotas en curso</p>
</header>
<div className="rounded-xl border border-white/10 bg-dark-300/50 p-12 text-center text-gray-400">
Cargando sesiones...
</div>
</div>
)
}
if (listQuery.isError) {
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white sm:text-3xl">Sesiones Activas</h1>
<p className="mt-1 text-gray-400">Conexiones remotas en curso</p>
</header>
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-12 text-center text-red-400">
Error al cargar sesiones. Intente de nuevo.
</div>
</div>
)
}
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white sm:text-3xl">Sesiones Activas</h1>
<p className="mt-1 text-gray-400">Conexiones remotas en curso</p>
</header>
{sessions.length === 0 ? (
<div className="rounded-xl border border-white/10 bg-dark-300/50 p-12 text-center text-gray-400">
No hay sesiones activas.
</div>
) : (
<ul className="space-y-5">
{sessions.map((session) => {
const durationSeconds = liveDurations[session.id] ?? session.initialSeconds
const duration = formatDurationSeconds(durationSeconds)
const isEnding = endSessionMutation.isPending && endSessionMutation.variables?.sesionId === session.id
return (
<li key={session.id}>
<SessionCard
id={session.id}
deviceName={session.deviceName}
userEmail={session.userEmail}
sessionType={session.sessionType}
duration={duration}
onEnd={handleEndSession}
isEnding={isEnding}
/>
</li>
)
})}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,207 @@
'use client'
import { useState, useMemo, useCallback } from 'react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import SummaryMetricCard from '@/components/software/SummaryMetricCard'
import SoftwareTable from '@/components/software/SoftwareTable'
import type { SoftwareItem } from '@/components/software/SoftwareRow'
import { Download } from 'lucide-react'
const MOCK_SOFTWARE: SoftwareItem[] = [
{
id: '1',
name: 'Google Chrome',
version: '120.0.6099.130',
vendor: 'Google LLC',
installations: 10,
lastUpdate: '15/01/2024',
licensed: false,
needsUpdate: true,
},
{
id: '2',
name: 'Microsoft Office 365',
version: '16.0.17029.20028',
vendor: 'Microsoft Corporation',
installations: 8,
lastUpdate: '20/01/2024',
licensed: true,
needsUpdate: false,
},
{
id: '3',
name: 'Adobe Acrobat Reader DC',
version: '23.006.20360',
vendor: 'Adobe Inc.',
installations: 12,
lastUpdate: '10/01/2024',
licensed: true,
needsUpdate: true,
},
{
id: '4',
name: 'Visual Studio Code',
version: '1.85.1',
vendor: 'Microsoft Corporation',
installations: 5,
lastUpdate: '18/01/2024',
licensed: false,
needsUpdate: false,
},
{
id: '5',
name: 'Slack',
version: '4.33.90',
vendor: 'Slack Technologies',
installations: 7,
lastUpdate: '12/01/2024',
licensed: true,
needsUpdate: false,
},
{
id: '6',
name: 'Zoom',
version: '5.16.10',
vendor: 'Zoom Video Communications',
installations: 6,
lastUpdate: '08/01/2024',
licensed: false,
needsUpdate: true,
},
{
id: '7',
name: '7-Zip',
version: '23.01',
vendor: 'Igor Pavlov',
installations: 15,
lastUpdate: '22/12/2023',
licensed: false,
needsUpdate: false,
},
{
id: '8',
name: 'Node.js',
version: '20.10.0',
vendor: 'OpenJS Foundation',
installations: 4,
lastUpdate: '05/01/2024',
licensed: false,
needsUpdate: true,
},
{
id: '9',
name: 'Git',
version: '2.43.0',
vendor: 'Software Freedom Conservancy',
installations: 5,
lastUpdate: '14/01/2024',
licensed: false,
needsUpdate: false,
},
{
id: '10',
name: 'Windows Security',
version: '10.0.22621.1',
vendor: 'Microsoft Corporation',
installations: 12,
lastUpdate: '19/01/2024',
licensed: true,
needsUpdate: false,
},
]
function exportToCsv(items: SoftwareItem[]): string {
const headers = ['Nombre', 'Versión', 'Editor', 'Instalaciones', 'Última actualización']
const rows = items.map((i) =>
[i.name, i.version, i.vendor, i.installations, i.lastUpdate].join(',')
)
return [headers.join(','), ...rows].join('\n')
}
export default function InventarioPage() {
useSelectedClient()
const [softwareList] = useState<SoftwareItem[]>(MOCK_SOFTWARE)
const [search, setSearch] = useState('')
const filteredSoftware = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return softwareList
return softwareList.filter((item) =>
item.name.toLowerCase().includes(q) ||
item.vendor.toLowerCase().includes(q) ||
item.version.toLowerCase().includes(q)
)
}, [softwareList, search])
const uniqueCount = filteredSoftware.length
const licensedCount = useMemo(
() => filteredSoftware.filter((i) => i.licensed).length,
[filteredSoftware]
)
const needsUpdateCount = useMemo(
() => filteredSoftware.filter((i) => i.needsUpdate).length,
[filteredSoftware]
)
const handleExport = useCallback(() => {
const csv = exportToCsv(filteredSoftware)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `inventario-software-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}, [filteredSoftware])
return (
<div className="space-y-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">
Inventario de Software
</h1>
<p className="mt-1 text-gray-400">
Software instalado en los dispositivos
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar software..."
className="w-64 rounded-lg border border-white/10 bg-dark-300 px-4 py-2 text-sm text-gray-200 placeholder-gray-500 transition-colors focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
/>
<button
type="button"
onClick={handleExport}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-all hover:bg-cyan-500"
>
<Download className="h-4 w-4" />
Exportar
</button>
</div>
</header>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
<SummaryMetricCard
value={uniqueCount}
label="Programas Únicos"
/>
<SummaryMetricCard
value={licensedCount}
label="Con Licencia"
/>
<SummaryMetricCard
value={needsUpdateCount}
label="Requieren Actualización"
/>
</div>
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<SoftwareTable items={filteredSoftware} />
</div>
</div>
)
}

View File

@@ -0,0 +1,124 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import { trpc } from '@/lib/trpc-client'
import TerminalWindow from '@/components/terminal/TerminalWindow'
import QuickCommands from '@/components/terminal/QuickCommands'
const MOCK_RESPONSES: Record<string, string> = {
systeminfo: 'Nombre del host: PC-RECEPCION-01\nOS: Microsoft Windows 11 Pro\n...',
ipconfig: 'Adaptador Ethernet: 192.168.1.10\nMáscara de subred: 255.255.255.0\n...',
tasklist: 'Nombre de imagen PID Nombre de sesión\nchrome.exe 1234 Console\n...',
netstat: 'Conexiones activas\n Proto Dirección local Dirección remota\n TCP 0.0.0.0:443 LISTENING\n...',
'CPU Info': 'Procesador: Intel Core i7-10700\nUso: 23%\n...',
'RAM Info': 'Memoria total: 16 GB\nEn uso: 8.2 GB\nDisponible: 7.8 GB\n...',
'dir C:\\': ' Volume in drive C is OS\n Directory of C:\\\n archivos...\n...',
hostname: 'PC-RECEPCION-01',
}
function getMockResponse(cmd: string): string {
return MOCK_RESPONSES[cmd] ?? `Comando ejecutado: ${cmd}\n(Salida simulada)`
}
export default function TerminalPage() {
const { selectedClientId } = useSelectedClient()
const clienteId = selectedClientId ?? undefined
const searchParams = useSearchParams()
const deviceIdFromUrl = searchParams.get('deviceId')
const listQuery = trpc.equipos.list.useQuery(
{ clienteId, limit: 100 },
{ refetchOnWindowFocus: false }
)
const devices = (listQuery.data?.dispositivos ?? []).map((d) => ({
id: d.id,
nombre: d.nombre,
}))
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('')
useEffect(() => {
if (!deviceIdFromUrl || !listQuery.data?.dispositivos?.length) return
const exists = listQuery.data.dispositivos.some((d) => d.id === deviceIdFromUrl)
if (exists) setSelectedDeviceId(deviceIdFromUrl)
}, [deviceIdFromUrl, listQuery.data?.dispositivos])
const [outputLines, setOutputLines] = useState<string[]>([])
const [command, setCommand] = useState('')
const selectedDevice = selectedDeviceId
? devices.find((d) => d.id === selectedDeviceId)
: null
const connectedDeviceName = selectedDevice?.nombre ?? null
const isConnected = !!selectedDeviceId
const handleSendCommand = useCallback(() => {
const trimmed = command.trim()
if (!trimmed) return
const response = getMockResponse(trimmed)
const newLines = [`$ ${trimmed}`, ...response.split('\n').map((line) => `> ${line}`)]
setOutputLines((prev) => [...prev, ...newLines])
setCommand('')
}, [command])
const handleClear = useCallback(() => {
setOutputLines([])
}, [])
const handleCopy = useCallback(() => {
const text = outputLines.join('\n')
if (text) {
void navigator.clipboard.writeText(text)
}
}, [outputLines])
const handleQuickCommand = useCallback((cmd: string) => {
setCommand(cmd)
}, [])
return (
<div className="space-y-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">
Terminal Remoto
</h1>
<p className="mt-1 text-gray-400">
Ejecuta comandos en dispositivos conectados
</p>
</div>
<div className="shrink-0">
<select
value={selectedDeviceId}
onChange={(e) => setSelectedDeviceId(e.target.value)}
className="w-64 rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
>
<option value="">-- Seleccionar dispositivo --</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.nombre}
</option>
))}
</select>
</div>
</header>
<TerminalWindow
connectedDeviceName={connectedDeviceName}
outputLines={outputLines}
command={command}
onCommandChange={setCommand}
onSendCommand={handleSendCommand}
onClear={handleClear}
onCopy={handleCopy}
disabled={!isConnected}
/>
<QuickCommands onSelectCommand={handleQuickCommand} />
</div>
)
}

View File

@@ -0,0 +1,88 @@
'use client'
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
import type { DeviceStatusBreakdown } from '@/mocks/dashboardData'
interface DeviceStatusChartProps {
data: DeviceStatusBreakdown
}
const COLORS = {
online: '#22c55e',
offline: '#64748b',
advertencia: '#eab308',
}
export default function DeviceStatusChart({ data }: DeviceStatusChartProps) {
const total = data.online + data.offline + data.advertencia
const segments = [
{ name: 'En Línea', value: data.online, color: COLORS.online },
{ name: 'Fuera de Línea', value: data.offline, color: COLORS.offline },
{ name: 'Advertencia', value: data.advertencia, color: COLORS.advertencia },
].filter((s) => s.value > 0)
if (total === 0) {
return (
<div className="card p-6">
<h3 className="font-medium text-gray-200 mb-4">Estado de Dispositivos</h3>
<div className="flex h-48 items-center justify-center text-gray-500 text-sm">
Sin datos
</div>
</div>
)
}
return (
<div className="card p-6">
<h3 className="font-medium text-gray-200 mb-4">Estado de Dispositivos</h3>
<div className="flex flex-col sm:flex-row items-center gap-4">
<div className="w-full max-w-[200px] h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={segments}
cx="50%"
cy="50%"
innerRadius={56}
outerRadius={80}
paddingAngle={2}
dataKey="value"
>
{segments.map((entry, index) => (
<Cell key={index} fill={entry.color} stroke="transparent" />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'var(--card)',
border: '1px solid var(--border)',
borderRadius: '8px',
}}
formatter={(value: number) => [
`${value} (${total > 0 ? Math.round((value / total) * 100) : 0}%)`,
'',
]}
/>
</PieChart>
</ResponsiveContainer>
</div>
<ul className="flex flex-col gap-2 w-full sm:w-auto">
{segments.map((s) => (
<li key={s.name} className="flex items-center justify-between gap-4 text-sm">
<span className="flex items-center gap-2">
<span
className="h-3 w-3 rounded-full shrink-0"
style={{ backgroundColor: s.color }}
/>
{s.name}
</span>
<span className="text-gray-400 tabular-nums">
{s.value} ({total > 0 ? Math.round((s.value / total) * 100) : 0}%)
</span>
</li>
))}
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
'use client'
import { cn } from '@/lib/utils'
import type { QuickConnectionItem, QuickConnectionStatus } from '@/mocks/dashboardData'
interface QuickConnectionCardProps {
item: QuickConnectionItem
onClick?: (id: string) => void
}
const statusConfig: Record<
QuickConnectionStatus,
{ dot: string; label: string; text: string }
> = {
online: { dot: 'bg-success', label: 'En línea', text: 'text-success' },
advertencia: { dot: 'bg-warning', label: 'Advertencia', text: 'text-warning' },
offline: { dot: 'bg-gray-500', label: 'Fuera de línea', text: 'text-gray-500' },
}
export default function QuickConnectionCard({ item, onClick }: QuickConnectionCardProps) {
const config = statusConfig[item.status]
return (
<button
type="button"
onClick={() => onClick?.(item.id)}
className={cn(
'card p-4 text-left transition-all hover:scale-[1.02] hover:border-primary-500/50',
'flex items-center justify-between gap-3'
)}
>
<span className="font-medium text-gray-200 truncate">{item.name}</span>
<span className={cn('inline-flex items-center gap-1.5 text-sm shrink-0', config.text)}>
<span className={cn('h-2 w-2 rounded-full', config.dot)} />
{config.label}
</span>
</button>
)
}

View File

@@ -0,0 +1,92 @@
'use client'
import Link from 'next/link'
import {
LogIn,
AlertTriangle,
Link2,
Link2Off,
} from 'lucide-react'
import { cn, formatRelativeTime } from '@/lib/utils'
import type { RecentActivityItem, RecentActivityType } from '@/mocks/dashboardData'
import type { AlertSeverity } from '@/mocks/dashboardData'
function formatRelative(date: Date): string {
const s = formatRelativeTime(date)
return s === 'ahora' ? 'Hace un momento' : s.replace(/^hace/, 'Hace')
}
interface RecentActivityListProps {
items: RecentActivityItem[]
isLoading?: boolean
}
const severityIconBg: Record<AlertSeverity, string> = {
CRITICAL: 'bg-danger/20 text-danger',
WARNING: 'bg-warning/20 text-warning',
INFO: 'bg-info/20 text-info',
}
const typeConfig: Record<
RecentActivityType,
{ icon: React.ReactNode; label: string }
> = {
login: { icon: <LogIn className="w-4 h-4" />, label: 'Login' },
alert: { icon: <AlertTriangle className="w-4 h-4" />, label: 'Alerta' },
connection: { icon: <Link2 className="w-4 h-4" />, label: 'Conexión' },
disconnection: { icon: <Link2Off className="w-4 h-4" />, label: 'Desconexión' },
}
export default function RecentActivityList({ items, isLoading }: RecentActivityListProps) {
return (
<div className="card overflow-hidden flex flex-col h-full min-h-0">
<div className="card-header flex items-center justify-between">
<h3 className="font-medium text-gray-200">Actividad Reciente</h3>
<Link href="/alerts" className="text-sm text-primary-500 hover:underline">
Ver todas
</Link>
</div>
<div className="overflow-y-auto flex-1 divide-y divide-dark-100 max-h-[320px]">
{isLoading ? (
<div className="p-6 text-center text-sm text-gray-500">
Cargando...
</div>
) : items.length === 0 ? (
<div className="p-6 text-center text-sm text-gray-500">
No hay alertas recientes
</div>
) : (
items.map((item) => {
const config = typeConfig[item.type]
const iconBg =
item.type === 'alert' && item.severity
? severityIconBg[item.severity]
: 'bg-dark-300 text-gray-400'
return (
<div
key={item.id}
className="flex items-start gap-3 p-4 hover:bg-dark-300/30 transition-colors"
>
<div className={cn('p-2 rounded-lg shrink-0', iconBg)}>
{config.icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm text-gray-200">
{item.description}
{' · '}
<span className="font-medium text-primary-400">
{item.deviceName}
</span>
</p>
<p className="text-xs text-gray-500 mt-0.5">
{formatRelative(item.timestamp)}
</p>
</div>
</div>
)
})
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
'use client'
import { cn } from '@/lib/utils'
interface SummaryCardProps {
title: string
value: number
secondary?: string
icon: React.ReactNode
iconBgClass?: string
iconColorClass?: string
}
export default function SummaryCard({
title,
value,
secondary,
icon,
iconBgClass = 'bg-dark-300',
iconColorClass = 'text-primary-400',
}: SummaryCardProps) {
return (
<div className="card p-4 transition-all hover:scale-[1.02]">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-400">{title}</p>
<p className="text-3xl font-bold mt-1">{value}</p>
{secondary && (
<p className="text-xs text-gray-500 mt-1">{secondary}</p>
)}
</div>
<div className={cn('p-3 rounded-lg', iconBgClass)}>
<span className={iconColorClass}>{icon}</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
import { cn } from '@/lib/utils'
import type { SystemHealthMetric } from '@/mocks/dashboardData'
interface SystemHealthCardProps {
metric: SystemHealthMetric
}
const statusBarClass = {
healthy: 'bg-success',
warning: 'bg-warning',
critical: 'bg-danger',
}
export default function SystemHealthCard({ metric }: SystemHealthCardProps) {
const percent = metric.unit === '%' ? metric.value : Math.min(100, (metric.value / 200) * 100)
const barClass = statusBarClass[metric.status]
return (
<div className="card p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-400">{metric.label}</span>
<span className="text-lg font-semibold tabular-nums text-gray-200">
{metric.value}
{metric.unit}
</span>
</div>
<div className="h-2 rounded-full bg-dark-300 overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', barClass)}
style={{ width: `${percent}%` }}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,373 @@
'use client'
import { useState, useEffect } from 'react'
import { X } from 'lucide-react'
import { trpc } from '@/lib/trpc-client'
import { cn } from '@/lib/utils'
const TIPO_OPTIONS: { value: string; label: string }[] = [
{ value: 'PC', label: 'PC' },
{ value: 'LAPTOP', label: 'Laptop' },
{ value: 'SERVIDOR', label: 'Servidor' },
{ value: 'CELULAR', label: 'Celular' },
{ value: 'TABLET', label: 'Tablet' },
{ value: 'ROUTER', label: 'Router' },
{ value: 'SWITCH', label: 'Switch' },
{ value: 'FIREWALL', label: 'Firewall' },
{ value: 'AP', label: 'Access Point' },
{ value: 'IMPRESORA', label: 'Impresora' },
{ value: 'OTRO', label: 'Otro' },
]
const ESTADO_OPTIONS: { value: string; label: string }[] = [
{ value: 'DESCONOCIDO', label: 'Desconocido' },
{ value: 'ONLINE', label: 'En línea' },
{ value: 'OFFLINE', label: 'Fuera de línea' },
{ value: 'ALERTA', label: 'Advertencia' },
{ value: 'MANTENIMIENTO', label: 'Mantenimiento' },
]
export interface AddDeviceFormValues {
tipo: string
nombre: string
descripcion: string
ubicacionId: string
estado: string
ip: string
mac: string
sistemaOperativo: string
versionSO: string
fabricante: string
modelo: string
serial: string
}
const initialValues: AddDeviceFormValues = {
tipo: 'PC',
nombre: '',
descripcion: '',
ubicacionId: '',
estado: 'DESCONOCIDO',
ip: '',
mac: '',
sistemaOperativo: '',
versionSO: '',
fabricante: '',
modelo: '',
serial: '',
}
interface AddDeviceModalProps {
open: boolean
onClose: () => void
clienteId: string | undefined
onSuccess?: () => void
}
const inputClass =
'w-full rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 placeholder-gray-500 focus:border-primary-500/50 focus:outline-none focus:ring-2 focus:ring-primary-500/20'
export default function AddDeviceModal({
open,
onClose,
clienteId,
onSuccess,
}: AddDeviceModalProps) {
const [form, setForm] = useState<AddDeviceFormValues>(initialValues)
const [submitError, setSubmitError] = useState<string | null>(null)
const utils = trpc.useUtils()
const ubicacionesQuery = trpc.clientes.ubicaciones.list.useQuery(
{ clienteId: clienteId! },
{ enabled: open && !!clienteId }
)
const createMutation = trpc.equipos.create.useMutation({
onSuccess: () => {
utils.equipos.list.invalidate()
utils.clientes.dashboardStats.invalidate()
onSuccess?.()
handleClose()
},
onError: (err) => {
setSubmitError(err.message)
},
})
useEffect(() => {
if (!open) {
setForm(initialValues)
setSubmitError(null)
}
}, [open])
const handleClose = () => {
setSubmitError(null)
onClose()
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setSubmitError(null)
if (!clienteId) {
setSubmitError('Seleccione un cliente.')
return
}
createMutation.mutate({
clienteId,
tipo: form.tipo as AddDeviceFormValues['tipo'],
nombre: form.nombre.trim(),
descripcion: form.descripcion.trim() || undefined,
ubicacionId: form.ubicacionId || undefined,
estado: form.estado as AddDeviceFormValues['estado'],
ip: form.ip.trim() || undefined,
mac: form.mac.trim() || undefined,
sistemaOperativo: form.sistemaOperativo.trim() || undefined,
versionSO: form.versionSO.trim() || undefined,
fabricante: form.fabricante.trim() || undefined,
modelo: form.modelo.trim() || undefined,
serial: form.serial.trim() || undefined,
})
}
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="add-device-title"
>
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={handleClose}
/>
<div
className={cn(
'relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-xl border border-white/10 bg-dark-400 shadow-xl',
'flex flex-col'
)}
>
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<h2 id="add-device-title" className="text-lg font-semibold text-white">
Agregar Dispositivo
</h2>
<button
type="button"
onClick={handleClose}
className="rounded-lg p-2 text-gray-400 hover:bg-white/10 hover:text-white transition-colors"
aria-label="Cerrar"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 p-4 gap-4">
{submitError && (
<div className="rounded-lg bg-red-500/20 border border-red-500/40 px-4 py-2 text-sm text-red-400">
{submitError}
</div>
)}
{!clienteId && (
<p className="text-sm text-amber-400">
Seleccione un cliente en el selector del header para poder agregar dispositivos.
</p>
)}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">
Tipo *
</label>
<select
value={form.tipo}
onChange={(e) => setForm((f) => ({ ...f, tipo: e.target.value }))}
className={inputClass}
required
>
{TIPO_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">
Nombre *
</label>
<input
type="text"
value={form.nombre}
onChange={(e) => setForm((f) => ({ ...f, nombre: e.target.value }))}
className={inputClass}
placeholder="Ej: PC-Oficina-01"
required
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">
Descripción
</label>
<textarea
value={form.descripcion}
onChange={(e) => setForm((f) => ({ ...f, descripcion: e.target.value }))}
className={cn(inputClass, 'min-h-[80px] resize-y')}
placeholder="Opcional"
rows={2}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">
Ubicación
</label>
<select
value={form.ubicacionId}
onChange={(e) => setForm((f) => ({ ...f, ubicacionId: e.target.value }))}
className={inputClass}
disabled={!clienteId}
>
<option value="">Sin ubicación</option>
{(ubicacionesQuery.data ?? []).map((u) => (
<option key={u.id} value={u.id}>
{u.nombre}
</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">
Estado inicial
</label>
<select
value={form.estado}
onChange={(e) => setForm((f) => ({ ...f, estado: e.target.value }))}
className={inputClass}
>
{ESTADO_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">
IP
</label>
<input
type="text"
value={form.ip}
onChange={(e) => setForm((f) => ({ ...f, ip: e.target.value }))}
className={inputClass}
placeholder="192.168.1.10"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">
MAC
</label>
<input
type="text"
value={form.mac}
onChange={(e) => setForm((f) => ({ ...f, mac: e.target.value }))}
className={inputClass}
placeholder="Opcional"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">
Sistema operativo
</label>
<input
type="text"
value={form.sistemaOperativo}
onChange={(e) => setForm((f) => ({ ...f, sistemaOperativo: e.target.value }))}
className={inputClass}
placeholder="Windows 11, Ubuntu, etc."
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">
Versión SO
</label>
<input
type="text"
value={form.versionSO}
onChange={(e) => setForm((f) => ({ ...f, versionSO: e.target.value }))}
className={inputClass}
placeholder="Opcional"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">
Fabricante
</label>
<input
type="text"
value={form.fabricante}
onChange={(e) => setForm((f) => ({ ...f, fabricante: e.target.value }))}
className={inputClass}
placeholder="Dell, HP, Cisco..."
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">
Modelo
</label>
<input
type="text"
value={form.modelo}
onChange={(e) => setForm((f) => ({ ...f, modelo: e.target.value }))}
className={inputClass}
placeholder="Opcional"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">
Número de serie
</label>
<input
type="text"
value={form.serial}
onChange={(e) => setForm((f) => ({ ...f, serial: e.target.value }))}
className={inputClass}
placeholder="Opcional"
/>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-white/10">
<button
type="button"
onClick={handleClose}
className="btn btn-secondary"
>
Cancelar
</button>
<button
type="submit"
disabled={!clienteId || createMutation.isPending}
className="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{createMutation.isPending ? 'Creando...' : 'Crear dispositivo'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,6 +1,5 @@
'use client'
import Link from 'next/link'
import { Monitor, FolderOpen, Terminal, Info, ExternalLink } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -17,6 +16,8 @@ export interface DeviceCardProps {
onConectar?: (id: string) => void
onArchivos?: (id: string) => void
onTerminal?: (id: string) => void
onInfo?: (id: string, name?: string) => void
isConnecting?: boolean
}
const statusConfig: Record<
@@ -46,16 +47,23 @@ export default function DeviceCard({
onConectar,
onArchivos,
onTerminal,
onInfo,
isConnecting = false,
}: DeviceCardProps) {
const statusStyle = statusConfig[status]
const osLabel = normalizeOS(os)
const detailUrl = id ? `/devices/${id}` : '#'
const handleCardClick = () => id && onInfo?.(id, name)
return (
<div
role={id && onInfo ? 'button' : undefined}
tabIndex={id && onInfo ? 0 : undefined}
onClick={handleCardClick}
onKeyDown={(e) => id && onInfo && (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onInfo(id, name))}
className={cn(
'rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/80 to-dark-400/80 p-5',
'transition-all duration-200 hover:border-primary-500/30 hover:shadow-lg hover:shadow-black/20'
'transition-all duration-200 hover:border-primary-500/30 hover:shadow-lg hover:shadow-black/20',
id && onInfo && 'cursor-pointer'
)}
>
<div className="flex items-start gap-3">
@@ -89,14 +97,21 @@ export default function DeviceCard({
</div>
</div>
<div className="mt-4 grid grid-cols-4 gap-2">
<div className="mt-4 grid grid-cols-4 gap-2" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => id && onConectar?.(id)}
className="flex flex-col items-center gap-1 rounded-lg bg-dark-200/80 py-2.5 text-gray-400 transition-colors hover:bg-dark-100 hover:text-primary-400 border border-white/5"
onClick={() => id && status === 'online' && !isConnecting && onConectar?.(id)}
disabled={status !== 'online' || isConnecting}
title={status !== 'online' ? 'Solo disponible para dispositivos en línea' : 'Conectar escritorio remoto'}
className={cn(
'flex flex-col items-center gap-1 rounded-lg py-2.5 border border-white/5',
status === 'online' && !isConnecting
? 'bg-dark-200/80 text-gray-400 transition-colors hover:bg-dark-100 hover:text-primary-400'
: 'bg-dark-200/50 text-gray-600 cursor-not-allowed'
)}
>
<ExternalLink className="h-4 w-4" />
<span className="text-xs font-medium">Conectar</span>
<ExternalLink className={cn('h-4 w-4', isConnecting && 'animate-pulse')} />
<span className="text-xs font-medium">{isConnecting ? 'Conectando…' : 'Conectar'}</span>
</button>
<button
type="button"
@@ -114,13 +129,14 @@ export default function DeviceCard({
<Terminal className="h-4 w-4" />
<span className="text-xs font-medium">Terminal</span>
</button>
<Link
href={detailUrl}
<button
type="button"
onClick={() => id && onInfo?.(id, name)}
className="flex flex-col items-center gap-1 rounded-lg bg-dark-200/80 py-2.5 text-gray-400 transition-colors hover:bg-dark-100 hover:text-primary-400 border border-white/5"
>
<Info className="h-4 w-4" />
<span className="text-xs font-medium">Info</span>
</Link>
</button>
</div>
</div>
)

View File

@@ -0,0 +1,52 @@
'use client'
// TODO: wire actions to MeshCentral remote session (equipos.iniciarSesion) and /terminal, /archivos routes
import { ExternalLink, Terminal, FolderOpen } from 'lucide-react'
interface ActionBarProps {
deviceId: string
onConnect?: (id: string) => void
onTerminal?: (id: string) => void
onFiles?: (id: string) => void
loading?: boolean
}
export default function ActionBar({
deviceId,
onConnect,
onTerminal,
onFiles,
loading = false,
}: ActionBarProps) {
return (
<div className="sticky bottom-0 left-0 right-0 flex flex-wrap items-center gap-2 border-t border-white/10 bg-dark-400/95 backdrop-blur px-4 py-3">
<button
type="button"
onClick={() => onConnect?.(deviceId)}
disabled={loading}
className="btn btn-primary inline-flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
{loading ? 'Conectando...' : 'Conectar Escritorio'}
</button>
<button
type="button"
onClick={() => onTerminal?.(deviceId)}
disabled={loading}
className="btn btn-secondary inline-flex items-center gap-2"
>
<Terminal className="h-4 w-4" />
Terminal
</button>
<button
type="button"
onClick={() => onFiles?.(deviceId)}
disabled={loading}
className="btn btn-secondary inline-flex items-center gap-2"
>
<FolderOpen className="h-4 w-4" />
Archivos
</button>
</div>
)
}

View File

@@ -0,0 +1,478 @@
'use client'
import { useState, useEffect } from 'react'
import {
Monitor,
Laptop,
Server,
Smartphone,
Tablet,
Router,
Network,
Shield,
Wifi,
Printer,
HelpCircle,
X,
Pencil,
} from 'lucide-react'
import { trpc } from '@/lib/trpc-client'
import { cn } from '@/lib/utils'
import { mapDeviceToDetail } from '@/mocks/deviceDetailData'
import InfoSection from './InfoSection'
import SoftwareList from './SoftwareList'
import ActionBar from './ActionBar'
const DEVICE_TYPE_ICONS: Record<string, React.ReactNode> = {
PC: <Monitor className="h-6 w-6" />,
LAPTOP: <Laptop className="h-6 w-6" />,
SERVIDOR: <Server className="h-6 w-6" />,
CELULAR: <Smartphone className="h-6 w-6" />,
TABLET: <Tablet className="h-6 w-6" />,
ROUTER: <Router className="h-6 w-6" />,
SWITCH: <Network className="h-6 w-6" />,
FIREWALL: <Shield className="h-6 w-6" />,
AP: <Wifi className="h-6 w-6" />,
IMPRESORA: <Printer className="h-6 w-6" />,
OTRO: <HelpCircle className="h-6 w-6" />,
}
const DEVICE_TYPE_OPTIONS: { value: string; label: string }[] = [
{ value: 'PC', label: 'PC' },
{ value: 'LAPTOP', label: 'Laptop' },
{ value: 'SERVIDOR', label: 'Servidor' },
{ value: 'CELULAR', label: 'Celular' },
{ value: 'TABLET', label: 'Tablet' },
{ value: 'ROUTER', label: 'Router' },
{ value: 'SWITCH', label: 'Switch' },
{ value: 'FIREWALL', label: 'Firewall' },
{ value: 'AP', label: 'Access Point' },
{ value: 'IMPRESORA', label: 'Impresora' },
{ value: 'OTRO', label: 'Otro' },
]
const DEVICE_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: 'DESCONOCIDO', label: 'Desconocido' },
{ value: 'ONLINE', label: 'En línea' },
{ value: 'OFFLINE', label: 'Fuera de línea' },
{ value: 'ALERTA', label: 'Advertencia' },
{ value: 'MANTENIMIENTO', label: 'Mantenimiento' },
]
const INPUT_CLASS =
'w-full rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 placeholder-gray-500 focus:border-primary-500/50 focus:outline-none focus:ring-2 focus:ring-primary-500/20'
type DeviceTypeValue =
| 'PC'
| 'LAPTOP'
| 'SERVIDOR'
| 'CELULAR'
| 'TABLET'
| 'ROUTER'
| 'SWITCH'
| 'FIREWALL'
| 'AP'
| 'IMPRESORA'
| 'OTRO'
type DeviceStatusValue = 'ONLINE' | 'OFFLINE' | 'ALERTA' | 'MANTENIMIENTO' | 'DESCONOCIDO'
interface EditFormState {
tipo: string
nombre: string
descripcion: string
ubicacionId: string
estado: string
ip: string
mac: string
sistemaOperativo: string
versionSO: string
fabricante: string
modelo: string
serial: string
}
interface DeviceFromApi {
tipo: string
nombre: string
descripcion?: string | null
ubicacionId?: string | null
estado: string
ip?: string | null
mac?: string | null
sistemaOperativo?: string | null
versionSO?: string | null
fabricante?: string | null
modelo?: string | null
serial?: string | null
}
function buildEditFormFromDevice(device: DeviceFromApi): EditFormState {
return {
tipo: device.tipo,
nombre: device.nombre,
descripcion: device.descripcion ?? '',
ubicacionId: device.ubicacionId ?? '',
estado: device.estado,
ip: device.ip ?? '',
mac: device.mac ?? '',
sistemaOperativo: device.sistemaOperativo ?? '',
versionSO: device.versionSO ?? '',
fabricante: device.fabricante ?? '',
modelo: device.modelo ?? '',
serial: device.serial ?? '',
}
}
interface DeviceDetailModalProps {
open: boolean
onClose: () => void
deviceId: string | null
deviceName?: string
onConnect?: (id: string) => void
onTerminal?: (id: string) => void
onFiles?: (id: string) => void
}
export default function DeviceDetailModal({
open,
onClose,
deviceId,
deviceName = 'Dispositivo',
onConnect,
onTerminal,
onFiles,
}: DeviceDetailModalProps) {
const [isEditing, setIsEditing] = useState(false)
const [editForm, setEditForm] = useState<EditFormState | null>(null)
const [editError, setEditError] = useState<string | null>(null)
const utils = trpc.useUtils()
const deviceQuery = trpc.equipos.byId.useQuery(
{ id: deviceId! },
{ enabled: open && !!deviceId }
)
const device = deviceQuery.data
const clientId = device?.clienteId
const locationsQuery = trpc.clientes.ubicaciones.list.useQuery(
{ clienteId: clientId! },
{ enabled: open && !!clientId && isEditing }
)
const updateMutation = trpc.equipos.update.useMutation({
onSuccess: () => {
utils.equipos.byId.invalidate({ id: deviceId! })
utils.equipos.list.invalidate()
utils.clientes.dashboardStats.invalidate()
setIsEditing(false)
setEditForm(null)
setEditError(null)
},
onError: (err) => {
setEditError(err.message)
},
})
useEffect(() => {
if (!open) {
setIsEditing(false)
setEditForm(null)
setEditError(null)
}
}, [open])
useEffect(() => {
if (isEditing && device) {
setEditForm(buildEditFormFromDevice(device))
setEditError(null)
}
}, [isEditing, device])
const detail = device ? mapDeviceToDetail(device) : null
const isLoading = deviceQuery.isLoading
const hasError = deviceQuery.isError
const systemItems = detail
? [
{ label: 'Sistema Operativo', value: detail.systemInfo.sistemaOperativo },
{ label: 'Procesador', value: detail.systemInfo.procesador },
{ label: 'Memoria RAM', value: detail.systemInfo.memoriaRam },
{
label: 'Almacenamiento',
value:
detail.systemInfo.almacenamientoUsoPercent != null
? `${detail.systemInfo.almacenamiento} (${detail.systemInfo.almacenamientoUsoPercent}% uso)`
: detail.systemInfo.almacenamiento,
},
]
: []
const networkItems = detail
? [
{ label: 'Dirección IP', value: detail.networkInfo.direccionIp },
{ label: 'Dirección MAC', value: detail.networkInfo.direccionMac },
{ label: 'Gateway', value: detail.networkInfo.gateway },
{ label: 'DNS', value: detail.networkInfo.dns },
]
: []
const handleSaveEdit = (e: React.FormEvent) => {
e.preventDefault()
if (!deviceId || !editForm) return
setEditError(null)
updateMutation.mutate({
id: deviceId,
tipo: editForm.tipo as DeviceTypeValue,
nombre: editForm.nombre.trim(),
descripcion: editForm.descripcion.trim() || null,
ubicacionId: editForm.ubicacionId || null,
estado: editForm.estado as DeviceStatusValue,
ip: editForm.ip.trim() || null,
mac: editForm.mac.trim() || null,
sistemaOperativo: editForm.sistemaOperativo.trim() || null,
versionSO: editForm.versionSO.trim() || null,
fabricante: editForm.fabricante.trim() || null,
modelo: editForm.modelo.trim() || null,
serial: editForm.serial.trim() || null,
})
}
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="device-detail-title"
>
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div
className={cn(
'relative w-full max-w-2xl max-h-[90vh] overflow-hidden rounded-xl border border-white/10 bg-dark-400 shadow-xl',
'flex flex-col'
)}
>
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
<div className="flex items-center gap-3 min-w-0">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-dark-300 border border-white/10 text-gray-400">
{detail ? DEVICE_TYPE_ICONS[detail.tipo] ?? DEVICE_TYPE_ICONS.OTRO : DEVICE_TYPE_ICONS.OTRO}
</div>
<h2
id="device-detail-title"
className="text-lg font-semibold text-white truncate"
>
{detail?.nombre ?? deviceName}
</h2>
</div>
<div className="flex items-center gap-1 shrink-0">
{detail && !isEditing && (
<button
type="button"
onClick={() => setIsEditing(true)}
className="rounded-lg p-2 text-gray-400 hover:bg-white/10 hover:text-primary-400 transition-colors"
aria-label="Editar dispositivo"
>
<Pencil className="w-5 h-5" />
</button>
)}
<button
type="button"
onClick={onClose}
className="rounded-lg p-2 text-gray-400 hover:bg-white/10 hover:text-white transition-colors"
aria-label="Cerrar"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{isLoading && !editForm && (
<div className="py-12 text-center text-sm text-gray-500">
Cargando información del dispositivo...
</div>
)}
{hasError && (
<div className="rounded-lg bg-red-500/20 border border-red-500/40 px-4 py-3 text-sm text-red-400">
No se pudo cargar el dispositivo. Intente de nuevo.
</div>
)}
{isEditing && editForm && device && (
<form onSubmit={handleSaveEdit} className="space-y-4">
{editError && (
<div className="rounded-lg bg-red-500/20 border border-red-500/40 px-4 py-2 text-sm text-red-400">
{editError}
</div>
)}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">Tipo</label>
<select
value={editForm.tipo}
onChange={(e) => setEditForm((f) => f && { ...f, tipo: e.target.value })}
className={INPUT_CLASS}
>
{DEVICE_TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">Nombre *</label>
<input
type="text"
value={editForm.nombre}
onChange={(e) => setEditForm((f) => f && { ...f, nombre: e.target.value })}
className={INPUT_CLASS}
required
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">Descripción</label>
<textarea
value={editForm.descripcion}
onChange={(e) => setEditForm((f) => f && { ...f, descripcion: e.target.value })}
className={cn(INPUT_CLASS, 'min-h-[80px] resize-y')}
rows={2}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">Ubicación</label>
<select
value={editForm.ubicacionId}
onChange={(e) => setEditForm((f) => f && { ...f, ubicacionId: e.target.value })}
className={INPUT_CLASS}
>
<option value="">Sin ubicación</option>
{(locationsQuery.data ?? []).map((loc) => (
<option key={loc.id} value={loc.id}>{loc.nombre}</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">Estado</label>
<select
value={editForm.estado}
onChange={(e) => setEditForm((f) => f && { ...f, estado: e.target.value })}
className={INPUT_CLASS}
>
{DEVICE_STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">IP</label>
<input
type="text"
value={editForm.ip}
onChange={(e) => setEditForm((f) => f && { ...f, ip: e.target.value })}
className={INPUT_CLASS}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">MAC</label>
<input
type="text"
value={editForm.mac}
onChange={(e) => setEditForm((f) => f && { ...f, mac: e.target.value })}
className={INPUT_CLASS}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">Sistema operativo</label>
<input
type="text"
value={editForm.sistemaOperativo}
onChange={(e) => setEditForm((f) => f && { ...f, sistemaOperativo: e.target.value })}
className={INPUT_CLASS}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">Versión SO</label>
<input
type="text"
value={editForm.versionSO}
onChange={(e) => setEditForm((f) => f && { ...f, versionSO: e.target.value })}
className={INPUT_CLASS}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">Fabricante</label>
<input
type="text"
value={editForm.fabricante}
onChange={(e) => setEditForm((f) => f && { ...f, fabricante: e.target.value })}
className={INPUT_CLASS}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">Modelo</label>
<input
type="text"
value={editForm.modelo}
onChange={(e) => setEditForm((f) => f && { ...f, modelo: e.target.value })}
className={INPUT_CLASS}
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">Número de serie</label>
<input
type="text"
value={editForm.serial}
onChange={(e) => setEditForm((f) => f && { ...f, serial: e.target.value })}
className={INPUT_CLASS}
/>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-white/10">
<button
type="button"
onClick={() => setIsEditing(false)}
className="btn btn-secondary"
>
Cancelar
</button>
<button
type="submit"
disabled={updateMutation.isPending}
className="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{updateMutation.isPending ? 'Guardando...' : 'Guardar'}
</button>
</div>
</form>
)}
{detail && !isLoading && !isEditing && (
<>
<InfoSection title="Información del sistema" items={systemItems} />
<InfoSection title="Información de red" items={networkItems} />
<SoftwareList items={detail.software} />
</>
)}
</div>
{detail && !isEditing && (
<ActionBar
deviceId={detail.id}
onConnect={onConnect}
onTerminal={onTerminal}
onFiles={onFiles}
loading={isLoading}
/>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,19 @@
'use client'
interface InfoCardProps {
label: string
value: string
}
export default function InfoCard({ label, value }: InfoCardProps) {
return (
<div className="rounded-lg border border-white/10 bg-dark-300/80 px-4 py-3">
<p className="text-xs font-medium uppercase tracking-wider text-gray-400">
{label}
</p>
<p className="mt-1 text-sm font-medium text-gray-100 truncate" title={value}>
{value}
</p>
</div>
)
}

View File

@@ -0,0 +1,21 @@
'use client'
import InfoCard from './InfoCard'
interface InfoSectionProps {
title: string
items: { label: string; value: string }[]
}
export default function InfoSection({ title, items }: InfoSectionProps) {
return (
<section>
<h3 className="mb-3 text-sm font-semibold text-gray-300">{title}</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{items.map((item) => (
<InfoCard key={item.label} label={item.label} value={item.value} />
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,36 @@
'use client'
import type { InstalledSoftware } from '@/mocks/deviceDetailData'
interface SoftwareListProps {
items: InstalledSoftware[]
}
export default function SoftwareList({ items }: SoftwareListProps) {
return (
<section>
<h3 className="mb-3 text-sm font-semibold text-gray-300">
Software instalado
</h3>
<div className="max-h-48 overflow-y-auto rounded-lg border border-white/10 bg-dark-300/80 divide-y divide-white/5">
{items.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-gray-500">
Sin información de software
</div>
) : (
items.map((s) => (
<div
key={s.id}
className="flex items-center justify-between gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
>
<span className="text-sm text-gray-200 truncate">{s.nombre}</span>
<span className="text-xs text-gray-500 shrink-0 tabular-nums">
{s.version}
</span>
</div>
))
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,91 @@
'use client'
import { useRef } from 'react'
import { FolderOpen, Upload, RefreshCw } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { FileItem } from './FileRow'
import FileTable from './FileTable'
interface FileExplorerContainerProps {
selectedDeviceName: string | null
currentPath: string
files: FileItem[]
onFolderClick?: (name: string) => void
onRefresh?: () => void
onUpload?: () => void
}
export default function FileExplorerContainer({
selectedDeviceName,
currentPath,
files,
onFolderClick,
onRefresh,
onUpload,
}: FileExplorerContainerProps) {
const pathRef = useRef<HTMLDivElement>(null)
const hasDevice = !!selectedDeviceName
return (
<div
className={cn(
'flex h-[520px] min-h-[320px] flex-col overflow-hidden rounded-xl',
'border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'shadow-lg'
)}
>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-white/10 bg-dark-200/80 px-4 py-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<button
type="button"
onClick={onUpload}
disabled={!hasDevice}
className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
>
<Upload className="h-4 w-4" />
Subir
</button>
<div
ref={pathRef}
className="min-w-0 flex-1 overflow-x-auto rounded-lg border border-white/10 bg-dark-300/80 px-3 py-2 font-mono text-sm text-gray-400"
>
{currentPath}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={onRefresh}
disabled={!hasDevice}
className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
>
<RefreshCw className="h-4 w-4" />
Actualizar
</button>
<button
type="button"
onClick={onUpload}
disabled={!hasDevice}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<Upload className="h-4 w-4" />
Subir
</button>
</div>
</div>
<div className="flex flex-1 flex-col overflow-hidden">
{!hasDevice ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
<FolderOpen className="h-16 w-16 text-gray-600" />
<p className="text-gray-500">
Selecciona un dispositivo para explorar archivos
</p>
</div>
) : (
<FileTable files={files} onFolderClick={onFolderClick} />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
'use client'
import { Folder, FileText } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface FileItem {
id: string
name: string
type: 'folder' | 'file'
size: string | null
modifiedAt: string
}
interface FileRowProps {
file: FileItem
onFolderClick?: (name: string) => void
}
export default function FileRow({ file, onFolderClick }: FileRowProps) {
const isFolder = file.type === 'folder'
return (
<tr
className={cn(
'border-b border-white/5 transition-colors last:border-b-0',
'hover:bg-white/5 cursor-pointer',
isFolder && 'cursor-pointer'
)}
onClick={() => isFolder && onFolderClick?.(file.name)}
>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
{isFolder ? (
<Folder className="h-5 w-5 shrink-0 text-amber-400/90" />
) : (
<FileText className="h-5 w-5 shrink-0 text-gray-500" />
)}
<span className="font-medium text-gray-200 truncate">{file.name}</span>
</div>
</td>
<td className="py-3 px-4 text-sm text-gray-500 font-mono">
{file.size ?? '—'}
</td>
<td className="py-3 px-4 text-sm text-gray-500">
{isFolder ? 'Carpeta' : 'Archivo'}
</td>
<td className="py-3 px-4 text-sm text-gray-500">{file.modifiedAt}</td>
</tr>
)
}

View File

@@ -0,0 +1,35 @@
'use client'
import type { FileItem } from './FileRow'
import FileRow from './FileRow'
interface FileTableProps {
files: FileItem[]
onFolderClick?: (name: string) => void
}
export default function FileTable({ files, onFolderClick }: FileTableProps) {
return (
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse">
<thead className="sticky top-0 z-10 bg-dark-300/95 backdrop-blur">
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th className="py-3 px-4">Nombre</th>
<th className="py-3 px-4 w-28">Tamaño</th>
<th className="py-3 px-4 w-24">Tipo</th>
<th className="py-3 px-4 w-36">Fecha modificación</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<FileRow
key={file.id}
file={file}
onFolderClick={onFolderClick}
/>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,28 @@
'use client'
import { cn } from '@/lib/utils'
import type { AppDeployment } from '@/mocks/mdmDashboardData'
interface CorporateAppRowProps {
app: AppDeployment
}
export default function CorporateAppRow({ app }: CorporateAppRowProps) {
const isIncomplete = app.deployed < app.total
return (
<div className="flex items-center justify-between border-b border-white/5 py-3 last:border-b-0 transition-colors hover:bg-white/5">
<div className="min-w-0">
<p className="font-medium text-gray-200">{app.name}</p>
<p className="text-sm text-gray-500">v{app.version}</p>
</div>
<span
className={cn(
'shrink-0 text-sm tabular-nums',
isIncomplete ? 'text-amber-400' : 'text-gray-400'
)}
>
{app.deployed}/{app.total} dispositivos
</span>
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import type { AppDeployment } from '@/mocks/mdmDashboardData'
import CorporateAppRow from './CorporateAppRow'
interface CorporateAppsListProps {
apps: AppDeployment[]
}
export default function CorporateAppsList({ apps }: CorporateAppsListProps) {
return (
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<div className="border-b border-white/10 px-4 py-3">
<h3 className="text-sm font-medium text-gray-400">
Apps Corporativas
</h3>
</div>
<div className="divide-y divide-white/5">
{apps.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
Sin apps desplegadas
</div>
) : (
apps.map((app) => (
<div key={app.id} className="px-4">
<CorporateAppRow app={app} />
</div>
))
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
'use client'
import { cn } from '@/lib/utils'
import type { Device, DeviceStatus } from '@/mocks/mdmDashboardData'
const statusConfig: Record<DeviceStatus, { dot: string; text: string }> = {
online: { dot: 'bg-green-500', text: 'text-green-400' },
offline: { dot: 'bg-red-500', text: 'text-red-400' },
kiosk: { dot: 'bg-blue-500', text: 'text-blue-400' },
}
const statusLabel: Record<DeviceStatus, string> = {
online: 'Online',
offline: 'Offline',
kiosk: 'Kiosk',
}
interface DeviceItemProps {
device: Device
}
export default function DeviceItem({ device }: DeviceItemProps) {
const config = statusConfig[device.status]
return (
<div className="flex items-center justify-between border-b border-white/5 py-3 last:border-b-0 transition-colors hover:bg-white/5">
<div className="min-w-0 flex-1">
<p className="font-medium text-gray-200">{device.name}</p>
<p className="mt-0.5 text-sm text-gray-500">
Android {device.androidVersion} · Batería {device.batteryPercent}%
</p>
</div>
<span className={cn('ml-4 inline-flex items-center gap-1.5 text-sm shrink-0', config.text)}>
<span className={cn('h-2 w-2 rounded-full', config.dot)} />
{statusLabel[device.status]}
</span>
</div>
)
}

View File

@@ -0,0 +1,45 @@
'use client'
import { cn } from '@/lib/utils'
type AccentColor = 'green' | 'blue' | 'cyan' | 'amber'
interface HeadwindMetricCardProps {
label: string
value: string | number
subtitle: string
accent?: AccentColor
}
const accentColors: Record<AccentColor, string> = {
green: 'text-emerald-400',
blue: 'text-blue-400',
cyan: 'text-cyan-400',
amber: 'text-amber-400',
}
export default function HeadwindMetricCard({
label,
value,
subtitle,
accent = 'cyan',
}: HeadwindMetricCardProps) {
return (
<div
className={cn(
'rounded-xl border border-white/10 overflow-hidden',
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'flex flex-col justify-center px-5 py-6',
'transition-all duration-200 hover:border-white/20 hover:shadow-lg'
)}
>
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
{label}
</span>
<span className={cn('mt-1 text-4xl font-bold tabular-nums', accentColors[accent])}>
{value}
</span>
<span className="mt-1 text-sm text-gray-400">{subtitle}</span>
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import type { Device } from '@/mocks/mdmDashboardData'
import DeviceItem from './DeviceItem'
interface MobileDeviceListProps {
devices: Device[]
}
export default function MobileDeviceList({ devices }: MobileDeviceListProps) {
return (
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<div className="border-b border-white/10 px-4 py-3">
<h3 className="text-sm font-medium text-gray-400">
Dispositivos Móviles
</h3>
</div>
<div className="divide-y divide-white/5">
{devices.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
Sin dispositivos
</div>
) : (
devices.map((device) => (
<div key={device.id} className="px-4">
<DeviceItem device={device} />
</div>
))
)}
</div>
</div>
)
}

View File

@@ -67,13 +67,13 @@ const menuConfig: SidebarMenuSection[] = [
items: [
{
label: 'LibreNMS',
href: '/configuracion',
href: '/librenms',
icon: <Network className="w-5 h-5" />,
badge: { type: 'green', value: 'OK' },
},
{
label: 'Headwind MDM',
href: '/configuracion',
href: '/headwind',
icon: <Smartphone className="w-5 h-5" />,
badge: { type: 'blue', value: 12 },
},
@@ -102,11 +102,12 @@ const menuConfig: SidebarMenuSection[] = [
interface SidebarProps {
activeAlertsCount?: number
devicesCount?: number
sessionsCount?: number
open?: boolean
onClose?: () => void
}
export default function Sidebar({ activeAlertsCount, devicesCount, open = false, onClose }: SidebarProps) {
export default function Sidebar({ activeAlertsCount, devicesCount, sessionsCount, open = false, onClose }: SidebarProps) {
const pathname = usePathname()
const isActive = (href: string) => {
@@ -122,6 +123,10 @@ export default function Sidebar({ activeAlertsCount, devicesCount, open = false,
if (item.href === '/devices' && devicesCount !== undefined) {
return { type: 'red', value: devicesCount }
}
if (item.href === '/sesiones' && sessionsCount !== undefined) {
if (sessionsCount === 0) return undefined
return { type: 'red', value: sessionsCount }
}
return item.badge
}

View File

@@ -0,0 +1,33 @@
'use client'
import { cn } from '@/lib/utils'
export type AlertSeverity = 'critical' | 'warning' | 'info'
export interface SnmpAlert {
id: string
title: string
detail: string
severity: AlertSeverity
}
const severityStyles: Record<AlertSeverity, string> = {
critical: 'text-red-400',
warning: 'text-yellow-400',
info: 'text-blue-400',
}
interface AlertItemProps {
alert: SnmpAlert
}
export default function AlertItem({ alert }: AlertItemProps) {
return (
<div className="border-b border-white/5 py-4 last:border-b-0">
<p className={cn('font-medium', severityStyles[alert.severity])}>
{alert.title}
</p>
<p className="mt-1 text-sm text-gray-500">{alert.detail}</p>
</div>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
import type { SnmpAlert } from './AlertItem'
import AlertItem from './AlertItem'
interface AlertListProps {
alerts: SnmpAlert[]
}
export default function AlertList({ alerts }: AlertListProps) {
return (
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<div className="border-b border-white/10 px-4 py-3">
<h3 className="text-sm font-medium text-gray-400">
Alertas SNMP Recientes
</h3>
</div>
<div className="px-4">
{alerts.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
Sin alertas
</div>
) : (
alerts.map((alert) => (
<AlertItem key={alert.id} alert={alert} />
))
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import type { NetworkDevice } from './DeviceRow'
import DeviceRow from './DeviceRow'
interface DeviceListProps {
devices: NetworkDevice[]
}
export default function DeviceList({ devices }: DeviceListProps) {
return (
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<div className="border-b border-white/10 px-4 py-3">
<h3 className="text-sm font-medium text-gray-400">
Dispositivos de Red
</h3>
</div>
<div className="divide-y divide-white/5">
{devices.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
Sin dispositivos
</div>
) : (
devices.map((device) => (
<div key={device.id} className="px-4">
<DeviceRow device={device} />
</div>
))
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
import type { DeviceStatus } from './StatusBadge'
import StatusBadge from './StatusBadge'
export interface NetworkDevice {
id: string
name: string
model?: string
status: DeviceStatus
}
const statusLabel: Record<DeviceStatus, string> = {
online: 'Online',
warning: 'Warning',
critical: 'Critical',
}
interface DeviceRowProps {
device: NetworkDevice
}
export default function DeviceRow({ device }: DeviceRowProps) {
return (
<div
className="flex items-center justify-between border-b border-white/5 py-3 last:border-b-0 transition-colors hover:bg-white/5"
>
<div className="min-w-0">
<span className="font-medium text-gray-200">{device.name}</span>
{device.model && (
<span className="ml-2 text-sm text-gray-500"> {device.model}</span>
)}
</div>
<StatusBadge status={device.status} label={statusLabel[device.status]} />
</div>
)
}

View File

@@ -0,0 +1,45 @@
'use client'
import { cn } from '@/lib/utils'
type AccentColor = 'green' | 'cyan' | 'yellow' | 'blue'
interface LibrenmsMetricCardProps {
label: string
value: string | number
subtitle: string
accent?: AccentColor
}
const accentColors: Record<AccentColor, string> = {
green: 'text-emerald-400',
cyan: 'text-cyan-400',
yellow: 'text-amber-400',
blue: 'text-blue-400',
}
export default function LibrenmsMetricCard({
label,
value,
subtitle,
accent = 'cyan',
}: LibrenmsMetricCardProps) {
return (
<div
className={cn(
'rounded-xl border border-white/10 overflow-hidden',
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'flex flex-col justify-center px-5 py-6',
'transition-all duration-200 hover:border-white/20 hover:shadow-lg'
)}
>
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
{label}
</span>
<span className={cn('mt-1 text-4xl font-bold tabular-nums', accentColors[accent])}>
{value}
</span>
<span className="mt-1 text-sm text-gray-400">{subtitle}</span>
</div>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import { cn } from '@/lib/utils'
export interface LibrenmsNodeItem {
id: string
name: string
ip: string
type: string
status: 'up' | 'down' | 'warning'
lastUpdate: string
}
interface LibrenmsRowProps {
item: LibrenmsNodeItem
}
const statusConfig = {
up: { label: 'En línea', dot: 'bg-emerald-500' },
down: { label: 'Fuera de línea', dot: 'bg-red-500' },
warning: { label: 'Alerta', dot: 'bg-amber-500' },
}
export default function LibrenmsRow({ item }: LibrenmsRowProps) {
const status = statusConfig[item.status]
return (
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5 cursor-pointer">
<td className="py-3 px-4">
<span className="font-medium text-gray-200">{item.name}</span>
</td>
<td className="py-3 px-4 font-mono text-sm text-gray-400">
{item.ip}
</td>
<td className="py-3 px-4">
<span className="rounded-full bg-cyan-500/10 px-3 py-1 text-xs text-cyan-400">
{item.type}
</span>
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<span className={cn('h-2 w-2 rounded-full', status.dot)} />
<span className="text-sm text-gray-400">{status.label}</span>
</div>
</td>
<td className="py-3 px-4 text-sm text-gray-500">{item.lastUpdate}</td>
</tr>
)
}

View File

@@ -0,0 +1,26 @@
'use client'
import { cn } from '@/lib/utils'
export type DeviceStatus = 'online' | 'warning' | 'critical'
interface StatusBadgeProps {
status: DeviceStatus
label: string
}
const statusConfig: Record<DeviceStatus, { dot: string; text: string }> = {
online: { dot: 'bg-green-500', text: 'text-green-400' },
warning: { dot: 'bg-yellow-500', text: 'text-yellow-400' },
critical: { dot: 'bg-red-500', text: 'text-red-400' },
}
export default function StatusBadge({ status, label }: StatusBadgeProps) {
const config = statusConfig[status]
return (
<span className={cn('inline-flex items-center gap-1.5 text-sm', config.text)}>
<span className={cn('h-2 w-2 shrink-0 rounded-full', config.dot)} />
{label}
</span>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import { cn } from '@/lib/utils'
interface LineChartProps {
className?: string
height?: number
data?: number[]
}
export default function LineChart({
className,
height = 160,
data = [],
}: LineChartProps) {
const points = data.length >= 2
? data
.map((v, i) => {
const x = (i / Math.max(1, data.length - 1)) * 100
const y = 100 - Math.min(100, Math.max(0, v))
return `${x},${y}`
})
.join(' ')
: '0,100 100,100'
return (
<div
className={cn(
'rounded-lg bg-dark-200/80 border border-white/5 overflow-hidden',
className
)}
style={{ height }}
>
{data.length >= 2 ? (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="w-full h-full text-cyan-500/30"
>
<polyline
fill="none"
stroke="currentColor"
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
className="transition-all duration-500"
/>
</svg>
) : (
<div className="flex h-full items-center justify-center text-gray-600 text-sm">
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,72 @@
'use client'
import { cn } from '@/lib/utils'
import LineChart from './LineChart'
export interface MetricCardFooterRow {
label: string
value: string
}
interface MetricCardProps {
title: string
value: string
valueSuffix?: string
footerStats: MetricCardFooterRow[]
chartData?: number[]
highUsage?: boolean
}
export default function MetricCard({
title,
value,
valueSuffix,
footerStats,
chartData,
highUsage = false,
}: MetricCardProps) {
return (
<div
className={cn(
'flex flex-col rounded-xl border border-white/10 overflow-hidden',
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'transition-all duration-200 hover:border-white/20'
)}
>
<div className="flex shrink-0 items-center justify-between px-4 py-3 border-b border-white/5">
<span className="text-sm font-medium text-gray-400">{title}</span>
<span
className={cn(
'font-mono text-lg font-semibold tabular-nums transition-colors duration-300',
highUsage ? 'text-red-400' : 'text-cyan-400'
)}
>
{value}
{valueSuffix != null && (
<span className="text-sm font-normal text-gray-500 ml-0.5">
{valueSuffix}
</span>
)}
</span>
</div>
<div className="shrink-0 px-4 pt-3">
<LineChart height={150} data={chartData ?? []} />
</div>
<div className="shrink-0 space-y-1.5 px-4 py-3 border-t border-white/5">
{footerStats.map((row) => (
<div
key={row.label}
className="flex justify-between text-xs"
>
<span className="text-gray-500">{row.label}</span>
<span className="font-mono text-gray-400 tabular-nums">
{row.value}
</span>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
'use client'
import { cn } from '@/lib/utils'
export interface ProcessItem {
id: string
name: string
pid: number
cpu: number
memory: string
state: string
}
interface ProcessRowProps {
process: ProcessItem
}
export default function ProcessRow({ process }: ProcessRowProps) {
return (
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5">
<td className="py-2.5 px-4 text-sm text-gray-200 truncate max-w-[200px]">
{process.name}
</td>
<td className="py-2.5 px-4 font-mono text-sm text-gray-400 tabular-nums">
{process.pid}
</td>
<td className="py-2.5 px-4 font-mono text-sm text-cyan-400 tabular-nums">
{process.cpu}%
</td>
<td className="py-2.5 px-4 font-mono text-sm text-gray-400">
{process.memory}
</td>
<td className="py-2.5 px-4 text-sm text-gray-500">{process.state}</td>
</tr>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import type { ProcessItem } from './ProcessRow'
import ProcessRow from './ProcessRow'
interface ProcessTableProps {
processes: ProcessItem[]
noDevice?: boolean
}
export default function ProcessTable({ processes, noDevice }: ProcessTableProps) {
if (noDevice) {
return (
<div className="rounded-xl border border-white/10 bg-dark-300/50 overflow-hidden">
<div className="px-4 py-3 border-b border-white/10">
<h3 className="text-sm font-medium text-gray-400">
Procesos Activos (Top 10 por CPU)
</h3>
</div>
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
Selecciona un dispositivo
</div>
</div>
)
}
return (
<div className="rounded-xl border border-white/10 bg-dark-300/50 overflow-hidden">
<div className="px-4 py-3 border-b border-white/10">
<h3 className="text-sm font-medium text-gray-400">
Procesos Activos (Top 10 por CPU)
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th className="py-3 px-4">Proceso</th>
<th className="py-3 px-4 w-20">PID</th>
<th className="py-3 px-4 w-24">CPU %</th>
<th className="py-3 px-4 w-24">Memoria</th>
<th className="py-3 px-4 w-24">Estado</th>
</tr>
</thead>
<tbody>
{processes.length === 0 ? (
<tr>
<td
colSpan={5}
className="py-8 text-center text-sm text-gray-500"
>
Sin datos
</td>
</tr>
) : (
processes.map((p) => (
<ProcessRow key={p.id} process={p} />
))
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
export interface DateRangeValue {
desde: string
hasta: string
}
interface DateRangeFilterProps {
value: DateRangeValue
onChange: (value: DateRangeValue) => void
}
export default function DateRangeFilter({ value, onChange }: DateRangeFilterProps) {
const handleDesdeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const desde = e.target.value
const hasta = value.hasta && desde > value.hasta ? desde : value.hasta
onChange({ desde, hasta })
}
const handleHastaChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const hasta = e.target.value
onChange({ ...value, hasta })
}
const isHastaBeforeDesde =
value.desde && value.hasta && value.hasta < value.desde
return (
<div className="flex flex-wrap items-center gap-4">
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
Desde
</span>
<input
type="date"
value={value.desde}
onChange={handleDesdeChange}
className="rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
Hasta
</span>
<input
type="date"
value={value.hasta}
onChange={handleHastaChange}
min={value.desde || undefined}
className="rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
/>
</label>
{isHastaBeforeDesde && (
<p className="text-sm text-amber-400" role="alert">
La fecha Hasta no puede ser anterior a Desde
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import { cn } from '@/lib/utils'
import { FileText, FileSpreadsheet } from 'lucide-react'
interface ReportCardProps {
title: string
description: string
onExportPdf: () => void
onExportExcel: () => void
loading?: boolean
}
export default function ReportCard({
title,
description,
onExportPdf,
onExportExcel,
loading = false,
}: ReportCardProps) {
const btnBase =
'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50'
const btnPrimary =
'bg-cyan-600 text-white hover:bg-cyan-500'
const btnOutlined =
'border border-white/10 text-gray-300 hover:bg-white/5 hover:text-gray-200'
return (
<div
className={cn(
'rounded-xl border border-white/10 overflow-hidden',
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'flex flex-col'
)}
>
<div className="border-b border-white/10 px-4 py-4">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<p className="mt-2 text-sm text-gray-400">{description}</p>
</div>
<div className="flex flex-wrap items-center gap-2 p-4">
<button
type="button"
onClick={onExportPdf}
disabled={loading}
className={cn(btnBase, btnPrimary)}
>
<FileText className="h-4 w-4" />
Exportar PDF
</button>
<button
type="button"
onClick={onExportExcel}
disabled={loading}
className={cn(btnBase, btnOutlined)}
>
<FileSpreadsheet className="h-4 w-4" />
Excel
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,95 @@
'use client'
import { Monitor, Terminal, FolderOpen } from 'lucide-react'
import { cn } from '@/lib/utils'
export type SessionTypeLabel = 'Escritorio Remoto' | 'Terminal' | 'Archivos'
export interface SessionCardProps {
id: string
deviceName: string
userEmail: string
sessionType: SessionTypeLabel
duration: string
onEnd?: (id: string) => void
isEnding?: boolean
}
const typeConfig: Record<
SessionTypeLabel,
{ icon: React.ReactNode; bgClass: string }
> = {
'Escritorio Remoto': {
icon: <Monitor className="h-5 w-5" />,
bgClass: 'bg-cyan-500/15 text-cyan-400',
},
Terminal: {
icon: <Terminal className="h-5 w-5" />,
bgClass: 'bg-emerald-500/15 text-emerald-400',
},
Archivos: {
icon: <FolderOpen className="h-5 w-5" />,
bgClass: 'bg-amber-500/15 text-amber-400',
},
}
export default function SessionCard({
id,
deviceName,
userEmail,
sessionType,
duration,
onEnd,
isEnding,
}: SessionCardProps) {
const config = typeConfig[sessionType]
return (
<div
className={cn(
'flex flex-col gap-4 rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/80 to-dark-400/80 p-5 transition-all duration-200',
'sm:flex-row sm:items-center sm:justify-between',
'hover:border-primary-500/30 hover:shadow-lg hover:shadow-black/20'
)}
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<div
className={cn(
'flex h-12 w-12 shrink-0 items-center justify-center rounded-xl',
config.bgClass
)}
>
{config.icon}
</div>
<div className="min-w-0">
<p className="text-lg font-semibold text-gray-100 truncate">
{deviceName}
</p>
<p className="text-sm text-gray-500 truncate">{userEmail}</p>
</div>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<span className="font-mono text-sm font-medium text-cyan-400 tabular-nums">
{duration}
</span>
<span className="text-xs text-gray-500">{sessionType}</span>
</div>
<div className="shrink-0 sm:pl-4">
<button
type="button"
onClick={() => onEnd?.(id)}
disabled={isEnding}
className={cn(
'rounded-lg border border-red-500/60 px-4 py-2 text-sm font-medium text-red-400',
'transition-colors hover:bg-red-500/15 hover:border-red-500/80',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isEnding ? 'Terminando…' : 'Terminar'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
'use client'
import { cn } from '@/lib/utils'
export interface SoftwareItem {
id: string
name: string
version: string
vendor: string
installations: number
lastUpdate: string
licensed?: boolean
needsUpdate?: boolean
}
interface SoftwareRowProps {
item: SoftwareItem
}
export default function SoftwareRow({ item }: SoftwareRowProps) {
return (
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5">
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-200">{item.name}</span>
{item.licensed && (
<span className="rounded bg-cyan-500/20 px-1.5 py-0.5 text-xs text-cyan-400">
Licencia
</span>
)}
{item.needsUpdate && (
<span className="rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-400">
Actualizar
</span>
)}
</div>
</td>
<td className="py-3 px-4 font-mono text-sm text-gray-400">
{item.version}
</td>
<td className="py-3 px-4 text-sm text-gray-400">{item.vendor}</td>
<td className="py-3 px-4 font-mono text-sm text-cyan-400 tabular-nums">
{item.installations}
</td>
<td className="py-3 px-4 text-sm text-gray-500">{item.lastUpdate}</td>
</tr>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
import type { SoftwareItem } from './SoftwareRow'
import SoftwareRow from './SoftwareRow'
interface SoftwareTableProps {
items: SoftwareItem[]
}
export default function SoftwareTable({ items }: SoftwareTableProps) {
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wide text-gray-400">
<th className="py-3 px-4">Nombre</th>
<th className="py-3 px-4 w-40">Versión</th>
<th className="py-3 px-4 w-48">Editor</th>
<th className="py-3 px-4 w-28">Instalaciones</th>
<th className="py-3 px-4 w-36">Última actualización</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td
colSpan={5}
className="py-12 text-center text-sm text-gray-500"
>
No hay resultados
</td>
</tr>
) : (
items.map((item) => (
<SoftwareRow key={item.id} item={item} />
))
)}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
import { cn } from '@/lib/utils'
interface SummaryMetricCardProps {
value: number
label: string
className?: string
}
export default function SummaryMetricCard({
value,
label,
className,
}: SummaryMetricCardProps) {
return (
<div
className={cn(
'rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'flex flex-col items-center justify-center py-6 px-4',
'transition-all duration-200 hover:border-white/20',
className
)}
>
<span className="text-4xl font-bold text-cyan-400 tabular-nums">
{value}
</span>
<span className="mt-2 text-sm text-gray-400">{label}</span>
</div>
)
}

View File

@@ -0,0 +1,36 @@
'use client'
export interface QuickCommandsProps {
onSelectCommand: (command: string) => void
}
const COMMANDS = [
'systeminfo',
'ipconfig',
'tasklist',
'netstat',
'CPU Info',
'RAM Info',
'dir C:\\',
'hostname',
]
export default function QuickCommands({ onSelectCommand }: QuickCommandsProps) {
return (
<section className="space-y-3">
<h3 className="text-sm font-medium text-gray-400">Comandos Rápidos</h3>
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin">
{COMMANDS.map((cmd) => (
<button
key={cmd}
type="button"
onClick={() => onSelectCommand(cmd)}
className="shrink-0 rounded-lg border border-white/10 bg-dark-300/80 px-4 py-2 font-mono text-xs text-gray-300 transition-colors hover:border-cyan-500/30 hover:bg-dark-200 hover:text-cyan-400"
>
{cmd}
</button>
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,153 @@
'use client'
import { useRef, useEffect } from 'react'
import { Send, Copy } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface TerminalWindowProps {
connectedDeviceName: string | null
outputLines: string[]
command: string
onCommandChange: (value: string) => void
onSendCommand: () => void
onClear: () => void
onCopy: () => void
disabled?: boolean
}
const ASCII_PLACEHOLDER = `
__ __ _ ____ _ _
| \\/ | ___ ___| |__ / ___|___ _ __ | |_ _ __ __ _| |
| |\\/| |/ _ \\/ __| '_ \\| | / _ \\ '_ \\| __| '__/ _\` | |
| | | | __/\\__ \\ | | | |__| __/ | | | |_| | | (_| | |
|_| |_|\\___||___/_| |_|\\____\\___|_| |_|\\__|_| \\__,_|_|
`
export default function TerminalWindow({
connectedDeviceName,
outputLines,
command,
onCommandChange,
onSendCommand,
onClear,
onCopy,
disabled = false,
}: TerminalWindowProps) {
const outputEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
outputEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [outputLines])
const isEmpty = outputLines.length === 0
const showPlaceholder = !connectedDeviceName
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
onSendCommand()
}
}
return (
<div
className={cn(
'flex h-[520px] min-h-[320px] flex-col overflow-hidden rounded-xl',
'border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'shadow-lg'
)}
>
<div className="flex shrink-0 items-center justify-between border-b border-white/10 bg-dark-200/80 px-4 py-2.5">
<div className="flex items-center gap-3">
<div className="flex gap-1.5">
<span className="h-3 w-3 rounded-full bg-red-500/90" />
<span className="h-3 w-3 rounded-full bg-amber-500/90" />
<span className="h-3 w-3 rounded-full bg-emerald-500/90" />
</div>
<span className="text-sm font-medium text-gray-400">
bash {connectedDeviceName ?? 'No conectado'}
</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onClear}
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200"
>
Limpiar
</button>
<button
type="button"
onClick={onCopy}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200"
>
<Copy className="h-3.5 w-3.5" />
Copiar
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 font-mono text-sm">
{showPlaceholder ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<pre className="whitespace-pre text-cyan-400/80">{ASCII_PLACEHOLDER}</pre>
<p className="mt-6 text-gray-500">
Selecciona un dispositivo para iniciar una sesión de terminal.
</p>
</div>
) : isEmpty ? (
<div className="flex h-full flex-col justify-end">
<div ref={outputEndRef} />
</div>
) : (
<div className="space-y-0.5">
{outputLines.map((line, i) => (
<div
key={i}
className={cn(
line.startsWith('$ ')
? 'text-cyan-400'
: line.startsWith('> ')
? 'text-gray-500'
: 'text-gray-300'
)}
>
{line}
</div>
))}
<div ref={outputEndRef} />
</div>
)}
</div>
<div className="shrink-0 border-t border-white/10 bg-dark-200/60 px-4 py-3">
<div className="flex items-center gap-2">
<span className="shrink-0 font-mono text-cyan-400">$</span>
<input
type="text"
value={command}
onChange={(e) => onCommandChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Escribe un comando..."
disabled={disabled}
className={cn(
'min-w-0 flex-1 bg-transparent font-mono text-gray-200 placeholder-gray-500',
'focus:outline-none disabled:cursor-not-allowed disabled:opacity-60'
)}
/>
<button
type="button"
onClick={onSendCommand}
disabled={disabled}
className={cn(
'flex shrink-0 items-center justify-center rounded-lg bg-cyan-600 px-4 py-2',
'text-white transition-all hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-50'
)}
>
<Send className="h-4 w-4" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -24,6 +24,14 @@ export function formatUptime(seconds: number): string {
return `${minutes}m`
}
export function formatDurationSeconds(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
const pad = (n: number) => n.toString().padStart(2, '0')
return `${pad(h)}:${pad(m)}:${pad(s)}`
}
export function formatDate(date: Date | string): string {
const d = new Date(date)
return d.toLocaleDateString('es-MX', {

View File

@@ -0,0 +1,83 @@
export interface DashboardStats {
totalDispositivos: number
dispositivosOnline: number
dispositivosOffline: number
dispositivosAlerta: number
secondary?: {
total?: string
online?: string
offline?: string
alerta?: string
}
}
export interface DeviceStatusBreakdown {
online: number
offline: number
advertencia: number
}
export type RecentActivityType = 'login' | 'alert' | 'connection' | 'disconnection'
export type AlertSeverity = 'INFO' | 'WARNING' | 'CRITICAL'
export interface RecentActivityItem {
id: string
type: RecentActivityType
description: string
deviceName: string
timestamp: Date
severity?: AlertSeverity
}
export interface SystemHealthMetric {
label: string
value: number
unit: string
status: 'healthy' | 'warning' | 'critical'
}
export interface SystemHealth {
cpu: SystemHealthMetric
ram: SystemHealthMetric
network: SystemHealthMetric
}
export type QuickConnectionStatus = 'online' | 'advertencia' | 'offline'
export interface QuickConnectionItem {
id: string
name: string
status: QuickConnectionStatus
}
export const MOCK_DASHBOARD_SECONDARY = {
total: '+2 este mes',
online: '60% disponibilidad',
offline: '-1 vs ayer',
alerta: '2 requieren atención',
}
export const MOCK_RECENT_ACTIVITY: RecentActivityItem[] = [
{ id: '1', type: 'login', description: 'Sesión iniciada', deviceName: 'PC-ADMIN-01', timestamp: new Date(Date.now() - 1000 * 60 * 2) },
{ id: '2', type: 'alert', description: 'CPU alta detectada', deviceName: 'LAPTOP-VENTAS', timestamp: new Date(Date.now() - 1000 * 60 * 15) },
{ id: '3', type: 'connection', description: 'Dispositivo conectado', deviceName: 'SRV-PRINCIPAL', timestamp: new Date(Date.now() - 1000 * 60 * 32) },
{ id: '4', type: 'disconnection', description: 'Conexión perdida', deviceName: 'SRV-BACKUP', timestamp: new Date(Date.now() - 1000 * 60 * 120) },
{ id: '5', type: 'alert', description: 'Actualización pendiente', deviceName: 'PC-OFICINA-02', timestamp: new Date(Date.now() - 1000 * 60 * 45) },
{ id: '6', type: 'connection', description: 'Dispositivo conectado', deviceName: 'ROUTER-PRINCIPAL', timestamp: new Date(Date.now() - 1000 * 60 * 90) },
]
export const MOCK_SYSTEM_HEALTH: SystemHealth = {
cpu: { label: 'CPU Promedio', value: 42, unit: '%', status: 'healthy' },
ram: { label: 'RAM Promedio', value: 68, unit: '%', status: 'warning' },
network: { label: 'Red', value: 125, unit: 'MB/s', status: 'healthy' },
}
export const MOCK_QUICK_CONNECTIONS: QuickConnectionItem[] = [
{ id: '1', name: 'SRV-PRINCIPAL', status: 'online' },
{ id: '2', name: 'PC-ADMIN-01', status: 'online' },
{ id: '3', name: 'LAPTOP-VENTAS', status: 'advertencia' },
{ id: '4', name: 'ROUTER-PRINCIPAL', status: 'online' },
{ id: '5', name: 'SW-CORE-01', status: 'online' },
{ id: '6', name: 'SRV-BACKUP', status: 'offline' },
]

View File

@@ -0,0 +1,103 @@
export interface DeviceSystemInfo {
sistemaOperativo: string
procesador: string
memoriaRam: string
almacenamiento: string
almacenamientoUsoPercent?: number
}
export interface DeviceNetworkInfo {
direccionIp: string
direccionMac: string
gateway: string
dns: string
}
export interface InstalledSoftware {
id: string
nombre: string
version: string
}
export interface DeviceDetail {
id: string
nombre: string
tipo: string
systemInfo: DeviceSystemInfo
networkInfo: DeviceNetworkInfo
software: InstalledSoftware[]
}
export function mapDeviceToDetail(device: {
id: string
nombre: string
tipo: string
sistemaOperativo?: string | null
versionSO?: string | null
cpu?: string | null
ram?: number | null
disco?: number | null
discoUsage?: number | null
ip?: string | null
mac?: string | null
software?: { id: string; nombre: string; version: string | null }[]
}): DeviceDetail {
const ramStr = device.ram != null ? `${device.ram} MB` : '—'
const discoStr = device.disco != null ? `${device.disco} GB` : '—'
return {
id: device.id,
nombre: device.nombre,
tipo: device.tipo,
systemInfo: {
sistemaOperativo: device.sistemaOperativo?.trim() || '—',
procesador: device.cpu?.trim() || '—',
memoriaRam: ramStr,
almacenamiento: discoStr,
almacenamientoUsoPercent: device.discoUsage ?? undefined,
},
networkInfo: {
direccionIp: device.ip?.trim() || '—',
direccionMac: device.mac?.trim() || '—',
gateway: '—',
dns: '—',
},
software: (device.software ?? []).map((s) => ({
id: s.id,
nombre: s.nombre,
version: s.version ?? '—',
})),
}
}
export function fetchDeviceDetailMock(deviceId: string): Promise<DeviceDetail> {
return new Promise((resolve) => {
setTimeout(
() =>
resolve({
id: deviceId,
nombre: 'PC-RECEPCION-01',
tipo: 'PC',
systemInfo: {
sistemaOperativo: 'Windows 11 Pro',
procesador: 'Intel Core i5-12400',
memoriaRam: '16384 MB',
almacenamiento: '512 GB',
almacenamientoUsoPercent: 62,
},
networkInfo: {
direccionIp: '192.168.1.101',
direccionMac: '00:1A:2B:3C:4D:5E',
gateway: '192.168.1.1',
dns: '8.8.8.8',
},
software: [
{ id: '1', nombre: 'Microsoft Office 365', version: '16.0.17029' },
{ id: '2', nombre: 'Google Chrome', version: '120.0.6099' },
{ id: '3', nombre: 'Adobe Acrobat Reader', version: '23.006' },
{ id: '4', nombre: 'WinRAR', version: '6.24' },
],
}),
400
)
})
}

View File

@@ -0,0 +1,58 @@
export type DeviceStatus = 'online' | 'offline' | 'kiosk'
export interface Device {
id: string
name: string
androidVersion: string
batteryPercent: number
status: DeviceStatus
}
export interface AppDeployment {
id: string
name: string
version: string
deployed: number
total: number
}
export interface DashboardStats {
totalAndroidDevices: number
deployedApps: number
activePolicies: number
averageBatteryPercent: number
}
export const MOCK_DASHBOARD_STATS: DashboardStats = {
totalAndroidDevices: 12,
deployedApps: 8,
activePolicies: 5,
averageBatteryPercent: 78,
}
export const MOCK_DEVICES: Device[] = [
{ id: '1', name: 'Samsung Galaxy A54 Ventas01', androidVersion: '14', batteryPercent: 92, status: 'online' },
{ id: '2', name: 'Samsung Galaxy A54 Ventas02', androidVersion: '14', batteryPercent: 45, status: 'online' },
{ id: '3', name: 'Xiaomi Redmi Note 12 Almacén', androidVersion: '13', batteryPercent: 100, status: 'kiosk' },
{ id: '4', name: 'Motorola Moto G Reparto01', androidVersion: '13', batteryPercent: 12, status: 'offline' },
{ id: '5', name: 'Samsung Galaxy A34 Oficina', androidVersion: '14', batteryPercent: 78, status: 'online' },
]
export const MOCK_APP_DEPLOYMENTS: AppDeployment[] = [
{ id: '1', name: 'App Corporativa Ventas', version: '2.1.0', deployed: 12, total: 12 },
{ id: '2', name: 'Headwind Kiosk', version: '1.4.2', deployed: 10, total: 12 },
{ id: '3', name: 'Authenticator', version: '6.2.1', deployed: 12, total: 12 },
{ id: '4', name: 'Microsoft Teams', version: '1416/1.0.0', deployed: 8, total: 12 },
]
export function getMdmDashboardData(): {
stats: DashboardStats
devices: Device[]
appDeployments: AppDeployment[]
} {
return {
stats: MOCK_DASHBOARD_STATS,
devices: MOCK_DEVICES,
appDeployments: MOCK_APP_DEPLOYMENTS,
}
}

100
src/mocks/reportService.ts Normal file
View File

@@ -0,0 +1,100 @@
export interface ReportFilters {
desde: string
hasta: string
}
export interface InventoryReportItem {
id: string
nombre: string
tipo: string
cliente: string
ip: string
so: string
estado: string
}
export interface InventoryReport {
periodo: { desde: string; hasta: string }
total: number
items: InventoryReportItem[]
}
export interface ResourceUsageReportDevice {
dispositivo: string
cliente: string
cpuPromedio: number
memoriaPromedio: number
redPromedioMB: number
}
export interface ResourceUsageReport {
periodo: { desde: string; hasta: string }
dispositivos: ResourceUsageReportDevice[]
}
export interface AlertsReportItem {
id: string
fecha: string
titulo: string
severidad: string
estado: string
dispositivo: string
resueltoEn: string | null
}
export interface AlertsReport {
periodo: { desde: string; hasta: string }
total: number
items: AlertsReportItem[]
}
function toDateStr(d: Date): string {
return d.toISOString().split('T')[0]
}
// TODO: replace with trpc.reportes.inventario (and optional date filter if backend supports it)
export async function fetchInventoryReport(
startDate: Date,
endDate: Date
): Promise<InventoryReport> {
await new Promise((r) => setTimeout(r, 300))
return {
periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) },
total: 42,
items: [
{ id: '1', nombre: 'PC-Oficina-01', tipo: 'PC', cliente: 'Cliente A', ip: '192.168.1.10', so: 'Windows 11', estado: 'Activo' },
{ id: '2', nombre: 'LAPTOP-Ventas-02', tipo: 'LAPTOP', cliente: 'Cliente A', ip: '192.168.1.22', so: 'Windows 11', estado: 'Activo' },
{ id: '3', nombre: 'SRV-DC-01', tipo: 'SERVIDOR', cliente: 'Cliente B', ip: '10.0.0.5', so: 'Windows Server 2022', estado: 'Activo' },
],
}
}
export async function fetchResourceUsageReport(
startDate: Date,
endDate: Date
): Promise<ResourceUsageReport> {
await new Promise((r) => setTimeout(r, 300))
return {
periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) },
dispositivos: [
{ dispositivo: 'PC-Oficina-01', cliente: 'Cliente A', cpuPromedio: 24, memoriaPromedio: 62, redPromedioMB: 120 },
{ dispositivo: 'LAPTOP-Ventas-02', cliente: 'Cliente A', cpuPromedio: 18, memoriaPromedio: 45, redPromedioMB: 85 },
{ dispositivo: 'SRV-DC-01', cliente: 'Cliente B', cpuPromedio: 12, memoriaPromedio: 78, redPromedioMB: 340 },
],
}
}
export async function fetchAlertsReport(
startDate: Date,
endDate: Date
): Promise<AlertsReport> {
await new Promise((r) => setTimeout(r, 300))
return {
periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) },
total: 28,
items: [
{ id: '1', fecha: '2024-01-15T10:30:00Z', titulo: 'CPU alto', severidad: 'CRITICAL', estado: 'RESUELTA', dispositivo: 'PC-Oficina-01', resueltoEn: '2024-01-15T10:45:00Z' },
{ id: '2', fecha: '2024-01-15T09:00:00Z', titulo: 'Disco lleno', severidad: 'WARNING', estado: 'RESUELTA', dispositivo: 'SRV-DC-01', resueltoEn: '2024-01-15T11:00:00Z' },
],
}
}

View File

@@ -162,6 +162,7 @@ export const clientesRouter = router({
dispositivosAlerta,
alertasActivas,
alertasCriticas,
sesionesActivas,
] = await Promise.all([
ctx.prisma.dispositivo.count({ where }),
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ONLINE' } }),
@@ -173,6 +174,12 @@ export const clientesRouter = router({
ctx.prisma.alerta.count({
where: { ...where, estado: 'ACTIVA', severidad: 'CRITICAL' },
}),
ctx.prisma.sesionRemota.count({
where: {
finalizadaEn: null,
...(clienteId ? { dispositivo: { clienteId } } : {}),
},
}),
])
return {
@@ -182,7 +189,7 @@ export const clientesRouter = router({
dispositivosAlerta,
alertasActivas,
alertasCriticas,
sesionesActivas: 0, // TODO: implementar
sesionesActivas,
}
}),

View File

@@ -4,7 +4,124 @@ import { TipoDispositivo } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { MeshCentralClient } from '@/server/services/meshcentral/client'
const tipoDispositivoSchema = z.enum([
'PC', 'LAPTOP', 'SERVIDOR', 'CELULAR', 'TABLET', 'ROUTER', 'SWITCH',
'FIREWALL', 'AP', 'IMPRESORA', 'OTRO',
])
const estadoDispositivoSchema = z.enum([
'ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO',
])
export const equiposRouter = router({
create: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
tipo: tipoDispositivoSchema,
nombre: z.string().min(1, 'Nombre requerido'),
descripcion: z.string().optional(),
ubicacionId: z.string().optional().nullable(),
estado: estadoDispositivoSchema.optional(),
ip: z.string().optional().nullable(),
mac: z.string().optional().nullable(),
sistemaOperativo: z.string().optional().nullable(),
versionSO: z.string().optional().nullable(),
fabricante: z.string().optional().nullable(),
modelo: z.string().optional().nullable(),
serial: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId ?? input.clienteId
if (!clienteId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Se requiere cliente (seleccione un cliente o use un usuario con cliente asignado)',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'No puede crear dispositivos para otro cliente' })
}
return ctx.prisma.dispositivo.create({
data: {
clienteId,
tipo: input.tipo as TipoDispositivo,
nombre: input.nombre.trim(),
descripcion: input.descripcion?.trim() || null,
ubicacionId: input.ubicacionId || null,
estado: (input.estado as 'ONLINE' | 'OFFLINE' | 'ALERTA' | 'MANTENIMIENTO' | 'DESCONOCIDO') ?? 'DESCONOCIDO',
ip: input.ip?.trim() || null,
mac: input.mac?.trim() || null,
sistemaOperativo: input.sistemaOperativo?.trim() || null,
versionSO: input.versionSO?.trim() || null,
fabricante: input.fabricante?.trim() || null,
modelo: input.modelo?.trim() || null,
serial: input.serial?.trim() || null,
},
include: {
cliente: { select: { id: true, nombre: true } },
ubicacion: { select: { id: true, nombre: true } },
},
})
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
tipo: tipoDispositivoSchema.optional(),
nombre: z.string().min(1, 'Nombre requerido').optional(),
descripcion: z.string().optional().nullable(),
ubicacionId: z.string().optional().nullable(),
estado: estadoDispositivoSchema.optional(),
ip: z.string().optional().nullable(),
mac: z.string().optional().nullable(),
sistemaOperativo: z.string().optional().nullable(),
versionSO: z.string().optional().nullable(),
fabricante: z.string().optional().nullable(),
modelo: z.string().optional().nullable(),
serial: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.dispositivo.findUnique({
where: { id: input.id },
select: { clienteId: true },
})
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
}
if (ctx.user.clienteId && ctx.user.clienteId !== existing.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'No puede editar este dispositivo' })
}
const data: Record<string, unknown> = {}
if (input.tipo !== undefined) data.tipo = input.tipo as TipoDispositivo
if (input.nombre !== undefined) data.nombre = input.nombre.trim()
if (input.descripcion !== undefined) data.descripcion = input.descripcion?.trim() || null
if (input.ubicacionId !== undefined) data.ubicacionId = input.ubicacionId || null
if (input.estado !== undefined) data.estado = input.estado
if (input.ip !== undefined) data.ip = input.ip?.trim() || null
if (input.mac !== undefined) data.mac = input.mac?.trim() || null
if (input.sistemaOperativo !== undefined) data.sistemaOperativo = input.sistemaOperativo?.trim() || null
if (input.versionSO !== undefined) data.versionSO = input.versionSO?.trim() || null
if (input.fabricante !== undefined) data.fabricante = input.fabricante?.trim() || null
if (input.modelo !== undefined) data.modelo = input.modelo?.trim() || null
if (input.serial !== undefined) data.serial = input.serial?.trim() || null
return ctx.prisma.dispositivo.update({
where: { id: input.id },
data,
include: {
cliente: { select: { id: true, nombre: true } },
ubicacion: { select: { id: true, nombre: true } },
software: { orderBy: { nombre: 'asc' }, take: 100 },
},
})
}),
// Listar equipos de computo (PC, laptop, servidor)
list: protectedProcedure
.input(

View File

@@ -5,6 +5,7 @@ import { equiposRouter } from './equipos.router'
import { celularesRouter } from './celulares.router'
import { redRouter } from './red.router'
import { alertasRouter } from './alertas.router'
import { sesionesRouter } from './sesiones.router'
import { reportesRouter } from './reportes.router'
import { usuariosRouter } from './usuarios.router'
import { configuracionRouter } from './configuracion.router'
@@ -16,6 +17,7 @@ export const appRouter = router({
celulares: celularesRouter,
red: redRouter,
alertas: alertasRouter,
sesiones: sesionesRouter,
reportes: reportesRouter,
usuarios: usuariosRouter,
configuracion: configuracionRouter,

View File

@@ -0,0 +1,47 @@
import { z } from 'zod'
import { router, protectedProcedure } from '../trpc'
export const sesionesRouter = router({
list: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
limit: z.number().default(50),
}).optional()
)
.query(async ({ ctx, input }) => {
const { clienteId, limit = 50 } = input || {}
const clientFilter = clienteId ?? ctx.user.clienteId ?? undefined
const where = {
finalizadaEn: null,
...(clientFilter ? { dispositivo: { clienteId: clientFilter } } : {}),
}
const sessions = await ctx.prisma.sesionRemota.findMany({
where,
include: {
usuario: { select: { id: true, email: true, nombre: true } },
dispositivo: { select: { id: true, nombre: true, clienteId: true } },
},
orderBy: { iniciadaEn: 'desc' },
take: limit,
})
return { sessions }
}),
count: protectedProcedure
.input(z.object({ clienteId: z.string().optional() }).optional())
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input?.clienteId
const clientFilter = clienteId ?? ctx.user.clienteId ?? undefined
const where = {
finalizadaEn: null,
...(clientFilter ? { dispositivo: { clienteId: clientFilter } } : {}),
}
return ctx.prisma.sesionRemota.count({ where })
}),
})