Devices functions
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user