Devices functions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
@@ -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('')
|
||||
|
||||
|
||||
373
src/components/devices/AddDeviceModal.tsx
Normal file
373
src/components/devices/AddDeviceModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
52
src/components/devices/device-detail/ActionBar.tsx
Normal file
52
src/components/devices/device-detail/ActionBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
478
src/components/devices/device-detail/DeviceDetailModal.tsx
Normal file
478
src/components/devices/device-detail/DeviceDetailModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
src/components/devices/device-detail/InfoCard.tsx
Normal file
19
src/components/devices/device-detail/InfoCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
src/components/devices/device-detail/InfoSection.tsx
Normal file
21
src/components/devices/device-detail/InfoSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/components/devices/device-detail/SoftwareList.tsx
Normal file
36
src/components/devices/device-detail/SoftwareList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
src/mocks/deviceDetailData.ts
Normal file
103
src/mocks/deviceDetailData.ts
Normal 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
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user