Mejoras integrales en UI, lógica de negocio y auditoría
Este commit introduce una serie de mejoras significativas en toda la aplicación, abordando la experiencia de usuario, la consistencia de los datos, la robustez del backend y la implementación de un historial de cambios completo. ✨ **Funcionalidades y Mejoras (Features & Enhancements)** * **Historial de Auditoría Completo:** * Se implementa el registro en el historial para todas las acciones CRUD manuales: creación de equipos, adición y eliminación de discos, RAM y usuarios. * Los cambios de campos simples (IP, Hostname, etc.) ahora también se registran detalladamente. * **Consistencia de Datos Mejorada:** * **RAM:** La selección de RAM en el modal de "Añadir RAM" y la vista de "Administración" ahora agrupan los módulos por especificaciones (Fabricante, Tamaño, Velocidad), eliminando las entradas duplicadas causadas por diferentes `part_number`. * **Arquitectura:** El campo de edición para la arquitectura del equipo se ha cambiado de un input de texto a un selector con las opciones fijas "32 bits" y "64 bits". * **Experiencia de Usuario (UX) Optimizada:** * El botón de "Wake On Lan" (WOL) ahora se deshabilita visualmente si el equipo no tiene una dirección MAC registrada. * Se corrige el apilamiento de modales: los sub-modales (Añadir Disco/RAM/Usuario) ahora siempre aparecen por encima del modal principal de detalles y bloquean su cierre. * El historial de cambios se actualiza en tiempo real en la interfaz después de añadir o eliminar un componente, sin necesidad de cerrar y reabrir el modal. 🐛 **Correcciones (Bug Fixes)** * **Actualización de Estado en Vivo:** Al añadir/eliminar un módulo de RAM, los campos "RAM Instalada" y "Última Actualización" ahora se recalculan en el backend y se actualizan instantáneamente en el frontend. * **Historial de Sectores Legible:** Se corrige el registro del historial para que al cambiar un sector se guarde el *nombre* del sector (ej. "Técnica") en lugar de su ID numérico. * **Formulario de Edición:** El dropdown de "Sector" en el modo de edición ahora preselecciona correctamente el sector asignado actualmente al equipo. * **Error Crítico al Añadir RAM:** Se soluciona un error del servidor (`Sequence contains more than one element`) que ocurría al añadir manualmente un tipo de RAM que ya existía con múltiples `part_number`. Se reemplazó `QuerySingleOrDefaultAsync` por `QueryFirstOrDefaultAsync` para mayor robustez. * **Eliminación Segura:** Se impide la eliminación de un sector si este tiene equipos asignados, protegiendo la integridad de los datos. ♻️ **Refactorización (Refactoring)** * **Servicio de API Centralizado:** Toda la lógica de llamadas `fetch` del frontend ha sido extraída de los componentes y centralizada en un único servicio (`apiService.ts`), mejorando drásticamente la mantenibilidad y organización del código. * **Optimización de Renders:** Se ha optimizado el rendimiento de los modales mediante el uso del hook `useCallback` para memorizar funciones que se pasan como props. * **Nulabilidad en C#:** Se han resuelto múltiples advertencias de compilación (`CS8620`) en el backend al especificar explícitamente los tipos de referencia anulables (`string?`), mejorando la seguridad de tipos del código.
This commit is contained in:
		| @@ -1,38 +1,33 @@ | ||||
