From 242c1345c064bf5fbc0dc752579b052bdc34a7df Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 7 Oct 2025 14:44:16 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20Implementaci=C3=B3n=20de=20gesti=C3=B3n?= =?UTF-8?q?=20manual=20y=20panel=20de=20administraci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Se introduce una refactorización masiva y se añaden nuevas funcionalidades críticas para la gestión del inventario, incluyendo un panel de administración para la limpieza de datos y un sistema completo para la gestión manual de equipos. ### Nuevas Funcionalidades * **Panel de Administración:** Se crea una nueva vista de "Administración" para la gestión de datos maestros. Permite unificar valores inconsistentes (ej: "W10" -> "Windows 10 Pro") y eliminar registros maestros no utilizados (ej: Módulos de RAM) para mantener la base de datos limpia. * **Gestión de Sectores (CRUD):** Se añade una vista dedicada para crear, editar y eliminar sectores de la organización. * **Diferenciación Manual vs. Automático:** Se introduce una columna `origen` en la base de datos para distinguir entre los datos recopilados automáticamente por el script y los introducidos manualmente por el usuario. La UI ahora refleja visualmente este origen. * **CRUD de Equipos Manuales:** Se implementa la capacidad de crear, editar y eliminar equipos de origen "manual" a través de la interfaz de usuario. Se protege la eliminación de equipos automáticos. * **Gestión de Componentes Manuales:** Se permite añadir y eliminar componentes (Discos, RAM, Usuarios) a los equipos de origen "manual". ### Mejoras de UI/UX * **Refactorización de Estilos:** Se migran todos los estilos en línea del componente `SimpleTable` a un archivo CSS Module (`SimpleTable.module.css`), mejorando la mantenibilidad y el rendimiento. * **Notificaciones de Usuario:** Se integra `react-hot-toast` para proporcionar feedback visual inmediato (carga, éxito, error) en todas las operaciones asíncronas, reemplazando los `alert`. * **Componentización:** Se extraen todos los modales (`ModalDetallesEquipo`, `ModalAnadirEquipo`, etc.) a sus propios componentes, limpiando y simplificando drásticamente el componente `SimpleTable`. * **Paginación en Tabla Principal:** Se implementa paginación completa en la tabla de equipos, con controles para navegar, ir a una página específica y cambiar el número de items por página. Se añade un indicador de carga inicial. * **Navegación Mejorada:** Se reemplaza la navegación por botones con un componente `Navbar` estilizado y dedicado, mejorando la estructura visual y de código. * **Autocompletado de Datos:** Se introduce un componente `AutocompleteInput` reutilizable para guiar al usuario a usar datos consistentes al rellenar campos como OS, CPU y Motherboard. Se implementa búsqueda dinámica para la asociación de usuarios. * **Validación de MAC Address:** Se añade validación de formato en tiempo real y auto-formateo para el campo de MAC Address, reduciendo errores humanos. * **Consistencia de Iconos:** Se unifica el icono de eliminación a (🗑️) en toda la aplicación para una experiencia de usuario más coherente. ### Mejoras en el Backend / API * **Seguridad de Credenciales:** Las credenciales SSH para la función Wake On Lan se mueven del código fuente a `appsettings.json`. * **Nuevo `AdminController`:** Se crea un controlador dedicado para las tareas administrativas, con endpoints para obtener valores únicos de componentes y para ejecutar la lógica de unificación y eliminación. * **Endpoints de Gestión Manual:** Se añaden rutas específicas (`/manual/...` y `/asociacion/...`) para la manipulación de datos de origen manual, separando la lógica de la gestión automática. * **Protección de Datos Automáticos:** Los endpoints `DELETE` y `PUT` ahora validan el campo `origen` para prevenir la modificación o eliminación no deseada de datos generados automáticamente. * **Correcciones y Refinamiento:** Se soluciona el mapeo incorrecto de fechas (`created_at`, `updated_at`), se corrigen errores de compilación y se refinan las consultas SQL para incluir los nuevos campos. --- backend/Controllers/AdminController.cs | 192 ++++ backend/Controllers/EquiposController.cs | 533 +++++++++-- backend/Controllers/UsuariosController.cs | 13 + backend/Inventario.API.csproj | 1 + backend/Models/Equipo.cs | 35 +- backend/Models/MemoriaRam.cs | 8 +- backend/appsettings.Development.json | 8 +- backend/appsettings.json | 6 + .../net9.0/Inventario.API.AssemblyInfo.cs | 2 +- .../Inventario.API.csproj.nuget.dgspec.json | 4 + backend/obj/project.assets.json | 78 ++ frontend/package-lock.json | 28 +- frontend/package.json | 1 + frontend/src/App.css | 44 +- frontend/src/App.tsx | 20 +- frontend/src/components/AutocompleteInput.tsx | 66 ++ .../src/components/GestionComponentes.tsx | 217 +++++ frontend/src/components/GestionSectores.tsx | 142 +++ frontend/src/components/ModalAnadirDisco.tsx | 46 + frontend/src/components/ModalAnadirEquipo.tsx | 125 +++ frontend/src/components/ModalAnadirRam.tsx | 47 + .../src/components/ModalAnadirUsuario.tsx | 44 + frontend/src/components/ModalCambiarClave.tsx | 65 ++ .../src/components/ModalDetallesEquipo.tsx | 198 ++++ frontend/src/components/ModalEditarSector.tsx | 57 ++ frontend/src/components/ModalSector.tsx | 59 ++ frontend/src/components/Navbar.tsx | 41 + .../src/components/SimpleTable.module.css | 490 ++++++++++ frontend/src/components/SimpleTable.tsx | 874 ++++++++---------- frontend/src/main.tsx | 22 +- frontend/src/types/interfaces.ts | 42 +- 31 files changed, 2911 insertions(+), 597 deletions(-) create mode 100644 backend/Controllers/AdminController.cs create mode 100644 frontend/src/components/AutocompleteInput.tsx create mode 100644 frontend/src/components/GestionComponentes.tsx create mode 100644 frontend/src/components/GestionSectores.tsx create mode 100644 frontend/src/components/ModalAnadirDisco.tsx create mode 100644 frontend/src/components/ModalAnadirEquipo.tsx create mode 100644 frontend/src/components/ModalAnadirRam.tsx create mode 100644 frontend/src/components/ModalAnadirUsuario.tsx create mode 100644 frontend/src/components/ModalCambiarClave.tsx create mode 100644 frontend/src/components/ModalDetallesEquipo.tsx create mode 100644 frontend/src/components/ModalEditarSector.tsx create mode 100644 frontend/src/components/ModalSector.tsx create mode 100644 frontend/src/components/Navbar.tsx create mode 100644 frontend/src/components/SimpleTable.module.css diff --git a/backend/Controllers/AdminController.cs b/backend/Controllers/AdminController.cs new file mode 100644 index 0000000..a66aab1 --- /dev/null +++ b/backend/Controllers/AdminController.cs @@ -0,0 +1,192 @@ +// backend/Controllers/AdminController.cs +using Dapper; +using Inventario.API.Data; +using Microsoft.AspNetCore.Mvc; + +namespace Inventario.API.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class AdminController : ControllerBase + { + private readonly DapperContext _context; + + public AdminController(DapperContext context) + { + _context = context; + } + + // DTO para devolver los valores y su conteo + public class ComponenteValorDto + { + public string Valor { get; set; } = ""; + public int Conteo { get; set; } + } + + [HttpGet("componentes/{tipo}")] + public async Task GetComponenteValores(string tipo) + { + var allowedTypes = new Dictionary + { + { "os", "Os" }, + { "cpu", "Cpu" }, + { "motherboard", "Motherboard" }, + { "architecture", "Architecture" } + }; + + if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName)) + { + return BadRequest("Tipo de componente no válido."); + } + + var query = $@" + SELECT {columnName} AS Valor, COUNT(*) AS Conteo + FROM dbo.equipos + WHERE {columnName} IS NOT NULL AND {columnName} != '' + GROUP BY {columnName} + ORDER BY Conteo DESC, Valor ASC;"; + + using (var connection = _context.CreateConnection()) + { + var valores = await connection.QueryAsync(query); + return Ok(valores); + } + } + + // 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) + { + var allowedTypes = new Dictionary + { + { "os", "Os" }, + { "cpu", "Cpu" }, + { "motherboard", "Motherboard" }, + { "architecture", "Architecture" } + }; + + if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName)) + { + return BadRequest("Tipo de componente no válido."); + } + + if (dto.ValorAntiguo == dto.ValorNuevo) + { + return BadRequest("El valor antiguo y el nuevo no pueden ser iguales."); + } + + var query = $@" + UPDATE dbo.equipos + SET {columnName} = @ValorNuevo + WHERE {columnName} = @ValorAntiguo;"; + + using (var connection = _context.CreateConnection()) + { + var filasAfectadas = await connection.ExecuteAsync(query, new { dto.ValorNuevo, dto.ValorAntiguo }); + return Ok(new { message = $"Se unificaron {filasAfectadas} registros.", filasAfectadas }); + } + } + + // 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; } + } + + [HttpGet("componentes/ram")] + public async Task GetComponentesRam() + { + var query = @" + SELECT + mr.Id, mr.part_number as PartNumber, 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 + ORDER BY + Conteo DESC, mr.Fabricante, mr.Tamano;"; + + using (var connection = _context.CreateConnection()) + { + var valores = await connection.QueryAsync(query); + return Ok(valores); + } + } + + [HttpDelete("componentes/ram/{id}")] + public async Task BorrarComponenteRam(int id) + { + 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 }); + + if (usageCount > 0) + { + return Conflict($"Este módulo 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."); + } + + return NoContent(); + } + } + + [HttpDelete("componentes/{tipo}/{valor}")] + public async Task BorrarComponenteTexto(string tipo, string valor) + { + var allowedTypes = new Dictionary + { + { "os", "Os" }, + { "cpu", "Cpu" }, + { "motherboard", "Motherboard" }, + { "architecture", "Architecture" } + }; + + if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName)) + { + return BadRequest("Tipo de componente no válido."); + } + + 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."); + } + + // 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(); + } + } + } +} \ No newline at end of file diff --git a/backend/Controllers/EquiposController.cs b/backend/Controllers/EquiposController.cs index 0f0a3ed..3142492 100644 --- a/backend/Controllers/EquiposController.cs +++ b/backend/Controllers/EquiposController.cs @@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Mvc; using System.Data; using System.Net.NetworkInformation; using Microsoft.Data.SqlClient; +using Renci.SshNet; +using System.Text.RegularExpressions; namespace Inventario.API.Controllers { @@ -15,24 +17,27 @@ namespace Inventario.API.Controllers public class EquiposController : ControllerBase { private readonly DapperContext _context; + private readonly IConfiguration _configuration; // 1. Añadimos el campo para la configuración - public EquiposController(DapperContext context) + // 2. Modificamos el constructor para inyectar IConfiguration + public EquiposController(DapperContext context, IConfiguration configuration) { _context = context; + _configuration = configuration; // Asignamos la configuración inyectada } - // --- MÉTODOS CRUD BÁSICOS (Ya implementados) --- + // --- MÉTODOS CRUD BÁSICOS --- // GET /api/equipos [HttpGet] public async Task Consultar() { 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.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, s.Id as Id, s.Nombre, - u.Id as Id, u.Username, u.Password, - d.Id as Id, d.Mediatype, d.Size, - mr.Id as Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad, emr.Slot + 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, + mr.Id as Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad, emr.Slot, emr.Origen as Origen, emr.Id as EquipoMemoriaRamId FROM dbo.equipos e LEFT JOIN dbo.sectores s ON e.sector_id = s.id LEFT JOIN dbo.usuarios_equipos ue ON e.id = ue.equipo_id @@ -46,7 +51,8 @@ namespace Inventario.API.Controllers { var equipoDict = new Dictionary(); - await connection.QueryAsync( + // CAMBIO: Se actualizan los tipos en la función de mapeo de Dapper + await connection.QueryAsync( query, (equipo, sector, usuario, disco, memoria) => { if (!equipoDict.TryGetValue(equipo.Id, out var equipoActual)) @@ -55,6 +61,7 @@ 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)) @@ -64,7 +71,7 @@ namespace Inventario.API.Controllers return equipoActual; }, - splitOn: "Id,Id,Id,Id" // Dapper divide en cada 'Id' + splitOn: "Id,Id,Id,Id" ); return Ok(equipoDict.Values.OrderBy(e => e.Sector?.Nombre).ThenBy(e => e.Hostname)); } @@ -77,7 +84,7 @@ namespace Inventario.API.Controllers var query = @"SELECT e.*, s.Id as SectorId, s.Nombre as SectorNombre, - u.Id as UsuarioId, u.Username, u.Password + u.Id as UsuarioId, u.Username, u.Password, ue.Origen as Origen FROM dbo.equipos e LEFT JOIN dbo.sectores s ON e.sector_id = s.id LEFT JOIN dbo.usuarios_equipos ue ON e.id = ue.equipo_id @@ -87,7 +94,8 @@ namespace Inventario.API.Controllers using (var connection = _context.CreateConnection()) { var equipoDict = new Dictionary(); - var equipo = (await connection.QueryAsync( + + var equipo = (await connection.QueryAsync( query, (e, sector, usuario) => { if (!equipoDict.TryGetValue(e.Id, out var equipoActual)) @@ -107,6 +115,12 @@ namespace Inventario.API.Controllers if (equipo == null) return NotFound("Equipo no encontrado."); + var discosQuery = "SELECT d.*, ed.Origen, ed.Id as EquipoDiscoId FROM dbo.discos d JOIN dbo.equipos_discos ed ON d.Id = ed.disco_id WHERE ed.equipo_id = @Id"; + equipo.Discos = (await connection.QueryAsync(discosQuery, new { equipo.Id })).ToList(); + + var ramQuery = "SELECT mr.*, emr.Slot, emr.Origen, emr.Id as EquipoMemoriaRamId FROM dbo.memorias_ram mr JOIN dbo.equipos_memorias_ram emr ON mr.Id = emr.memoria_ram_id WHERE emr.equipo_id = @Id"; + equipo.MemoriasRam = (await connection.QueryAsync(ramQuery, new { equipo.Id })).ToList(); + return Ok(equipo); } } @@ -124,8 +138,8 @@ namespace Inventario.API.Controllers if (equipoExistente == null) { // Crear - var insertQuery = @"INSERT INTO dbo.equipos (Hostname, Ip, Mac, Motherboard, Cpu, Ram_installed, Ram_slots, Os, Architecture) - VALUES (@Hostname, @Ip, @Mac, @Motherboard, @Cpu, @Ram_installed, @Ram_slots, @Os, @Architecture); + var insertQuery = @"INSERT INTO dbo.equipos (Hostname, Ip, Mac, Motherboard, Cpu, Ram_installed, Ram_slots, Os, Architecture, Origen) + VALUES (@Hostname, @Ip, @Mac, @Motherboard, @Cpu, @Ram_installed, @Ram_slots, @Os, @Architecture, 'automatica'); SELECT CAST(SCOPE_IDENTITY() as int);"; var nuevoId = await connection.ExecuteScalarAsync(insertQuery, equipoData); equipoData.Id = nuevoId; @@ -177,12 +191,16 @@ namespace Inventario.API.Controllers [HttpDelete("{id}")] public async Task Borrar(int id) { - // La base de datos está configurada con ON DELETE CASCADE, por lo que las relaciones se borrarán automáticamente. - var query = "DELETE FROM dbo.equipos WHERE Id = @Id;"; + var query = "DELETE FROM dbo.equipos WHERE Id = @Id AND Origen = 'manual';"; using (var connection = _context.CreateConnection()) { var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id }); - if (filasAfectadas == 0) return NotFound("Equipo no encontrado."); + if (filasAfectadas == 0) + { + // Puede que no se haya borrado porque no existe o porque es automático. + // Damos un mensaje de error genérico pero informativo. + return NotFound("Equipo no encontrado o no se puede eliminar porque fue generado automáticamente."); + } return NoContent(); } } @@ -222,8 +240,8 @@ namespace Inventario.API.Controllers public async Task AsociarUsuario(string hostname, [FromBody] AsociacionUsuarioDto dto) { var query = @" - INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id) - SELECT e.id, u.id + INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen) + SELECT e.id, u.id, 'automatica' FROM dbo.equipos e, dbo.usuarios u WHERE e.Hostname = @Hostname AND u.Username = @Username;"; @@ -283,14 +301,7 @@ namespace Inventario.API.Controllers JOIN dbo.discos d ON ed.disco_id = d.id WHERE ed.equipo_id = @EquipoId;"; - // Creamos una clase anónima temporal para mapear el resultado del JOIN - var discosEnDb = (await connection.QueryAsync(discosActualesQuery, new { EquipoId = equipo.Id }, transaction)).Select(d => new - { - Id = (int)d.Id, - Mediatype = (string)d.Mediatype, - Size = (int)d.Size, - EquipoDiscoId = (int)d.EquipoDiscoId - }).ToList(); + 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} @@ -344,9 +355,9 @@ namespace Inventario.API.Controllers { // 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); - if (disco == null) continue; // Si el disco no existe en la tabla maestra, lo ignoramos + if (disco == null) continue; - await connection.ExecuteAsync("INSERT INTO dbo.equipos_discos (equipo_id, disco_id) VALUES (@EquipoId, @DiscoId);", new { EquipoId = equipo.Id, DiscoId = disco.Id }, transaction); + 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"; @@ -381,81 +392,85 @@ namespace Inventario.API.Controllers } [HttpPost("{hostname}/ram")] - public async Task AsociarRam(string hostname, [FromBody] List memoriasDesdeCliente) + public async Task AsociarRam(string hostname, [FromBody] List memoriasDesdeCliente) { var equipoQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;"; using var connection = _context.CreateConnection(); connection.Open(); var equipo = await connection.QuerySingleOrDefaultAsync(equipoQuery, new { Hostname = hostname }); - - if (equipo == null) - { - return NotFound("Equipo no encontrado."); - } - + if (equipo == null) return NotFound("Equipo no encontrado."); using var transaction = connection.BeginTransaction(); try { - // 1. OBTENER ASOCIACIONES DE RAM ACTUALES var ramActualQuery = @" SELECT emr.Id as EquipoMemoriaRamId, emr.Slot, mr.Id, mr.part_number as PartNumber, 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.equipo_id = @EquipoId;"; - var ramEnDb = (await connection.QueryAsync(ramActualQuery, new { EquipoId = equipo.Id }, transaction)).ToList(); - // 2. CREAR "HUELLAS DIGITALES" ÚNICAS PARA COMPARAR - // Una huella única para cada módulo en un slot. Ej: "DIMM0_Kingston_8_3200" - Func crearHuella = ram => - $"{ram.Slot}_{ram.PartNumber ?? ""}_{ram.Tamano}_{ram.Velocidad ?? 0}"; - + Func crearHuella = ram => $"{ram.Slot}_{ram.PartNumber ?? ""}_{ram.Tamano}_{ram.Velocidad ?? 0}"; var huellasCliente = new HashSet(memoriasDesdeCliente.Select(crearHuella)); var huellasDb = new HashSet(ramEnDb.Select(crearHuella)); - // 3. CALCULAR Y EJECUTAR ELIMINACIONES - var asociacionesAEliminar = ramEnDb - .Where(ramDb => !huellasCliente.Contains(crearHuella(ramDb))) - .Select(ramDb => (int)ramDb.EquipoMemoriaRamId) - .ToList(); + var cambios = new Dictionary(); + Func formatRamDetails = ram => + { + var parts = new List { ram.Fabricante, $"{ram.Tamano}GB", ram.PartNumber, ram.Velocidad?.ToString() + "MHz" }; + return string.Join(" ", parts.Where(p => !string.IsNullOrEmpty(p))); + }; + var modulosEliminados = ramEnDb.Where(ramDb => !huellasCliente.Contains(crearHuella(ramDb))).ToList(); + foreach (var modulo in modulosEliminados) + { + var campo = $"RAM Slot {modulo.Slot}"; + cambios[campo] = (formatRamDetails(modulo), "Vacio"); + } + + var modulosInsertados = memoriasDesdeCliente.Where(ramCliente => !huellasDb.Contains(crearHuella(ramCliente))).ToList(); + foreach (var modulo in modulosInsertados) + { + var campo = $"RAM Slot {modulo.Slot}"; + var valorNuevo = formatRamDetails(modulo); + if (cambios.ContainsKey(campo)) + { + cambios[campo] = (cambios[campo].anterior, valorNuevo); + } + else + { + cambios[campo] = ("Vacio", valorNuevo); + } + } + + var asociacionesAEliminar = modulosEliminados.Select(ramDb => (int)ramDb.EquipoMemoriaRamId).ToList(); if (asociacionesAEliminar.Any()) { await connection.ExecuteAsync("DELETE FROM dbo.equipos_memorias_ram WHERE Id IN @Ids;", new { Ids = asociacionesAEliminar }, transaction); } - // 4. CALCULAR Y EJECUTAR INSERCIONES - var memoriasAInsertar = memoriasDesdeCliente.Where(ramCliente => !huellasDb.Contains(crearHuella(ramCliente))).ToList(); - - foreach (var memInfo in memoriasAInsertar) + foreach (var memInfo in modulosInsertados) { - // Buscar o crear el módulo de RAM en la tabla maestra 'memorias_ram' - var findRamQuery = @"SELECT * FROM dbo.memorias_ram WHERE - (part_number = @PartNumber OR (part_number IS NULL AND @PartNumber IS NULL)) AND - tamano = @Tamano AND (velocidad = @Velocidad OR (velocidad IS NULL AND @Velocidad IS NULL));"; - + var findRamQuery = @"SELECT * FROM dbo.memorias_ram WHERE (part_number = @PartNumber OR (part_number IS NULL AND @PartNumber IS NULL)) AND tamano = @Tamano AND (velocidad = @Velocidad OR (velocidad IS NULL AND @Velocidad IS NULL));"; var memoriaMaestra = await connection.QuerySingleOrDefaultAsync(findRamQuery, memInfo, transaction); - int memoriaMaestraId; if (memoriaMaestra == null) { - var insertRamQuery = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad) - VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad); - SELECT CAST(SCOPE_IDENTITY() as int);"; + var insertRamQuery = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad) VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad); SELECT CAST(SCOPE_IDENTITY() as int);"; memoriaMaestraId = await connection.ExecuteScalarAsync(insertRamQuery, memInfo, transaction); } else { memoriaMaestraId = memoriaMaestra.Id; } - // Crear la asociación en la tabla intermedia - var insertAsociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot) VALUES (@EquipoId, @MemoriaRamId, @Slot);"; + var insertAsociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot, origen) VALUES (@EquipoId, @MemoriaRamId, @Slot, 'automatica');"; await connection.ExecuteAsync(insertAsociacionQuery, new { EquipoId = equipo.Id, MemoriaRamId = memoriaMaestraId, memInfo.Slot }, transaction); } - // (Opcional, pero recomendado) Registrar cambios en el historial. - // La lógica exacta para el historial de RAM puede ser compleja y la omitimos por ahora para centrarnos en la sincronización. + if (cambios.Count > 0) + { + await HistorialHelper.RegistrarCambios(_context, equipo.Id, cambios); + } transaction.Commit(); return Ok(new { message = "Módulos de RAM sincronizados correctamente." }); @@ -478,32 +493,398 @@ namespace Inventario.API.Controllers { using (var ping = new Ping()) { - var reply = await ping.SendPingAsync(request.Ip, 2000); // Timeout de 2 segundos - return Ok(new { isAlive = reply.Status == IPStatus.Success, latency = reply.RoundtripTime }); + var reply = await ping.SendPingAsync(request.Ip, 2000); + bool isAlive = reply.Status == IPStatus.Success; + if (!isAlive) + { + reply = await ping.SendPingAsync(request.Ip, 2000); + isAlive = reply.Status == IPStatus.Success; + } + return Ok(new { isAlive, latency = isAlive ? reply.RoundtripTime : (long?)null }); } } catch (PingException ex) { - // Maneja errores comunes como "Host desconocido" - return Ok(new { isAlive = false, error = ex.Message }); + Console.WriteLine($"Error de Ping para {request.Ip}: {ex.Message}"); + return Ok(new { isAlive = false, error = "Host no alcanzable o desconocido." }); } catch (Exception ex) { - return StatusCode(500, $"Error interno al hacer ping: {ex.Message}"); + Console.WriteLine($"Error interno al hacer ping a {request.Ip}: {ex.Message}"); + return StatusCode(500, "Error interno del servidor al realizar el ping."); } } - // WOL (Wake-On-LAN) es más complejo porque requiere ejecutar comandos de sistema operativo. - // Lo dejamos pendiente para no añadir complejidad de configuración de SSH por ahora. [HttpPost("wake-on-lan")] public IActionResult EnviarWol([FromBody] WolRequestDto request) { - Console.WriteLine($"Recibida solicitud WOL para MAC: {request.Mac}"); - return Ok(new { message = "Solicitud WOL recibida. La ejecución del comando está pendiente de implementación." }); + var mac = request.Mac; + var ip = request.Ip; + + if (string.IsNullOrWhiteSpace(mac) || !Regex.IsMatch(mac, "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$")) + { + return BadRequest("Formato de dirección MAC inválido."); + } + if (string.IsNullOrWhiteSpace(ip) || !Regex.IsMatch(ip, @"^(\d{1,3}\.){3}\d{1,3}$")) + { + return BadRequest("Formato de dirección IP inválido."); + } + + var octetos = ip.Split('.'); + if (octetos.Length != 4) + { + return BadRequest("Formato de dirección IP incorrecto."); + } + + var vlanNumber = octetos[2]; + var interfaceName = $"vlan{vlanNumber}"; + + // 3. Leemos los valores desde la configuración en lugar de hardcodearlos + var sshHost = _configuration.GetValue("SshSettings:Host"); + var sshPort = _configuration.GetValue("SshSettings:Port"); + var sshUser = _configuration.GetValue("SshSettings:User"); + var sshPass = _configuration.GetValue("SshSettings:Password"); + + if (string.IsNullOrEmpty(sshHost) || string.IsNullOrEmpty(sshUser) || string.IsNullOrEmpty(sshPass)) + { + Console.WriteLine("Error: La configuración SSH no está completa en appsettings.json."); + return StatusCode(500, "La configuración del servidor SSH está incompleta."); + } + + try + { + using (var client = new SshClient(sshHost, sshPort, sshUser, sshPass)) + { + client.Connect(); + if (client.IsConnected) + { + var command = $"/usr/sbin/etherwake -b -i {interfaceName} {mac}"; + var sshCommand = client.CreateCommand(command); + sshCommand.Execute(); + + Console.WriteLine($"Comando WOL ejecutado: {sshCommand.CommandText}"); + + client.Disconnect(); + } + else + { + Console.WriteLine("Error: No se pudo conectar al servidor SSH."); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error al ejecutar comando WOL: {ex.Message}"); + } + + return NoContent(); + } + + [HttpPost("manual")] + public async Task CrearEquipoManual([FromBody] CrearEquipoManualDto equipoDto) + { + 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);"; + + using (var connection = _context.CreateConnection()) + { + var existente = await connection.QuerySingleOrDefaultAsync(findQuery, new { equipoDto.Hostname }); + if (existente.HasValue) + { + return Conflict($"El hostname '{equipoDto.Hostname}' ya existe."); + } + + 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 }); + + 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); + } + } + + // --- ENDPOINTS PARA BORRADO MANUAL DE ASOCIACIONES --- + + [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) + { + return NotFound("Asociación de disco no encontrada o no se puede eliminar porque es automática."); + } + return NoContent(); + } + } + + [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) + { + return NotFound("Asociación de RAM no encontrada o no se puede eliminar porque es automática."); + } + return NoContent(); + } + } + + [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 }); + if (filasAfectadas == 0) + { + return NotFound("Asociación de usuario no encontrada o no se puede eliminar porque es automática."); + } + return NoContent(); + } + } + + [HttpPut("manual/{id}")] + public async Task ActualizarEquipoManual(int id, [FromBody] EditarEquipoManualDto equipoDto) + { + 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) + { + return NotFound("El equipo no existe."); + } + if (equipoActual.Origen != "manual") + { + 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 }); + if (hostExistente.HasValue) + { + return Conflict($"El hostname '{equipoDto.Hostname}' ya está en uso por otro equipo."); + } + } + + // 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 filasAfectadas = await connection.ExecuteAsync(updateQuery, new + { + equipoDto.Hostname, + equipoDto.Ip, + mac = equipoDto.Mac, + equipoDto.Motherboard, + equipoDto.Cpu, + equipoDto.Os, + equipoDto.Sector_id, + Id = id + }); + + if (filasAfectadas == 0) + { + // 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 + } + } + + [HttpPost("manual/{equipoId}/disco")] + public async Task AsociarDiscoManual(int equipoId, [FromBody] AsociarDiscoManualDto dto) + { + using (var connection = _context.CreateConnection()) + { + var equipo = await connection.QuerySingleOrDefaultAsync("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); + int discoId; + if (discoMaestro == null) + { + discoId = await connection.ExecuteScalarAsync("INSERT INTO dbo.discos (Mediatype, Size) VALUES (@Mediatype, @Size); SELECT CAST(SCOPE_IDENTITY() as int);", dto); + } + else + { + 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 }); + + return Ok(new { message = "Disco asociado manualmente.", equipoDiscoId = nuevaAsociacionId }); + } + } + + [HttpPost("manual/{equipoId}/ram")] + public async Task AsociarRamManual(int equipoId, [FromBody] AsociarRamManualDto dto) + { + using (var connection = _context.CreateConnection()) + { + var equipo = await connection.QuerySingleOrDefaultAsync("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); + 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); + } + 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 }); + + return Ok(new { message = "RAM asociada manualmente.", equipoMemoriaRamId = nuevaAsociacionId }); + } + } + + [HttpPost("manual/{equipoId}/usuario")] + public async Task AsociarUsuarioManual(int equipoId, [FromBody] AsociarUsuarioManualDto dto) + { + using (var connection = _context.CreateConnection()) + { + var equipo = await connection.QuerySingleOrDefaultAsync("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); + if (usuario == null) + { + usuarioId = await connection.ExecuteScalarAsync("INSERT INTO dbo.usuarios (Username) VALUES (@Username); SELECT CAST(SCOPE_IDENTITY() as int);", dto); + } + else + { + 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');"; + await connection.ExecuteAsync(asociacionQuery, new { EquipoId = equipoId, UsuarioId = usuarioId }); + } + catch (SqlException ex) when (ex.Number == 2627) + { + return Conflict("El usuario ya está asociado a este equipo."); + } + + return Ok(new { message = "Usuario asociado manualmente." }); + } + } + + [HttpGet("distinct/{fieldName}")] + public async Task GetDistinctFieldValues(string fieldName) + { + // 1. Lista blanca de campos permitidos para evitar inyección SQL y exposición de datos. + var allowedFields = new List { "os", "cpu", "motherboard", "architecture" }; + + if (!allowedFields.Contains(fieldName.ToLower())) + { + return BadRequest("El campo especificado no es válido o no está permitido."); + } + + // 2. Construir la consulta de forma segura + var query = $"SELECT DISTINCT {fieldName} FROM dbo.equipos WHERE {fieldName} IS NOT NULL AND {fieldName} != '' ORDER BY {fieldName};"; + + using (var connection = _context.CreateConnection()) + { + var values = await connection.QueryAsync(query); + return Ok(values); + } + } + + // DTOs locales para las peticiones + public class PingRequestDto { public string? Ip { get; set; } } + + public class WolRequestDto + { + public string? Mac { get; set; } + public string? Ip { get; set; } + } + + class DiscoAsociado + { + public int Id { get; set; } + public string Mediatype { get; set; } = ""; + public int Size { get; set; } + public int EquipoDiscoId { get; set; } + } + + public class CrearEquipoManualDto + { + public required string Hostname { get; set; } + public required string Ip { get; set; } + public string? Motherboard { get; set; } + public string? Cpu { get; set; } + public string? Os { get; set; } + public int? Sector_id { get; set; } + } + + public class EditarEquipoManualDto + { + public required string Hostname { get; set; } + public required string Ip { get; set; } + public string? Mac { get; set; } + public string? Motherboard { get; set; } + public string? Cpu { get; set; } + public string? Os { get; set; } + public int? Sector_id { get; set; } + } + + public class AsociarDiscoManualDto + { + public required string Mediatype { get; set; } + public int Size { get; set; } + } + + public class AsociarRamManualDto + { + public required string Slot { get; set; } + public int Tamano { get; set; } + public string? Fabricante { get; set; } + public int? Velocidad { get; set; } + } + + public class AsociarUsuarioManualDto + { + public required string Username { get; set; } } } - - // DTOs locales para las peticiones - public class PingRequestDto { public string? Ip { get; set; } } - public class WolRequestDto { public string? Mac { get; set; } } } \ No newline at end of file diff --git a/backend/Controllers/UsuariosController.cs b/backend/Controllers/UsuariosController.cs index e722bf5..8344bd0 100644 --- a/backend/Controllers/UsuariosController.cs +++ b/backend/Controllers/UsuariosController.cs @@ -119,5 +119,18 @@ namespace Inventario.API.Controllers return NoContent(); // Respuesta HTTP 204 No Content } } + + // --- GET /api/usuarios/buscar/{termino} --- + [HttpGet("buscar/{termino}")] + public async Task BuscarUsuarios(string termino) + { + // Usamos LIKE para una búsqueda flexible. El '%' son comodines. + var query = "SELECT Username FROM dbo.usuarios WHERE Username LIKE @SearchTerm ORDER BY Username;"; + using (var connection = _context.CreateConnection()) + { + var usuarios = await connection.QueryAsync(query, new { SearchTerm = $"%{termino}%" }); + return Ok(usuarios); + } + } } } \ No newline at end of file diff --git a/backend/Inventario.API.csproj b/backend/Inventario.API.csproj index a2a8337..9b01086 100644 --- a/backend/Inventario.API.csproj +++ b/backend/Inventario.API.csproj @@ -15,6 +15,7 @@ all + diff --git a/backend/Models/Equipo.cs b/backend/Models/Equipo.cs index 54b3185..6736ef7 100644 --- a/backend/Models/Equipo.cs +++ b/backend/Models/Equipo.cs @@ -1,3 +1,4 @@ +// backend/Models/Equipo.cs namespace Inventario.API.Models { public class Equipo @@ -5,22 +6,42 @@ namespace Inventario.API.Models public int Id { get; set; } public string Hostname { get; set; } = string.Empty; public string Ip { get; set; } = string.Empty; - public string? Mac { get; set; } // Mac puede ser nulo, así que usamos string? + public string? Mac { get; set; } public string Motherboard { get; set; } = string.Empty; public string Cpu { get; set; } = string.Empty; public int Ram_installed { get; set; } - public int? Ram_slots { get; set; } // Puede ser nulo + public int? Ram_slots { get; set; } public string Os { get; set; } = string.Empty; public string Architecture { get; set; } = string.Empty; public DateTime Created_at { get; set; } public DateTime Updated_at { get; set; } - public int? Sector_id { get; set; } // Puede ser nulo + public int? Sector_id { get; set; } + public string Origen { get; set; } = "automatica"; - // Propiedades de navegación (no mapeadas directamente a la BD) + // Propiedades de navegación actualizadas public Sector? Sector { get; set; } - public List Usuarios { get; set; } = new(); - public List Discos { get; set; } = new(); - public List MemoriasRam { get; set; } = new(); + 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 Historial { get; set; } = new(); } + + // Nuevo modelo para discos con su origen + public class DiscoDetalle : Disco + { + public string Origen { get; set; } = "manual"; + } + + // 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"; + } + + // Nuevo modelo para usuarios con su origen + public class UsuarioEquipoDetalle : Usuario + { + public string Origen { get; set; } = "manual"; + } } \ No newline at end of file diff --git a/backend/Models/MemoriaRam.cs b/backend/Models/MemoriaRam.cs index 154e5e8..62b1b9d 100644 --- a/backend/Models/MemoriaRam.cs +++ b/backend/Models/MemoriaRam.cs @@ -1,4 +1,4 @@ -// Este modelo representa la tabla memorias_ram +// backend/Models/MemoriaRam.cs namespace Inventario.API.Models { public class MemoriaRam @@ -9,10 +9,4 @@ namespace Inventario.API.Models public int Tamano { get; set; } public int? Velocidad { get; set; } } - - // Este es un modelo combinado para devolver la información completa al frontend - public class MemoriaRamDetalle : MemoriaRam - { - public string Slot { get; set; } = string.Empty; - } } \ No newline at end of file diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json index 0c208ae..90fd5f0 100644 --- a/backend/appsettings.Development.json +++ b/backend/appsettings.Development.json @@ -4,5 +4,11 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "SshSettings": { + "Host": "192.168.10.1", + "Port": 22110, + "User": "root", + "Password": "PTP.847eld23" } -} +} \ No newline at end of file diff --git a/backend/appsettings.json b/backend/appsettings.json index c885d4b..fbb00fb 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -8,5 +8,11 @@ "AllowedHosts": "*", "ConnectionStrings": { "DefaultConnection": "Server=TECNICA3;Database=InventarioDB;User Id=apiequipos;Password=@Apiequipos513@;TrustServerCertificate=True" + }, + "SshSettings": { + "Host": "192.168.10.1", + "Port": 22110, + "User": "root", + "Password": "PTP.847eld23" } } \ No newline at end of file diff --git a/backend/obj/Debug/net9.0/Inventario.API.AssemblyInfo.cs b/backend/obj/Debug/net9.0/Inventario.API.AssemblyInfo.cs index 67654b2..e7a26c1 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+3fbc9abf584ee687cb8cd7cea9ab20b716bdd897")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+99d98cc588b3922b6aa3ab9045fcee9cb31de1f3")] [assembly: System.Reflection.AssemblyProductAttribute("Inventario.API")] [assembly: System.Reflection.AssemblyTitleAttribute("Inventario.API")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/backend/obj/Inventario.API.csproj.nuget.dgspec.json b/backend/obj/Inventario.API.csproj.nuget.dgspec.json index ddbaa8b..23572bd 100644 --- a/backend/obj/Inventario.API.csproj.nuget.dgspec.json +++ b/backend/obj/Inventario.API.csproj.nuget.dgspec.json @@ -72,6 +72,10 @@ "target": "Package", "version": "[9.0.9, )" }, + "SSH.NET": { + "target": "Package", + "version": "[2025.0.0, )" + }, "Swashbuckle.AspNetCore": { "target": "Package", "version": "[9.0.6, )" diff --git a/backend/obj/project.assets.json b/backend/obj/project.assets.json index f525582..4b6d23a 100644 --- a/backend/obj/project.assets.json +++ b/backend/obj/project.assets.json @@ -39,6 +39,19 @@ } } }, + "BouncyCastle.Cryptography/2.5.1": { + "type": "package", + "compile": { + "lib/net6.0/BouncyCastle.Cryptography.dll": { + "related": ".xml" + } + }, + "runtime": { + "lib/net6.0/BouncyCastle.Cryptography.dll": { + "related": ".xml" + } + } + }, "Dapper/2.1.66": { "type": "package", "compile": { @@ -1019,6 +1032,23 @@ "buildTransitive/Mono.TextTemplating.targets": {} } }, + "SSH.NET/2025.0.0": { + "type": "package", + "dependencies": { + "BouncyCastle.Cryptography": "2.5.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + }, + "compile": { + "lib/net9.0/Renci.SshNet.dll": { + "related": ".xml" + } + }, + "runtime": { + "lib/net9.0/Renci.SshNet.dll": { + "related": ".xml" + } + } + }, "Swashbuckle.AspNetCore/9.0.6": { "type": "package", "dependencies": { @@ -1531,6 +1561,26 @@ "lib/netstandard2.0/Azure.Identity.xml" ] }, + "BouncyCastle.Cryptography/2.5.1": { + "sha512": "zy8TMeTP+1FH2NrLaNZtdRbBdq7u5MI+NFZQOBSM69u5RFkciinwzV2eveY6Kjf5MzgsYvvl6kTStsj3JrXqkg==", + "type": "package", + "path": "bouncycastle.cryptography/2.5.1", + "files": [ + ".nupkg.metadata", + ".signature.p7s", + "LICENSE.md", + "README.md", + "bouncycastle.cryptography.2.5.1.nupkg.sha512", + "bouncycastle.cryptography.nuspec", + "lib/net461/BouncyCastle.Cryptography.dll", + "lib/net461/BouncyCastle.Cryptography.xml", + "lib/net6.0/BouncyCastle.Cryptography.dll", + "lib/net6.0/BouncyCastle.Cryptography.xml", + "lib/netstandard2.0/BouncyCastle.Cryptography.dll", + "lib/netstandard2.0/BouncyCastle.Cryptography.xml", + "packageIcon.png" + ] + }, "Dapper/2.1.66": { "sha512": "/q77jUgDOS+bzkmk3Vy9SiWMaetTw+NOoPAV0xPBsGVAyljd5S6P+4RUW7R3ZUGGr9lDRyPKgAMj2UAOwvqZYw==", "type": "package", @@ -3607,6 +3657,29 @@ "readme.md" ] }, + "SSH.NET/2025.0.0": { + "sha512": "AKYbB+q2zFkNQbBFx5gXdv+Wje0baBtADQ35WnMKi4bg1ka74wTQtWoPd+fOWcydohdfsD0nfT8ErMOAPxtSfA==", + "type": "package", + "path": "ssh.net/2025.0.0", + "files": [ + ".nupkg.metadata", + ".signature.p7s", + "README.md", + "SS-NET-icon-h500.png", + "lib/net462/Renci.SshNet.dll", + "lib/net462/Renci.SshNet.xml", + "lib/net8.0/Renci.SshNet.dll", + "lib/net8.0/Renci.SshNet.xml", + "lib/net9.0/Renci.SshNet.dll", + "lib/net9.0/Renci.SshNet.xml", + "lib/netstandard2.0/Renci.SshNet.dll", + "lib/netstandard2.0/Renci.SshNet.xml", + "lib/netstandard2.1/Renci.SshNet.dll", + "lib/netstandard2.1/Renci.SshNet.xml", + "ssh.net.2025.0.0.nupkg.sha512", + "ssh.net.nuspec" + ] + }, "Swashbuckle.AspNetCore/9.0.6": { "sha512": "q/UfEAgrk6qQyjHXgsW9ILw0YZLfmPtWUY4wYijliX6supozC+TkzU0G6FTnn/dPYxnChjM8g8lHjWHF6VKy+A==", "type": "package", @@ -4348,6 +4421,7 @@ "Microsoft.Data.SqlClient >= 6.1.1", "Microsoft.EntityFrameworkCore.Design >= 9.0.9", "Microsoft.EntityFrameworkCore.SqlServer >= 9.0.9", + "SSH.NET >= 2025.0.0", "Swashbuckle.AspNetCore >= 9.0.6" ] }, @@ -4423,6 +4497,10 @@ "target": "Package", "version": "[9.0.9, )" }, + "SSH.NET": { + "target": "Package", + "version": "[2025.0.0, )" + }, "Swashbuckle.AspNetCore": { "target": "Package", "version": "[9.0.6, )" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3f31b4c..b6ed6c9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@tanstack/react-table": "^8.21.3", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-hot-toast": "^2.6.0", "react-tooltip": "^5.29.1" }, "devDependencies": { @@ -2045,7 +2046,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -2499,6 +2499,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2988,6 +2997,23 @@ "react": "^19.2.0" } }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index dbd5f53..ce30c2d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@tanstack/react-table": "^8.21.3", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-hot-toast": "^2.6.0", "react-tooltip": "^5.29.1" }, "devDependencies": { diff --git a/frontend/src/App.css b/frontend/src/App.css index 8497752..79b48e7 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,5 +1,47 @@ main { - padding: 2rem; + padding: 0rem 2rem; max-width: 1600px; margin: 0 auto; +} + +/* Estilos para la nueva Barra de Navegación */ +.navbar { + background-color: #343a40; /* Un color oscuro para el fondo */ + padding: 0 2rem; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + display: flex; + justify-content: space-between; + align-items: center; +} + +.nav-links { + display: flex; +} + +.nav-link { + background: none; + border: none; + color: #adb5bd; /* Color de texto gris claro */ + padding: 1rem 1.5rem; + cursor: pointer; + font-size: 1rem; + font-weight: 500; + text-decoration: none; + transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out; + border-bottom: 3px solid transparent; /* Borde inferior para el indicador activo */ +} + +.nav-link:hover { + color: #ffffff; /* Texto blanco al pasar el ratón */ +} + +.nav-link-active { + color: #ffffff; + border-bottom: 3px solid #007bff; /* Indicador azul para la vista activa */ +} + +.app-title { + font-size: 1.5rem; + color: #ffffff; + font-weight: bold; } \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a1c45ce..010fceb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,25 @@ +import { useState } from 'react'; import SimpleTable from "./components/SimpleTable"; +import GestionSectores from "./components/GestionSectores"; +import GestionComponentes from './components/GestionComponentes'; +import Navbar from './components/Navbar'; import './App.css'; +export type View = 'equipos' | 'sectores' | 'admin'; + function App() { + const [currentView, setCurrentView] = useState('equipos'); + return ( -
- -
+ <> + + +
+ {currentView === 'equipos' && } + {currentView === 'sectores' && } + {currentView === 'admin' && } +
+ ); } diff --git a/frontend/src/components/AutocompleteInput.tsx b/frontend/src/components/AutocompleteInput.tsx new file mode 100644 index 0000000..71caaad --- /dev/null +++ b/frontend/src/components/AutocompleteInput.tsx @@ -0,0 +1,66 @@ +import React, { useState, useEffect } from 'react'; + +interface AutocompleteInputProps { + value: string; + onChange: (e: React.ChangeEvent) => void; + name: string; + placeholder?: string; + // CAMBIO: La función ahora recibe el término de búsqueda + fetchSuggestions: (query: string) => Promise; + className?: string; +} + +const AutocompleteInput: React.FC = ({ + value, + onChange, + name, + placeholder, + fetchSuggestions, + className +}) => { + const [suggestions, setSuggestions] = useState([]); + const dataListId = `suggestions-for-${name}`; + + // CAMBIO: Lógica de "debouncing" para buscar mientras se escribe + 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) + .then(setSuggestions) + .catch(err => console.error(`Error fetching suggestions for ${name}:`, err)); + }, 300); + + // Limpia el temporizador si el usuario sigue escribiendo + return () => { + clearTimeout(handler); + }; + }, [value, fetchSuggestions, name]); + + return ( + <> + + + {suggestions.map((suggestion, index) => ( + + + ); +}; + +export default AutocompleteInput; \ No newline at end of file diff --git a/frontend/src/components/GestionComponentes.tsx b/frontend/src/components/GestionComponentes.tsx new file mode 100644 index 0000000..40d4102 --- /dev/null +++ b/frontend/src/components/GestionComponentes.tsx @@ -0,0 +1,217 @@ +import { useState, useEffect } from 'react'; +import toast from 'react-hot-toast'; +import styles from './SimpleTable.module.css'; + +const BASE_URL = 'http://localhost:5198/api'; + +// 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 [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()) + .then(data => { + setValores(data); + }) + .catch(_err => { + toast.error(`No se pudieron cargar los datos de ${componentType}.`); + }) + .finally(() => setIsLoading(false)); + }, [componentType]); + + const handleOpenModal = (valor: string) => { + setValorAntiguo(valor); + setValorNuevo(valor); + setIsModalOpen(true); + }; + + const handleUnificar = async () => { + const toastId = toast.loading('Unificando valores...'); + try { + 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(); + setValores(refreshedData); + + toast.success('Valores unificados correctamente.', { id: toastId }); + setIsModalOpen(false); + } catch (error) { + if (error instanceof Error) toast.error(error.message, { id: toastId }); + } + }; + + 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.")) { + return; + } + + const toastId = toast.loading('Eliminando módulo...'); + try { + const response = await fetch(`${BASE_URL}/admin/componentes/ram/${ramId}`, { 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 RamValue).id !== ramId)); + toast.success("Módulo 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 }); + } + }; + + 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 (item as TextValue).valor; + }; + + return ( +
+

Gestión de Componentes Maestros

+

Unifica valores inconsistentes y elimina registros no utilizados.

+ +
+ + +
+ + {isLoading ? ( +

Cargando...

+ ) : ( + + + + + + + + + + {valores.map((item) => ( + + + + + + ))} + +
Valor RegistradoNº de EquiposAcciones
{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) + <> + + + + )} +
+
+ )} + + {isModalOpen && ( +
+
+

Unificar Valor

+

Se reemplazarán todas las instancias de:

+ {valorAntiguo} + + setValorNuevo(e.target.value)} className={styles.modalInput} /> +
+ + +
+
+
+ )} +
+ ); +}; + +export default GestionComponentes; \ No newline at end of file diff --git a/frontend/src/components/GestionSectores.tsx b/frontend/src/components/GestionSectores.tsx new file mode 100644 index 0000000..78a6bf6 --- /dev/null +++ b/frontend/src/components/GestionSectores.tsx @@ -0,0 +1,142 @@ +import { useState, useEffect } from 'react'; +import toast from 'react-hot-toast'; +import type { Sector } from '../types/interfaces'; +import styles from './SimpleTable.module.css'; +import ModalSector from './ModalSector'; + +const BASE_URL = 'http://localhost:5198/api'; + +const GestionSectores = () => { + const [sectores, setSectores] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingSector, setEditingSector] = useState(null); + + useEffect(() => { + fetch(`${BASE_URL}/sectores`) + .then(res => res.json()) + .then((data: Sector[]) => { + setSectores(data); + setIsLoading(false); + }) + .catch(err => { + toast.error("No se pudieron cargar los sectores."); + console.error(err); + setIsLoading(false); + }); + }, []); + + const handleOpenCreateModal = () => { + setEditingSector(null); // Poner en modo 'crear' + setIsModalOpen(true); + }; + + const handleOpenEditModal = (sector: Sector) => { + setEditingSector(sector); // Poner en modo 'editar' con los datos del sector + setIsModalOpen(true); + }; + + const handleSave = async (id: number | null, nombre: string) => { + const isEditing = id !== null; + const url = isEditing ? `${BASE_URL}/sectores/${id}` : `${BASE_URL}/sectores`; + const method = isEditing ? 'PUT' : 'POST'; + const toastId = toast.loading(isEditing ? 'Actualizando...' : 'Creando...'); + + try { + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nombre }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'La operación falló.'); + } + + if (isEditing) { + // Actualizar el sector en la lista local + setSectores(prev => prev.map(s => s.id === id ? { ...s, nombre } : s)); + toast.success('Sector actualizado.', { id: toastId }); + } else { + // Añadir el nuevo sector a la lista local + const nuevoSector = await response.json(); + setSectores(prev => [...prev, nuevoSector]); + toast.success('Sector creado.', { id: toastId }); + } + + setIsModalOpen(false); // Cerrar el modal + } catch (error) { + if (error instanceof Error) toast.error(error.message, { id: toastId }); + } + }; + + const handleDelete = async (id: number) => { + if (!window.confirm("¿Estás seguro de eliminar este sector? Los equipos asociados quedarán sin sector.")) { + return; + } + + const toastId = toast.loading('Eliminando...'); + try { + const response = await fetch(`${BASE_URL}/sectores/${id}`, { method: 'DELETE' }); + if (response.status === 409) { + throw new Error("No se puede eliminar. Hay equipos asignados a este sector."); + } + if (!response.ok) { + throw new Error("El sector no se pudo eliminar."); + } + setSectores(prev => prev.filter(s => s.id !== id)); + toast.success("Sector eliminado.", { id: toastId }); + } catch (error) { + if (error instanceof Error) toast.error(error.message, { id: toastId }); + } + }; + + if (isLoading) { + return
Cargando sectores...
; + } + + return ( +
+
+

Gestión de Sectores

+ +
+ + + + + + + + + {sectores.map(sector => ( + + + + + ))} + +
Nombre del SectorAcciones
{sector.nombre} +
+ + +
+
+ + {isModalOpen && ( + setIsModalOpen(false)} + onSave={handleSave} + /> + )} +
+ ); +}; + +export default GestionSectores; \ No newline at end of file diff --git a/frontend/src/components/ModalAnadirDisco.tsx b/frontend/src/components/ModalAnadirDisco.tsx new file mode 100644 index 0000000..aa55887 --- /dev/null +++ b/frontend/src/components/ModalAnadirDisco.tsx @@ -0,0 +1,46 @@ +// frontend/src/components/ModalAnadirDisco.tsx +import React, { useState } from 'react'; +import styles from './SimpleTable.module.css'; + +interface Props { + onClose: () => void; + onSave: (disco: { mediatype: string, size: number }) => void; +} + +const ModalAnadirDisco: React.FC = ({ onClose, onSave }) => { + const [mediatype, setMediatype] = useState('SSD'); + const [size, setSize] = useState(''); + + const handleSave = () => { + if (size && parseInt(size, 10) > 0) { + onSave({ mediatype, size: parseInt(size, 10) }); + } + }; + + return ( +
+
+

