Devices functions

This commit is contained in:
2026-02-19 13:12:02 -06:00
parent bd9bffb57c
commit d999cf6298
12 changed files with 1327 additions and 20 deletions

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useMemo, useCallback } from 'react'
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'
@@ -51,6 +52,9 @@ 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 }
@@ -64,6 +68,15 @@ export default function FileExplorerPage() {
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

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

@@ -1,6 +1,7 @@
'use client'
import { useState, useCallback } from 'react'
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'
@@ -25,6 +26,9 @@ 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 }
@@ -36,6 +40,12 @@ export default function TerminalPage() {
}))
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('')

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,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

@@ -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(