From 268c1c2bf99b66ab7fb5c6dd1a2d1134d636839e Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 8 Oct 2025 13:27:44 -0300 Subject: [PATCH] =?UTF-8?q?Mejoras=20integrales=20en=20UI,=20l=C3=B3gica?= =?UTF-8?q?=20de=20negocio=20y=20auditor=C3=ADa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/Controllers/AdminController.cs | 94 +++--- backend/Controllers/EquiposController.cs | 258 +++++++++++------ backend/Controllers/MemoriasRamController.cs | 35 ++- backend/Controllers/SectoresController.cs | 33 ++- backend/Helpers/HistorialHelper.cs | 13 +- backend/Models/Equipo.cs | 12 +- backend/appsettings.Development.json | 3 + .../net9.0/Inventario.API.AssemblyInfo.cs | 2 +- frontend/img/favicon.ico | Bin 15406 -> 0 bytes frontend/{img => public}/power.png | Bin frontend/src/components/AutocompleteInput.tsx | 69 +++-- .../src/components/GestionComponentes.tsx | 102 +++---- frontend/src/components/GestionSectores.tsx | 50 +--- frontend/src/components/ModalAnadirDisco.tsx | 2 +- frontend/src/components/ModalAnadirEquipo.tsx | 26 +- frontend/src/components/ModalAnadirRam.tsx | 74 ++++- .../src/components/ModalAnadirUsuario.tsx | 22 +- .../src/components/ModalDetallesEquipo.tsx | 111 +++++-- .../src/components/SimpleTable.module.css | 34 ++- frontend/src/components/SimpleTable.tsx | 273 ++++++------------ frontend/src/services/apiService.ts | 120 ++++++++ frontend/vite.config.ts | 14 +- 22 files changed, 834 insertions(+), 513 deletions(-) delete mode 100644 frontend/img/favicon.ico rename frontend/{img => public}/power.png (100%) create mode 100644 frontend/src/services/apiService.ts diff --git a/backend/Controllers/AdminController.cs b/backend/Controllers/AdminController.cs index a66aab1..cd26301 100644 --- a/backend/Controllers/AdminController.cs +++ b/backend/Controllers/AdminController.cs @@ -16,12 +16,33 @@ namespace Inventario.API.Controllers _context = context; } - // DTO para devolver los valores y su conteo + // --- DTOs para los componentes --- public class ComponenteValorDto { public string Valor { get; set; } = ""; public int Conteo { get; set; } } + public class UnificarComponenteDto + { + public required string ValorNuevo { get; set; } + public required string ValorAntiguo { get; set; } + } + + public class RamAgrupadaDto + { + public string? Fabricante { get; set; } + public int Tamano { get; set; } + public int? Velocidad { get; set; } + public int Conteo { get; set; } + } + + public class BorrarRamAgrupadaDto + { + public string? Fabricante { get; set; } + public int Tamano { get; set; } + public int? Velocidad { get; set; } + } + [HttpGet("componentes/{tipo}")] public async Task GetComponenteValores(string tipo) @@ -53,13 +74,6 @@ namespace Inventario.API.Controllers } } - // DTO para la petición de unificación - public class UnificarComponenteDto - { - public required string ValorNuevo { get; set; } - public required string ValorAntiguo { get; set; } - } - [HttpPut("componentes/{tipo}/unificar")] public async Task UnificarComponenteValores(string tipo, [FromBody] UnificarComponenteDto dto) { @@ -93,63 +107,64 @@ namespace Inventario.API.Controllers } } - // DTO para devolver los valores de RAM y su conteo - public class RamMaestraDto - { - public int Id { get; set; } - public string? Part_number { get; set; } - public string? Fabricante { get; set; } - public int Tamano { get; set; } - public int? Velocidad { get; set; } - public int Conteo { get; set; } - } - + // --- Devuelve la RAM agrupada --- [HttpGet("componentes/ram")] public async Task GetComponentesRam() { var query = @" SELECT - mr.Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad, + mr.Fabricante, + mr.Tamano, + mr.Velocidad, COUNT(emr.memoria_ram_id) as Conteo FROM dbo.memorias_ram mr LEFT JOIN dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id GROUP BY - mr.Id, mr.part_number, mr.Fabricante, mr.Tamano, mr.Velocidad + mr.Fabricante, + mr.Tamano, + mr.Velocidad ORDER BY Conteo DESC, mr.Fabricante, mr.Tamano;"; using (var connection = _context.CreateConnection()) { - var valores = await connection.QueryAsync(query); + var valores = await connection.QueryAsync(query); return Ok(valores); } } - [HttpDelete("componentes/ram/{id}")] - public async Task BorrarComponenteRam(int id) + // --- Elimina un grupo completo --- + [HttpDelete("componentes/ram")] + public async Task BorrarComponenteRam([FromBody] BorrarRamAgrupadaDto dto) { using (var connection = _context.CreateConnection()) { - // 1. Verificación de seguridad: Asegurarse de que el módulo no esté en uso. - var usageQuery = "SELECT COUNT(*) FROM dbo.equipos_memorias_ram WHERE memoria_ram_id = @Id;"; - var usageCount = await connection.ExecuteScalarAsync(usageQuery, new { Id = id }); + // Verificación de seguridad: Asegurarse de que el grupo no esté en uso. + var usageQuery = @" + SELECT COUNT(emr.id) + FROM dbo.memorias_ram mr + LEFT JOIN dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id + WHERE (mr.Fabricante = @Fabricante OR (mr.Fabricante IS NULL AND @Fabricante IS NULL)) + AND mr.Tamano = @Tamano + AND (mr.Velocidad = @Velocidad OR (mr.Velocidad IS NULL AND @Velocidad IS NULL));"; + + var usageCount = await connection.ExecuteScalarAsync(usageQuery, dto); if (usageCount > 0) { - return Conflict($"Este módulo de RAM está en uso por {usageCount} equipo(s) y no puede ser eliminado."); + return Conflict(new { message = $"Este grupo de RAM está en uso por {usageCount} equipo(s) y no puede ser eliminado." }); } - // 2. Si no está en uso, proceder con la eliminación. - var deleteQuery = "DELETE FROM dbo.memorias_ram WHERE Id = @Id;"; - var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { Id = id }); - - if (filasAfectadas == 0) - { - return NotFound("Módulo de RAM no encontrado."); - } + // Si no está en uso, proceder con la eliminación de todos los registros maestros que coincidan. + var deleteQuery = @" + DELETE FROM dbo.memorias_ram + WHERE (Fabricante = @Fabricante OR (Fabricante IS NULL AND @Fabricante IS NULL)) + AND Tamano = @Tamano + AND (Velocidad = @Velocidad OR (Velocidad IS NULL AND @Velocidad IS NULL));"; + await connection.ExecuteAsync(deleteQuery, dto); return NoContent(); } } @@ -172,19 +187,14 @@ namespace Inventario.API.Controllers using (var connection = _context.CreateConnection()) { - // 1. Verificación de seguridad: Asegurarse de que el valor no esté en uso. var usageQuery = $"SELECT COUNT(*) FROM dbo.equipos WHERE {columnName} = @Valor;"; var usageCount = await connection.ExecuteScalarAsync(usageQuery, new { Valor = valor }); if (usageCount > 0) { - return Conflict($"Este valor está en uso por {usageCount} equipo(s) y no puede ser eliminado. Intente unificarlo en su lugar."); + return Conflict(new { message = $"Este valor está en uso por {usageCount} equipo(s) y no puede ser eliminado. Intente unificarlo en su lugar." }); } - // Esta parte es más conceptual. Un componente de texto no existe en una tabla maestra, - // por lo que no hay nada que "eliminar". El hecho de que el conteo sea 0 significa - // que ya no existe en la práctica. Devolvemos éxito para confirmar esto. - // Si tuviéramos tablas maestras (ej: dbo.sistemas_operativos), aquí iría la consulta DELETE. return NoContent(); } } diff --git a/backend/Controllers/EquiposController.cs b/backend/Controllers/EquiposController.cs index 3142492..4a4c546 100644 --- a/backend/Controllers/EquiposController.cs +++ b/backend/Controllers/EquiposController.cs @@ -33,7 +33,7 @@ namespace Inventario.API.Controllers { var query = @" SELECT - e.Id, e.Hostname, e.Ip, e.Mac, e.Motherboard, e.Cpu, e.Ram_installed, e.Ram_slots, e.Os, e.Architecture, e.created_at, e.updated_at, e.Origen, + e.Id, e.Hostname, e.Ip, e.Mac, e.Motherboard, e.Cpu, e.Ram_installed, e.Ram_slots, e.Os, e.Architecture, e.created_at, e.updated_at, e.Origen, e.sector_id, s.Id as Id, s.Nombre, u.Id as Id, u.Username, u.Password, ue.Origen as Origen, d.Id as Id, d.Mediatype, d.Size, ed.Origen as Origen, ed.Id as EquipoDiscoId, @@ -51,7 +51,6 @@ namespace Inventario.API.Controllers { var equipoDict = new Dictionary(); - // CAMBIO: Se actualizan los tipos en la función de mapeo de Dapper await connection.QueryAsync( query, (equipo, sector, usuario, disco, memoria) => { @@ -61,12 +60,11 @@ namespace Inventario.API.Controllers equipoActual.Sector = sector; equipoDict.Add(equipoActual.Id, equipoActual); } - // CAMBIO: Se ajusta la lógica para evitar duplicados en los nuevos tipos detallados if (usuario != null && !equipoActual.Usuarios.Any(u => u.Id == usuario.Id)) equipoActual.Usuarios.Add(usuario); - if (disco != null && !equipoActual.Discos.Any(d => d.Id == disco.Id)) + if (disco != null && !equipoActual.Discos.Any(d => d.EquipoDiscoId == disco.EquipoDiscoId)) equipoActual.Discos.Add(disco); - if (memoria != null && !equipoActual.MemoriasRam.Any(m => m.Id == memoria.Id && m.Slot == memoria.Slot)) + if (memoria != null && !equipoActual.MemoriasRam.Any(m => m.EquipoMemoriaRamId == memoria.EquipoMemoriaRamId)) equipoActual.MemoriasRam.Add(memoria); return equipoActual; @@ -148,7 +146,7 @@ namespace Inventario.API.Controllers else { // Actualizar y registrar historial - var cambios = new Dictionary(); + var cambios = new Dictionary(); // Comparamos campos para registrar en historial if (equipoData.Ip != equipoExistente.Ip) cambios["ip"] = (equipoExistente.Ip, equipoData.Ip); @@ -279,7 +277,6 @@ namespace Inventario.API.Controllers [HttpPost("{hostname}/asociardiscos")] public async Task AsociarDiscos(string hostname, [FromBody] List discosDesdeCliente) { - // 1. OBTENER EL EQUIPO var equipoQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;"; using var connection = _context.CreateConnection(); connection.Open(); @@ -290,21 +287,16 @@ namespace Inventario.API.Controllers return NotFound("Equipo no encontrado."); } - // Iniciar una transacción para asegurar que todas las operaciones se completen o ninguna lo haga. using var transaction = connection.BeginTransaction(); try { - // 2. OBTENER ASOCIACIONES Y DISCOS ACTUALES DE LA BD var discosActualesQuery = @" - SELECT d.Id, d.Mediatype, d.Size, ed.Id as EquipoDiscoId - FROM dbo.equipos_discos ed - JOIN dbo.discos d ON ed.disco_id = d.id - WHERE ed.equipo_id = @EquipoId;"; - + SELECT d.Id, d.Mediatype, d.Size, ed.Id as EquipoDiscoId + FROM dbo.equipos_discos ed + JOIN dbo.discos d ON ed.disco_id = d.id + WHERE ed.equipo_id = @EquipoId;"; var discosEnDb = (await connection.QueryAsync(discosActualesQuery, new { EquipoId = equipo.Id }, transaction)).ToList(); - // 3. AGRUPAR Y CONTAR DISCOS (del cliente y de la BD) - // Crea un diccionario estilo: {"SSD_256": 2, "HDD_1024": 1} var discosClienteContados = discosDesdeCliente .GroupBy(d => $"{d.Mediatype}_{d.Size}") .ToDictionary(g => g.Key, g => g.Count()); @@ -313,28 +305,23 @@ namespace Inventario.API.Controllers .GroupBy(d => $"{d.Mediatype}_{d.Size}") .ToDictionary(g => g.Key, g => g.Count()); - var cambios = new Dictionary(); + var cambios = new Dictionary(); - // 4. CALCULAR Y EJECUTAR ELIMINACIONES var discosAEliminar = new List(); foreach (var discoDb in discosEnDb) { var key = $"{discoDb.Mediatype}_{discoDb.Size}"; if (discosClienteContados.TryGetValue(key, out int count) && count > 0) { - // Este disco todavía existe en el cliente, decrementamos el contador y lo saltamos. discosClienteContados[key]--; } else { - // Este disco ya no está en el cliente, marcamos su asociación para eliminar. discosAEliminar.Add(discoDb.EquipoDiscoId); - - // Registrar para el historial var nombreDisco = $"Disco {discoDb.Mediatype} {discoDb.Size}GB"; var anterior = discosDbContados.GetValueOrDefault(key, 0); if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior - 1).ToString()); - else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo) - 1).ToString()); + else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo!) - 1).ToString()); } } if (discosAEliminar.Any()) @@ -342,39 +329,33 @@ namespace Inventario.API.Controllers await connection.ExecuteAsync("DELETE FROM dbo.equipos_discos WHERE Id IN @Ids;", new { Ids = discosAEliminar }, transaction); } - // 5. CALCULAR Y EJECUTAR INSERCIONES foreach (var discoCliente in discosDesdeCliente) { var key = $"{discoCliente.Mediatype}_{discoCliente.Size}"; if (discosDbContados.TryGetValue(key, out int count) && count > 0) { - // Este disco ya existía, decrementamos para no volver a añadirlo. discosDbContados[key]--; } else { - // Este es un disco nuevo que hay que asociar. - var disco = await connection.QuerySingleOrDefaultAsync("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;", discoCliente, transaction); + var disco = await connection.QueryFirstOrDefaultAsync("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;", discoCliente, transaction); if (disco == null) continue; await connection.ExecuteAsync("INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'automatica');", new { EquipoId = equipo.Id, DiscoId = disco.Id }, transaction); - - // Registrar para el historial var nombreDisco = $"Disco {disco.Mediatype} {disco.Size}GB"; var anterior = discosDbContados.GetValueOrDefault(key, 0); if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior + 1).ToString()); - else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo) + 1).ToString()); + else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo!) + 1).ToString()); } } - // 6. REGISTRAR CAMBIOS Y CONFIRMAR TRANSACCIÓN if (cambios.Count > 0) { - // Formateamos los valores para el historial var cambiosFormateados = cambios.ToDictionary( kvp => kvp.Key, - kvp => ($"{kvp.Value.anterior} Instalados", $"{kvp.Value.nuevo} Instalados") + kvp => ((string?)$"{kvp.Value.anterior} Instalados", (string?)$"{kvp.Value.nuevo} Instalados") ); + await HistorialHelper.RegistrarCambios(_context, equipo.Id, cambiosFormateados); } @@ -385,7 +366,6 @@ namespace Inventario.API.Controllers catch (Exception ex) { transaction.Rollback(); - // Loggear el error en el servidor Console.WriteLine($"Error al asociar discos para {hostname}: {ex.Message}"); return StatusCode(500, "Ocurrió un error interno al procesar la solicitud."); } @@ -413,7 +393,8 @@ namespace Inventario.API.Controllers var huellasCliente = new HashSet(memoriasDesdeCliente.Select(crearHuella)); var huellasDb = new HashSet(ramEnDb.Select(crearHuella)); - var cambios = new Dictionary(); + var cambios = new Dictionary(); + Func formatRamDetails = ram => { var parts = new List { ram.Fabricante, $"{ram.Tamano}GB", ram.PartNumber, ram.Velocidad?.ToString() + "MHz" }; @@ -585,13 +566,13 @@ namespace Inventario.API.Controllers { var findQuery = "SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname;"; var insertQuery = @" - INSERT INTO dbo.equipos (Hostname, Ip, Motherboard, Cpu, Os, Sector_id, Origen, Ram_installed, Architecture) - VALUES (@Hostname, @Ip, @Motherboard, @Cpu, @Os, @Sector_id, 'manual', 0, ''); - SELECT CAST(SCOPE_IDENTITY() as int);"; + INSERT INTO dbo.equipos (Hostname, Ip, Motherboard, Cpu, Os, Sector_id, Origen, Ram_installed, Architecture) + VALUES (@Hostname, @Ip, @Motherboard, @Cpu, @Os, @Sector_id, 'manual', 0, ''); + SELECT CAST(SCOPE_IDENTITY() as int);"; using (var connection = _context.CreateConnection()) { - var existente = await connection.QuerySingleOrDefaultAsync(findQuery, new { equipoDto.Hostname }); + var existente = await connection.QueryFirstOrDefaultAsync(findQuery, new { equipoDto.Hostname }); if (existente.HasValue) { return Conflict($"El hostname '{equipoDto.Hostname}' ya existe."); @@ -599,14 +580,13 @@ namespace Inventario.API.Controllers var nuevoId = await connection.ExecuteScalarAsync(insertQuery, equipoDto); - // Devolvemos el objeto completo para que el frontend pueda actualizar su estado - var nuevoEquipo = await connection.QuerySingleOrDefaultAsync("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = nuevoId }); + await HistorialHelper.RegistrarCambioUnico(_context, nuevoId, "Equipo", null, "Equipo creado manualmente"); + var nuevoEquipo = await connection.QuerySingleOrDefaultAsync("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = nuevoId }); if (nuevoEquipo == null) { return StatusCode(500, "No se pudo recuperar el equipo después de crearlo."); } - return CreatedAtAction(nameof(ConsultarDetalle), new { hostname = nuevoEquipo.Hostname }, nuevoEquipo); } } @@ -616,14 +596,28 @@ namespace Inventario.API.Controllers [HttpDelete("asociacion/disco/{equipoDiscoId}")] public async Task BorrarAsociacionDisco(int equipoDiscoId) { - var query = "DELETE FROM dbo.equipos_discos WHERE Id = @EquipoDiscoId AND Origen = 'manual';"; using (var connection = _context.CreateConnection()) { - var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoDiscoId = equipoDiscoId }); - if (filasAfectadas == 0) + var infoQuery = @" + SELECT ed.equipo_id, d.Mediatype, d.Size + FROM dbo.equipos_discos ed + JOIN dbo.discos d ON ed.disco_id = d.id + WHERE ed.Id = @EquipoDiscoId AND ed.Origen = 'manual'"; + var info = await connection.QueryFirstOrDefaultAsync<(int equipo_id, string Mediatype, int Size)>(infoQuery, new { EquipoDiscoId = equipoDiscoId }); + + if (info == default) { - return NotFound("Asociación de disco no encontrada o no se puede eliminar porque es automática."); + return NotFound("Asociación de disco no encontrada o no es manual."); } + + var deleteQuery = "DELETE FROM dbo.equipos_discos WHERE Id = @EquipoDiscoId;"; + await connection.ExecuteAsync(deleteQuery, new { EquipoDiscoId = equipoDiscoId }); + + var descripcion = $"Disco {info.Mediatype} {info.Size}GB"; + await HistorialHelper.RegistrarCambioUnico(_context, info.equipo_id, "Componente", descripcion, "Eliminado"); + + await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = info.equipo_id }); + return NoContent(); } } @@ -631,14 +625,39 @@ namespace Inventario.API.Controllers [HttpDelete("asociacion/ram/{equipoMemoriaRamId}")] public async Task BorrarAsociacionRam(int equipoMemoriaRamId) { - var query = "DELETE FROM dbo.equipos_memorias_ram WHERE Id = @EquipoMemoriaRamId AND Origen = 'manual';"; using (var connection = _context.CreateConnection()) { - var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoMemoriaRamId = equipoMemoriaRamId }); - if (filasAfectadas == 0) + var infoQuery = @" + SELECT emr.equipo_id, emr.Slot, mr.Fabricante, mr.Tamano, mr.Velocidad + FROM dbo.equipos_memorias_ram emr + JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id + WHERE emr.Id = @Id AND emr.Origen = 'manual'"; + var info = await connection.QueryFirstOrDefaultAsync<(int equipo_id, string Slot, string? Fabricante, int Tamano, int? Velocidad)>(infoQuery, new { Id = equipoMemoriaRamId }); + + if (info == default) { - return NotFound("Asociación de RAM no encontrada o no se puede eliminar porque es automática."); + return NotFound("Asociación de RAM no encontrada o no es manual."); } + + var deleteQuery = "DELETE FROM dbo.equipos_memorias_ram WHERE Id = @EquipoMemoriaRamId;"; + await connection.ExecuteAsync(deleteQuery, new { EquipoMemoriaRamId = equipoMemoriaRamId }); + + var descripcion = $"Módulo RAM: Slot {info.Slot} - {info.Fabricante ?? ""} {info.Tamano}GB {info.Velocidad?.ToString() ?? ""}MHz"; + await HistorialHelper.RegistrarCambioUnico(_context, info.equipo_id, "Componente", descripcion, "Eliminado"); + + var updateQuery = @" + UPDATE e + SET + e.ram_installed = ISNULL((SELECT SUM(mr.Tamano) + FROM dbo.equipos_memorias_ram emr + JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id + WHERE emr.equipo_id = @Id), 0), + e.updated_at = GETDATE() + FROM dbo.equipos e + WHERE e.Id = @Id;"; + + await connection.ExecuteAsync(updateQuery, new { Id = info.equipo_id }); + return NoContent(); } } @@ -646,14 +665,27 @@ namespace Inventario.API.Controllers [HttpDelete("asociacion/usuario/{equipoId}/{usuarioId}")] public async Task BorrarAsociacionUsuario(int equipoId, int usuarioId) { - var query = "DELETE FROM dbo.usuarios_equipos WHERE equipo_id = @EquipoId AND usuario_id = @UsuarioId AND Origen = 'manual';"; using (var connection = _context.CreateConnection()) { - var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoId = equipoId, UsuarioId = usuarioId }); + var username = await connection.QuerySingleOrDefaultAsync("SELECT Username FROM dbo.usuarios WHERE Id = @UsuarioId", new { UsuarioId = usuarioId }); + if (username == null) + { + return NotFound("Usuario no encontrado."); + } + + var deleteQuery = "DELETE FROM dbo.usuarios_equipos WHERE equipo_id = @EquipoId AND usuario_id = @UsuarioId AND Origen = 'manual';"; + var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { EquipoId = equipoId, UsuarioId = usuarioId }); + if (filasAfectadas == 0) { - return NotFound("Asociación de usuario no encontrada o no se puede eliminar porque es automática."); + return NotFound("Asociación de usuario no encontrada o no es manual."); } + + var descripcion = $"Usuario {username}"; + await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", descripcion, "Eliminado"); + + await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = equipoId }); + return NoContent(); } } @@ -663,7 +695,6 @@ namespace Inventario.API.Controllers { using (var connection = _context.CreateConnection()) { - // 1. Verificar que el equipo existe y es manual var equipoActual = await connection.QuerySingleOrDefaultAsync("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = id }); if (equipoActual == null) { @@ -674,7 +705,6 @@ namespace Inventario.API.Controllers return Forbid("No se puede modificar un equipo generado automáticamente."); } - // 2. (Opcional pero recomendado) Verificar que el nuevo hostname no exista ya en otro equipo if (equipoActual.Hostname != equipoDto.Hostname) { var hostExistente = await connection.QuerySingleOrDefaultAsync("SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname AND Id != @Id", new { equipoDto.Hostname, Id = id }); @@ -684,19 +714,46 @@ namespace Inventario.API.Controllers } } - // 3. Construir y ejecutar la consulta de actualización - var updateQuery = @"UPDATE dbo.equipos SET - Hostname = @Hostname, - Ip = @Ip, - Mac = @Mac, - Motherboard = @Motherboard, - Cpu = @Cpu, - Os = @Os, - Sector_id = @Sector_id, - updated_at = GETDATE() - WHERE Id = @Id AND Origen = 'manual';"; + var allSectores = await connection.QueryAsync("SELECT Id, Nombre FROM dbo.sectores;"); + var sectorMap = allSectores.ToDictionary(s => s.Id, s => s.Nombre); - var filasAfectadas = await connection.ExecuteAsync(updateQuery, new + var cambios = new Dictionary(); + + if (equipoActual.Hostname != equipoDto.Hostname) cambios["Hostname"] = (equipoActual.Hostname, equipoDto.Hostname); + if (equipoActual.Ip != equipoDto.Ip) cambios["IP"] = (equipoActual.Ip, equipoDto.Ip); + if (equipoActual.Mac != equipoDto.Mac) cambios["MAC Address"] = (equipoActual.Mac ?? "N/A", equipoDto.Mac ?? "N/A"); + if (equipoActual.Motherboard != equipoDto.Motherboard) cambios["Motherboard"] = (equipoActual.Motherboard ?? "N/A", equipoDto.Motherboard ?? "N/A"); + if (equipoActual.Cpu != equipoDto.Cpu) cambios["CPU"] = (equipoActual.Cpu ?? "N/A", equipoDto.Cpu ?? "N/A"); + if (equipoActual.Os != equipoDto.Os) cambios["Sistema Operativo"] = (equipoActual.Os ?? "N/A", equipoDto.Os ?? "N/A"); + if (equipoActual.Architecture != equipoDto.Architecture) cambios["Arquitectura"] = (equipoActual.Architecture ?? "N/A", equipoDto.Architecture ?? "N/A"); + if (equipoActual.Ram_slots != equipoDto.Ram_slots) cambios["Slots RAM"] = (equipoActual.Ram_slots?.ToString() ?? "N/A", equipoDto.Ram_slots?.ToString() ?? "N/A"); + + if (equipoActual.Sector_id != equipoDto.Sector_id) + { + string nombreAnterior = equipoActual.Sector_id.HasValue && sectorMap.TryGetValue(equipoActual.Sector_id.Value, out var oldName) + ? oldName + : "Ninguno"; + string nombreNuevo = equipoDto.Sector_id.HasValue && sectorMap.TryGetValue(equipoDto.Sector_id.Value, out var newName) + ? newName + : "Ninguno"; + cambios["Sector"] = (nombreAnterior, nombreNuevo); + } + + var updateQuery = @"UPDATE dbo.equipos SET + Hostname = @Hostname, + Ip = @Ip, + Mac = @Mac, + Motherboard = @Motherboard, + Cpu = @Cpu, + Os = @Os, + Sector_id = @Sector_id, + Ram_slots = @Ram_slots, + Architecture = @Architecture, -- Campo añadido a la actualización + updated_at = GETDATE() + OUTPUT INSERTED.* + WHERE Id = @Id AND Origen = 'manual';"; + + var equipoActualizado = await connection.QuerySingleOrDefaultAsync(updateQuery, new { equipoDto.Hostname, equipoDto.Ip, @@ -705,16 +762,24 @@ namespace Inventario.API.Controllers equipoDto.Cpu, equipoDto.Os, equipoDto.Sector_id, + equipoDto.Ram_slots, + equipoDto.Architecture, Id = id }); - if (filasAfectadas == 0) + if (equipoActualizado == null) { - // Esto no debería pasar si las primeras verificaciones pasaron, pero es una salvaguarda return StatusCode(500, "No se pudo actualizar el equipo."); } - return NoContent(); // Éxito en la actualización + if (cambios.Count > 0) + { + await HistorialHelper.RegistrarCambios(_context, id, cambios); + } + + var equipoCompleto = await ConsultarDetalle(equipoActualizado.Hostname); + + return equipoCompleto; } } @@ -723,11 +788,10 @@ namespace Inventario.API.Controllers { using (var connection = _context.CreateConnection()) { - var equipo = await connection.QuerySingleOrDefaultAsync("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId }); + var equipo = await connection.QueryFirstOrDefaultAsync("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId }); if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales."); - // Buscar o crear el disco maestro - var discoMaestro = await connection.QuerySingleOrDefaultAsync("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size", dto); + var discoMaestro = await connection.QueryFirstOrDefaultAsync("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size", dto); int discoId; if (discoMaestro == null) { @@ -738,10 +802,14 @@ namespace Inventario.API.Controllers discoId = discoMaestro.Id; } - // Crear la asociación manual var asociacionQuery = "INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'manual'); SELECT CAST(SCOPE_IDENTITY() as int);"; var nuevaAsociacionId = await connection.ExecuteScalarAsync(asociacionQuery, new { EquipoId = equipoId, DiscoId = discoId }); + var descripcion = $"Disco {dto.Mediatype} {dto.Size}GB"; + await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", null, $"Añadido: {descripcion}"); + + await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = equipoId }); + return Ok(new { message = "Disco asociado manualmente.", equipoDiscoId = nuevaAsociacionId }); } } @@ -751,25 +819,41 @@ namespace Inventario.API.Controllers { using (var connection = _context.CreateConnection()) { - var equipo = await connection.QuerySingleOrDefaultAsync("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId }); + var equipo = await connection.QueryFirstOrDefaultAsync("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId }); if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales."); - - // Lógica similar a la de discos para buscar/crear el módulo maestro int ramId; - var ramMaestra = await connection.QuerySingleOrDefaultAsync("SELECT * FROM dbo.memorias_ram WHERE Tamano = @Tamano AND Fabricante = @Fabricante AND Velocidad = @Velocidad", dto); + var ramMaestra = await connection.QueryFirstOrDefaultAsync( + "SELECT * FROM dbo.memorias_ram WHERE (Fabricante = @Fabricante OR (Fabricante IS NULL AND @Fabricante IS NULL)) AND Tamano = @Tamano AND (Velocidad = @Velocidad OR (Velocidad IS NULL AND @Velocidad IS NULL))", dto); + if (ramMaestra == null) { - ramId = await connection.ExecuteScalarAsync("INSERT INTO dbo.memorias_ram (Tamano, Fabricante, Velocidad) VALUES (@Tamano, @Fabricante, @Velocidad); SELECT CAST(SCOPE_IDENTITY() as int);", dto); + var insertQuery = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad) + VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad); + SELECT CAST(SCOPE_IDENTITY() as int);"; + ramId = await connection.ExecuteScalarAsync(insertQuery, dto); } else { ramId = ramMaestra.Id; } - // Crear la asociación manual var asociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot, origen) VALUES (@EquipoId, @RamId, @Slot, 'manual'); SELECT CAST(SCOPE_IDENTITY() as int);"; var nuevaAsociacionId = await connection.ExecuteScalarAsync(asociacionQuery, new { EquipoId = equipoId, RamId = ramId, dto.Slot }); + var descripcion = $"Módulo RAM: Slot {dto.Slot} - {dto.Fabricante ?? ""} {dto.Tamano}GB {dto.Velocidad?.ToString() ?? ""}MHz"; + await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", null, $"Añadido: {descripcion}"); + + var updateQuery = @" + UPDATE e SET + e.ram_installed = ISNULL((SELECT SUM(mr.Tamano) + FROM dbo.equipos_memorias_ram emr + JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id + WHERE emr.equipo_id = @Id), 0), + e.updated_at = GETDATE() + FROM dbo.equipos e + WHERE e.Id = @Id;"; + await connection.ExecuteAsync(updateQuery, new { Id = equipoId }); + return Ok(new { message = "RAM asociada manualmente.", equipoMemoriaRamId = nuevaAsociacionId }); } } @@ -779,12 +863,11 @@ namespace Inventario.API.Controllers { using (var connection = _context.CreateConnection()) { - var equipo = await connection.QuerySingleOrDefaultAsync("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId }); + var equipo = await connection.QueryFirstOrDefaultAsync("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId }); if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales."); - // Buscar o crear el usuario maestro int usuarioId; - var usuario = await connection.QuerySingleOrDefaultAsync("SELECT * FROM dbo.usuarios WHERE Username = @Username", dto); + var usuario = await connection.QueryFirstOrDefaultAsync("SELECT * FROM dbo.usuarios WHERE Username = @Username", dto); if (usuario == null) { usuarioId = await connection.ExecuteScalarAsync("INSERT INTO dbo.usuarios (Username) VALUES (@Username); SELECT CAST(SCOPE_IDENTITY() as int);", dto); @@ -794,7 +877,6 @@ namespace Inventario.API.Controllers usuarioId = usuario.Id; } - // Crear la asociación manual try { var asociacionQuery = "INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen) VALUES (@EquipoId, @UsuarioId, 'manual');"; @@ -805,6 +887,11 @@ namespace Inventario.API.Controllers return Conflict("El usuario ya está asociado a este equipo."); } + var descripcion = $"Usuario {dto.Username}"; + await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", null, $"Añadido: {descripcion}"); + + await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = equipoId }); + return Ok(new { message = "Usuario asociado manualmente." }); } } @@ -866,6 +953,8 @@ namespace Inventario.API.Controllers public string? Cpu { get; set; } public string? Os { get; set; } public int? Sector_id { get; set; } + public int? Ram_slots { get; set; } + public string? Architecture { get; set; } } public class AsociarDiscoManualDto @@ -880,6 +969,7 @@ namespace Inventario.API.Controllers public int Tamano { get; set; } public string? Fabricante { get; set; } public int? Velocidad { get; set; } + public string? PartNumber { get; set; } } public class AsociarUsuarioManualDto diff --git a/backend/Controllers/MemoriasRamController.cs b/backend/Controllers/MemoriasRamController.cs index 807e500..55535c0 100644 --- a/backend/Controllers/MemoriasRamController.cs +++ b/backend/Controllers/MemoriasRamController.cs @@ -20,7 +20,24 @@ namespace Inventario.API.Controllers [HttpGet] public async Task Consultar() { - var query = "SELECT Id, part_number as PartNumber, Fabricante, Tamano, Velocidad FROM dbo.memorias_ram;"; + var query = @" + SELECT + MIN(Id) as Id, + MIN(part_number) as PartNumber, -- Tomamos un part_number como ejemplo para el modelo + Fabricante, + Tamano, + Velocidad + FROM + dbo.memorias_ram + WHERE + Fabricante IS NOT NULL AND Fabricante != '' + GROUP BY + Fabricante, + Tamano, + Velocidad + ORDER BY + Fabricante, Tamano, Velocidad;"; + using (var connection = _context.CreateConnection()) { var memorias = await connection.QueryAsync(query); @@ -151,5 +168,21 @@ namespace Inventario.API.Controllers } } } + + // --- GET /api/memoriasram/buscar/{termino} --- + [HttpGet("buscar/{termino}")] + public async Task BuscarMemoriasRam(string termino) + { + var query = @"SELECT Id, part_number as PartNumber, Fabricante, Tamano, Velocidad + FROM dbo.memorias_ram + WHERE Fabricante LIKE @SearchTerm OR part_number LIKE @SearchTerm + ORDER BY Fabricante, Tamano;"; + + using (var connection = _context.CreateConnection()) + { + var memorias = await connection.QueryAsync(query, new { SearchTerm = $"%{termino}%" }); + return Ok(memorias); + } + } } } \ No newline at end of file diff --git a/backend/Controllers/SectoresController.cs b/backend/Controllers/SectoresController.cs index 291053a..c7a0c60 100644 --- a/backend/Controllers/SectoresController.cs +++ b/backend/Controllers/SectoresController.cs @@ -1,3 +1,5 @@ +// backend/Controllers/SectoresController.cs + using Dapper; using Inventario.API.Data; using Inventario.API.Models; @@ -105,18 +107,29 @@ namespace Inventario.API.Controllers [HttpDelete("{id}")] public async Task BorrarSector(int id) { - var query = "DELETE FROM dbo.sectores WHERE Id = @Id;"; - using (var connection = _context.CreateConnection()) - { - var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id }); - - if (filasAfectadas == 0) + using (var connection = _context.CreateConnection()) { - return NotFound(); - } + // 1. VERIFICAR SI EL SECTOR ESTÁ EN USO + var usageQuery = "SELECT COUNT(1) FROM dbo.equipos WHERE sector_id = @Id;"; + var usageCount = await connection.ExecuteScalarAsync(usageQuery, new { Id = id }); - return NoContent(); - } + if (usageCount > 0) + { + // 2. DEVOLVER HTTP 409 CONFLICT SI ESTÁ EN USO + return Conflict(new { message = $"No se puede eliminar. Hay {usageCount} equipo(s) asignados a este sector." }); + } + + // 3. SI NO ESTÁ EN USO, PROCEDER CON LA ELIMINACIÓN + var deleteQuery = "DELETE FROM dbo.sectores WHERE Id = @Id;"; + var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { Id = id }); + + if (filasAfectadas == 0) + { + return NotFound(); + } + + return NoContent(); + } } } } \ No newline at end of file diff --git a/backend/Helpers/HistorialHelper.cs b/backend/Helpers/HistorialHelper.cs index d410654..4e4b0c0 100644 --- a/backend/Helpers/HistorialHelper.cs +++ b/backend/Helpers/HistorialHelper.cs @@ -1,12 +1,12 @@ +// backend/Helpers/HistorialHelper.cs using Dapper; using Inventario.API.Data; -using Inventario.API.Models; namespace Inventario.API.Helpers { public static class HistorialHelper { - public static async Task RegistrarCambios(DapperContext context, int equipoId, Dictionary cambios) + public static async Task RegistrarCambios(DapperContext context, int equipoId, Dictionary cambios) { var query = @"INSERT INTO dbo.historial_equipos (equipo_id, campo_modificado, valor_anterior, valor_nuevo) VALUES (@EquipoId, @CampoModificado, @ValorAnterior, @ValorNuevo);"; @@ -25,5 +25,14 @@ namespace Inventario.API.Helpers } } } + + public static async Task RegistrarCambioUnico(DapperContext context, int equipoId, string campo, string? valorAnterior, string? valorNuevo) + { + var cambio = new Dictionary + { + { campo, (valorAnterior, valorNuevo) } + }; + await RegistrarCambios(context, equipoId, cambio); + } } } \ No newline at end of file diff --git a/backend/Models/Equipo.cs b/backend/Models/Equipo.cs index 6736ef7..a1128e5 100644 --- a/backend/Models/Equipo.cs +++ b/backend/Models/Equipo.cs @@ -18,28 +18,26 @@ namespace Inventario.API.Models public int? Sector_id { get; set; } public string Origen { get; set; } = "automatica"; - // Propiedades de navegación actualizadas public Sector? Sector { get; set; } - public List Usuarios { get; set; } = new(); // Tipo actualizado - public List Discos { get; set; } = new(); // Tipo actualizado - public List MemoriasRam { get; set; } = new(); // Tipo actualizado + public List Usuarios { get; set; } = new(); + public List Discos { get; set; } = new(); + public List MemoriasRam { get; set; } = new(); public List Historial { get; set; } = new(); } - // Nuevo modelo para discos con su origen public class DiscoDetalle : Disco { public string Origen { get; set; } = "manual"; + public int EquipoDiscoId { get; set; } } - // Nuevo modelo para memorias RAM con su origen y slot public class MemoriaRamEquipoDetalle : MemoriaRam { public string Slot { get; set; } = string.Empty; public string Origen { get; set; } = "manual"; + public int EquipoMemoriaRamId { get; set; } } - // Nuevo modelo para usuarios con su origen public class UsuarioEquipoDetalle : Usuario { public string Origen { get; set; } = "manual"; diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json index 90fd5f0..f171ea0 100644 --- a/backend/appsettings.Development.json +++ b/backend/appsettings.Development.json @@ -5,6 +5,9 @@ "Microsoft.AspNetCore": "Warning" } }, + "ConnectionStrings": { + "DefaultConnection": "Server=TECNICA3;Database=InventarioDB;User Id=apiequipos;Password=@Apiequipos513@;TrustServerCertificate=True" + }, "SshSettings": { "Host": "192.168.10.1", "Port": 22110, diff --git a/backend/obj/Debug/net9.0/Inventario.API.AssemblyInfo.cs b/backend/obj/Debug/net9.0/Inventario.API.AssemblyInfo.cs index a9a4ec3..e2a04d9 100644 --- a/backend/obj/Debug/net9.0/Inventario.API.AssemblyInfo.cs +++ b/backend/obj/Debug/net9.0/Inventario.API.AssemblyInfo.cs @@ -13,7 +13,7 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("Inventario.API")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+04f1134be432cfc59ba887bba19eb9b563256780")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+177ad5596221b094cc62c04b38044cc9f09a107e")] [assembly: System.Reflection.AssemblyProductAttribute("Inventario.API")] [assembly: System.Reflection.AssemblyTitleAttribute("Inventario.API")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/frontend/img/favicon.ico b/frontend/img/favicon.ico deleted file mode 100644 index 072355576d7093f0f8ab04022c4af1257af26269..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15406 zcmeHOOKe|Eo@crl9|K4-&oO9>l>j*?G z{W`Dz@%{gK+;jgsP9#2)IFxw&@dV0&#LJH*5))#}pMVf(z~!5P%7mA$i$&Mj)Qyc$z>8yh#<$IwT-jdhIS9BaC`WgWU` zjLtc!Q=X$G0SxCscW7*@UJ>{1?1)x#L-O9**%bHg?J7CoxOZn)>})!+P3I}K$rcgS z?xLKN*nn}T<={+KKGPY^_8N#c)i)g_ zw^gsYW9m6TV_QyD>Lec0L%2xq_GV3<_f#j{#7nWIcD-Dht5u3WsaHx+iTF?-(>m9S zejkr{rD+|gKJwSoWvas_*`{^bDwn@ftCXH|){8%lqVn?Gsg=d+B9g!(bvSlijne9K zWYe}MZ2e1fsaJXL+RN0dHu?Sbzd!uvqhs%!5@W+4DmD5C*M|p>_G3q!J$opZ8GmBo z+T=HFbou=0H^$x>+=E?>g6&JAr{A#2Q$N`d;_M+EV?I0eNu=uhMe2kX^Mfg z&CkJ`A0AiFa=M#@?j?K&(x25G>#Re&gOCk*C-maz&l`<}b=LJeimdy0xiBxa^}C8` zT-i6w3D%6seo%*dPlO#m9(td4&yhUsZMM%b=eeh{v;CmHwmXy_D`~$yR%F9uo9vei z@mGGf>j~So$2}My_Fwm#>qh^14Y_k0wi}$EV0=ttVf%V4Ry_ZG%$_|Bhe@yOGw}ra zj>tJ?f5WBGe|;VUy28Htn63DrE9^gE;27BK+vhfn!QN&aVf$>GaG30~T$E4Ycc>^{ z-@bqD@I18HH^tlAW1)Cczc*%y1IB&(?Pri~-&=Qb{w?|}V^J|P+2=XX?Q;xuUHier z`b_p!{HMM7mv*#R>KIs0V9qsOZ`_SdSXQ?$^9@*c*7R-$zujg+hUqB2Vl0Hv|rq?7F?(^h$E~R|*-3<57hxxhW z3v>sa2ZsDy>SOfX59{u|t(_~bA0d4ADdzKY8EKpB|3Cjxo&+h{#EYP)Jjt6X9KN9>K9dZDS z)lw2x!c0EY)|OA}*f*UuvDK&|C$i?{8Je|K*+>2CALWXuE&JS@2#swb2Sa_Fr>P^y zBIS`sQTZ7+=MG+$Ctbu>uNJ|hbo%nhpEza7t7CF84F>;w71c{ZujlXvrLZmgqp2IMKPDKQ1*vFHEN zY*q$fBa69sNVfJ(-Rw;-2N-2ftq~I*tQo?Ztyl8jqw}-AUVH}ga2$P;7`u+M^hVdT zQM^%ITW)QvE{Ps#x3i7qAP!&y#wpMsVW#sFj|cfzC=|Z1xw7(Q${)5`cKXCHww#;3 zw2W_3JyJ5t4vz)w0UKcyz{x`geLVR=8Ea>+BYoua%uM{V)=59^;~oa8 zD{PscmvWgW7&n!8_xVwNHaU%bb_;wlncZ&H8N4i%?!YoUje(9EAK8|L4DoH#5*U18SlBT<2ysPZ~Ia2 z!{Kio2QY7l{U=!W_rmxKnG2$r9Qv=K0)8jJzrSF-m;>W`eP#4FZQmO>Pt#x7o+l?g zdVVNUFLFlJk=KlSntjpBe(=YU!;A9aAn-@WFO-LE%h_3Sr2x_5vzHxR7Z{;==$#_p8MG&Ui>X_3-a2{+-Sg^V;=s9Pxe=C;jSSZ^wc^6n{Uqop86W z2j8de#$v&5T`L_hg#4D+dx$;p_lG~`xSIU1#3cM|><@p3xLRz>`1XLco3#`>{+RJ~ z@*#Ho6#MWRK0u$W_zlm4SnoekYm9%N9pVYE{r>mwKGuH-lfOPXoeA`e(VsJl-x`0z z^DK_|n_}qu(0g2Z#UHo*{b29Beu8vXo->LZdvDGVo5Htt9P`ML7-bsV{ ze(knj5A}!nRD1PbZ??TZ5_aUUZt}YM-zn7`;P)|W@ZnFTeR z)XC**$#27lGU#CQ=5$Yq!eh9{*y2i9CFWe_2jcSO$B>Ku1+|U*Q8p11J?~}g zJZ4iHi#O80T)24g^YjgXe4u&2dio=N?^MFPwC!oertk*O03PeP)MCTWo(LTe@IP29 YOWEYx3)7R|CCr5V)4xw6u%8J02hW2) => void; name: string; placeholder?: string; - // CAMBIO: La función ahora recibe el término de búsqueda - fetchSuggestions: (query: string) => Promise; className?: string; -} +} & ( // Esto crea una unión: o es estático o es dinámico + | { + mode: 'static'; + fetchSuggestions: () => Promise; // No necesita 'query' + } + | { + mode: 'dynamic'; + fetchSuggestions: (query: string) => Promise; // Necesita 'query' + } +); -const AutocompleteInput: React.FC = ({ - value, - onChange, - name, - placeholder, - fetchSuggestions, - className -}) => { +const AutocompleteInput: React.FC = (props) => { + const { value, onChange, name, placeholder, className } = props; const [suggestions, setSuggestions] = useState([]); const dataListId = `suggestions-for-${name}`; - // CAMBIO: Lógica de "debouncing" para buscar mientras se escribe + // --- Lógica para el modo ESTÁTICO --- + // Se ejecuta UNA SOLA VEZ cuando el componente se monta useEffect(() => { - // No buscar si el input está vacío o es muy corto - if (value.length < 2) { - setSuggestions([]); - return; - } - - // Configura un temporizador para esperar 300ms después de la última pulsación - const handler = setTimeout(() => { - fetchSuggestions(value) + if (props.mode === 'static') { + props.fetchSuggestions() .then(setSuggestions) - .catch(err => console.error(`Error fetching suggestions for ${name}:`, err)); - }, 300); + .catch(err => console.error(`Error fetching static suggestions for ${name}:`, err)); + } + // La lista de dependencias asegura que solo se ejecute si estas props cambian (lo cual no harán) + }, [props.mode, props.fetchSuggestions, name]); - // Limpia el temporizador si el usuario sigue escribiendo - return () => { - clearTimeout(handler); - }; - }, [value, fetchSuggestions, name]); + // --- Lógica para el modo DINÁMICO --- + // Se ejecuta cada vez que el usuario escribe, con un debounce + useEffect(() => { + if (props.mode === 'dynamic') { + if (value.length < 2) { + setSuggestions([]); + return; + } + const handler = setTimeout(() => { + props.fetchSuggestions(value) + .then(setSuggestions) + .catch(err => console.error(`Error fetching dynamic suggestions for ${name}:`, err)); + }, 300); + return () => clearTimeout(handler); + } + }, [value, props.mode, props.fetchSuggestions, name]); return ( <> @@ -52,7 +61,7 @@ const AutocompleteInput: React.FC = ({ placeholder={placeholder} className={className} list={dataListId} - autoComplete="off" // Importante para que no interfiera el autocompletado del navegador + autoComplete="off" /> {suggestions.map((suggestion, index) => ( diff --git a/frontend/src/components/GestionComponentes.tsx b/frontend/src/components/GestionComponentes.tsx index 6719f19..88868ed 100644 --- a/frontend/src/components/GestionComponentes.tsx +++ b/frontend/src/components/GestionComponentes.tsx @@ -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 = () => { {valores.map((item) => ( - + {renderValor(item)} {item.conteo}
{componentType === 'ram' ? ( - // Lógica solo para RAM (no tiene sentido "unificar" un objeto complejo) ) : ( - // Lógica para todos los demás tipos de componentes (texto) <> -
diff --git a/frontend/src/components/ModalAnadirDisco.tsx b/frontend/src/components/ModalAnadirDisco.tsx index aa55887..149c200 100644 --- a/frontend/src/components/ModalAnadirDisco.tsx +++ b/frontend/src/components/ModalAnadirDisco.tsx @@ -18,7 +18,7 @@ const ModalAnadirDisco: React.FC = ({ onClose, onSave }) => { }; return ( -
+

Añadir Disco Manualmente

diff --git a/frontend/src/components/ModalAnadirEquipo.tsx b/frontend/src/components/ModalAnadirEquipo.tsx index 8a6dad2..c42c76e 100644 --- a/frontend/src/components/ModalAnadirEquipo.tsx +++ b/frontend/src/components/ModalAnadirEquipo.tsx @@ -1,5 +1,6 @@ // frontend/src/components/ModalAnadirEquipo.tsx -import React, { useState } from 'react'; + +import React, { useState, useCallback } from 'react'; // <-- 1. Importar useCallback import type { Sector, Equipo } from '../types/interfaces'; import AutocompleteInput from './AutocompleteInput'; import styles from './SimpleTable.module.css'; @@ -31,12 +32,17 @@ const ModalAnadirEquipo: React.FC = ({ sectores, onClose }; const handleSaveClick = () => { - // La UI pasará un objeto compatible con el DTO del backend onSave(nuevoEquipo as any); }; const isFormValid = nuevoEquipo.hostname.trim() !== '' && nuevoEquipo.ip.trim() !== ''; + // --- 2. Memorizar las funciones con useCallback --- + // El array vacío `[]` al final asegura que la función NUNCA se vuelva a crear. + const fetchMotherboardSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json()), []); + const fetchCpuSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json()), []); + const fetchOsSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json()), []); + return (
@@ -49,7 +55,8 @@ const ModalAnadirEquipo: React.FC = ({ sectores, onClose value={nuevoEquipo.hostname} onChange={handleChange} className={styles.modalInput} - placeholder="Ej: CONTABILIDAD-01" + placeholder="Ej: TECNICA10" + autoComplete="off" /> @@ -59,7 +66,8 @@ const ModalAnadirEquipo: React.FC = ({ sectores, onClose value={nuevoEquipo.ip} onChange={handleChange} className={styles.modalInput} - placeholder="Ej: 192.168.1.50" + placeholder="Ej: 192.168.10.50" + autoComplete="off" /> @@ -75,31 +83,35 @@ const ModalAnadirEquipo: React.FC = ({ sectores, onClose ))} + {/* --- 3. Usar las funciones memorizadas --- */} fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json())} + fetchSuggestions={fetchMotherboardSuggestions} /> fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json())} + fetchSuggestions={fetchCpuSuggestions} /> fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json())} + fetchSuggestions={fetchOsSuggestions} />
diff --git a/frontend/src/components/ModalAnadirRam.tsx b/frontend/src/components/ModalAnadirRam.tsx index 4008a50..cd85486 100644 --- a/frontend/src/components/ModalAnadirRam.tsx +++ b/frontend/src/components/ModalAnadirRam.tsx @@ -1,40 +1,100 @@ // frontend/src/components/ModalAnadirRam.tsx -import React, { useState } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import styles from './SimpleTable.module.css'; +import AutocompleteInput from './AutocompleteInput'; +import { memoriaRamService } from '../services/apiService'; +import type { MemoriaRam } from '../types/interfaces'; interface Props { onClose: () => void; - onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => void; + onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number, partNumber?: string }) => void; } const ModalAnadirRam: React.FC = ({ onClose, onSave }) => { - const [ram, setRam] = useState({ slot: '', tamano: '', fabricante: '', velocidad: '' }); + const [ram, setRam] = useState({ + slot: '', + tamano: '', + fabricante: '', + velocidad: '', + partNumber: '' + }); + + const [allRamModules, setAllRamModules] = useState([]); + + useEffect(() => { + memoriaRamService.getAll() + .then(setAllRamModules) + .catch(err => console.error("No se pudieron cargar los módulos de RAM", err)); + }, []); const handleChange = (e: React.ChangeEvent) => { - setRam(prev => ({ ...prev, [e.target.name]: e.target.value })); + const { name, value } = e.target; + setRam(prev => ({ ...prev, [name]: value })); }; + const fetchRamSuggestions = useCallback(async () => { + return allRamModules.map(r => + `${r.fabricante || 'Desconocido'} | ${r.tamano}GB | ${r.velocidad ? r.velocidad + 'MHz' : 'N/A'}` + ); + }, [allRamModules]); + + useEffect(() => { + const selectedSuggestion = ram.partNumber; + + const match = allRamModules.find(s => + `${s.fabricante || 'Desconocido'} | ${s.tamano}GB | ${s.velocidad ? s.velocidad + 'MHz' : 'N/A'}` === selectedSuggestion + ); + + if (match) { + setRam(prev => ({ + ...prev, + fabricante: match.fabricante || '', + tamano: match.tamano.toString(), + velocidad: match.velocidad?.toString() || '', + partNumber: match.partNumber || '' + })); + } + }, [ram.partNumber, allRamModules]); + + const handleSave = () => { onSave({ slot: ram.slot, tamano: parseInt(ram.tamano, 10), fabricante: ram.fabricante || undefined, velocidad: ram.velocidad ? parseInt(ram.velocidad, 10) : undefined, + partNumber: ram.partNumber || undefined, }); }; return ( -
+