Añadir Disco Manualmente

+ + + + setSize(e.target.value)} + className={styles.modalInput} + placeholder="Ej: 500" + /> +
+ + +
+
+
+ ); +}; + +export default ModalAnadirDisco; \ No newline at end of file diff --git a/frontend/src/components/ModalAnadirEquipo.tsx b/frontend/src/components/ModalAnadirEquipo.tsx new file mode 100644 index 0000000..f82d6bd --- /dev/null +++ b/frontend/src/components/ModalAnadirEquipo.tsx @@ -0,0 +1,125 @@ +// frontend/src/components/ModalAnadirEquipo.tsx +import React, { useState } from 'react'; +import type { Sector, Equipo } from '../types/interfaces'; +import AutocompleteInput from './AutocompleteInput'; +import styles from './SimpleTable.module.css'; + +interface ModalAnadirEquipoProps { + sectores: Sector[]; + onClose: () => void; + onSave: (nuevoEquipo: Omit) => void; +} + +const BASE_URL = 'http://localhost:5198/api'; + +const ModalAnadirEquipo: React.FC = ({ sectores, onClose, onSave }) => { + const [nuevoEquipo, setNuevoEquipo] = useState({ + hostname: '', + ip: '', + motherboard: '', + cpu: '', + os: '', + sector_id: undefined as number | undefined, + }); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setNuevoEquipo(prev => ({ + ...prev, + [name]: name === 'sector_id' ? (value ? parseInt(value, 10) : undefined) : value, + })); + }; + + 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() !== ''; + + return ( +
+
+

