479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
'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>
|
|
)
|
|
}
|