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,19 +1,16 @@
// frontend/src/components/SimpleTable.tsx
import React, { useEffect, useState } from 'react';
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
flexRender,
type CellContext
useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel,
getPaginationRowModel, flexRender, type CellContext
} from '@tanstack/react-table';
import { Tooltip } from 'react-tooltip';
import toast from 'react-hot-toast';
import type { Equipo, Sector, Usuario, UsuarioEquipoDetalle } from '../types/interfaces';
import styles from './SimpleTable.module.css';
import { equipoService, sectorService, usuarioService } from '../services/apiService';
import ModalAnadirEquipo from './ModalAnadirEquipo';
import ModalEditarSector from './ModalEditarSector';
import ModalCambiarClave from './ModalCambiarClave';
@@ -37,11 +34,19 @@ const SimpleTable = () => {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [addingComponent, setAddingComponent] = useState<'disco' | 'ram' | 'usuario' | null>(null);
const [isLoading, setIsLoading] = useState(true);
const BASE_URL = '/api';
const refreshHistory = async (hostname: string) => {
try {
const data = await equipoService.getHistory(hostname);
setHistorial(data.historial);
} catch (error) {
console.error('Error refreshing history:', error);
}
};
useEffect(() => {
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
if (selectedEquipo || modalData || modalPasswordData) {
if (selectedEquipo || modalData || modalPasswordData || isAddModalOpen) {
document.body.classList.add('scroll-lock');
document.body.style.paddingRight = `${scrollBarWidth}px`;
} else {
@@ -52,7 +57,7 @@ const SimpleTable = () => {
document.body.classList.remove('scroll-lock');
document.body.style.paddingRight = '0';
};
}, [selectedEquipo, modalData, modalPasswordData]);
}, [selectedEquipo, modalData, modalPasswordData, isAddModalOpen]);
useEffect(() => {
if (!selectedEquipo) return;
@@ -60,17 +65,7 @@ const SimpleTable = () => {
const checkPing = async () => {
if (!selectedEquipo.ip) return;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`${BASE_URL}/equipos/ping`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip: selectedEquipo.ip }),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) throw new Error('Error en la respuesta');
const data = await response.json();
const data = await equipoService.ping(selectedEquipo.ip);
if (isMounted) setIsOnline(data.isAlive);
} catch (error) {
if (isMounted) setIsOnline(false);
@@ -79,22 +74,21 @@ const SimpleTable = () => {
};
checkPing();
const interval = setInterval(checkPing, 10000);
return () => {
isMounted = false;
clearInterval(interval);
setIsOnline(false);
};
return () => { isMounted = false; clearInterval(interval); setIsOnline(false); };
}, [selectedEquipo]);
const handleCloseModal = () => {
if (addingComponent) {
toast.error("Debes cerrar la ventana de añadir componente primero.");
return;
}
setSelectedEquipo(null);
setIsOnline(false);
};
useEffect(() => {
if (selectedEquipo) {
fetch(`${BASE_URL}/equipos/${selectedEquipo.hostname}/historial`)
.then(response => response.json())
equipoService.getHistory(selectedEquipo.hostname)
.then(data => setHistorial(data.historial))
.catch(error => console.error('Error fetching history:', error));
}
@@ -109,21 +103,17 @@ const SimpleTable = () => {
useEffect(() => {
setIsLoading(true);
Promise.all([
fetch(`${BASE_URL}/equipos`).then(res => res.json()),
fetch(`${BASE_URL}/sectores`).then(res => res.json())
equipoService.getAll(),
sectorService.getAll()
]).then(([equiposData, sectoresData]) => {
setData(equiposData);
setFilteredData(equiposData);
const sectoresOrdenados = [...sectoresData].sort((a, b) =>
a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' })
);
const sectoresOrdenados = [...sectoresData].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }));
setSectores(sectoresOrdenados);
}).catch(error => {
toast.error("No se pudieron cargar los datos iniciales.");
console.error("Error al cargar datos:", error);
}).finally(() => {
setIsLoading(false);
});
}).finally(() => setIsLoading(false));
}, []);
const handleSectorChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
@@ -138,16 +128,14 @@ const SimpleTable = () => {
if (!modalData || !modalData.sector) return;
const toastId = toast.loading('Guardando...');
try {
const response = await fetch(`${BASE_URL}/equipos/${modalData.id}/sector/${modalData.sector.id}`, { method: 'PATCH' });
if (!response.ok) throw new Error('Error al asociar el sector');
await equipoService.updateSector(modalData.id, modalData.sector.id);
const updatedData = data.map(e => e.id === modalData.id ? { ...e, sector: modalData.sector } : e);
setData(updatedData);
setFilteredData(updatedData);
toast.success('Sector actualizado.', { id: toastId });
setModalData(null);
} catch (error) {
toast.error('No se pudo actualizar.', { id: toastId });
console.error(error);
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
@@ -155,16 +143,7 @@ const SimpleTable = () => {
if (!modalPasswordData) return;
const toastId = toast.loading('Actualizando...');
try {
const response = await fetch(`${BASE_URL}/usuarios/${modalPasswordData.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || 'Error al actualizar');
}
const updatedUser = await response.json();
const updatedUser = await usuarioService.updatePassword(modalPasswordData.id, password);
const updatedData = data.map(equipo => ({
...equipo,
usuarios: equipo.usuarios?.map(user => user.id === updatedUser.id ? { ...user, password } : user)
@@ -182,9 +161,7 @@ const SimpleTable = () => {
if (!window.confirm(`¿Quitar a ${username} de este equipo?`)) return;
const toastId = toast.loading(`Quitando a ${username}...`);
try {
const response = await fetch(`${BASE_URL}/equipos/${hostname}/usuarios/${username}`, { method: 'DELETE' });
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Error al desasociar');
await usuarioService.removeUserFromEquipo(hostname, username);
const updateFunc = (prev: Equipo[]) => prev.map(e => e.hostname === hostname ? { ...e, usuarios: e.usuarios.filter(u => u.username !== username) } : e);
setData(updateFunc);
setFilteredData(updateFunc);
@@ -198,57 +175,38 @@ const SimpleTable = () => {
if (!window.confirm('¿Eliminar este equipo y sus relaciones?')) return false;
const toastId = toast.loading('Eliminando equipo...');
try {
const response = await fetch(`${BASE_URL}/equipos/${id}`, { method: 'DELETE' });
if (response.status === 204) {
setData(prev => prev.filter(e => e.id !== id));
setFilteredData(prev => prev.filter(e => e.id !== id));
toast.success('Equipo eliminado.', { id: toastId });
return true;
}
const errorText = await response.text();
throw new Error(errorText || 'Error desconocido');
await equipoService.deleteManual(id);
setData(prev => prev.filter(e => e.id !== id));
setFilteredData(prev => prev.filter(e => e.id !== id));
toast.success('Equipo eliminado.', { id: toastId });
return true;
} catch (error) {
if (error instanceof Error) toast.error(`Error: ${error.message}`, { id: toastId });
if (error instanceof Error) toast.error(error.message, { id: toastId });
return false;
}
};
const handleRemoveAssociation = async (
type: 'disco' | 'ram' | 'usuario',
associationId: number | { equipoId: number, usuarioId: number }
) => {
let url = '';
let successMessage = '';
if (type === 'disco' && typeof associationId === 'number') {
url = `${BASE_URL}/equipos/asociacion/disco/${associationId}`;
successMessage = 'Disco desasociado del equipo.';
} else if (type === 'ram' && typeof associationId === 'number') {
url = `${BASE_URL}/equipos/asociacion/ram/${associationId}`;
successMessage = 'Módulo de RAM desasociado.';
} else if (type === 'usuario' && typeof associationId === 'object') {
url = `${BASE_URL}/equipos/asociacion/usuario/${associationId.equipoId}/${associationId.usuarioId}`;
successMessage = 'Usuario desasociado del equipo.';
} else {
return; // No hacer nada si los parámetros son incorrectos
}
const handleRemoveAssociation = async (type: 'disco' | 'ram' | 'usuario', associationId: number | { equipoId: number, usuarioId: number }) => {
if (!window.confirm('¿Estás seguro de que quieres eliminar esta asociación manual?')) return;
const toastId = toast.loading('Eliminando asociación...');
try {
const response = await fetch(url, { method: 'DELETE' });
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `Error al eliminar la asociación.`);
let successMessage = '';
if (type === 'disco' && typeof associationId === 'number') {
await equipoService.removeDiscoAssociation(associationId);
successMessage = 'Disco desasociado del equipo.';
} else if (type === 'ram' && typeof associationId === 'number') {
await equipoService.removeRamAssociation(associationId);
successMessage = 'Módulo de RAM desasociado.';
} else if (type === 'usuario' && typeof associationId === 'object') {
await equipoService.removeUserAssociation(associationId.equipoId, associationId.usuarioId);
successMessage = 'Usuario desasociado del equipo.';
} else {
throw new Error('Tipo de asociación no válido');
}
// Actualizar el estado local para reflejar el cambio inmediatamente
const updateState = (prevEquipos: Equipo[]) => prevEquipos.map(equipo => {
if (equipo.id !== selectedEquipo?.id) return equipo;
let updatedEquipo = { ...equipo };
if (type === 'disco' && typeof associationId === 'number') {
updatedEquipo.discos = equipo.discos.filter(d => d.equipoDiscoId !== associationId);
@@ -262,107 +220,75 @@ const SimpleTable = () => {
setData(updateState);
setFilteredData(updateState);
setSelectedEquipo(prev => prev ? updateState([prev])[0] : null); // Actualiza también el modal
setSelectedEquipo(prev => prev ? updateState([prev])[0] : null);
if (selectedEquipo) {
await refreshHistory(selectedEquipo.hostname);
}
toast.success(successMessage, { id: toastId });
} catch (error) {
if (error instanceof Error) {
toast.error(error.message, { id: toastId });
}
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleCreateEquipo = async (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => {
const toastId = toast.loading('Creando nuevo equipo...');
try {
const response = await fetch(`${BASE_URL}/equipos/manual`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nuevoEquipo),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Error al crear el equipo.');
}
const equipoCreado = await response.json();
// Actualizamos el estado local para ver el nuevo equipo inmediatamente
const equipoCreado = await equipoService.createManual(nuevoEquipo);
setData(prev => [...prev, equipoCreado]);
setFilteredData(prev => [...prev, equipoCreado]);
toast.success(`Equipo '${equipoCreado.hostname}' creado.`, { id: toastId });
setIsAddModalOpen(false); // Cerramos el modal
setIsAddModalOpen(false);
} catch (error) {
if (error instanceof Error) {
toast.error(error.message, { id: toastId });
}
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleEditEquipo = async (id: number, equipoEditado: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => {
const handleEditEquipo = async (id: number, equipoEditado: any) => {
const toastId = toast.loading('Guardando cambios...');
try {
const response = await fetch(`${BASE_URL}/equipos/manual/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(equipoEditado),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Error al actualizar el equipo.');
}
// Actualizar el estado local para reflejar los cambios
const updateState = (prevEquipos: Equipo[]) => prevEquipos.map(equipo => {
if (equipo.id === id) {
return { ...equipo, ...equipoEditado };
}
return equipo;
});
const equipoActualizadoDesdeBackend = await equipoService.updateManual(id, equipoEditado);
const updateState = (prev: Equipo[]) =>
prev.map(e => e.id === id ? equipoActualizadoDesdeBackend : e);
setData(updateState);
setFilteredData(updateState);
setSelectedEquipo(prev => prev ? updateState([prev])[0] : null);
setSelectedEquipo(equipoActualizadoDesdeBackend);
toast.success('Equipo actualizado.', { id: toastId });
return true; // Indica que el guardado fue exitoso
return true;
} catch (error) {
if (error instanceof Error) {
toast.error(error.message, { id: toastId });
}
return false; // Indica que el guardado falló
if (error instanceof Error) toast.error(error.message, { id: toastId });
return false;
}
};
const handleAddComponent = async (type: 'disco' | 'ram' | 'usuario', data: any) => {
const handleAddComponent = async (type: 'disco' | 'ram' | 'usuario', componentData: any) => {
if (!selectedEquipo) return;
const toastId = toast.loading(`Añadiendo ${type}...`);
try {
const response = await fetch(`${BASE_URL}/equipos/manual/${selectedEquipo.id}/${type}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `Error al añadir ${type}.`);
let serviceCall;
switch (type) {
case 'disco': serviceCall = equipoService.addDisco(selectedEquipo.id, componentData); break;
case 'ram': serviceCall = equipoService.addRam(selectedEquipo.id, componentData); break;
case 'usuario': serviceCall = equipoService.addUsuario(selectedEquipo.id, componentData); break;
default: throw new Error('Tipo de componente no válido');
}
await serviceCall;
// Refrescar los datos del equipo para ver el cambio
const refreshedEquipo = await (await fetch(`${BASE_URL}/equipos/${selectedEquipo.hostname}`)).json();
// Usar el servicio directamente para obtener el equipo actualizado
const refreshedEquipo = await equipoService.getAll().then(equipos => equipos.find(e => e.id === selectedEquipo.id));
if (!refreshedEquipo) throw new Error("No se pudo recargar el equipo");
const updateState = (prev: Equipo[]) => prev.map(e => e.id === selectedEquipo.id ? refreshedEquipo : e);
setData(updateState);
setFilteredData(updateState);
setSelectedEquipo(refreshedEquipo);
await refreshHistory(selectedEquipo.hostname);
toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} añadido.`, { id: toastId });
setAddingComponent(null); // Cerrar modal
setAddingComponent(null);
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
@@ -447,7 +373,7 @@ const SimpleTable = () => {
],
columnVisibility: { id: false, mac: false },
pagination: {
pageSize: 15, // Mostrar 15 filas por página por defecto
pageSize: 15,
},
},
state: {
@@ -536,7 +462,6 @@ const SimpleTable = () => {
</select>
</div>
{/* --- 2. Renderizar los controles ANTES de la tabla --- */}
{PaginacionControles}
<div style={{ overflowX: 'auto', maxHeight: '70vh', border: '1px solid #dee2e6', borderRadius: '8px' }}>
@@ -545,7 +470,7 @@ const SimpleTable = () => {
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(h => (
<th key={h.id} className={styles.th}>
<th key={h.id} className={styles.th} onClick={h.column.getToggleSortingHandler()}>
{flexRender(h.column.columnDef.header, h.getContext())}
{h.column.getIsSorted() && (<span className={styles.sortIndicator}>{h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}</span>)}
</th>
@@ -567,28 +492,13 @@ const SimpleTable = () => {
</table>
</div>
{/* --- 3. Renderizar los controles DESPUÉS de la tabla --- */}
{PaginacionControles}
{showScrollButton && (<button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className={styles.scrollToTop} title="Volver arriba"></button>)}
{modalData && (
<ModalEditarSector
modalData={modalData}
setModalData={setModalData}
sectores={sectores}
onClose={() => setModalData(null)}
onSave={handleSave}
/>
)}
{modalData && <ModalEditarSector modalData={modalData} setModalData={setModalData} sectores={sectores} onClose={() => setModalData(null)} onSave={handleSave} />}
{modalPasswordData && (
<ModalCambiarClave
usuario={modalPasswordData}
onClose={() => setModalPasswordData(null)}
onSave={handleSavePassword}
/>
)}
{modalPasswordData && <ModalCambiarClave usuario={modalPasswordData} onClose={() => setModalPasswordData(null)} onSave={handleSavePassword} />}
{selectedEquipo && (
<ModalDetallesEquipo
@@ -601,20 +511,17 @@ const SimpleTable = () => {
onEdit={handleEditEquipo}
sectores={sectores}
onAddComponent={type => setAddingComponent(type)}
isChildModalOpen={addingComponent !== null}
/>
)}
{isAddModalOpen && (
<ModalAnadirEquipo
sectores={sectores}
onClose={() => setIsAddModalOpen(false)}
onSave={handleCreateEquipo}
/>
)}
{isAddModalOpen && <ModalAnadirEquipo sectores={sectores} onClose={() => setIsAddModalOpen(false)} onSave={handleCreateEquipo} />}
{addingComponent === 'disco' && <ModalAnadirDisco onClose={() => setAddingComponent(null)} onSave={(data) => handleAddComponent('disco', data)} />}
{addingComponent === 'ram' && <ModalAnadirRam onClose={() => setAddingComponent(null)} onSave={(data) => handleAddComponent('ram', data)} />}
{addingComponent === 'usuario' && <ModalAnadirUsuario onClose={() => setAddingComponent(null)} onSave={(data) => handleAddComponent('usuario', data)} />}
{addingComponent === 'disco' && <ModalAnadirDisco onClose={() => setAddingComponent(null)} onSave={(data: { mediatype: string, size: number }) => handleAddComponent('disco', data)} />}
{addingComponent === 'ram' && <ModalAnadirRam onClose={() => setAddingComponent(null)} onSave={(data: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => handleAddComponent('ram', data)} />}
{addingComponent === 'usuario' && <ModalAnadirUsuario onClose={() => setAddingComponent(null)} onSave={(data: { username: string }) => handleAddComponent('usuario', data)} />}
</div>
);
};