| // frontend/src/components/GestionComponentes.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import toast from 'react-hot-toast'; | ||||
| import styles from './SimpleTable.module.css'; | ||||
|  | ||||
| const BASE_URL = '/api'; | ||||
| import { adminService } from '../services/apiService'; | ||||
|  | ||||
| // Interfaces para los diferentes tipos de datos | ||||
| interface TextValue { | ||||
|   valor: string; | ||||
|   conteo: number; | ||||
| } | ||||
|  | ||||
| interface RamValue { | ||||
|   id: number; | ||||
|   fabricante?: string; | ||||
|   tamano: number; | ||||
|   velocidad?: number; | ||||
|   partNumber?: string; | ||||
|   conteo: number; | ||||
| } | ||||
|  | ||||
| const GestionComponentes = () => { | ||||
|   const [componentType, setComponentType] = useState('os'); | ||||
|   const [valores, setValores] = useState<(TextValue | RamValue)[]>([]); // Estado que acepta ambos tipos | ||||
|   const [valores, setValores] = useState<(TextValue | RamValue)[]>([]); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|  | ||||
|   const [isModalOpen, setIsModalOpen] = useState(false); | ||||
|   const [valorAntiguo, setValorAntiguo] = useState(''); | ||||
|   const [valorNuevo, setValorNuevo] = useState(''); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setIsLoading(true); | ||||
|     const endpoint = componentType === 'ram' ? `${BASE_URL}/admin/componentes/ram` : `${BASE_URL}/admin/componentes/${componentType}`; | ||||
|  | ||||
|     fetch(endpoint) | ||||
|       .then(res => res.json()) | ||||
|     adminService.getComponentValues(componentType) | ||||
|       .then(data => { | ||||
|         setValores(data); | ||||
|       }) | ||||
| @@ -51,21 +46,9 @@ const GestionComponentes = () => { | ||||
|   const handleUnificar = async () => { | ||||
|     const toastId = toast.loading('Unificando valores...'); | ||||
|     try { | ||||
|       const response = await fetch(`${BASE_URL}/admin/componentes/${componentType}/unificar`, { | ||||
|         method: 'PUT', | ||||
|         headers: { 'Content-Type': 'application/json' }, | ||||
|         body: JSON.stringify({ valorAntiguo, valorNuevo }), | ||||
|       }); | ||||
|  | ||||
|       if (!response.ok) { | ||||
|         const error = await response.json(); | ||||
|         throw new Error(error.message || 'La unificación falló.'); | ||||
|       } | ||||
|  | ||||
|       // Refrescar la lista para ver el resultado | ||||
|       const refreshedData = await (await fetch(`${BASE_URL}/admin/componentes/${componentType}`)).json(); | ||||
|       await adminService.unifyComponentValues(componentType, valorAntiguo, valorNuevo); | ||||
|       const refreshedData = await adminService.getComponentValues(componentType); | ||||
|       setValores(refreshedData); | ||||
|  | ||||
|       toast.success('Valores unificados correctamente.', { id: toastId }); | ||||
|       setIsModalOpen(false); | ||||
|     } catch (error) { | ||||
| @@ -73,54 +56,51 @@ const GestionComponentes = () => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleDeleteRam = async (ramId: number) => { | ||||
|     if (!window.confirm("¿Estás seguro de eliminar este módulo de RAM de la base de datos maestra? Esta acción es irreversible.")) { | ||||
|   // 2. FUNCIÓN DELETE ACTUALIZADA: Ahora maneja un grupo | ||||
|   const handleDeleteRam = async (ramGroup: RamValue) => { | ||||
|     if (!window.confirm("¿Estás seguro de eliminar todas las entradas maestras para este tipo de RAM? Esta acción es irreversible.")) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const toastId = toast.loading('Eliminando módulo...'); | ||||
|     const toastId = toast.loading('Eliminando grupo de módulos...'); | ||||
|     try { | ||||
|       const response = await fetch(`${BASE_URL}/admin/componentes/ram/${ramId}`, { method: 'DELETE' }); | ||||
|       // El servicio ahora espera el objeto del grupo | ||||
|       await adminService.deleteRamComponent({ | ||||
|           fabricante: ramGroup.fabricante, | ||||
|           tamano: ramGroup.tamano, | ||||
|           velocidad: ramGroup.velocidad | ||||
|       }); | ||||
|  | ||||
|       if (!response.ok) { | ||||
|         const error = await response.json(); | ||||
|         throw new Error(error.message || 'No se pudo eliminar.'); | ||||
|       } | ||||
|  | ||||
|       setValores(prev => prev.filter(v => (v as RamValue).id !== ramId)); | ||||
|       toast.success("Módulo de RAM eliminado.", { id: toastId }); | ||||
|       setValores(prev => prev.filter(v => { | ||||
|           const currentRam = v as RamValue; | ||||
|           return !(currentRam.fabricante === ramGroup.fabricante && | ||||
|                    currentRam.tamano === ramGroup.tamano && | ||||
|                    currentRam.velocidad === ramGroup.velocidad); | ||||
|       })); | ||||
|       toast.success("Grupo de módulos de RAM eliminado.", { id: toastId }); | ||||
|     } catch (error) { | ||||
|       if (error instanceof Error) toast.error(error.message, { id: toastId }); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleDeleteTexto = async (valor: string) => { | ||||
|         if (!window.confirm(`Este valor ya no está en uso. ¿Quieres intentar eliminarlo de la base de datos maestra? (Si no existe una tabla maestra, esta acción solo confirmará que no hay usos)`)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const toastId = toast.loading('Eliminando valor...'); | ||||
|         try { | ||||
|             // La API necesita el valor codificado para manejar caracteres especiales como '/' | ||||
|             const encodedValue = encodeURIComponent(valor); | ||||
|             const response = await fetch(`${BASE_URL}/admin/componentes/${componentType}/${encodedValue}`, { method: 'DELETE' }); | ||||
|  | ||||
|             if (!response.ok) { | ||||
|                 const error = await response.json(); | ||||
|                 throw new Error(error.message || 'No se pudo eliminar.'); | ||||
|             } | ||||
|  | ||||
|             setValores(prev => prev.filter(v => (v as TextValue).valor !== valor)); | ||||
|             toast.success("Valor eliminado/confirmado como no existente.", { id: toastId }); | ||||
|         } catch (error) { | ||||
|             if (error instanceof Error) toast.error(error.message, { id: toastId }); | ||||
|         } | ||||
|     }; | ||||
|     if (!window.confirm(`Este valor ya no está en uso. ¿Quieres eliminarlo de la base de datos maestra?`)) { | ||||
|         return; | ||||
|     } | ||||
|     const toastId = toast.loading('Eliminando valor...'); | ||||
|     try { | ||||
|       await adminService.deleteTextComponent(componentType, valor); | ||||
|       setValores(prev => prev.filter(v => (v as TextValue).valor !== valor)); | ||||
|       toast.success("Valor eliminado.", { id: toastId }); | ||||
|     } catch (error) { | ||||
|         if (error instanceof Error) toast.error(error.message, { id: toastId }); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const renderValor = (item: TextValue | RamValue) => { | ||||
|     if (componentType === 'ram') { | ||||
|       const ram = item as RamValue; | ||||
|       return `${ram.fabricante || ''} ${ram.tamano}GB ${ram.velocidad ? ram.velocidad + 'MHz' : ''} (${ram.partNumber || 'N/P'})`; | ||||
|       return `${ram.fabricante || 'Desconocido'} ${ram.tamano}GB ${ram.velocidad ? ram.velocidad + 'MHz' : ''}`; | ||||
|     } | ||||
|     return (item as TextValue).valor; | ||||
|   }; | ||||
| @@ -154,24 +134,22 @@ const GestionComponentes = () => { | ||||
|                     </thead> | ||||
|                     <tbody> | ||||
|                         {valores.map((item) => ( | ||||
|                             <tr key={componentType === 'ram' ? (item as RamValue).id : (item as TextValue).valor} className={styles.tr}> | ||||
|                             <tr key={componentType === 'ram' ? `${(item as RamValue).fabricante}-${(item as RamValue).tamano}-${(item as RamValue).velocidad}` : (item as TextValue).valor} className={styles.tr}> | ||||
|                                 <td className={styles.td}>{renderValor(item)}</td> | ||||
|                                 <td className={styles.td}>{item.conteo}</td> | ||||
|                                 <td className={styles.td}> | ||||
|                                     <div style={{display: 'flex', gap: '5px'}}> | ||||
|                                         {componentType === 'ram' ? ( | ||||
|                                             // Lógica solo para RAM (no tiene sentido "unificar" un objeto complejo) | ||||
|                                             <button  | ||||
|                                                 onClick={() => handleDeleteRam((item as RamValue).id)}  | ||||
|                                                 onClick={() => handleDeleteRam(item as RamValue)}  | ||||
|                                                 className={styles.deleteUserButton}  | ||||
|                                                 style={{fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px', cursor: item.conteo > 0 ? 'not-allowed' : 'pointer', opacity: item.conteo > 0 ? 0.5 : 1}} | ||||
|                                                 disabled={item.conteo > 0} | ||||
|                                                 title={item.conteo > 0 ? `En uso por ${item.conteo} equipo(s)` : 'Eliminar este módulo maestro'} | ||||
|                                                 title={item.conteo > 0 ? `En uso por ${item.conteo} equipo(s)` : 'Eliminar este grupo de módulos maestros'} | ||||
|                                             > | ||||
|                                                 🗑️ Eliminar | ||||
|                                             </button> | ||||
|                                         ) : ( | ||||
|                                             // Lógica para todos los demás tipos de componentes (texto) | ||||
|                                             <> | ||||
|                                                 <button onClick={() => handleOpenModal((item as TextValue).valor)} className={styles.tableButton}> | ||||
|                                                     ✏️ Unificar | ||||
|   | ||||
		Reference in New Issue
	
	Block a user