Devices functions
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'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 { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||||
import { trpc } from '@/lib/trpc-client'
|
import { trpc } from '@/lib/trpc-client'
|
||||||
import FileExplorerContainer from '@/components/files/FileExplorerContainer'
|
import FileExplorerContainer from '@/components/files/FileExplorerContainer'
|
||||||
@@ -51,6 +52,9 @@ export default function FileExplorerPage() {
|
|||||||
const { selectedClientId } = useSelectedClient()
|
const { selectedClientId } = useSelectedClient()
|
||||||
const clienteId = selectedClientId ?? undefined
|
const clienteId = selectedClientId ?? undefined
|
||||||
|
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const deviceIdFromUrl = searchParams.get('deviceId')
|
||||||
|
|
||||||
const listQuery = trpc.equipos.list.useQuery(
|
const listQuery = trpc.equipos.list.useQuery(
|
||||||
{ clienteId, limit: 100 },
|
{ clienteId, limit: 100 },
|
||||||
{ refetchOnWindowFocus: false }
|
{ refetchOnWindowFocus: false }
|
||||||
@@ -64,6 +68,15 @@ export default function FileExplorerPage() {
|
|||||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('')
|
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('')
|
||||||
const [currentPath, setCurrentPath] = useState('C:\\')
|
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
|
const selectedDevice = selectedDeviceId
|
||||||
? devices.find((d) => d.id === selectedDeviceId)
|
? devices.find((d) => d.id === selectedDeviceId)
|
||||||
: null
|
: null
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
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 { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||||
import { trpc } from '@/lib/trpc-client'
|
import { trpc } from '@/lib/trpc-client'
|
||||||
import { formatRelativeTime } from '@/lib/utils'
|
import { formatRelativeTime } from '@/lib/utils'
|
||||||
import DeviceCard, { type DeviceCardStatus } from '@/components/devices/DeviceCard'
|
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'
|
type StateFilter = '' | 'ONLINE' | 'OFFLINE' | 'ALERTA' | 'MANTENIMIENTO' | 'DESCONOCIDO'
|
||||||
|
|
||||||
@@ -61,6 +64,12 @@ export default function DevicesPage() {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [stateFilter, setStateFilter] = useState<StateFilter>('')
|
const [stateFilter, setStateFilter] = useState<StateFilter>('')
|
||||||
const [osFilter, setOsFilter] = useState('')
|
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(
|
const listQuery = trpc.equipos.list.useQuery(
|
||||||
{
|
{
|
||||||
@@ -86,23 +95,81 @@ export default function DevicesPage() {
|
|||||||
}))
|
}))
|
||||||
}, [listQuery.data])
|
}, [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) => {
|
const handleConnect = (id: string) => {
|
||||||
console.log('Conectar', id)
|
setConnectError(null)
|
||||||
|
setConnectingId(id)
|
||||||
|
iniciarSesionMutation.mutate({ dispositivoId: id, tipo: 'desktop' })
|
||||||
}
|
}
|
||||||
const handleFiles = (id: string) => {
|
const handleFiles = (id: string) => {
|
||||||
console.log('Archivos', id)
|
router.push(`/archivos?deviceId=${encodeURIComponent(id)}`)
|
||||||
}
|
}
|
||||||
const handleTerminal = (id: string) => {
|
const handleTerminal = (id: string) => {
|
||||||
console.log('Terminal', id)
|
router.push(`/terminal?deviceId=${encodeURIComponent(id)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<header>
|
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold text-white sm:text-3xl">Dispositivos</h1>
|
<div>
|
||||||
<p className="mt-1 text-gray-400">Administración de equipos conectados</p>
|
<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>
|
</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="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
<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}
|
onConectar={handleConnect}
|
||||||
onArchivos={handleFiles}
|
onArchivos={handleFiles}
|
||||||
onTerminal={handleTerminal}
|
onTerminal={handleTerminal}
|
||||||
|
onInfo={(id, name) => openDetail(id, name ?? device.name)}
|
||||||
|
isConnecting={connectingId === device.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'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 { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||||
import { trpc } from '@/lib/trpc-client'
|
import { trpc } from '@/lib/trpc-client'
|
||||||
import TerminalWindow from '@/components/terminal/TerminalWindow'
|
import TerminalWindow from '@/components/terminal/TerminalWindow'
|
||||||
@@ -25,6 +26,9 @@ export default function TerminalPage() {
|
|||||||
const { selectedClientId } = useSelectedClient()
|
const { selectedClientId } = useSelectedClient()
|
||||||
const clienteId = selectedClientId ?? undefined
|
const clienteId = selectedClientId ?? undefined
|
||||||
|
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const deviceIdFromUrl = searchParams.get('deviceId')
|
||||||
|
|
||||||
const listQuery = trpc.equipos.list.useQuery(
|
const listQuery = trpc.equipos.list.useQuery(
|
||||||
{ clienteId, limit: 100 },
|
{ clienteId, limit: 100 },
|
||||||
{ refetchOnWindowFocus: false }
|
{ refetchOnWindowFocus: false }
|
||||||
@@ -36,6 +40,12 @@ export default function TerminalPage() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('')
|
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 [outputLines, setOutputLines] = useState<string[]>([])
|
||||||
const [command, setCommand] = useState('')
|
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'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { Monitor, FolderOpen, Terminal, Info, ExternalLink } from 'lucide-react'
|
import { Monitor, FolderOpen, Terminal, Info, ExternalLink } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -17,6 +16,8 @@ export interface DeviceCardProps {
|
|||||||
onConectar?: (id: string) => void
|
onConectar?: (id: string) => void
|
||||||
onArchivos?: (id: string) => void
|
onArchivos?: (id: string) => void
|
||||||
onTerminal?: (id: string) => void
|
onTerminal?: (id: string) => void
|
||||||
|
onInfo?: (id: string, name?: string) => void
|
||||||
|
isConnecting?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusConfig: Record<
|
const statusConfig: Record<
|
||||||
@@ -46,16 +47,23 @@ export default function DeviceCard({
|
|||||||
onConectar,
|
onConectar,
|
||||||
onArchivos,
|
onArchivos,
|
||||||
onTerminal,
|
onTerminal,
|
||||||
|
onInfo,
|
||||||
|
isConnecting = false,
|
||||||
}: DeviceCardProps) {
|
}: DeviceCardProps) {
|
||||||
const statusStyle = statusConfig[status]
|
const statusStyle = statusConfig[status]
|
||||||
const osLabel = normalizeOS(os)
|
const osLabel = normalizeOS(os)
|
||||||
const detailUrl = id ? `/devices/${id}` : '#'
|
const handleCardClick = () => id && onInfo?.(id, name)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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(
|
className={cn(
|
||||||
'rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/80 to-dark-400/80 p-5',
|
'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">
|
<div className="flex items-start gap-3">
|
||||||
@@ -89,14 +97,21 @@ export default function DeviceCard({
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => id && onConectar?.(id)}
|
onClick={() => id && status === 'online' && !isConnecting && 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"
|
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" />
|
<ExternalLink className={cn('h-4 w-4', isConnecting && 'animate-pulse')} />
|
||||||
<span className="text-xs font-medium">Conectar</span>
|
<span className="text-xs font-medium">{isConnecting ? 'Conectando…' : 'Conectar'}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -114,13 +129,14 @@ export default function DeviceCard({
|
|||||||
<Terminal className="h-4 w-4" />
|
<Terminal className="h-4 w-4" />
|
||||||
<span className="text-xs font-medium">Terminal</span>
|
<span className="text-xs font-medium">Terminal</span>
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<button
|
||||||
href={detailUrl}
|
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"
|
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" />
|
<Info className="h-4 w-4" />
|
||||||
<span className="text-xs font-medium">Info</span>
|
<span className="text-xs font-medium">Info</span>
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||||
import { MeshCentralClient } from '@/server/services/meshcentral/client'
|
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({
|
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)
|
// Listar equipos de computo (PC, laptop, servidor)
|
||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
Reference in New Issue
Block a user