Añadir Nuevo Equipo Manualmente

+ + + + + + + + + + + + fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json())} + /> + + + fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json())} + /> + + + fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json())} + /> + +
+ + +
+
+
+ ); +}; + +export default ModalAnadirEquipo; \ No newline at end of file diff --git a/frontend/src/components/ModalAnadirRam.tsx b/frontend/src/components/ModalAnadirRam.tsx new file mode 100644 index 0000000..4008a50 --- /dev/null +++ b/frontend/src/components/ModalAnadirRam.tsx @@ -0,0 +1,47 @@ +// frontend/src/components/ModalAnadirRam.tsx +import React, { useState } from 'react'; +import styles from './SimpleTable.module.css'; + +interface Props { + onClose: () => void; + onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => void; +} + +const ModalAnadirRam: React.FC = ({ onClose, onSave }) => { + const [ram, setRam] = useState({ slot: '', tamano: '', fabricante: '', velocidad: '' }); + + const handleChange = (e: React.ChangeEvent) => { + setRam(prev => ({ ...prev, [e.target.name]: e.target.value })); + }; + + const handleSave = () => { + onSave({ + slot: ram.slot, + tamano: parseInt(ram.tamano, 10), + fabricante: ram.fabricante || undefined, + velocidad: ram.velocidad ? parseInt(ram.velocidad, 10) : undefined, + }); + }; + + return ( +
+
+