Añadir Módulo de RAM

+ + + + + - + + - + + +
diff --git a/frontend/src/components/ModalAnadirUsuario.tsx b/frontend/src/components/ModalAnadirUsuario.tsx index 26bdc64..0bbf138 100644 --- a/frontend/src/components/ModalAnadirUsuario.tsx +++ b/frontend/src/components/ModalAnadirUsuario.tsx @@ -1,30 +1,34 @@ -import React, { useState } from 'react'; +// frontend/src/components/ModalAnadirUsuario.tsx +import React, { useState, useCallback } from 'react'; import styles from './SimpleTable.module.css'; import AutocompleteInput from './AutocompleteInput'; +import { usuarioService } from '../services/apiService'; interface Props { onClose: () => void; onSave: (usuario: { username: string }) => void; } -const BASE_URL = '/api'; - const ModalAnadirUsuario: React.FC = ({ onClose, onSave }) => { const [username, setUsername] = useState(''); - const fetchUserSuggestions = async (query: string): Promise => { + const fetchUserSuggestions = useCallback(async (query: string): Promise => { if (!query) return []; - const response = await fetch(`${BASE_URL}/usuarios/buscar/${query}`); - if (!response.ok) return []; - return response.json(); - }; + try { + return await usuarioService.search(query); + } catch (error) { + console.error("Error buscando usuarios", error); + return []; + } + }, []); return ( -
+

