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.
		
			
				
	
	
		
			195 lines
		
	
	
		
			9.2 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			195 lines
		
	
	
		
			9.2 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| // frontend/src/components/GestionComponentes.tsx
 | ||
| import { useState, useEffect } from 'react';
 | ||
| import toast from 'react-hot-toast';
 | ||
| import styles from './SimpleTable.module.css';
 | ||
| import { adminService } from '../services/apiService';
 | ||
| 
 | ||
| // Interfaces para los diferentes tipos de datos
 | ||
| interface TextValue {
 | ||
|   valor: string;
 | ||
|   conteo: number;
 | ||
| }
 | ||
| 
 | ||
| interface RamValue {
 | ||
|   fabricante?: string;
 | ||
|   tamano: number;
 | ||
|   velocidad?: number;
 | ||
|   conteo: number;
 | ||
| }
 | ||
| 
 | ||
| const GestionComponentes = () => {
 | ||
|   const [componentType, setComponentType] = useState('os');
 | ||
|   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);
 | ||
|     adminService.getComponentValues(componentType)
 | ||
|       .then(data => {
 | ||
|         setValores(data);
 | ||
|       })
 | ||
|       .catch(_err => {
 | ||
|         toast.error(`No se pudieron cargar los datos de ${componentType}.`);
 | ||
|       })
 | ||
|       .finally(() => setIsLoading(false));
 | ||
|   }, [componentType]);
 | ||
| 
 | ||
|   const handleOpenModal = (valor: string) => {
 | ||
|     setValorAntiguo(valor);
 | ||
|     setValorNuevo(valor);
 | ||
|     setIsModalOpen(true);
 | ||
|   };
 | ||
| 
 | ||
|   const handleUnificar = async () => {
 | ||
|     const toastId = toast.loading('Unificando valores...');
 | ||
|     try {
 | ||
|       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) {
 | ||
|       if (error instanceof Error) toast.error(error.message, { id: toastId });
 | ||
|     }
 | ||
|   };
 | ||
| 
 | ||
|   // 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 grupo de módulos...');
 | ||
|     try {
 | ||
|       // El servicio ahora espera el objeto del grupo
 | ||
|       await adminService.deleteRamComponent({
 | ||
|           fabricante: ramGroup.fabricante,
 | ||
|           tamano: ramGroup.tamano,
 | ||
|           velocidad: ramGroup.velocidad
 | ||
|       });
 | ||
| 
 | ||
|       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 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 || 'Desconocido'} ${ram.tamano}GB ${ram.velocidad ? ram.velocidad + 'MHz' : ''}`;
 | ||
|     }
 | ||
|     return (item as TextValue).valor;
 | ||
|   };
 | ||
| 
 | ||
|   return (
 | ||
|         <div>
 | ||
|             <h2>Gestión de Componentes Maestros</h2>
 | ||
|             <p>Unifica valores inconsistentes y elimina registros no utilizados.</p>
 | ||
| 
 | ||
|             <div style={{ marginBottom: '1.5rem' }}>
 | ||
|                 <label><strong>Selecciona un tipo de componente:</strong></label>
 | ||
|                 <select value={componentType} onChange={e => setComponentType(e.target.value)} className={styles.sectorSelect} style={{marginLeft: '10px'}}>
 | ||
|                     <option value="os">Sistema Operativo</option>
 | ||
|                     <option value="cpu">CPU</option>
 | ||
|                     <option value="motherboard">Motherboard</option>
 | ||
|                     <option value="architecture">Arquitectura</option>
 | ||
|                     <option value="ram">Memorias RAM</option>
 | ||
|                 </select>
 | ||
|             </div>
 | ||
|             
 | ||
|             {isLoading ? (
 | ||
|                 <p>Cargando...</p>
 | ||
|             ) : (
 | ||
|                 <table className={styles.table}>
 | ||
|                     <thead>
 | ||
|                         <tr>
 | ||
|                             <th className={styles.th}>Valor Registrado</th>
 | ||
|                             <th className={styles.th} style={{width: '150px'}}>Nº de Equipos</th>
 | ||
|                             <th className={styles.th} style={{width: '200px'}}>Acciones</th>
 | ||
|                         </tr>
 | ||
|                     </thead>
 | ||
|                     <tbody>
 | ||
|                         {valores.map((item) => (
 | ||
|                             <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' ? (
 | ||
|                                             <button 
 | ||
|                                                 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 grupo de módulos maestros'}
 | ||
|                                             >
 | ||
|                                                 🗑️ Eliminar
 | ||
|                                             </button>
 | ||
|                                         ) : (
 | ||
|                                             <>
 | ||
|                                                 <button onClick={() => handleOpenModal((item as TextValue).valor)} className={styles.tableButton}>
 | ||
|                                                     ✏️ Unificar
 | ||
|                                                 </button>
 | ||
|                                                 <button 
 | ||
|                                                     onClick={() => handleDeleteTexto((item as TextValue).valor)} 
 | ||
|                                                     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). Unifique primero.` : 'Eliminar este valor'}
 | ||
|                                                 >
 | ||
|                                                     🗑️ Eliminar
 | ||
|                                                 </button>
 | ||
|                                             </>
 | ||
|                                         )}
 | ||
|                                     </div>
 | ||
|                                 </td>
 | ||
|                             </tr>
 | ||
|                         ))}
 | ||
|                     </tbody>
 | ||
|                 </table>
 | ||
|             )}
 | ||
| 
 | ||
|             {isModalOpen && (
 | ||
|                 <div className={styles.modalOverlay}>
 | ||
|                     <div className={styles.modal}>
 | ||
|                         <h3>Unificar Valor</h3>
 | ||
|                         <p>Se reemplazarán todas las instancias de:</p>
 | ||
|                         <strong style={{ display: 'block', marginBottom: '1rem', background: '#e9ecef', padding: '8px', borderRadius: '4px' }}>{valorAntiguo}</strong>
 | ||
|                         <label>Por el nuevo valor:</label>
 | ||
|                         <input type="text" value={valorNuevo} onChange={e => setValorNuevo(e.target.value)} className={styles.modalInput} />
 | ||
|                         <div className={styles.modalActions}>
 | ||
|                             <button onClick={handleUnificar} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!valorNuevo.trim() || valorNuevo === valorAntiguo}>Unificar</button>
 | ||
|                             <button onClick={() => setIsModalOpen(false)} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
 | ||
|                         </div>
 | ||
|                     </div>
 | ||
|                 </div>
 | ||
|             )}
 | ||
|         </div>
 | ||
|     );
 | ||
| };
 | ||
| 
 | ||
| export default GestionComponentes; |