Añadir Módulo de RAM

+ + + + + + + + +
+ + +
+
+
+ ); +}; + +export default ModalAnadirRam; \ No newline at end of file diff --git a/frontend/src/components/ModalAnadirUsuario.tsx b/frontend/src/components/ModalAnadirUsuario.tsx new file mode 100644 index 0000000..79f288f --- /dev/null +++ b/frontend/src/components/ModalAnadirUsuario.tsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; +import styles from './SimpleTable.module.css'; +import AutocompleteInput from './AutocompleteInput'; + +interface Props { + onClose: () => void; + onSave: (usuario: { username: string }) => void; +} + +const BASE_URL = 'http://localhost:5198/api'; + +const ModalAnadirUsuario: React.FC = ({ onClose, onSave }) => { + const [username, setUsername] = useState(''); + + const fetchUserSuggestions = async (query: string): Promise => { + if (!query) return []; + const response = await fetch(`${BASE_URL}/usuarios/buscar/${query}`); + if (!response.ok) return []; + return response.json(); + }; + + return ( +
+
+

Añadir Usuario Manualmente

+ + setUsername(e.target.value)} + className={styles.modalInput} + fetchSuggestions={fetchUserSuggestions} + placeholder="Escribe para buscar o crear un nuevo usuario" + /> +
+ + +
+
+
+ ); +}; + +export default ModalAnadirUsuario; \ No newline at end of file diff --git a/frontend/src/components/ModalCambiarClave.tsx b/frontend/src/components/ModalCambiarClave.tsx new file mode 100644 index 0000000..416074f --- /dev/null +++ b/frontend/src/components/ModalCambiarClave.tsx @@ -0,0 +1,65 @@ +// frontend/src/components/ModalCambiarClave.tsx +import React, { useState, useEffect, useRef } from 'react'; +import type { Usuario } from '../types/interfaces'; +import styles from './SimpleTable.module.css'; + +interface ModalCambiarClaveProps { + usuario: Usuario; // El componente padre asegura que esto no sea nulo + onClose: () => void; + onSave: (password: string) => void; +} + +const ModalCambiarClave: React.FC = ({ usuario, onClose, onSave }) => { + const [newPassword, setNewPassword] = useState(''); + const passwordInputRef = useRef(null); + + useEffect(() => { + // Enfocar el input cuando el modal se abre + setTimeout(() => passwordInputRef.current?.focus(), 100); + }, []); + + const handleSaveClick = () => { + if (newPassword.trim()) { + onSave(newPassword); + } + }; + + return ( +
+
+