Añadir Usuario Manualmente

setUsername(e.target.value)} diff --git a/frontend/src/components/ModalDetallesEquipo.tsx b/frontend/src/components/ModalDetallesEquipo.tsx index 0fde8f8..fca7a84 100644 --- a/frontend/src/components/ModalDetallesEquipo.tsx +++ b/frontend/src/components/ModalDetallesEquipo.tsx @@ -1,17 +1,18 @@ // frontend/src/components/ModalDetallesEquipo.tsx -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import type { Equipo, HistorialEquipo, Sector } from '../types/interfaces'; import { Tooltip } from 'react-tooltip'; import styles from './SimpleTable.module.css'; import toast from 'react-hot-toast'; import AutocompleteInput from './AutocompleteInput'; +import { equipoService } from '../services/apiService'; -// Interfaces actualizadas para las props interface ModalDetallesEquipoProps { equipo: Equipo; isOnline: boolean; historial: HistorialEquipo[]; sectores: Sector[]; + isChildModalOpen: boolean; onClose: () => void; onDelete: (id: number) => Promise; onRemoveAssociation: (type: 'disco' | 'ram' | 'usuario', id: any) => void; @@ -19,10 +20,9 @@ interface ModalDetallesEquipoProps { onAddComponent: (type: 'disco' | 'ram' | 'usuario') => void; } -const BASE_URL = '/api'; - const ModalDetallesEquipo: React.FC = ({ - equipo, isOnline, historial, sectores, onClose, onDelete, onRemoveAssociation, onEdit, onAddComponent + equipo, isOnline, historial, sectores, isChildModalOpen, + onClose, onDelete, onRemoveAssociation, onEdit, onAddComponent }) => { const [isEditing, setIsEditing] = useState(false); const [editableEquipo, setEditableEquipo] = useState({ ...equipo }); @@ -75,19 +75,20 @@ const ModalDetallesEquipo: React.FC = ({ setIsEditing(false); }; + const handleEditClick = () => { + setEditableEquipo({ ...equipo }); + setIsEditing(true); + }; + const handleWolClick = async () => { + // La validación ahora es redundante por el 'disabled', pero la dejamos como buena práctica if (!equipo.mac || !equipo.ip) { toast.error("Este equipo no tiene MAC o IP para encenderlo."); return; } const toastId = toast.loading('Enviando paquete WOL...'); try { - const response = await fetch(`${BASE_URL}/equipos/wake-on-lan`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mac: equipo.mac, ip: equipo.ip }) - }); - if (!response.ok) throw new Error("La respuesta del servidor no fue exitosa."); + await equipoService.wakeOnLan(equipo.mac, equipo.ip); toast.success('Solicitud de encendido enviada.', { id: toastId }); } catch (error) { toast.error('Error al enviar la solicitud.', { id: toastId }); @@ -109,9 +110,14 @@ const ModalDetallesEquipo: React.FC = ({ const isSaveDisabled = !editableEquipo.hostname || !editableEquipo.ip || !isMacValid; + const fetchOsSuggestions = useCallback(() => equipoService.getDistinctValues('os'), []); + const fetchMotherboardSuggestions = useCallback(() => equipoService.getDistinctValues('motherboard'), []); + const fetchCpuSuggestions = useCallback(() => equipoService.getDistinctValues('cpu'), []); + return (
- + +

