Files
MSP-CAS/src/components/devices/device-detail/DeviceDetailModal.tsx
2026-02-19 13:12:02 -06:00

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>
)
}