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:
2025-10-08 13:27:44 -03:00
parent 177ad55962
commit 268c1c2bf9
22 changed files with 834 additions and 513 deletions

View File

@@ -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