Detalles del equipo: {equipo.hostname}

@@ -123,26 +129,30 @@ const ModalDetallesEquipo: React.FC = ({ ) : ( - + )}
)}
- {/* COLUMNA PRINCIPAL */}
- {/* SECCIÓN DE DATOS PRINCIPALES */}

🔗 Datos Principales

- {equipo.origen === 'manual' && (
)} + {equipo.origen === 'manual' && ( +
+ + + +
+ )}
-
Hostname:{isEditing ? : {equipo.hostname}}
-
IP:{isEditing ? : {equipo.ip}}
+
Hostname:{isEditing ? : {equipo.hostname}}
+
IP:{isEditing ? : {equipo.ip}}
MAC Address:{isEditing ? (
{!isMacValid && Formato inválido.}
) : ({equipo.mac || 'N/A'})}
-
Sistema Operativo:{isEditing ? fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json())} /> : {equipo.os || 'N/A'}}
+
Sistema Operativo:{isEditing ? : {equipo.os || 'N/A'}}
Sector:{isEditing ? : {equipo.sector?.nombre || 'No asignado'}}
Creación:{formatDate(equipo.created_at)}
Última Actualización:{formatDate(equipo.updated_at)}
@@ -150,36 +160,78 @@ const ModalDetallesEquipo: React.FC = ({
- {/* SECCIÓN DE COMPONENTES */}

💻 Componentes

-
Motherboard:{isEditing ? fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json())} /> : {equipo.motherboard || 'N/A'}}
-
CPU:{isEditing ? fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json())} /> : {equipo.cpu || 'N/A'}}
+
Motherboard:{isEditing ? : {equipo.motherboard || 'N/A'}}
+
CPU:{isEditing ? : {equipo.cpu || 'N/A'}}
RAM Instalada:{equipo.ram_installed} GB
-
Arquitectura:{equipo.architecture || 'N/A'}
+
+ Arquitectura: + {isEditing ? ( + + ) : ( + {equipo.architecture || 'N/A'} + )} +
Discos:{equipo.discos?.length > 0 ? equipo.discos.map(d => (
{d.origen === 'manual' ? '⌨️' : '⚙️'}{` ${d.mediatype} ${d.size}GB`}
{d.origen === 'manual' && ()}
)) : 'N/A'}
-
Slots RAM:{equipo.ram_slots || 'N/A'}
+
+ Total Slots RAM: + {isEditing ? ( + + ) : ( + {equipo.ram_slots || 'N/A'} + )} +
Módulos RAM:{equipo.memoriasRam?.length > 0 ? equipo.memoriasRam.map(m => (
{m.origen === 'manual' ? '⌨️' : '⚙️'}{` Slot ${m.slot}: ${m.partNumber || 'N/P'} ${m.tamano}GB ${m.velocidad || ''}MHz`}
{m.origen === 'manual' && ()}
)) : 'N/A'}
- {/* COLUMNA LATERAL */}
- {/* SECCIÓN DE ACCIONES */}