+ Cambiar contraseña para {usuario.username} +

+ +
+ + +
+
+
+ ); +}; + +export default ModalCambiarClave; \ No newline at end of file diff --git a/frontend/src/components/ModalDetallesEquipo.tsx b/frontend/src/components/ModalDetallesEquipo.tsx new file mode 100644 index 0000000..5903d9a --- /dev/null +++ b/frontend/src/components/ModalDetallesEquipo.tsx @@ -0,0 +1,198 @@ +// frontend/src/components/ModalDetallesEquipo.tsx +import React, { useState, useEffect } 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'; + +// Interfaces actualizadas para las props +interface ModalDetallesEquipoProps { + equipo: Equipo; + isOnline: boolean; + historial: HistorialEquipo[]; + sectores: Sector[]; + onClose: () => void; + onDelete: (id: number) => Promise; + onRemoveAssociation: (type: 'disco' | 'ram' | 'usuario', id: any) => void; + onEdit: (id: number, equipoEditado: any) => Promise; + onAddComponent: (type: 'disco' | 'ram' | 'usuario') => void; +} + +const BASE_URL = 'http://localhost:5198/api'; + +const ModalDetallesEquipo: React.FC = ({ + equipo, isOnline, historial, sectores, onClose, onDelete, onRemoveAssociation, onEdit, onAddComponent +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editableEquipo, setEditableEquipo] = useState({ ...equipo }); + const [isMacValid, setIsMacValid] = useState(true); + const macRegex = /^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$/; + + useEffect(() => { + if (editableEquipo.mac && editableEquipo.mac.length > 0) { + setIsMacValid(macRegex.test(editableEquipo.mac)); + } else { + setIsMacValid(true); + } + }, [editableEquipo.mac]); + + useEffect(() => { + setEditableEquipo({ ...equipo }); + }, [equipo]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setEditableEquipo(prev => ({ + ...prev, + [name]: name === 'sector_id' ? (value ? parseInt(value, 10) : null) : value, + })); + }; + + const handleMacBlur = (e: React.FocusEvent) => { + let value = e.target.value; + let cleaned = value.replace(/[^0-9A-Fa-f]/gi, '').toUpperCase().substring(0, 12); + if (cleaned.length === 12) { + value = cleaned.match(/.{1,2}/g)?.join(':') || ''; + } else { + value = cleaned; + } + setEditableEquipo(prev => ({ ...prev, mac: value })); + }; + + const handleSave = async () => { + if (!isMacValid) { + toast.error("El formato de la MAC Address es incorrecto."); + return; + } + const success = await onEdit(equipo.id, editableEquipo); + if (success) setIsEditing(false); + }; + + const handleCancel = () => { + setEditableEquipo({ ...equipo }); + setIsMacValid(true); + setIsEditing(false); + }; + + const handleWolClick = async () => { + 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."); + toast.success('Solicitud de encendido enviada.', { id: toastId }); + } catch (error) { + toast.error('Error al enviar la solicitud.', { id: toastId }); + console.error('Error al enviar la solicitud WOL:', error); + } + }; + + const handleDeleteClick = async () => { + const success = await onDelete(equipo.id); + if (success) onClose(); + }; + + const formatDate = (dateString: string | undefined | null) => { + if (!dateString || dateString.startsWith('0001-01-01')) return 'No registrado'; + return new Date(dateString).toLocaleString('es-ES', { + year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' + }); + }; + + const isSaveDisabled = !editableEquipo.hostname || !editableEquipo.ip || !isMacValid; + + return ( +
+ +
+
+

Detalles del equipo: {equipo.hostname}

+ {equipo.origen === 'manual' && ( +
+ {isEditing ? ( + <> + + + + ) : ( + + )} +
+ )} +
+ +
+ {/* COLUMNA PRINCIPAL */} +
+ {/* SECCIÓN DE DATOS PRINCIPALES */} +
+
+

🔗 Datos Principales