⚡ Acciones y Estado

Estado:
{isOnline ? 'En línea' : 'Sin conexión'}
-
Wake On Lan:Encender equipo remotamente
+ +
+ Wake On Lan: + + + {equipo.mac ? 'Encender equipo remotamente' : 'Se requiere una dirección MAC para esta acción'} + +
+
Eliminar Equipo:{equipo.origen === 'manual' ? 'Eliminar equipo permanentemente' : 'No se puede eliminar un equipo cargado automáticamente'}
- - {/* SECCIÓN DE HISTORIAL (FUERA DE LAS COLUMNAS) */} +

📜 Historial de cambios

@@ -189,7 +241,6 @@ const ModalDetallesEquipo: React.FC = ({
-
); diff --git a/frontend/src/components/SimpleTable.module.css b/frontend/src/components/SimpleTable.module.css index 60ca787..de4fc1c 100644 --- a/frontend/src/components/SimpleTable.module.css +++ b/frontend/src/components/SimpleTable.module.css @@ -92,6 +92,20 @@ border-color: #adb5bd; } +.tableButtonMas { + padding: 0.375rem 0.75rem; + border-radius: 4px; + border: 1px solid #007bff; + background-color: #007bff; + color: #ffffff; + cursor: pointer; + transition: all 0.2s ease; +} +.tableButtonMas:hover { + background-color: #0056b3; + border-color: #0056b3; +} + .deleteUserButton { background: none; border: none; @@ -487,4 +501,22 @@ .sectorContainer { display: flex; justify-content: space-between; align-items: center; width: 100%; gap: 0.5rem; } .sectorName { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sectorNameAssigned { color: #212529; font-style: normal; } -.sectorNameUnassigned { color: #6c757d; font-style: italic; } \ No newline at end of file +.sectorNameUnassigned { color: #6c757d; font-style: italic; } + +/* Estilo para el overlay de un modal anidado */ +.modalOverlay--nested { + /* z-index superior al del botón de cierre del modal principal (1004) */ + z-index: 1005; +} + +/* También nos aseguramos de que el contenido del modal anidado tenga un z-index superior */ +.modalOverlay--nested .modal { + z-index: 1006; +} + +/* Estilo para deshabilitar el botón de cierre del modal principal */ +.closeButton:disabled { + cursor: not-allowed; + opacity: 0.5; + background-color: #6c757d; /* Gris para indicar inactividad */ +} \ No newline at end of file diff --git a/frontend/src/components/SimpleTable.tsx b/frontend/src/components/SimpleTable.tsx index 33ed13b..1586251 100644 --- a/frontend/src/components/SimpleTable.tsx +++ b/frontend/src/components/SimpleTable.tsx @@ -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) => { @@ -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) => { 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) => { + 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 = () => {
- {/* --- 2. Renderizar los controles ANTES de la tabla --- */} {PaginacionControles}
@@ -545,7 +470,7 @@ const SimpleTable = () => { {table.getHeaderGroups().map(hg => ( {hg.headers.map(h => ( - + {flexRender(h.column.columnDef.header, h.getContext())} {h.column.getIsSorted() && ({h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'})} @@ -567,28 +492,13 @@ const SimpleTable = () => {
- {/* --- 3. Renderizar los controles DESPUÉS de la tabla --- */} {PaginacionControles} {showScrollButton && ()} - {modalData && ( - setModalData(null)} - onSave={handleSave} - /> - )} + {modalData && setModalData(null)} onSave={handleSave} />} - {modalPasswordData && ( - setModalPasswordData(null)} - onSave={handleSavePassword} - /> - )} + {modalPasswordData && setModalPasswordData(null)} onSave={handleSavePassword} />} {selectedEquipo && ( { onEdit={handleEditEquipo} sectores={sectores} onAddComponent={type => setAddingComponent(type)} + isChildModalOpen={addingComponent !== null} /> )} - {isAddModalOpen && ( - setIsAddModalOpen(false)} - onSave={handleCreateEquipo} - /> - )} + {isAddModalOpen && setIsAddModalOpen(false)} onSave={handleCreateEquipo} />} - {addingComponent === 'disco' && setAddingComponent(null)} onSave={(data) => handleAddComponent('disco', data)} />} - {addingComponent === 'ram' && setAddingComponent(null)} onSave={(data) => handleAddComponent('ram', data)} />} - {addingComponent === 'usuario' && setAddingComponent(null)} onSave={(data) => handleAddComponent('usuario', data)} />} + {addingComponent === 'disco' && setAddingComponent(null)} onSave={(data: { mediatype: string, size: number }) => handleAddComponent('disco', data)} />} + + {addingComponent === 'ram' && setAddingComponent(null)} onSave={(data: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => handleAddComponent('ram', data)} />} + + {addingComponent === 'usuario' && setAddingComponent(null)} onSave={(data: { username: string }) => handleAddComponent('usuario', data)} />}
); }; diff --git a/frontend/src/services/apiService.ts b/frontend/src/services/apiService.ts new file mode 100644 index 0000000..efc3384 --- /dev/null +++ b/frontend/src/services/apiService.ts @@ -0,0 +1,120 @@ +// frontend/src/services/apiService.ts + +import type { Equipo, Sector, HistorialEquipo, Usuario, MemoriaRam } from '../types/interfaces'; + +const BASE_URL = '/api'; + +async function request(url: string, options?: RequestInit): Promise { + const response = await fetch(url, options); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Error en la respuesta del servidor' })); + throw new Error(errorData.message || 'Ocurrió un error desconocido'); + } + + if (response.status === 204) { + return null as T; + } + + return response.json(); +} + +// --- Servicio para la gestión de Sectores --- +export const sectorService = { + getAll: () => request(`${BASE_URL}/sectores`), + create: (nombre: string) => request(`${BASE_URL}/sectores`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nombre }), + }), + update: (id: number, nombre: string) => request(`${BASE_URL}/sectores/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nombre }), + }), + delete: (id: number) => request(`${BASE_URL}/sectores/${id}`, { method: 'DELETE' }), +}; + +// --- Servicio para la gestión de Equipos --- +export const equipoService = { + getAll: () => request(`${BASE_URL}/equipos`), + getHistory: (hostname: string) => request<{ historial: HistorialEquipo[] }>(`${BASE_URL}/equipos/${hostname}/historial`), + ping: (ip: string) => request<{ isAlive: boolean }>(`${BASE_URL}/equipos/ping`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ip }), + }), + wakeOnLan: (mac: string, ip: string) => request(`${BASE_URL}/equipos/wake-on-lan`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mac, ip }), + }), + updateSector: (equipoId: number, sectorId: number) => request(`${BASE_URL}/equipos/${equipoId}/sector/${sectorId}`, { method: 'PATCH' }), + deleteManual: (id: number) => request(`${BASE_URL}/equipos/${id}`, { method: 'DELETE' }), + createManual: (nuevoEquipo: Omit) => request(`${BASE_URL}/equipos/manual`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(nuevoEquipo), + }), + updateManual: (id: number, equipoEditado: any) => + request(`${BASE_URL}/equipos/manual/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(equipoEditado), + }), + + removeDiscoAssociation: (id: number) => request(`${BASE_URL}/equipos/asociacion/disco/${id}`, { method: 'DELETE' }), + removeRamAssociation: (id: number) => request(`${BASE_URL}/equipos/asociacion/ram/${id}`, { method: 'DELETE' }), + removeUserAssociation: (equipoId: number, usuarioId: number) => request(`${BASE_URL}/equipos/asociacion/usuario/${equipoId}/${usuarioId}`, { method: 'DELETE' }), + addDisco: (equipoId: number, data: any) => request(`${BASE_URL}/equipos/manual/${equipoId}/disco`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }), + addRam: (equipoId: number, data: any) => request(`${BASE_URL}/equipos/manual/${equipoId}/ram`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }), + addUsuario: (equipoId: number, data: any) => request(`${BASE_URL}/equipos/manual/${equipoId}/usuario`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }), + getDistinctValues: (field: string) => request(`${BASE_URL}/equipos/distinct/${field}`), +}; + +// --- Servicio para Usuarios --- +export const usuarioService = { + updatePassword: (id: number, password: string) => request(`${BASE_URL}/usuarios/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }), + removeUserFromEquipo: (hostname: string, username: string) => request(`${BASE_URL}/equipos/${hostname}/usuarios/${username}`, { method: 'DELETE' }), + search: (term: string) => request(`${BASE_URL}/usuarios/buscar/${term}`), +}; + +// --- Servicio para RAM --- +export const memoriaRamService = { + getAll: () => request(`${BASE_URL}/memoriasram`), + search: (term: string) => request(`${BASE_URL}/memoriasram/buscar/${term}`), +}; + +// --- Servicio para Administración --- +export const adminService = { + getComponentValues: (type: string) => request(`${BASE_URL}/admin/componentes/${type}`), + unifyComponentValues: (type: string, valorAntiguo: string, valorNuevo: string) => request(`${BASE_URL}/admin/componentes/${type}/unificar`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ valorAntiguo, valorNuevo }), + }), + + deleteRamComponent: (ramGroup: { fabricante?: string, tamano: number, velocidad?: number }) => request(`${BASE_URL}/admin/componentes/ram`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ramGroup), + }), + + deleteTextComponent: (type: string, value: string) => request(`${BASE_URL}/admin/componentes/${type}/${encodeURIComponent(value)}`, { method: 'DELETE' }), +}; \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8b0f57b..7dd4e51 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,4 +4,16 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], -}) + // --- AÑADIR ESTA SECCIÓN COMPLETA --- + server: { + proxy: { + // Cualquier petición que empiece con '/api' será redirigida. + '/api': { + // Redirige al servidor de backend que corre en local. + target: 'http://localhost:5198', + // Necesario para evitar problemas de CORS y de origen. + changeOrigin: true, + }, + }, + }, +}) \ No newline at end of file