+ {equipo.origen === 'manual' && (
)} +
+
+
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'}}
+
Sector:{isEditing ? : {equipo.sector?.nombre || 'No asignado'}}
+
Creación:{formatDate(equipo.created_at)}
+
Última Actualización:{formatDate(equipo.updated_at)}
+
Usuarios:{equipo.usuarios?.length > 0 ? equipo.usuarios.map(u => (
{u.origen === 'manual' ? '⌨️' : '⚙️'}{` ${u.username}`}
{u.origen === 'manual' && ()}
)) : 'N/A'}
+
+
+ + {/* 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'}}
+
RAM Instalada:{equipo.ram_installed} GB
+
Arquitectura:{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'}
+
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
+
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

+
+ + + {historial.sort((a, b) => new Date(b.fecha_cambio).getTime() - new Date(a.fecha_cambio).getTime()).map((cambio, index) => ())} +
FechaCampoValor anteriorValor nuevo
{formatDate(cambio.fecha_cambio)}{cambio.campo_modificado}{cambio.valor_anterior}{cambio.valor_nuevo}
+
+
+ +
+
+ ); +}; + +export default ModalDetallesEquipo; \ No newline at end of file diff --git a/frontend/src/components/ModalEditarSector.tsx b/frontend/src/components/ModalEditarSector.tsx new file mode 100644 index 0000000..2b3ddd4 --- /dev/null +++ b/frontend/src/components/ModalEditarSector.tsx @@ -0,0 +1,57 @@ +// frontend/src/components/ModalEditarSector.tsx +import React from 'react'; +import type { Equipo, Sector } from '../types/interfaces'; +import styles from './SimpleTable.module.css'; + +interface ModalEditarSectorProps { + modalData: Equipo; // El componente padre asegura que esto no sea nulo + setModalData: (data: Equipo) => void; + sectores: Sector[]; + onClose: () => void; + onSave: () => void; +} + +const ModalEditarSector: React.FC = ({ modalData, setModalData, sectores, onClose, onSave }) => { + return ( +
+
+

Editar Sector para {modalData.hostname}

+ + +
+ + +
+
+
+ ); +}; + +export default ModalEditarSector; \ No newline at end of file diff --git a/frontend/src/components/ModalSector.tsx b/frontend/src/components/ModalSector.tsx new file mode 100644 index 0000000..4720954 --- /dev/null +++ b/frontend/src/components/ModalSector.tsx @@ -0,0 +1,59 @@ +import React, { useState, useEffect, useRef } from 'react'; +import type { Sector } from '../types/interfaces'; +import styles from './SimpleTable.module.css'; + +interface Props { + // Si 'sector' es nulo, es para crear. Si tiene datos, es para editar. + sector: Sector | null; + onClose: () => void; + onSave: (id: number | null, nombre: string) => void; +} + +const ModalSector: React.FC = ({ sector, onClose, onSave }) => { + const [nombre, setNombre] = useState(''); + const inputRef = useRef(null); + + const isEditing = sector !== null; + + useEffect(() => { + // Si estamos editando, rellenamos el campo con el nombre actual + if (isEditing) { + setNombre(sector.nombre); + } + // Enfocar el input al abrir el modal + setTimeout(() => inputRef.current?.focus(), 100); + }, [sector, isEditing]); + + const handleSave = () => { + if (nombre.trim()) { + onSave(isEditing ? sector.id : null, nombre.trim()); + } + }; + + return ( +
+
+

{isEditing ? 'Editar Sector' : 'Añadir Nuevo Sector'}

+ + setNombre(e.target.value)} + className={styles.modalInput} + onKeyDown={e => e.key === 'Enter' && handleSave()} + /> +
+ + +
+
+
+ ); +}; + +export default ModalSector; \ No newline at end of file diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000..1c7a7ec --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,41 @@ +// frontend/src/components/Navbar.tsx +import React from 'react'; +import type { View } from '../App'; // Importaremos el tipo desde App.tsx +import '../App.css'; // Usaremos los estilos globales que acabamos de crear + +interface NavbarProps { + currentView: View; + setCurrentView: (view: View) => void; +} + +const Navbar: React.FC = ({ currentView, setCurrentView }) => { + return ( +
+
+ Inventario IT +
+ +
+ ); +}; + +export default Navbar; \ No newline at end of file diff --git a/frontend/src/components/SimpleTable.module.css b/frontend/src/components/SimpleTable.module.css new file mode 100644 index 0000000..60ca787 --- /dev/null +++ b/frontend/src/components/SimpleTable.module.css @@ -0,0 +1,490 @@ +/* Estilos para el contenedor principal y controles */ +.controlsContainer { + display: flex; + gap: 20px; + margin-bottom: 10px; + align-items: center; +} + +.searchInput, .sectorSelect { + padding: 8px 12px; + border-radius: 6px; + border: 1px solid #ced4da; + font-size: 14px; +} + +/* Estilos de la tabla */ +.table { + border-collapse: collapse; + font-family: system-ui, -apple-system, sans-serif; + font-size: 0.875rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); + width: 100%; + min-width: 1200px; /* Ancho mínimo para forzar el scroll horizontal si es necesario */ +} + +.th { + color: #212529; + font-weight: 600; + padding: 0.75rem 1rem; + border-bottom: 2px solid #dee2e6; + text-align: left; + cursor: pointer; + user-select: none; + white-space: nowrap; + position: sticky; + top: 0; /* Mantiene la posición sticky en la parte superior del viewport */ + z-index: 2; + background-color: #f8f9fa; /* Es crucial tener un fondo sólido */ +} + +.sortIndicator { + margin-left: 0.5rem; + font-size: 1.2em; + display: inline-block; + transform: translateY(-1px); + color: #007bff; + min-width: 20px; +} + +.tooltip{ + z-index: 9999; +} + +.tr { + transition: background-color 0.2s ease; +} + +.tr:hover { + background-color: #f1f3f5; +} + +.td { + padding: 0.75rem 1rem; + border-bottom: 1px solid #e9ecef; + color: #495057; + background-color: white; +} + +/* Estilos de botones dentro de la tabla */ +.hostnameButton { + background: none; + border: none; + color: #007bff; + cursor: pointer; + text-decoration: underline; + padding: 0; + font-size: inherit; + font-family: inherit; +} + +.tableButton { + padding: 0.375rem 0.75rem; + border-radius: 4px; + border: 1px solid #dee2e6; + background-color: transparent; + color: #212529; + cursor: pointer; + transition: all 0.2s ease; +} +.tableButton:hover { + background-color: #e9ecef; + border-color: #adb5bd; +} + +.deleteUserButton { + background: none; + border: none; + cursor: pointer; + color: #dc3545; + font-size: 1rem; + padding: 0 5px; + opacity: 0.7; + transition: opacity 0.3s ease, color 0.3s ease; + line-height: 1; +} +.deleteUserButton:hover { + opacity: 1; + color: #a4202e; +} + +/* Estilo para el botón de scroll-to-top */ +.scrollToTop { + position: fixed; + bottom: 60px; + right: 20px; + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #007bff; + color: white; + border: none; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + font-size: 20px; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.3s, transform 0.3s; + z-index: 1002; +} +.scrollToTop:hover { + transform: translateY(-3px); + background-color: #0056b3; +} + +/* Estilos genéricos para modales */ +.modalOverlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.4); + z-index: 999; + display: flex; + align-items: center; + justify-content: center; +} + +.modal { + background-color: #ffffff; + border-radius: 12px; + padding: 2rem; + box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.12); + z-index: 1000; + min-width: 400px; + max-width: 90%; + border: 1px solid #e0e0e0; + font-family: 'Segoe UI', sans-serif; +} + +.modal h3 { + margin: 0 0 1.5rem; + color: #2d3436; +} + +.modal label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; +} + +.modalInput { + padding: 10px; + border-radius: 6px; + border: 1px solid #ced4da; + width: 100%; + box-sizing: border-box; + margin-top: 4px; /* Separado del label */ + margin-bottom: 4px; /* Espacio antes del siguiente elemento */ +} + +.modalActions { + display: flex; + gap: 10px; + margin-top: 1.5rem; + justify-content: flex-end; /* Alinea los botones a la derecha por defecto */ +} + +/* Estilos de botones para modales */ +.btn { + padding: 8px 20px; + border-radius: 6px; + border: none; + cursor: pointer; + transition: all 0.2s ease; + font-weight: 500; + font-size: 14px; +} + +.btnPrimary { + background-color: #007bff; + color: white; +} +.btnPrimary:hover { + background-color: #0056b3; +} +.btnPrimary:disabled { + background-color: #e9ecef; + color: #6c757d; + cursor: not-allowed; +} + +.btnSecondary { + background-color: #6c757d; + color: white; +} +.btnSecondary:hover { + background-color: #5a6268; +} + +/* ===== NUEVOS ESTILOS PARA EL MODAL DE DETALLES ===== */ + +.modalLarge { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: #f8f9fa; /* Un fondo ligeramente gris para el modal */ + z-index: 1003; + overflow-y: auto; + display: flex; + flex-direction: column; + padding: 2rem; + box-sizing: border-box; +} + +.modalLargeContent { + max-width: 1400px; /* Ancho máximo del contenido */ + width: 100%; + margin: 0 auto; /* Centrar el contenido */ +} + +.modalLargeHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.05rem; + padding-bottom: 0.05rem; +} + +.modalLargeHeader h2 { + font-weight: 400; + font-size: 1.5rem; + color: #343a40; +} + +.closeButton { + background: black; + color: white; + border: none; + border-radius: 50%; + width: 30px; + height: 30px; + cursor: pointer; + font-size: 20px; + display: flex; + align-items: center; + justify-content: center; + z-index: 1004; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + transition: transform 0.2s, background-color 0.2s; + position: fixed; + right: 30px; + top: 30px; +} +.closeButton:hover { + transform: scale(1.1); + background-color: #333; +} + +.modalBodyColumns { + display: flex; + gap: 2rem; +} + +.mainColumn { + flex: 3; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.sidebarColumn { + flex: 1; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.section { + background-color: #ffffff; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} + +.sectionTitle { + font-size: 1.25rem; + margin: 0 0 1rem 0; + padding-bottom: 0.75rem; + border-bottom: 1px solid #e9ecef; + color: #2d3436; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.detailsGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +/* CAMBIO: Se aplica el mismo estilo de grid a componentsGrid para que se vea igual que detailsGrid */ +.componentsGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.actionsGrid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; +} + +.detailItem, .detailItemFull { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 10px; + background-color: #f8f9fa; + border-radius: 4px; + border: 1px solid #e9ecef; +} + +.detailLabel { + color: #6c757d; + font-size: 0.8rem; + font-weight: 700; +} + +.detailValue { + color: #495057; + font-size: 0.9rem; + line-height: 1.4; + word-break: break-word; +} + +.componentItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 2px 0; +} + +.powerButton, .deleteButton { + background: none; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; + width: 100%; + justify-content: center; +} + +.powerButton:hover { + border-color: #007bff; + background-color: #e7f1ff; + color: #0056b3; +} + +.powerIcon { + width: 20px; + height: 20px; +} + +.deleteButton { + color: #dc3545; +} +.deleteButton:hover { + border-color: #dc3545; + background-color: #fbebee; + color: #a4202e; +} +.deleteButton:disabled { + color: #6c757d; + background-color: #e9ecef; + border-color: #dee2e6; +} + +.historyContainer { + max-height: 400px; + overflow-y: auto; + border: 1px solid #dee2e6; + border-radius: 4px; +} + +.historyTable { + width: 100%; + border-collapse: collapse; +} + +.historyTh { + background-color: #f8f9fa; + padding: 12px; + text-align: left; + font-size: 0.875rem; + position: sticky; + top: 0; +} + +.historyTd { + padding: 12px; + color: #495057; + font-size: 0.8125rem; + border-bottom: 1px solid #dee2e6; +} + +.historyTr:last-child .historyTd { + border-bottom: none; +} + +/* CAMBIO: Nueva clase para dar espacio a la sección de historial */ +.historySectionFullWidth { + margin-top: 2rem; +} + +.statusIndicator { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; +} + +.statusDot { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.statusOnline { + background-color: #28a745; + box-shadow: 0 0 8px #28a74580; +} + +.statusOffline { + background-color: #dc3545; + box-shadow: 0 0 8px #dc354580; +} + +.inputError { + border-color: #dc3545; + box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25); +} + +.errorMessage { + color: #dc3545; + font-size: 0.8rem; + margin-top: 4px; +} + +/* Clases para la sección de usuarios y claves - No se usan en el nuevo modal pero se mantienen por si acaso */ +.userList { min-width: 240px; } +.userItem { display: flex; align-items: center; justify-content: space-between; margin: 4px 0; padding: 6px; background-color: #f8f9fa; border-radius: 4px; position: relative; } +.userInfo { color: #495057; } +.userActions { display: flex; gap: 4px; align-items: center; } +.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 diff --git a/frontend/src/components/SimpleTable.tsx b/frontend/src/components/SimpleTable.tsx index 3f18ef5..5f49590 100644 --- a/frontend/src/components/SimpleTable.tsx +++ b/frontend/src/components/SimpleTable.tsx @@ -1,14 +1,26 @@ -import React, { useEffect, useRef, useState, type CSSProperties } from 'react'; +// frontend/src/components/SimpleTable.tsx +import React, { useEffect, useState } from 'react'; import { useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel, + getPaginationRowModel, flexRender, type CellContext } from '@tanstack/react-table'; import { Tooltip } from 'react-tooltip'; -import type { Equipo, Sector, Usuario, HistorialEquipo } from '../types/interfaces'; +import toast from 'react-hot-toast'; +import type { Equipo, Sector, Usuario, UsuarioEquipoDetalle } from '../types/interfaces'; +import styles from './SimpleTable.module.css'; + +import ModalAnadirEquipo from './ModalAnadirEquipo'; +import ModalEditarSector from './ModalEditarSector'; +import ModalCambiarClave from './ModalCambiarClave'; +import ModalDetallesEquipo from './ModalDetallesEquipo'; +import ModalAnadirDisco from './ModalAnadirDisco'; +import ModalAnadirRam from './ModalAnadirRam'; +import ModalAnadirUsuario from './ModalAnadirUsuario'; const SimpleTable = () => { const [data, setData] = useState([]); @@ -18,17 +30,17 @@ const SimpleTable = () => { const [modalData, setModalData] = useState(null); const [sectores, setSectores] = useState([]); const [modalPasswordData, setModalPasswordData] = useState(null); - const [newPassword, setNewPassword] = useState(''); const [showScrollButton, setShowScrollButton] = useState(false); const [selectedEquipo, setSelectedEquipo] = useState(null); - const [historial, setHistorial] = useState([]); + const [historial, setHistorial] = useState([]); const [isOnline, setIsOnline] = useState(false); - const passwordInputRef = useRef(null); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [addingComponent, setAddingComponent] = useState<'disco' | 'ram' | 'usuario' | null>(null); + const [isLoading, setIsLoading] = useState(true); const BASE_URL = 'http://localhost:5198/api'; useEffect(() => { const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth; - if (selectedEquipo || modalData || modalPasswordData) { document.body.classList.add('scroll-lock'); document.body.style.paddingRight = `${scrollBarWidth}px`; @@ -36,7 +48,6 @@ const SimpleTable = () => { document.body.classList.remove('scroll-lock'); document.body.style.paddingRight = '0'; } - return () => { document.body.classList.remove('scroll-lock'); document.body.style.paddingRight = '0'; @@ -44,44 +55,36 @@ const SimpleTable = () => { }, [selectedEquipo, modalData, modalPasswordData]); useEffect(() => { + if (!selectedEquipo) return; let isMounted = true; - const checkPing = async () => { - if (!selectedEquipo?.ip) return; - + 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(); if (isMounted) setIsOnline(data.isAlive); - } catch (error) { if (isMounted) setIsOnline(false); console.error('Error checking ping:', error); } }; - checkPing(); const interval = setInterval(checkPing, 10000); - return () => { isMounted = false; clearInterval(interval); setIsOnline(false); }; - }, [selectedEquipo?.ip]); + }, [selectedEquipo]); const handleCloseModal = () => { setSelectedEquipo(null); @@ -98,202 +101,285 @@ const SimpleTable = () => { }, [selectedEquipo]); useEffect(() => { - const handleScroll = () => { - setShowScrollButton(window.scrollY > 200); - }; - + const handleScroll = () => setShowScrollButton(window.scrollY > 200); window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, []); useEffect(() => { - if (modalPasswordData && passwordInputRef.current) { - passwordInputRef.current.focus(); - } - }, [modalPasswordData]); - - useEffect(() => { - fetch(`${BASE_URL}/equipos`) - .then(response => response.json()) - .then((fetchedData: Equipo[]) => { - setData(fetchedData); - setFilteredData(fetchedData); - }); - - fetch(`${BASE_URL}/sectores`) - .then(response => response.json()) - .then((fetchedSectores: Sector[]) => { - const sectoresOrdenados = [...fetchedSectores].sort((a, b) => - a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }) - ); - setSectores(sectoresOrdenados); - }); + setIsLoading(true); + Promise.all([ + fetch(`${BASE_URL}/equipos`).then(res => res.json()), + fetch(`${BASE_URL}/sectores`).then(res => res.json()) + ]).then(([equiposData, sectoresData]) => { + setData(equiposData); + setFilteredData(equiposData); + 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); + }); }, []); const handleSectorChange = (e: React.ChangeEvent) => { - const selectedValue = e.target.value; - setSelectedSector(selectedValue); - - if (selectedValue === 'Todos') { - setFilteredData(data); - } else if (selectedValue === 'Asignar') { - const filtered = data.filter(item => !item.sector); - setFilteredData(filtered); - } else { - const filtered = data.filter(item => item.sector?.nombre === selectedValue); - setFilteredData(filtered); - } + const value = e.target.value; + setSelectedSector(value); + if (value === 'Todos') setFilteredData(data); + else if (value === 'Asignar') setFilteredData(data.filter(i => !i.sector)); + else setFilteredData(data.filter(i => i.sector?.nombre === value)); }; const handleSave = async () => { 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', - headers: { 'Content-Type': 'application/json' } - }); - + 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'); - - // Actualizamos el dato localmente para reflejar el cambio inmediatamente 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); } }; - const handleSavePassword = async () => { + const handleSavePassword = async (password: string) => { 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: newPassword }), + body: JSON.stringify({ password }), }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Error al actualizar la contraseña'); + const err = await response.json(); + throw new Error(err.error || 'Error al actualizar'); } - const updatedUser = await response.json(); - const updatedData = data.map(equipo => ({ ...equipo, - usuarios: equipo.usuarios?.map(user => - user.id === updatedUser.id ? { ...user, password: newPassword } : user - ) + usuarios: equipo.usuarios?.map(user => user.id === updatedUser.id ? { ...user, password } : user) })); - setData(updatedData); setFilteredData(updatedData); + toast.success(`Contraseña actualizada.`, { id: toastId }); setModalPasswordData(null); - setNewPassword(''); - } catch (error) { - if (error instanceof Error) { - console.error('Error:', error); - alert(error.message); - } + if (error instanceof Error) toast.error(error.message, { id: toastId }); } }; const handleRemoveUser = async (hostname: string, username: string) => { 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 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 usuario'); - - const updateFunc = (prevData: Equipo[]) => prevData.map(equipo => { - if (equipo.hostname === hostname) { - return { - ...equipo, - usuarios: equipo.usuarios.filter(u => u.username !== username), - }; - } - return equipo; - }); - + if (!response.ok) throw new Error(result.error || 'Error al desasociar'); + const updateFunc = (prev: Equipo[]) => prev.map(e => e.hostname === hostname ? { ...e, usuarios: e.usuarios.filter(u => u.username !== username) } : e); setData(updateFunc); setFilteredData(updateFunc); - + toast.success(`${username} quitado.`, { id: toastId }); } catch (error) { - if (error instanceof Error) { - console.error('Error:', error); - alert(error.message); - } + if (error instanceof Error) toast.error(error.message, { id: toastId }); } }; const handleDelete = async (id: number) => { - if (!window.confirm('¿Estás seguro de eliminar este equipo y todas sus relaciones?')) return false; - + 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' - }); - + const response = await fetch(`${BASE_URL}/equipos/${id}`, { method: 'DELETE' }); if (response.status === 204) { - setData(prev => prev.filter(equipo => equipo.id !== id)); - setFilteredData(prev => prev.filter(equipo => equipo.id !== 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; } - const errorText = await response.text(); - throw new Error(errorText); + throw new Error(errorText || 'Error desconocido'); + } catch (error) { + if (error instanceof Error) toast.error(`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 + } + + 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.`); + } + + // 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); + } else if (type === 'ram' && typeof associationId === 'number') { + updatedEquipo.memoriasRam = equipo.memoriasRam.filter(m => m.equipoMemoriaRamId !== associationId); + } else if (type === 'usuario' && typeof associationId === 'object') { + updatedEquipo.usuarios = equipo.usuarios.filter(u => u.id !== associationId.usuarioId); + } + return updatedEquipo; + }); + + setData(updateState); + setFilteredData(updateState); + setSelectedEquipo(prev => prev ? updateState([prev])[0] : null); // Actualiza también el modal + + toast.success(successMessage, { id: toastId }); } catch (error) { if (error instanceof Error) { - console.error('Error eliminando equipo:', error); - alert(`Error al eliminar el equipo: ${error.message}`); + toast.error(error.message, { id: toastId }); } - return false; + } + }; + + 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 + setData(prev => [...prev, equipoCreado]); + setFilteredData(prev => [...prev, equipoCreado]); + + toast.success(`Equipo '${equipoCreado.hostname}' creado.`, { id: toastId }); + setIsAddModalOpen(false); // Cerramos el modal + } catch (error) { + if (error instanceof Error) { + toast.error(error.message, { id: toastId }); + } + } + }; + + const handleEditEquipo = async (id: number, equipoEditado: Omit) => { + 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; + }); + + setData(updateState); + setFilteredData(updateState); + setSelectedEquipo(prev => prev ? updateState([prev])[0] : null); + + toast.success('Equipo actualizado.', { id: toastId }); + return true; // Indica que el guardado fue exitoso + } catch (error) { + if (error instanceof Error) { + toast.error(error.message, { id: toastId }); + } + return false; // Indica que el guardado falló + } + }; + + const handleAddComponent = async (type: 'disco' | 'ram' | 'usuario', data: 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}.`); + } + + // Refrescar los datos del equipo para ver el cambio + const refreshedEquipo = await (await fetch(`${BASE_URL}/equipos/${selectedEquipo.hostname}`)).json(); + + const updateState = (prev: Equipo[]) => prev.map(e => e.id === selectedEquipo.id ? refreshedEquipo : e); + setData(updateState); + setFilteredData(updateState); + setSelectedEquipo(refreshedEquipo); + + toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} añadido.`, { id: toastId }); + setAddingComponent(null); // Cerrar modal + } catch (error) { + if (error instanceof Error) toast.error(error.message, { id: toastId }); } }; const columns = [ { header: "ID", accessorKey: "id", enableHiding: true }, { - header: "Nombre", - accessorKey: "hostname", - cell: ({ row }: CellContext) => ( - - ) + header: "Nombre", accessorKey: "hostname", + cell: ({ row }: CellContext) => () }, { header: "IP", accessorKey: "ip" }, { header: "MAC", accessorKey: "mac", enableHiding: true }, { header: "Motherboard", accessorKey: "motherboard" }, { header: "CPU", accessorKey: "cpu" }, { header: "RAM", accessorKey: "ram_installed" }, - { - header: "Discos", - accessorFn: (row: Equipo) => row.discos?.length > 0 - ? row.discos.map(d => `${d.mediatype} ${d.size}GB`).join(" | ") - : "Sin discos" - }, + { header: "Discos", accessorFn: (row: Equipo) => row.discos?.map(d => `${d.mediatype} ${d.size}GB`).join(" | ") || "Sin discos" }, { header: "OS", accessorKey: "os" }, { header: "Arquitectura", accessorKey: "architecture" }, { @@ -301,46 +387,30 @@ const SimpleTable = () => { cell: ({ row }: CellContext) => { const usuarios = row.original.usuarios || []; return ( -
- {usuarios.map((u: Usuario) => ( -
- +
+ {usuarios.map((u: UsuarioEquipoDetalle) => ( +
+ U: {u.username} - C: {u.password || 'N/A'} -
+
@@ -350,41 +420,13 @@ const SimpleTable = () => { } }, { - header: "Sector", - id: 'sector', - accessorFn: (row: Equipo) => row.sector?.nombre || 'Asignar', + header: "Sector", id: 'sector', accessorFn: (row: Equipo) => row.sector?.nombre || 'Asignar', cell: ({ row }: CellContext) => { const sector = row.original.sector; - return ( -
- - {sector?.nombre || 'Asignar'} - - - +
+ {sector?.nombre || 'Asignar'} +
); } @@ -397,12 +439,16 @@ const SimpleTable = () => { getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), initialState: { sorting: [ { id: 'sector', desc: false }, { id: 'hostname', desc: false } ], - columnVisibility: { id: false, mac: false } + columnVisibility: { id: false, mac: false }, + pagination: { + pageSize: 15, // Mostrar 15 filas por página por defecto + }, }, state: { globalFilter, @@ -410,309 +456,167 @@ const SimpleTable = () => { onGlobalFilterChange: setGlobalFilter, }); - return ( -
-

Equipos

-
+ if (isLoading) { + return ( +
+

Cargando Equipos...

+
+ ); + } + + const PaginacionControles = ( +
+
+ + + + +
+ + Página{' '} + + {table.getState().pagination.pageIndex + 1} de {table.getPageCount()} + + +
+ | Ir a pág: setGlobalFilter(e.target.value)} /> - Selección de sector: - { + table.setPageSize(Number(e.target.value)); + }} + className={styles.sectorSelect} + > + {[10, 15, 25, 50, 100].map(pageSize => ( + ))}
-
- - - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => ( - - ))} - - ))} - - - {table.getRowModel().rows.map(row => ( - - {row.getVisibleCells().map(cell => ( - - ))} - - ))} - -
- {flexRender(header.column.columnDef.header, header.getContext())} - {header.column.getIsSorted() && ( - - {header.column.getIsSorted() === 'asc' ? '⇑' : '⇓'} - - )} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
+
+ ); - {showScrollButton && ( + return ( +
+
+

Equipos ({table.getFilteredRowModel().rows.length})

- )} +
+
+ setGlobalFilter(e.target.value)} className={styles.searchInput} style={{ width: '300px' }} /> + Sector: + +
+ + {/* --- 2. Renderizar los controles ANTES de la tabla --- */} + {PaginacionControles} + +
+ + + {table.getHeaderGroups().map(hg => ( + + {hg.headers.map(h => ( + + ))} + + ))} + + + {table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ))} + +
+ {flexRender(h.column.columnDef.header, h.getContext())} + {h.column.getIsSorted() && ({h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'})} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ + {/* --- 3. Renderizar los controles DESPUÉS de la tabla --- */} + {PaginacionControles} + + {showScrollButton && ()} {modalData && ( -
-

Editar Sector

- - -
- - -
-
+ setModalData(null)} + onSave={handleSave} + /> )} + {modalPasswordData && ( -
-

- Cambiar contraseña para {modalPasswordData.username} -

- -
- - -
-
+ setModalPasswordData(null)} + onSave={handleSavePassword} + /> )} + {selectedEquipo && ( -
- -
-
-

Datos del equipo '{selectedEquipo.hostname}'

-
-
-

Datos actuales

-
- {Object.entries(selectedEquipo).map(([key, value]) => { - // Omitimos claves que mostraremos de forma personalizada o no son relevantes aquí - if (['id', 'usuarios', 'sector', 'discos', 'historial', 'equiposDiscos', 'memoriasRam', 'sector_id'].includes(key)) return null; - - const formattedValue = (key === 'created_at' || key === 'updated_at') - ? new Date(value as string).toLocaleString('es-ES') - : (value as any)?.toString() || 'N/A'; - - return ( -
- {key.replace(/_/g, ' ')}: - {formattedValue} -
- ); - })} - - {/* --- CORRECCIÓN 1: Mostrar nombre del sector --- */} -
- Sector: - - {selectedEquipo.sector?.nombre || 'No asignado'} - -
- -
- Modulos RAM: - - {selectedEquipo.memoriasRam?.map(m => `Slot ${m.slot}: ${m.partNumber || 'N/P'} ${m.tamano}GB ${m.velocidad || ''}MHz`).join(' | ') || 'N/A'} - -
-
- Discos: - - {selectedEquipo.discos?.map(d => `${d.mediatype} ${d.size}GB`).join(', ') || 'N/A'} - -
-
- Usuarios: - - {selectedEquipo.usuarios?.map(u => u.username).join(', ') || 'N/A'} - -
-
- Estado: -
-
- {isOnline ? 'En línea' : 'Sin conexión'} -
-
-
- Wake On Lan: - -
-
- Eliminar Equipo: - -
-
-
-
-

Historial de cambios

- - - - - - - - - - - {historial - .sort((a, b) => new Date(b.fecha_cambio).getTime() - new Date(a.fecha_cambio).getTime()) - .map((cambio, index) => ( - - - - - - - ))} - -
FechaCampo modificadoValor anteriorValor nuevo
- {new Date(cambio.fecha_cambio).toLocaleString('es-ES', { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit' - })} - {cambio.campo_modificado}{cambio.valor_anterior}{cambio.valor_nuevo}
-
-
-
+ setAddingComponent(type)} + /> )} + + {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)} />}
); }; -// --- ESTILOS --- -const modalStyle: CSSProperties = { - position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', backgroundColor: '#ffffff', - borderRadius: '12px', padding: '2rem', boxShadow: '0px 8px 30px rgba(0, 0, 0, 0.12)', zIndex: 1000, - minWidth: '400px', maxWidth: '90%', border: '1px solid #e0e0e0', fontFamily: 'Segoe UI, sans-serif' -}; -const buttonStyle = { - base: { padding: '8px 20px', borderRadius: '6px', border: 'none', cursor: 'pointer', transition: 'all 0.2s ease', fontWeight: '500', fontSize: '14px' } as CSSProperties, - primary: { backgroundColor: '#007bff', color: 'white' } as CSSProperties, - secondary: { backgroundColor: '#6c757d', color: 'white' } as CSSProperties, - disabled: { backgroundColor: '#e9ecef', color: '#6c757d', cursor: 'not-allowed' } as CSSProperties -}; -const inputStyle: CSSProperties = { padding: '10px', borderRadius: '6px', border: '1px solid #ced4da', width: '100%', boxSizing: 'border-box', margin: '8px 0' }; -const tableStyle: CSSProperties = { borderCollapse: 'collapse', fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.875rem', boxShadow: '0 1px 3px rgba(0,0,0,0.08)', tableLayout: 'auto', width: '100%' }; -const headerStyle: CSSProperties = { color: '#212529', fontWeight: 600, padding: '0.75rem 1rem', borderBottom: '2px solid #dee2e6', textAlign: 'left', cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', position: 'sticky', top: -1, zIndex: 2, backgroundColor: '#f8f9fa' }; -const cellStyle: CSSProperties = { padding: '0.75rem 1rem', borderBottom: '1px solid #e9ecef', color: '#495057', backgroundColor: 'white' }; -const rowStyle: CSSProperties = { transition: 'background-color 0.2s ease' }; -const tableButtonStyle: CSSProperties = { padding: '0.375rem 0.75rem', borderRadius: '4px', border: '1px solid #dee2e6', backgroundColor: 'transparent', color: '#212529', cursor: 'pointer', transition: 'all 0.2s ease' }; -const deleteButtonStyle: CSSProperties = { background: 'none', border: 'none', cursor: 'pointer', color: '#dc3545', fontSize: '1.5em', padding: '0 5px', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%' }; -const deleteUserButtonStyle: CSSProperties = { background: 'none', border: 'none', cursor: 'pointer', color: '#dc3545', fontSize: '1.2em', padding: '0 5px', opacity: 0.7, transition: 'opacity 0.3s ease, color 0.3s ease' }; -const scrollToTopStyle: CSSProperties = { position: 'fixed', bottom: '60px', right: '20px', width: '40px', height: '40px', borderRadius: '50%', backgroundColor: '#007bff', color: 'white', border: 'none', cursor: 'pointer', boxShadow: '0 2px 5px rgba(0,0,0,0.2)', fontSize: '20px', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'opacity 0.3s, transform 0.3s', zIndex: 1002 }; -const powerButtonStyle: CSSProperties = { background: 'none', border: 'none', cursor: 'pointer', padding: '5px', display: 'flex', alignItems: 'center', transition: 'transform 0.2s ease' }; -const powerIconStyle: CSSProperties = { width: '24px', height: '24px' }; -const modalGrandeStyle: CSSProperties = { position: 'fixed', top: '0', left: '0', width: '100vw', height: '100vh', backgroundColor: 'white', zIndex: 1003, overflowY: 'auto', display: 'flex', flexDirection: 'column', padding: '0px' }; -const modalHeaderStyle: CSSProperties = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #ddd', marginBottom: '10px', top: 0, backgroundColor: 'white', zIndex: 1000, paddingTop: '5px' }; -const tituloSeccionStyle: CSSProperties = { fontSize: '1rem', margin: '0 0 15px 0', color: '#2d3436', fontWeight: 600 }; -const closeButtonStyle: CSSProperties = { background: 'black', color: 'white', border: 'none', borderRadius: '50%', width: '30px', height: '30px', cursor: 'pointer', fontSize: '20px', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1004, boxShadow: '0 2px 5px rgba(0,0,0,0.2)', transition: 'transform 0.2s', position: 'fixed', right: '30px', top: '30px' }; -const seccionStyle: CSSProperties = { backgroundColor: '#f8f9fa', borderRadius: '8px', padding: '15px', boxShadow: '0 2px 4px rgba(0,0,0,0.05)', marginBottom: '10px' }; -const gridDatosStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '15px', marginTop: '15px' }; -const datoItemStyle: CSSProperties = { display: 'flex', justifyContent: 'space-between', padding: '10px', backgroundColor: 'white', borderRadius: '4px', boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }; -const labelStyle: CSSProperties = { color: '#6c757d', textTransform: 'capitalize', fontSize: '1rem', fontWeight: 800, marginRight: '8px' }; -const valorStyle: CSSProperties = { color: '#495057', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: '0.8125rem', lineHeight: '1.4' }; -const historialTableStyle: CSSProperties = { width: '100%', borderCollapse: 'collapse', marginTop: '15px' }; -const historialHeaderStyle: CSSProperties = { backgroundColor: '#007bff', color: 'white', padding: '12px', textAlign: 'left', fontSize: '0.875rem' }; -const historialCellStyle: CSSProperties = { padding: '12px', color: '#495057', fontSize: '0.8125rem' }; -const historialRowStyle: CSSProperties = { borderBottom: '1px solid #dee2e6' }; - export default SimpleTable; \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 4f6c4fb..bd49d23 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,30 @@ +// frontend/src/main.tsx import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.tsx' -import './index.css' // Importaremos un CSS base aquí +import './index.css' +import { Toaster } from 'react-hot-toast' // Importamos el Toaster ReactDOM.createRoot(document.getElementById('root')!).render( + , ) \ No newline at end of file diff --git a/frontend/src/types/interfaces.ts b/frontend/src/types/interfaces.ts index 659b11e..cf1b1c3 100644 --- a/frontend/src/types/interfaces.ts +++ b/frontend/src/types/interfaces.ts @@ -13,6 +13,11 @@ export interface Usuario { password?: string; // Es opcional ya que no siempre lo enviaremos } +// CAMBIO: Añadimos el origen y el ID de la asociación +export interface UsuarioEquipoDetalle extends Usuario { + origen: 'manual' | 'automatica'; +} + // Corresponde al modelo 'Disco' export interface Disco { id: number; @@ -20,18 +25,26 @@ export interface Disco { size: number; } -// Corresponde al modelo 'MemoriaRam' -export interface MemoriaRam { - id: number; - partNumber?: string; - fabricante?: string; - tamano: number; - velocidad?: number; +// CAMBIO: Añadimos el origen y el ID de la asociación +export interface DiscoDetalle extends Disco { + equipoDiscoId: number; // El ID de la tabla equipos_discos + origen: 'manual' | 'automatica'; } -// Interfaz combinada para mostrar los detalles de la RAM en la tabla de equipos -export interface MemoriaRamDetalle extends MemoriaRam { - slot: string; +// Corresponde al modelo 'MemoriaRam' +export interface MemoriaRam { + id: number; + partNumber?: string; + fabricante?: string; + tamano: number; + velocidad?: number; +} + +// CCorresponde al modelo 'MemoriaRamEquipoDetalle' +export interface MemoriaRamEquipoDetalle extends MemoriaRam { + equipoMemoriaRamId: number; // El ID de la tabla equipos_memorias_ram + slot: string; + origen: 'manual' | 'automatica'; } // Corresponde al modelo 'HistorialEquipo' @@ -59,11 +72,12 @@ export interface Equipo { created_at: string; updated_at: string; sector_id?: number; - + origen: 'manual' | 'automatica'; // Campo de origen para el equipo + // Propiedades de navegación que vienen de las relaciones (JOINs) sector?: Sector; - usuarios: Usuario[]; - discos: Disco[]; - memoriasRam: MemoriaRamDetalle[]; + usuarios: UsuarioEquipoDetalle[]; + discos: DiscoDetalle[]; + memoriasRam: MemoriaRamEquipoDetalle[]; historial: HistorialEquipo[]; } \ No newline at end of file