Mejoras integrales en UI, lógica de negocio y auditoría

Este commit introduce una serie de mejoras significativas en toda la aplicación, abordando la experiencia de usuario, la consistencia de los datos, la robustez del backend y la implementación de un historial de cambios completo.

 **Funcionalidades y Mejoras (Features & Enhancements)**

*   **Historial de Auditoría Completo:**
    *   Se implementa el registro en el historial para todas las acciones CRUD manuales: creación de equipos, adición y eliminación de discos, RAM y usuarios.
    *   Los cambios de campos simples (IP, Hostname, etc.) ahora también se registran detalladamente.

*   **Consistencia de Datos Mejorada:**
    *   **RAM:** La selección de RAM en el modal de "Añadir RAM" y la vista de "Administración" ahora agrupan los módulos por especificaciones (Fabricante, Tamaño, Velocidad), eliminando las entradas duplicadas causadas por diferentes `part_number`.
    *   **Arquitectura:** El campo de edición para la arquitectura del equipo se ha cambiado de un input de texto a un selector con las opciones fijas "32 bits" y "64 bits".

*   **Experiencia de Usuario (UX) Optimizada:**
    *   El botón de "Wake On Lan" (WOL) ahora se deshabilita visualmente si el equipo no tiene una dirección MAC registrada.
    *   Se corrige el apilamiento de modales: los sub-modales (Añadir Disco/RAM/Usuario) ahora siempre aparecen por encima del modal principal de detalles y bloquean su cierre.
    *   El historial de cambios se actualiza en tiempo real en la interfaz después de añadir o eliminar un componente, sin necesidad de cerrar y reabrir el modal.

🐛 **Correcciones (Bug Fixes)**

*   **Actualización de Estado en Vivo:** Al añadir/eliminar un módulo de RAM, los campos "RAM Instalada" y "Última Actualización" ahora se recalculan en el backend y se actualizan instantáneamente en el frontend.
*   **Historial de Sectores Legible:** Se corrige el registro del historial para que al cambiar un sector se guarde el *nombre* del sector (ej. "Técnica") en lugar de su ID numérico.
*   **Formulario de Edición:** El dropdown de "Sector" en el modo de edición ahora preselecciona correctamente el sector asignado actualmente al equipo.
*   **Error Crítico al Añadir RAM:** Se soluciona un error del servidor (`Sequence contains more than one element`) que ocurría al añadir manualmente un tipo de RAM que ya existía con múltiples `part_number`. Se reemplazó `QuerySingleOrDefaultAsync` por `QueryFirstOrDefaultAsync` para mayor robustez.
*   **Eliminación Segura:** Se impide la eliminación de un sector si este tiene equipos asignados, protegiendo la integridad de los datos.

♻️ **Refactorización (Refactoring)**

*   **Servicio de API Centralizado:** Toda la lógica de llamadas `fetch` del frontend ha sido extraída de los componentes y centralizada en un único servicio (`apiService.ts`), mejorando drásticamente la mantenibilidad y organización del código.
*   **Optimización de Renders:** Se ha optimizado el rendimiento de los modales mediante el uso del hook `useCallback` para memorizar funciones que se pasan como props.
*   **Nulabilidad en C#:** Se han resuelto múltiples advertencias de compilación (`CS8620`) en el backend al especificar explícitamente los tipos de referencia anulables (`string?`), mejorando la seguridad de tipos del código.
This commit is contained in:
2025-10-08 13:27:44 -03:00
parent 177ad55962
commit 268c1c2bf9
22 changed files with 834 additions and 513 deletions

View File

@@ -16,12 +16,33 @@ namespace Inventario.API.Controllers
_context = context; _context = context;
} }
// DTO para devolver los valores y su conteo // --- DTOs para los componentes ---
public class ComponenteValorDto public class ComponenteValorDto
{ {
public string Valor { get; set; } = ""; public string Valor { get; set; } = "";
public int Conteo { get; set; } public int Conteo { get; set; }
} }
public class UnificarComponenteDto
{
public required string ValorNuevo { get; set; }
public required string ValorAntiguo { get; set; }
}
public class RamAgrupadaDto
{
public string? Fabricante { get; set; }
public int Tamano { get; set; }
public int? Velocidad { get; set; }
public int Conteo { get; set; }
}
public class BorrarRamAgrupadaDto
{
public string? Fabricante { get; set; }
public int Tamano { get; set; }
public int? Velocidad { get; set; }
}
[HttpGet("componentes/{tipo}")] [HttpGet("componentes/{tipo}")]
public async Task<IActionResult> GetComponenteValores(string tipo) public async Task<IActionResult> GetComponenteValores(string tipo)
@@ -53,13 +74,6 @@ namespace Inventario.API.Controllers
} }
} }
// DTO para la petición de unificación
public class UnificarComponenteDto
{
public required string ValorNuevo { get; set; }
public required string ValorAntiguo { get; set; }
}
[HttpPut("componentes/{tipo}/unificar")] [HttpPut("componentes/{tipo}/unificar")]
public async Task<IActionResult> UnificarComponenteValores(string tipo, [FromBody] UnificarComponenteDto dto) public async Task<IActionResult> UnificarComponenteValores(string tipo, [FromBody] UnificarComponenteDto dto)
{ {
@@ -93,63 +107,64 @@ namespace Inventario.API.Controllers
} }
} }
// DTO para devolver los valores de RAM y su conteo // --- Devuelve la RAM agrupada ---
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")] [HttpGet("componentes/ram")]
public async Task<IActionResult> GetComponentesRam() public async Task<IActionResult> GetComponentesRam()
{ {
var query = @" var query = @"
SELECT SELECT
mr.Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad, mr.Fabricante,
mr.Tamano,
mr.Velocidad,
COUNT(emr.memoria_ram_id) as Conteo COUNT(emr.memoria_ram_id) as Conteo
FROM FROM
dbo.memorias_ram mr dbo.memorias_ram mr
LEFT JOIN LEFT JOIN
dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id
GROUP BY GROUP BY
mr.Id, mr.part_number, mr.Fabricante, mr.Tamano, mr.Velocidad mr.Fabricante,
mr.Tamano,
mr.Velocidad
ORDER BY ORDER BY
Conteo DESC, mr.Fabricante, mr.Tamano;"; Conteo DESC, mr.Fabricante, mr.Tamano;";
using (var connection = _context.CreateConnection()) using (var connection = _context.CreateConnection())
{ {
var valores = await connection.QueryAsync<RamMaestraDto>(query); var valores = await connection.QueryAsync<RamAgrupadaDto>(query);
return Ok(valores); return Ok(valores);
} }
} }
[HttpDelete("componentes/ram/{id}")] // --- Elimina un grupo completo ---
public async Task<IActionResult> BorrarComponenteRam(int id) [HttpDelete("componentes/ram")]
public async Task<IActionResult> BorrarComponenteRam([FromBody] BorrarRamAgrupadaDto dto)
{ {
using (var connection = _context.CreateConnection()) using (var connection = _context.CreateConnection())
{ {
// 1. Verificación de seguridad: Asegurarse de que el módulo no esté en uso. // Verificación de seguridad: Asegurarse de que el grupo no esté en uso.
var usageQuery = "SELECT COUNT(*) FROM dbo.equipos_memorias_ram WHERE memoria_ram_id = @Id;"; var usageQuery = @"
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Id = id }); SELECT COUNT(emr.id)
FROM dbo.memorias_ram mr
LEFT JOIN dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id
WHERE (mr.Fabricante = @Fabricante OR (mr.Fabricante IS NULL AND @Fabricante IS NULL))
AND mr.Tamano = @Tamano
AND (mr.Velocidad = @Velocidad OR (mr.Velocidad IS NULL AND @Velocidad IS NULL));";
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, dto);
if (usageCount > 0) if (usageCount > 0)
{ {
return Conflict($"Este módulo de RAM está en uso por {usageCount} equipo(s) y no puede ser eliminado."); return Conflict(new { message = $"Este grupo de RAM está en uso por {usageCount} equipo(s) y no puede ser eliminado." });
} }
// 2. Si no está en uso, proceder con la eliminación. // Si no está en uso, proceder con la eliminación de todos los registros maestros que coincidan.
var deleteQuery = "DELETE FROM dbo.memorias_ram WHERE Id = @Id;"; var deleteQuery = @"
var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { Id = id }); DELETE FROM dbo.memorias_ram
WHERE (Fabricante = @Fabricante OR (Fabricante IS NULL AND @Fabricante IS NULL))
if (filasAfectadas == 0) AND Tamano = @Tamano
{ AND (Velocidad = @Velocidad OR (Velocidad IS NULL AND @Velocidad IS NULL));";
return NotFound("Módulo de RAM no encontrado.");
}
await connection.ExecuteAsync(deleteQuery, dto);
return NoContent(); return NoContent();
} }
} }
@@ -172,19 +187,14 @@ namespace Inventario.API.Controllers
using (var connection = _context.CreateConnection()) 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 usageQuery = $"SELECT COUNT(*) FROM dbo.equipos WHERE {columnName} = @Valor;";
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Valor = valor }); var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Valor = valor });
if (usageCount > 0) if (usageCount > 0)
{ {
return Conflict($"Este valor está en uso por {usageCount} equipo(s) y no puede ser eliminado. Intente unificarlo en su lugar."); return Conflict(new { message = $"Este valor está en uso por {usageCount} equipo(s) y no puede ser eliminado. Intente unificarlo en su lugar." });
} }
// Esta parte es más conceptual. Un componente de texto no existe en una tabla maestra,
// por lo que no hay nada que "eliminar". El hecho de que el conteo sea 0 significa
// que ya no existe en la práctica. Devolvemos éxito para confirmar esto.
// Si tuviéramos tablas maestras (ej: dbo.sistemas_operativos), aquí iría la consulta DELETE.
return NoContent(); return NoContent();
} }
} }

View File

@@ -33,7 +33,7 @@ namespace Inventario.API.Controllers
{ {
var query = @" var query = @"
SELECT SELECT
e.Id, e.Hostname, e.Ip, e.Mac, e.Motherboard, e.Cpu, e.Ram_installed, e.Ram_slots, e.Os, e.Architecture, e.created_at, e.updated_at, e.Origen, e.Id, e.Hostname, e.Ip, e.Mac, e.Motherboard, e.Cpu, e.Ram_installed, e.Ram_slots, e.Os, e.Architecture, e.created_at, e.updated_at, e.Origen, e.sector_id,
s.Id as Id, s.Nombre, s.Id as Id, s.Nombre,
u.Id as Id, u.Username, u.Password, ue.Origen as Origen, 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, d.Id as Id, d.Mediatype, d.Size, ed.Origen as Origen, ed.Id as EquipoDiscoId,
@@ -51,7 +51,6 @@ namespace Inventario.API.Controllers
{ {
var equipoDict = new Dictionary<int, Equipo>(); var equipoDict = new Dictionary<int, Equipo>();
// CAMBIO: Se actualizan los tipos en la función de mapeo de Dapper
await connection.QueryAsync<Equipo, Sector, UsuarioEquipoDetalle, DiscoDetalle, MemoriaRamEquipoDetalle, Equipo>( await connection.QueryAsync<Equipo, Sector, UsuarioEquipoDetalle, DiscoDetalle, MemoriaRamEquipoDetalle, Equipo>(
query, (equipo, sector, usuario, disco, memoria) => query, (equipo, sector, usuario, disco, memoria) =>
{ {
@@ -61,12 +60,11 @@ namespace Inventario.API.Controllers
equipoActual.Sector = sector; equipoActual.Sector = sector;
equipoDict.Add(equipoActual.Id, equipoActual); 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)) if (usuario != null && !equipoActual.Usuarios.Any(u => u.Id == usuario.Id))
equipoActual.Usuarios.Add(usuario); equipoActual.Usuarios.Add(usuario);
if (disco != null && !equipoActual.Discos.Any(d => d.Id == disco.Id)) if (disco != null && !equipoActual.Discos.Any(d => d.EquipoDiscoId == disco.EquipoDiscoId))
equipoActual.Discos.Add(disco); equipoActual.Discos.Add(disco);
if (memoria != null && !equipoActual.MemoriasRam.Any(m => m.Id == memoria.Id && m.Slot == memoria.Slot)) if (memoria != null && !equipoActual.MemoriasRam.Any(m => m.EquipoMemoriaRamId == memoria.EquipoMemoriaRamId))
equipoActual.MemoriasRam.Add(memoria); equipoActual.MemoriasRam.Add(memoria);
return equipoActual; return equipoActual;
@@ -148,7 +146,7 @@ namespace Inventario.API.Controllers
else else
{ {
// Actualizar y registrar historial // Actualizar y registrar historial
var cambios = new Dictionary<string, (string anterior, string nuevo)>(); var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
// Comparamos campos para registrar en historial // Comparamos campos para registrar en historial
if (equipoData.Ip != equipoExistente.Ip) cambios["ip"] = (equipoExistente.Ip, equipoData.Ip); if (equipoData.Ip != equipoExistente.Ip) cambios["ip"] = (equipoExistente.Ip, equipoData.Ip);
@@ -279,7 +277,6 @@ namespace Inventario.API.Controllers
[HttpPost("{hostname}/asociardiscos")] [HttpPost("{hostname}/asociardiscos")]
public async Task<IActionResult> AsociarDiscos(string hostname, [FromBody] List<Disco> discosDesdeCliente) public async Task<IActionResult> AsociarDiscos(string hostname, [FromBody] List<Disco> discosDesdeCliente)
{ {
// 1. OBTENER EL EQUIPO
var equipoQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;"; var equipoQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;";
using var connection = _context.CreateConnection(); using var connection = _context.CreateConnection();
connection.Open(); connection.Open();
@@ -290,21 +287,16 @@ namespace Inventario.API.Controllers
return NotFound("Equipo no encontrado."); return NotFound("Equipo no encontrado.");
} }
// Iniciar una transacción para asegurar que todas las operaciones se completen o ninguna lo haga.
using var transaction = connection.BeginTransaction(); using var transaction = connection.BeginTransaction();
try try
{ {
// 2. OBTENER ASOCIACIONES Y DISCOS ACTUALES DE LA BD
var discosActualesQuery = @" var discosActualesQuery = @"
SELECT d.Id, d.Mediatype, d.Size, ed.Id as EquipoDiscoId SELECT d.Id, d.Mediatype, d.Size, ed.Id as EquipoDiscoId
FROM dbo.equipos_discos ed FROM dbo.equipos_discos ed
JOIN dbo.discos d ON ed.disco_id = d.id JOIN dbo.discos d ON ed.disco_id = d.id
WHERE ed.equipo_id = @EquipoId;"; WHERE ed.equipo_id = @EquipoId;";
var discosEnDb = (await connection.QueryAsync<DiscoAsociado>(discosActualesQuery, new { EquipoId = equipo.Id }, transaction)).ToList(); var discosEnDb = (await connection.QueryAsync<DiscoAsociado>(discosActualesQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
// 3. AGRUPAR Y CONTAR DISCOS (del cliente y de la BD)
// Crea un diccionario estilo: {"SSD_256": 2, "HDD_1024": 1}
var discosClienteContados = discosDesdeCliente var discosClienteContados = discosDesdeCliente
.GroupBy(d => $"{d.Mediatype}_{d.Size}") .GroupBy(d => $"{d.Mediatype}_{d.Size}")
.ToDictionary(g => g.Key, g => g.Count()); .ToDictionary(g => g.Key, g => g.Count());
@@ -313,28 +305,23 @@ namespace Inventario.API.Controllers
.GroupBy(d => $"{d.Mediatype}_{d.Size}") .GroupBy(d => $"{d.Mediatype}_{d.Size}")
.ToDictionary(g => g.Key, g => g.Count()); .ToDictionary(g => g.Key, g => g.Count());
var cambios = new Dictionary<string, (string anterior, string nuevo)>(); var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
// 4. CALCULAR Y EJECUTAR ELIMINACIONES
var discosAEliminar = new List<int>(); var discosAEliminar = new List<int>();
foreach (var discoDb in discosEnDb) foreach (var discoDb in discosEnDb)
{ {
var key = $"{discoDb.Mediatype}_{discoDb.Size}"; var key = $"{discoDb.Mediatype}_{discoDb.Size}";
if (discosClienteContados.TryGetValue(key, out int count) && count > 0) if (discosClienteContados.TryGetValue(key, out int count) && count > 0)
{ {
// Este disco todavía existe en el cliente, decrementamos el contador y lo saltamos.
discosClienteContados[key]--; discosClienteContados[key]--;
} }
else else
{ {
// Este disco ya no está en el cliente, marcamos su asociación para eliminar.
discosAEliminar.Add(discoDb.EquipoDiscoId); discosAEliminar.Add(discoDb.EquipoDiscoId);
// Registrar para el historial
var nombreDisco = $"Disco {discoDb.Mediatype} {discoDb.Size}GB"; var nombreDisco = $"Disco {discoDb.Mediatype} {discoDb.Size}GB";
var anterior = discosDbContados.GetValueOrDefault(key, 0); var anterior = discosDbContados.GetValueOrDefault(key, 0);
if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior - 1).ToString()); if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior - 1).ToString());
else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo) - 1).ToString()); else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo!) - 1).ToString());
} }
} }
if (discosAEliminar.Any()) if (discosAEliminar.Any())
@@ -342,39 +329,33 @@ namespace Inventario.API.Controllers
await connection.ExecuteAsync("DELETE FROM dbo.equipos_discos WHERE Id IN @Ids;", new { Ids = discosAEliminar }, transaction); await connection.ExecuteAsync("DELETE FROM dbo.equipos_discos WHERE Id IN @Ids;", new { Ids = discosAEliminar }, transaction);
} }
// 5. CALCULAR Y EJECUTAR INSERCIONES
foreach (var discoCliente in discosDesdeCliente) foreach (var discoCliente in discosDesdeCliente)
{ {
var key = $"{discoCliente.Mediatype}_{discoCliente.Size}"; var key = $"{discoCliente.Mediatype}_{discoCliente.Size}";
if (discosDbContados.TryGetValue(key, out int count) && count > 0) if (discosDbContados.TryGetValue(key, out int count) && count > 0)
{ {
// Este disco ya existía, decrementamos para no volver a añadirlo.
discosDbContados[key]--; discosDbContados[key]--;
} }
else else
{ {
// Este es un disco nuevo que hay que asociar. var disco = await connection.QueryFirstOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;", discoCliente, transaction);
var disco = await connection.QuerySingleOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;", discoCliente, transaction);
if (disco == null) continue; if (disco == null) continue;
await connection.ExecuteAsync("INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'automatica');", new { EquipoId = equipo.Id, DiscoId = disco.Id }, transaction); await connection.ExecuteAsync("INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'automatica');", new { EquipoId = equipo.Id, DiscoId = disco.Id }, transaction);
// Registrar para el historial
var nombreDisco = $"Disco {disco.Mediatype} {disco.Size}GB"; var nombreDisco = $"Disco {disco.Mediatype} {disco.Size}GB";
var anterior = discosDbContados.GetValueOrDefault(key, 0); var anterior = discosDbContados.GetValueOrDefault(key, 0);
if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior + 1).ToString()); if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior + 1).ToString());
else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo) + 1).ToString()); else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo!) + 1).ToString());
} }
} }
// 6. REGISTRAR CAMBIOS Y CONFIRMAR TRANSACCIÓN
if (cambios.Count > 0) if (cambios.Count > 0)
{ {
// Formateamos los valores para el historial
var cambiosFormateados = cambios.ToDictionary( var cambiosFormateados = cambios.ToDictionary(
kvp => kvp.Key, kvp => kvp.Key,
kvp => ($"{kvp.Value.anterior} Instalados", $"{kvp.Value.nuevo} Instalados") kvp => ((string?)$"{kvp.Value.anterior} Instalados", (string?)$"{kvp.Value.nuevo} Instalados")
); );
await HistorialHelper.RegistrarCambios(_context, equipo.Id, cambiosFormateados); await HistorialHelper.RegistrarCambios(_context, equipo.Id, cambiosFormateados);
} }
@@ -385,7 +366,6 @@ namespace Inventario.API.Controllers
catch (Exception ex) catch (Exception ex)
{ {
transaction.Rollback(); transaction.Rollback();
// Loggear el error en el servidor
Console.WriteLine($"Error al asociar discos para {hostname}: {ex.Message}"); Console.WriteLine($"Error al asociar discos para {hostname}: {ex.Message}");
return StatusCode(500, "Ocurrió un error interno al procesar la solicitud."); return StatusCode(500, "Ocurrió un error interno al procesar la solicitud.");
} }
@@ -413,7 +393,8 @@ namespace Inventario.API.Controllers
var huellasCliente = new HashSet<string>(memoriasDesdeCliente.Select(crearHuella)); var huellasCliente = new HashSet<string>(memoriasDesdeCliente.Select(crearHuella));
var huellasDb = new HashSet<string>(ramEnDb.Select(crearHuella)); var huellasDb = new HashSet<string>(ramEnDb.Select(crearHuella));
var cambios = new Dictionary<string, (string anterior, string nuevo)>(); var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
Func<dynamic, string> formatRamDetails = ram => Func<dynamic, string> formatRamDetails = ram =>
{ {
var parts = new List<string?> { ram.Fabricante, $"{ram.Tamano}GB", ram.PartNumber, ram.Velocidad?.ToString() + "MHz" }; var parts = new List<string?> { ram.Fabricante, $"{ram.Tamano}GB", ram.PartNumber, ram.Velocidad?.ToString() + "MHz" };
@@ -591,7 +572,7 @@ namespace Inventario.API.Controllers
using (var connection = _context.CreateConnection()) using (var connection = _context.CreateConnection())
{ {
var existente = await connection.QuerySingleOrDefaultAsync<int?>(findQuery, new { equipoDto.Hostname }); var existente = await connection.QueryFirstOrDefaultAsync<int?>(findQuery, new { equipoDto.Hostname });
if (existente.HasValue) if (existente.HasValue)
{ {
return Conflict($"El hostname '{equipoDto.Hostname}' ya existe."); return Conflict($"El hostname '{equipoDto.Hostname}' ya existe.");
@@ -599,14 +580,13 @@ namespace Inventario.API.Controllers
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoDto); var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoDto);
// Devolvemos el objeto completo para que el frontend pueda actualizar su estado await HistorialHelper.RegistrarCambioUnico(_context, nuevoId, "Equipo", null, "Equipo creado manualmente");
var nuevoEquipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = nuevoId });
var nuevoEquipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = nuevoId });
if (nuevoEquipo == null) if (nuevoEquipo == null)
{ {
return StatusCode(500, "No se pudo recuperar el equipo después de crearlo."); return StatusCode(500, "No se pudo recuperar el equipo después de crearlo.");
} }
return CreatedAtAction(nameof(ConsultarDetalle), new { hostname = nuevoEquipo.Hostname }, nuevoEquipo); return CreatedAtAction(nameof(ConsultarDetalle), new { hostname = nuevoEquipo.Hostname }, nuevoEquipo);
} }
} }
@@ -616,14 +596,28 @@ namespace Inventario.API.Controllers
[HttpDelete("asociacion/disco/{equipoDiscoId}")] [HttpDelete("asociacion/disco/{equipoDiscoId}")]
public async Task<IActionResult> BorrarAsociacionDisco(int equipoDiscoId) public async Task<IActionResult> BorrarAsociacionDisco(int equipoDiscoId)
{ {
var query = "DELETE FROM dbo.equipos_discos WHERE Id = @EquipoDiscoId AND Origen = 'manual';";
using (var connection = _context.CreateConnection()) using (var connection = _context.CreateConnection())
{ {
var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoDiscoId = equipoDiscoId }); var infoQuery = @"
if (filasAfectadas == 0) SELECT ed.equipo_id, d.Mediatype, d.Size
FROM dbo.equipos_discos ed
JOIN dbo.discos d ON ed.disco_id = d.id
WHERE ed.Id = @EquipoDiscoId AND ed.Origen = 'manual'";
var info = await connection.QueryFirstOrDefaultAsync<(int equipo_id, string Mediatype, int Size)>(infoQuery, new { EquipoDiscoId = equipoDiscoId });
if (info == default)
{ {
return NotFound("Asociación de disco no encontrada o no se puede eliminar porque es automática."); return NotFound("Asociación de disco no encontrada o no es manual.");
} }
var deleteQuery = "DELETE FROM dbo.equipos_discos WHERE Id = @EquipoDiscoId;";
await connection.ExecuteAsync(deleteQuery, new { EquipoDiscoId = equipoDiscoId });
var descripcion = $"Disco {info.Mediatype} {info.Size}GB";
await HistorialHelper.RegistrarCambioUnico(_context, info.equipo_id, "Componente", descripcion, "Eliminado");
await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = info.equipo_id });
return NoContent(); return NoContent();
} }
} }
@@ -631,14 +625,39 @@ namespace Inventario.API.Controllers
[HttpDelete("asociacion/ram/{equipoMemoriaRamId}")] [HttpDelete("asociacion/ram/{equipoMemoriaRamId}")]
public async Task<IActionResult> BorrarAsociacionRam(int equipoMemoriaRamId) public async Task<IActionResult> BorrarAsociacionRam(int equipoMemoriaRamId)
{ {
var query = "DELETE FROM dbo.equipos_memorias_ram WHERE Id = @EquipoMemoriaRamId AND Origen = 'manual';";
using (var connection = _context.CreateConnection()) using (var connection = _context.CreateConnection())
{ {
var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoMemoriaRamId = equipoMemoriaRamId }); var infoQuery = @"
if (filasAfectadas == 0) SELECT emr.equipo_id, emr.Slot, mr.Fabricante, mr.Tamano, mr.Velocidad
FROM dbo.equipos_memorias_ram emr
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
WHERE emr.Id = @Id AND emr.Origen = 'manual'";
var info = await connection.QueryFirstOrDefaultAsync<(int equipo_id, string Slot, string? Fabricante, int Tamano, int? Velocidad)>(infoQuery, new { Id = equipoMemoriaRamId });
if (info == default)
{ {
return NotFound("Asociación de RAM no encontrada o no se puede eliminar porque es automática."); return NotFound("Asociación de RAM no encontrada o no es manual.");
} }
var deleteQuery = "DELETE FROM dbo.equipos_memorias_ram WHERE Id = @EquipoMemoriaRamId;";
await connection.ExecuteAsync(deleteQuery, new { EquipoMemoriaRamId = equipoMemoriaRamId });
var descripcion = $"Módulo RAM: Slot {info.Slot} - {info.Fabricante ?? ""} {info.Tamano}GB {info.Velocidad?.ToString() ?? ""}MHz";
await HistorialHelper.RegistrarCambioUnico(_context, info.equipo_id, "Componente", descripcion, "Eliminado");
var updateQuery = @"
UPDATE e
SET
e.ram_installed = ISNULL((SELECT SUM(mr.Tamano)
FROM dbo.equipos_memorias_ram emr
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
WHERE emr.equipo_id = @Id), 0),
e.updated_at = GETDATE()
FROM dbo.equipos e
WHERE e.Id = @Id;";
await connection.ExecuteAsync(updateQuery, new { Id = info.equipo_id });
return NoContent(); return NoContent();
} }
} }
@@ -646,14 +665,27 @@ namespace Inventario.API.Controllers
[HttpDelete("asociacion/usuario/{equipoId}/{usuarioId}")] [HttpDelete("asociacion/usuario/{equipoId}/{usuarioId}")]
public async Task<IActionResult> BorrarAsociacionUsuario(int equipoId, int usuarioId) public async Task<IActionResult> 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()) using (var connection = _context.CreateConnection())
{ {
var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoId = equipoId, UsuarioId = usuarioId }); var username = await connection.QuerySingleOrDefaultAsync<string>("SELECT Username FROM dbo.usuarios WHERE Id = @UsuarioId", new { UsuarioId = usuarioId });
if (username == null)
{
return NotFound("Usuario no encontrado.");
}
var deleteQuery = "DELETE FROM dbo.usuarios_equipos WHERE equipo_id = @EquipoId AND usuario_id = @UsuarioId AND Origen = 'manual';";
var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { EquipoId = equipoId, UsuarioId = usuarioId });
if (filasAfectadas == 0) if (filasAfectadas == 0)
{ {
return NotFound("Asociación de usuario no encontrada o no se puede eliminar porque es automática."); return NotFound("Asociación de usuario no encontrada o no es manual.");
} }
var descripcion = $"Usuario {username}";
await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", descripcion, "Eliminado");
await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = equipoId });
return NoContent(); return NoContent();
} }
} }
@@ -663,7 +695,6 @@ namespace Inventario.API.Controllers
{ {
using (var connection = _context.CreateConnection()) using (var connection = _context.CreateConnection())
{ {
// 1. Verificar que el equipo existe y es manual
var equipoActual = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = id }); var equipoActual = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = id });
if (equipoActual == null) if (equipoActual == null)
{ {
@@ -674,7 +705,6 @@ namespace Inventario.API.Controllers
return Forbid("No se puede modificar un equipo generado automáticamente."); 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) if (equipoActual.Hostname != equipoDto.Hostname)
{ {
var hostExistente = await connection.QuerySingleOrDefaultAsync<int?>("SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname AND Id != @Id", new { equipoDto.Hostname, Id = id }); var hostExistente = await connection.QuerySingleOrDefaultAsync<int?>("SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname AND Id != @Id", new { equipoDto.Hostname, Id = id });
@@ -684,7 +714,31 @@ namespace Inventario.API.Controllers
} }
} }
// 3. Construir y ejecutar la consulta de actualización var allSectores = await connection.QueryAsync<Sector>("SELECT Id, Nombre FROM dbo.sectores;");
var sectorMap = allSectores.ToDictionary(s => s.Id, s => s.Nombre);
var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
if (equipoActual.Hostname != equipoDto.Hostname) cambios["Hostname"] = (equipoActual.Hostname, equipoDto.Hostname);
if (equipoActual.Ip != equipoDto.Ip) cambios["IP"] = (equipoActual.Ip, equipoDto.Ip);
if (equipoActual.Mac != equipoDto.Mac) cambios["MAC Address"] = (equipoActual.Mac ?? "N/A", equipoDto.Mac ?? "N/A");
if (equipoActual.Motherboard != equipoDto.Motherboard) cambios["Motherboard"] = (equipoActual.Motherboard ?? "N/A", equipoDto.Motherboard ?? "N/A");
if (equipoActual.Cpu != equipoDto.Cpu) cambios["CPU"] = (equipoActual.Cpu ?? "N/A", equipoDto.Cpu ?? "N/A");
if (equipoActual.Os != equipoDto.Os) cambios["Sistema Operativo"] = (equipoActual.Os ?? "N/A", equipoDto.Os ?? "N/A");
if (equipoActual.Architecture != equipoDto.Architecture) cambios["Arquitectura"] = (equipoActual.Architecture ?? "N/A", equipoDto.Architecture ?? "N/A");
if (equipoActual.Ram_slots != equipoDto.Ram_slots) cambios["Slots RAM"] = (equipoActual.Ram_slots?.ToString() ?? "N/A", equipoDto.Ram_slots?.ToString() ?? "N/A");
if (equipoActual.Sector_id != equipoDto.Sector_id)
{
string nombreAnterior = equipoActual.Sector_id.HasValue && sectorMap.TryGetValue(equipoActual.Sector_id.Value, out var oldName)
? oldName
: "Ninguno";
string nombreNuevo = equipoDto.Sector_id.HasValue && sectorMap.TryGetValue(equipoDto.Sector_id.Value, out var newName)
? newName
: "Ninguno";
cambios["Sector"] = (nombreAnterior, nombreNuevo);
}
var updateQuery = @"UPDATE dbo.equipos SET var updateQuery = @"UPDATE dbo.equipos SET
Hostname = @Hostname, Hostname = @Hostname,
Ip = @Ip, Ip = @Ip,
@@ -693,10 +747,13 @@ namespace Inventario.API.Controllers
Cpu = @Cpu, Cpu = @Cpu,
Os = @Os, Os = @Os,
Sector_id = @Sector_id, Sector_id = @Sector_id,
Ram_slots = @Ram_slots,
Architecture = @Architecture, -- Campo añadido a la actualización
updated_at = GETDATE() updated_at = GETDATE()
OUTPUT INSERTED.*
WHERE Id = @Id AND Origen = 'manual';"; WHERE Id = @Id AND Origen = 'manual';";
var filasAfectadas = await connection.ExecuteAsync(updateQuery, new var equipoActualizado = await connection.QuerySingleOrDefaultAsync<Equipo>(updateQuery, new
{ {
equipoDto.Hostname, equipoDto.Hostname,
equipoDto.Ip, equipoDto.Ip,
@@ -705,16 +762,24 @@ namespace Inventario.API.Controllers
equipoDto.Cpu, equipoDto.Cpu,
equipoDto.Os, equipoDto.Os,
equipoDto.Sector_id, equipoDto.Sector_id,
equipoDto.Ram_slots,
equipoDto.Architecture,
Id = id Id = id
}); });
if (filasAfectadas == 0) if (equipoActualizado == null)
{ {
// Esto no debería pasar si las primeras verificaciones pasaron, pero es una salvaguarda
return StatusCode(500, "No se pudo actualizar el equipo."); return StatusCode(500, "No se pudo actualizar el equipo.");
} }
return NoContent(); // Éxito en la actualización if (cambios.Count > 0)
{
await HistorialHelper.RegistrarCambios(_context, id, cambios);
}
var equipoCompleto = await ConsultarDetalle(equipoActualizado.Hostname);
return equipoCompleto;
} }
} }
@@ -723,11 +788,10 @@ namespace Inventario.API.Controllers
{ {
using (var connection = _context.CreateConnection()) using (var connection = _context.CreateConnection())
{ {
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId }); var equipo = await connection.QueryFirstOrDefaultAsync<Equipo>("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."); 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.QueryFirstOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size", dto);
var discoMaestro = await connection.QuerySingleOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size", dto);
int discoId; int discoId;
if (discoMaestro == null) if (discoMaestro == null)
{ {
@@ -738,10 +802,14 @@ namespace Inventario.API.Controllers
discoId = discoMaestro.Id; 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 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<int>(asociacionQuery, new { EquipoId = equipoId, DiscoId = discoId }); var nuevaAsociacionId = await connection.ExecuteScalarAsync<int>(asociacionQuery, new { EquipoId = equipoId, DiscoId = discoId });
var descripcion = $"Disco {dto.Mediatype} {dto.Size}GB";
await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", null, $"Añadido: {descripcion}");
await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = equipoId });
return Ok(new { message = "Disco asociado manualmente.", equipoDiscoId = nuevaAsociacionId }); return Ok(new { message = "Disco asociado manualmente.", equipoDiscoId = nuevaAsociacionId });
} }
} }
@@ -751,25 +819,41 @@ namespace Inventario.API.Controllers
{ {
using (var connection = _context.CreateConnection()) using (var connection = _context.CreateConnection())
{ {
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId }); var equipo = await connection.QueryFirstOrDefaultAsync<Equipo>("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."); 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; int ramId;
var ramMaestra = await connection.QuerySingleOrDefaultAsync<MemoriaRam>("SELECT * FROM dbo.memorias_ram WHERE Tamano = @Tamano AND Fabricante = @Fabricante AND Velocidad = @Velocidad", dto); var ramMaestra = await connection.QueryFirstOrDefaultAsync<MemoriaRam>(
"SELECT * FROM dbo.memorias_ram WHERE (Fabricante = @Fabricante OR (Fabricante IS NULL AND @Fabricante IS NULL)) AND Tamano = @Tamano AND (Velocidad = @Velocidad OR (Velocidad IS NULL AND @Velocidad IS NULL))", dto);
if (ramMaestra == null) if (ramMaestra == null)
{ {
ramId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.memorias_ram (Tamano, Fabricante, Velocidad) VALUES (@Tamano, @Fabricante, @Velocidad); SELECT CAST(SCOPE_IDENTITY() as int);", dto); var insertQuery = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad)
VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad);
SELECT CAST(SCOPE_IDENTITY() as int);";
ramId = await connection.ExecuteScalarAsync<int>(insertQuery, dto);
} }
else else
{ {
ramId = ramMaestra.Id; 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 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<int>(asociacionQuery, new { EquipoId = equipoId, RamId = ramId, dto.Slot }); var nuevaAsociacionId = await connection.ExecuteScalarAsync<int>(asociacionQuery, new { EquipoId = equipoId, RamId = ramId, dto.Slot });
var descripcion = $"Módulo RAM: Slot {dto.Slot} - {dto.Fabricante ?? ""} {dto.Tamano}GB {dto.Velocidad?.ToString() ?? ""}MHz";
await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", null, $"Añadido: {descripcion}");
var updateQuery = @"
UPDATE e SET
e.ram_installed = ISNULL((SELECT SUM(mr.Tamano)
FROM dbo.equipos_memorias_ram emr
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
WHERE emr.equipo_id = @Id), 0),
e.updated_at = GETDATE()
FROM dbo.equipos e
WHERE e.Id = @Id;";
await connection.ExecuteAsync(updateQuery, new { Id = equipoId });
return Ok(new { message = "RAM asociada manualmente.", equipoMemoriaRamId = nuevaAsociacionId }); return Ok(new { message = "RAM asociada manualmente.", equipoMemoriaRamId = nuevaAsociacionId });
} }
} }
@@ -779,12 +863,11 @@ namespace Inventario.API.Controllers
{ {
using (var connection = _context.CreateConnection()) using (var connection = _context.CreateConnection())
{ {
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId }); var equipo = await connection.QueryFirstOrDefaultAsync<Equipo>("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."); 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; int usuarioId;
var usuario = await connection.QuerySingleOrDefaultAsync<Usuario>("SELECT * FROM dbo.usuarios WHERE Username = @Username", dto); var usuario = await connection.QueryFirstOrDefaultAsync<Usuario>("SELECT * FROM dbo.usuarios WHERE Username = @Username", dto);
if (usuario == null) if (usuario == null)
{ {
usuarioId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.usuarios (Username) VALUES (@Username); SELECT CAST(SCOPE_IDENTITY() as int);", dto); usuarioId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.usuarios (Username) VALUES (@Username); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
@@ -794,7 +877,6 @@ namespace Inventario.API.Controllers
usuarioId = usuario.Id; usuarioId = usuario.Id;
} }
// Crear la asociación manual
try try
{ {
var asociacionQuery = "INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen) VALUES (@EquipoId, @UsuarioId, 'manual');"; var asociacionQuery = "INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen) VALUES (@EquipoId, @UsuarioId, 'manual');";
@@ -805,6 +887,11 @@ namespace Inventario.API.Controllers
return Conflict("El usuario ya está asociado a este equipo."); return Conflict("El usuario ya está asociado a este equipo.");
} }
var descripcion = $"Usuario {dto.Username}";
await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", null, $"Añadido: {descripcion}");
await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = equipoId });
return Ok(new { message = "Usuario asociado manualmente." }); return Ok(new { message = "Usuario asociado manualmente." });
} }
} }
@@ -866,6 +953,8 @@ namespace Inventario.API.Controllers
public string? Cpu { get; set; } public string? Cpu { get; set; }
public string? Os { get; set; } public string? Os { get; set; }
public int? Sector_id { get; set; } public int? Sector_id { get; set; }
public int? Ram_slots { get; set; }
public string? Architecture { get; set; }
} }
public class AsociarDiscoManualDto public class AsociarDiscoManualDto
@@ -880,6 +969,7 @@ namespace Inventario.API.Controllers
public int Tamano { get; set; } public int Tamano { get; set; }
public string? Fabricante { get; set; } public string? Fabricante { get; set; }
public int? Velocidad { get; set; } public int? Velocidad { get; set; }
public string? PartNumber { get; set; }
} }
public class AsociarUsuarioManualDto public class AsociarUsuarioManualDto

View File

@@ -20,7 +20,24 @@ namespace Inventario.API.Controllers
[HttpGet] [HttpGet]
public async Task<IActionResult> Consultar() public async Task<IActionResult> Consultar()
{ {
var query = "SELECT Id, part_number as PartNumber, Fabricante, Tamano, Velocidad FROM dbo.memorias_ram;"; var query = @"
SELECT
MIN(Id) as Id,
MIN(part_number) as PartNumber, -- Tomamos un part_number como ejemplo para el modelo
Fabricante,
Tamano,
Velocidad
FROM
dbo.memorias_ram
WHERE
Fabricante IS NOT NULL AND Fabricante != ''
GROUP BY
Fabricante,
Tamano,
Velocidad
ORDER BY
Fabricante, Tamano, Velocidad;";
using (var connection = _context.CreateConnection()) using (var connection = _context.CreateConnection())
{ {
var memorias = await connection.QueryAsync<MemoriaRam>(query); var memorias = await connection.QueryAsync<MemoriaRam>(query);
@@ -151,5 +168,21 @@ namespace Inventario.API.Controllers
} }
} }
} }
// --- GET /api/memoriasram/buscar/{termino} ---
[HttpGet("buscar/{termino}")]
public async Task<IActionResult> BuscarMemoriasRam(string termino)
{
var query = @"SELECT Id, part_number as PartNumber, Fabricante, Tamano, Velocidad
FROM dbo.memorias_ram
WHERE Fabricante LIKE @SearchTerm OR part_number LIKE @SearchTerm
ORDER BY Fabricante, Tamano;";
using (var connection = _context.CreateConnection())
{
var memorias = await connection.QueryAsync<MemoriaRam>(query, new { SearchTerm = $"%{termino}%" });
return Ok(memorias);
}
}
} }
} }

View File

@@ -1,3 +1,5 @@
// backend/Controllers/SectoresController.cs
using Dapper; using Dapper;
using Inventario.API.Data; using Inventario.API.Data;
using Inventario.API.Models; using Inventario.API.Models;
@@ -105,10 +107,21 @@ namespace Inventario.API.Controllers
[HttpDelete("{id}")] [HttpDelete("{id}")]
public async Task<IActionResult> BorrarSector(int id) public async Task<IActionResult> BorrarSector(int id)
{ {
var query = "DELETE FROM dbo.sectores WHERE Id = @Id;";
using (var connection = _context.CreateConnection()) using (var connection = _context.CreateConnection())
{ {
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id }); // 1. VERIFICAR SI EL SECTOR ESTÁ EN USO
var usageQuery = "SELECT COUNT(1) FROM dbo.equipos WHERE sector_id = @Id;";
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Id = id });
if (usageCount > 0)
{
// 2. DEVOLVER HTTP 409 CONFLICT SI ESTÁ EN USO
return Conflict(new { message = $"No se puede eliminar. Hay {usageCount} equipo(s) asignados a este sector." });
}
// 3. SI NO ESTÁ EN USO, PROCEDER CON LA ELIMINACIÓN
var deleteQuery = "DELETE FROM dbo.sectores WHERE Id = @Id;";
var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { Id = id });
if (filasAfectadas == 0) if (filasAfectadas == 0)
{ {

View File

@@ -1,12 +1,12 @@
// backend/Helpers/HistorialHelper.cs
using Dapper; using Dapper;
using Inventario.API.Data; using Inventario.API.Data;
using Inventario.API.Models;
namespace Inventario.API.Helpers namespace Inventario.API.Helpers
{ {
public static class HistorialHelper public static class HistorialHelper
{ {
public static async Task RegistrarCambios(DapperContext context, int equipoId, Dictionary<string, (string anterior, string nuevo)> cambios) public static async Task RegistrarCambios(DapperContext context, int equipoId, Dictionary<string, (string? anterior, string? nuevo)> cambios)
{ {
var query = @"INSERT INTO dbo.historial_equipos (equipo_id, campo_modificado, valor_anterior, valor_nuevo) var query = @"INSERT INTO dbo.historial_equipos (equipo_id, campo_modificado, valor_anterior, valor_nuevo)
VALUES (@EquipoId, @CampoModificado, @ValorAnterior, @ValorNuevo);"; VALUES (@EquipoId, @CampoModificado, @ValorAnterior, @ValorNuevo);";
@@ -25,5 +25,14 @@ namespace Inventario.API.Helpers
} }
} }
} }
public static async Task RegistrarCambioUnico(DapperContext context, int equipoId, string campo, string? valorAnterior, string? valorNuevo)
{
var cambio = new Dictionary<string, (string?, string?)>
{
{ campo, (valorAnterior, valorNuevo) }
};
await RegistrarCambios(context, equipoId, cambio);
}
} }
} }

View File

@@ -18,28 +18,26 @@ namespace Inventario.API.Models
public int? Sector_id { get; set; } public int? Sector_id { get; set; }
public string Origen { get; set; } = "automatica"; public string Origen { get; set; } = "automatica";
// Propiedades de navegación actualizadas
public Sector? Sector { get; set; } public Sector? Sector { get; set; }
public List<UsuarioEquipoDetalle> Usuarios { get; set; } = new(); // Tipo actualizado public List<UsuarioEquipoDetalle> Usuarios { get; set; } = new();
public List<DiscoDetalle> Discos { get; set; } = new(); // Tipo actualizado public List<DiscoDetalle> Discos { get; set; } = new();
public List<MemoriaRamEquipoDetalle> MemoriasRam { get; set; } = new(); // Tipo actualizado public List<MemoriaRamEquipoDetalle> MemoriasRam { get; set; } = new();
public List<HistorialEquipo> Historial { get; set; } = new(); public List<HistorialEquipo> Historial { get; set; } = new();
} }
// Nuevo modelo para discos con su origen
public class DiscoDetalle : Disco public class DiscoDetalle : Disco
{ {
public string Origen { get; set; } = "manual"; public string Origen { get; set; } = "manual";
public int EquipoDiscoId { get; set; }
} }
// Nuevo modelo para memorias RAM con su origen y slot
public class MemoriaRamEquipoDetalle : MemoriaRam public class MemoriaRamEquipoDetalle : MemoriaRam
{ {
public string Slot { get; set; } = string.Empty; public string Slot { get; set; } = string.Empty;
public string Origen { get; set; } = "manual"; public string Origen { get; set; } = "manual";
public int EquipoMemoriaRamId { get; set; }
} }
// Nuevo modelo para usuarios con su origen
public class UsuarioEquipoDetalle : Usuario public class UsuarioEquipoDetalle : Usuario
{ {
public string Origen { get; set; } = "manual"; public string Origen { get; set; } = "manual";

View File

@@ -5,6 +5,9 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"ConnectionStrings": {
"DefaultConnection": "Server=TECNICA3;Database=InventarioDB;User Id=apiequipos;Password=@Apiequipos513@;TrustServerCertificate=True"
},
"SshSettings": { "SshSettings": {
"Host": "192.168.10.1", "Host": "192.168.10.1",
"Port": 22110, "Port": 22110,

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Inventario.API")] [assembly: System.Reflection.AssemblyCompanyAttribute("Inventario.API")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+04f1134be432cfc59ba887bba19eb9b563256780")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+177ad5596221b094cc62c04b38044cc9f09a107e")]
[assembly: System.Reflection.AssemblyProductAttribute("Inventario.API")] [assembly: System.Reflection.AssemblyProductAttribute("Inventario.API")]
[assembly: System.Reflection.AssemblyTitleAttribute("Inventario.API")] [assembly: System.Reflection.AssemblyTitleAttribute("Inventario.API")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -1,46 +1,55 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
interface AutocompleteInputProps { // --- Interfaces de Props más robustas usando una unión discriminada ---
type AutocompleteInputProps = {
value: string; value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
name: string; name: string;
placeholder?: string; placeholder?: string;
// CAMBIO: La función ahora recibe el término de búsqueda
fetchSuggestions: (query: string) => Promise<string[]>;
className?: string; className?: string;
} & ( // Esto crea una unión: o es estático o es dinámico
| {
mode: 'static';
fetchSuggestions: () => Promise<string[]>; // No necesita 'query'
} }
| {
mode: 'dynamic';
fetchSuggestions: (query: string) => Promise<string[]>; // Necesita 'query'
}
);
const AutocompleteInput: React.FC<AutocompleteInputProps> = ({ const AutocompleteInput: React.FC<AutocompleteInputProps> = (props) => {
value, const { value, onChange, name, placeholder, className } = props;
onChange,
name,
placeholder,
fetchSuggestions,
className
}) => {
const [suggestions, setSuggestions] = useState<string[]>([]); const [suggestions, setSuggestions] = useState<string[]>([]);
const dataListId = `suggestions-for-${name}`; const dataListId = `suggestions-for-${name}`;
// CAMBIO: Lógica de "debouncing" para buscar mientras se escribe // --- Lógica para el modo ESTÁTICO ---
// Se ejecuta UNA SOLA VEZ cuando el componente se monta
useEffect(() => { useEffect(() => {
// No buscar si el input está vacío o es muy corto if (props.mode === 'static') {
props.fetchSuggestions()
.then(setSuggestions)
.catch(err => console.error(`Error fetching static suggestions for ${name}:`, err));
}
// La lista de dependencias asegura que solo se ejecute si estas props cambian (lo cual no harán)
}, [props.mode, props.fetchSuggestions, name]);
// --- Lógica para el modo DINÁMICO ---
// Se ejecuta cada vez que el usuario escribe, con un debounce
useEffect(() => {
if (props.mode === 'dynamic') {
if (value.length < 2) { if (value.length < 2) {
setSuggestions([]); setSuggestions([]);
return; return;
} }
// Configura un temporizador para esperar 300ms después de la última pulsación
const handler = setTimeout(() => { const handler = setTimeout(() => {
fetchSuggestions(value) props.fetchSuggestions(value)
.then(setSuggestions) .then(setSuggestions)
.catch(err => console.error(`Error fetching suggestions for ${name}:`, err)); .catch(err => console.error(`Error fetching dynamic suggestions for ${name}:`, err));
}, 300); }, 300);
return () => clearTimeout(handler);
// Limpia el temporizador si el usuario sigue escribiendo }
return () => { }, [value, props.mode, props.fetchSuggestions, name]);
clearTimeout(handler);
};
}, [value, fetchSuggestions, name]);
return ( return (
<> <>
@@ -52,7 +61,7 @@ const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
placeholder={placeholder} placeholder={placeholder}
className={className} className={className}
list={dataListId} list={dataListId}
autoComplete="off" // Importante para que no interfiera el autocompletado del navegador autoComplete="off"
/> />
<datalist id={dataListId}> <datalist id={dataListId}>
{suggestions.map((suggestion, index) => ( {suggestions.map((suggestion, index) => (

View File

@@ -1,38 +1,33 @@
// frontend/src/components/GestionComponentes.tsx
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import styles from './SimpleTable.module.css'; import styles from './SimpleTable.module.css';
import { adminService } from '../services/apiService';
const BASE_URL = '/api';
// Interfaces para los diferentes tipos de datos // Interfaces para los diferentes tipos de datos
interface TextValue { interface TextValue {
valor: string; valor: string;
conteo: number; conteo: number;
} }
interface RamValue { interface RamValue {
id: number;
fabricante?: string; fabricante?: string;
tamano: number; tamano: number;
velocidad?: number; velocidad?: number;
partNumber?: string;
conteo: number; conteo: number;
} }
const GestionComponentes = () => { const GestionComponentes = () => {
const [componentType, setComponentType] = useState('os'); const [componentType, setComponentType] = useState('os');
const [valores, setValores] = useState<(TextValue | RamValue)[]>([]); // Estado que acepta ambos tipos const [valores, setValores] = useState<(TextValue | RamValue)[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [valorAntiguo, setValorAntiguo] = useState(''); const [valorAntiguo, setValorAntiguo] = useState('');
const [valorNuevo, setValorNuevo] = useState(''); const [valorNuevo, setValorNuevo] = useState('');
useEffect(() => { useEffect(() => {
setIsLoading(true); setIsLoading(true);
const endpoint = componentType === 'ram' ? `${BASE_URL}/admin/componentes/ram` : `${BASE_URL}/admin/componentes/${componentType}`; adminService.getComponentValues(componentType)
fetch(endpoint)
.then(res => res.json())
.then(data => { .then(data => {
setValores(data); setValores(data);
}) })
@@ -51,21 +46,9 @@ const GestionComponentes = () => {
const handleUnificar = async () => { const handleUnificar = async () => {
const toastId = toast.loading('Unificando valores...'); const toastId = toast.loading('Unificando valores...');
try { try {
const response = await fetch(`${BASE_URL}/admin/componentes/${componentType}/unificar`, { await adminService.unifyComponentValues(componentType, valorAntiguo, valorNuevo);
method: 'PUT', const refreshedData = await adminService.getComponentValues(componentType);
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); setValores(refreshedData);
toast.success('Valores unificados correctamente.', { id: toastId }); toast.success('Valores unificados correctamente.', { id: toastId });
setIsModalOpen(false); setIsModalOpen(false);
} catch (error) { } catch (error) {
@@ -73,45 +56,42 @@ const GestionComponentes = () => {
} }
}; };
const handleDeleteRam = async (ramId: number) => { // 2. FUNCIÓN DELETE ACTUALIZADA: Ahora maneja un grupo
if (!window.confirm("¿Estás seguro de eliminar este módulo de RAM de la base de datos maestra? Esta acción es irreversible.")) { const handleDeleteRam = async (ramGroup: RamValue) => {
if (!window.confirm("¿Estás seguro de eliminar todas las entradas maestras para este tipo de RAM? Esta acción es irreversible.")) {
return; return;
} }
const toastId = toast.loading('Eliminando módulo...'); const toastId = toast.loading('Eliminando grupo de módulos...');
try { try {
const response = await fetch(`${BASE_URL}/admin/componentes/ram/${ramId}`, { method: 'DELETE' }); // El servicio ahora espera el objeto del grupo
await adminService.deleteRamComponent({
fabricante: ramGroup.fabricante,
tamano: ramGroup.tamano,
velocidad: ramGroup.velocidad
});
if (!response.ok) { setValores(prev => prev.filter(v => {
const error = await response.json(); const currentRam = v as RamValue;
throw new Error(error.message || 'No se pudo eliminar.'); return !(currentRam.fabricante === ramGroup.fabricante &&
} currentRam.tamano === ramGroup.tamano &&
currentRam.velocidad === ramGroup.velocidad);
setValores(prev => prev.filter(v => (v as RamValue).id !== ramId)); }));
toast.success("Módulo de RAM eliminado.", { id: toastId }); toast.success("Grupo de módulos de RAM eliminado.", { id: toastId });
} catch (error) { } catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId }); if (error instanceof Error) toast.error(error.message, { id: toastId });
} }
}; };
const handleDeleteTexto = async (valor: string) => { 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)`)) { if (!window.confirm(`Este valor ya no está en uso. ¿Quieres eliminarlo de la base de datos maestra?`)) {
return; return;
} }
const toastId = toast.loading('Eliminando valor...'); const toastId = toast.loading('Eliminando valor...');
try { try {
// La API necesita el valor codificado para manejar caracteres especiales como '/' await adminService.deleteTextComponent(componentType, valor);
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)); setValores(prev => prev.filter(v => (v as TextValue).valor !== valor));
toast.success("Valor eliminado/confirmado como no existente.", { id: toastId }); toast.success("Valor eliminado.", { id: toastId });
} catch (error) { } catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId }); if (error instanceof Error) toast.error(error.message, { id: toastId });
} }
@@ -120,7 +100,7 @@ const GestionComponentes = () => {
const renderValor = (item: TextValue | RamValue) => { const renderValor = (item: TextValue | RamValue) => {
if (componentType === 'ram') { if (componentType === 'ram') {
const ram = item as RamValue; const ram = item as RamValue;
return `${ram.fabricante || ''} ${ram.tamano}GB ${ram.velocidad ? ram.velocidad + 'MHz' : ''} (${ram.partNumber || 'N/P'})`; return `${ram.fabricante || 'Desconocido'} ${ram.tamano}GB ${ram.velocidad ? ram.velocidad + 'MHz' : ''}`;
} }
return (item as TextValue).valor; return (item as TextValue).valor;
}; };
@@ -154,24 +134,22 @@ const GestionComponentes = () => {
</thead> </thead>
<tbody> <tbody>
{valores.map((item) => ( {valores.map((item) => (
<tr key={componentType === 'ram' ? (item as RamValue).id : (item as TextValue).valor} className={styles.tr}> <tr key={componentType === 'ram' ? `${(item as RamValue).fabricante}-${(item as RamValue).tamano}-${(item as RamValue).velocidad}` : (item as TextValue).valor} className={styles.tr}>
<td className={styles.td}>{renderValor(item)}</td> <td className={styles.td}>{renderValor(item)}</td>
<td className={styles.td}>{item.conteo}</td> <td className={styles.td}>{item.conteo}</td>
<td className={styles.td}> <td className={styles.td}>
<div style={{display: 'flex', gap: '5px'}}> <div style={{display: 'flex', gap: '5px'}}>
{componentType === 'ram' ? ( {componentType === 'ram' ? (
// Lógica solo para RAM (no tiene sentido "unificar" un objeto complejo)
<button <button
onClick={() => handleDeleteRam((item as RamValue).id)} onClick={() => handleDeleteRam(item as RamValue)}
className={styles.deleteUserButton} className={styles.deleteUserButton}
style={{fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px', cursor: item.conteo > 0 ? 'not-allowed' : 'pointer', opacity: item.conteo > 0 ? 0.5 : 1}} style={{fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px', cursor: item.conteo > 0 ? 'not-allowed' : 'pointer', opacity: item.conteo > 0 ? 0.5 : 1}}
disabled={item.conteo > 0} disabled={item.conteo > 0}
title={item.conteo > 0 ? `En uso por ${item.conteo} equipo(s)` : 'Eliminar este módulo maestro'} title={item.conteo > 0 ? `En uso por ${item.conteo} equipo(s)` : 'Eliminar este grupo de módulos maestros'}
> >
🗑 Eliminar 🗑 Eliminar
</button> </button>
) : ( ) : (
// Lógica para todos los demás tipos de componentes (texto)
<> <>
<button onClick={() => handleOpenModal((item as TextValue).valor)} className={styles.tableButton}> <button onClick={() => handleOpenModal((item as TextValue).valor)} className={styles.tableButton}>
Unificar Unificar

View File

@@ -1,10 +1,11 @@
// frontend/src/components/GestionSectores.tsx
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import type { Sector } from '../types/interfaces'; import type { Sector } from '../types/interfaces';
import styles from './SimpleTable.module.css'; import styles from './SimpleTable.module.css';
import ModalSector from './ModalSector'; import ModalSector from './ModalSector';
import { sectorService } from '../services/apiService';
const BASE_URL = '/api';
const GestionSectores = () => { const GestionSectores = () => {
const [sectores, setSectores] = useState<Sector[]>([]); const [sectores, setSectores] = useState<Sector[]>([]);
@@ -13,59 +14,44 @@ const GestionSectores = () => {
const [editingSector, setEditingSector] = useState<Sector | null>(null); const [editingSector, setEditingSector] = useState<Sector | null>(null);
useEffect(() => { useEffect(() => {
fetch(`${BASE_URL}/sectores`) sectorService.getAll()
.then(res => res.json()) .then(data => {
.then((data: Sector[]) => {
setSectores(data); setSectores(data);
setIsLoading(false);
}) })
.catch(err => { .catch(err => {
toast.error("No se pudieron cargar los sectores."); toast.error("No se pudieron cargar los sectores.");
console.error(err); console.error(err);
})
.finally(() => {
setIsLoading(false); setIsLoading(false);
}); });
}, []); }, []);
const handleOpenCreateModal = () => { const handleOpenCreateModal = () => {
setEditingSector(null); // Poner en modo 'crear' setEditingSector(null);
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleOpenEditModal = (sector: Sector) => { const handleOpenEditModal = (sector: Sector) => {
setEditingSector(sector); // Poner en modo 'editar' con los datos del sector setEditingSector(sector);
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleSave = async (id: number | null, nombre: string) => { const handleSave = async (id: number | null, nombre: string) => {
const isEditing = id !== null; 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...'); const toastId = toast.loading(isEditing ? 'Actualizando...' : 'Creando...');
try { 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) { if (isEditing) {
// Actualizar el sector en la lista local await sectorService.update(id, nombre);
setSectores(prev => prev.map(s => s.id === id ? { ...s, nombre } : s)); setSectores(prev => prev.map(s => s.id === id ? { ...s, nombre } : s));
toast.success('Sector actualizado.', { id: toastId }); toast.success('Sector actualizado.', { id: toastId });
} else { } else {
// Añadir el nuevo sector a la lista local const nuevoSector = await sectorService.create(nombre);
const nuevoSector = await response.json();
setSectores(prev => [...prev, nuevoSector]); setSectores(prev => [...prev, nuevoSector]);
toast.success('Sector creado.', { id: toastId }); toast.success('Sector creado.', { id: toastId });
} }
setIsModalOpen(false);
setIsModalOpen(false); // Cerrar el modal
} catch (error) { } catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId }); if (error instanceof Error) toast.error(error.message, { id: toastId });
} }
@@ -78,13 +64,7 @@ const GestionSectores = () => {
const toastId = toast.loading('Eliminando...'); const toastId = toast.loading('Eliminando...');
try { try {
const response = await fetch(`${BASE_URL}/sectores/${id}`, { method: 'DELETE' }); await sectorService.delete(id);
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)); setSectores(prev => prev.filter(s => s.id !== id));
toast.success("Sector eliminado.", { id: toastId }); toast.success("Sector eliminado.", { id: toastId });
} catch (error) { } catch (error) {

View File

@@ -18,7 +18,7 @@ const ModalAnadirDisco: React.FC<Props> = ({ onClose, onSave }) => {
}; };
return ( return (
<div className={styles.modalOverlay}> <div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
<div className={styles.modal}> <div className={styles.modal}>
<h3>Añadir Disco Manualmente</h3> <h3>Añadir Disco Manualmente</h3>
<label>Tipo de Disco</label> <label>Tipo de Disco</label>

View File

@@ -1,5 +1,6 @@
// frontend/src/components/ModalAnadirEquipo.tsx // frontend/src/components/ModalAnadirEquipo.tsx
import React, { useState } from 'react';
import React, { useState, useCallback } from 'react'; // <-- 1. Importar useCallback
import type { Sector, Equipo } from '../types/interfaces'; import type { Sector, Equipo } from '../types/interfaces';
import AutocompleteInput from './AutocompleteInput'; import AutocompleteInput from './AutocompleteInput';
import styles from './SimpleTable.module.css'; import styles from './SimpleTable.module.css';
@@ -31,12 +32,17 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
}; };
const handleSaveClick = () => { const handleSaveClick = () => {
// La UI pasará un objeto compatible con el DTO del backend
onSave(nuevoEquipo as any); onSave(nuevoEquipo as any);
}; };
const isFormValid = nuevoEquipo.hostname.trim() !== '' && nuevoEquipo.ip.trim() !== ''; const isFormValid = nuevoEquipo.hostname.trim() !== '' && nuevoEquipo.ip.trim() !== '';
// --- 2. Memorizar las funciones con useCallback ---
// El array vacío `[]` al final asegura que la función NUNCA se vuelva a crear.
const fetchMotherboardSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json()), []);
const fetchCpuSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json()), []);
const fetchOsSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json()), []);
return ( return (
<div className={styles.modalOverlay}> <div className={styles.modalOverlay}>
<div className={styles.modal} style={{ minWidth: '500px' }}> <div className={styles.modal} style={{ minWidth: '500px' }}>
@@ -49,7 +55,8 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
value={nuevoEquipo.hostname} value={nuevoEquipo.hostname}
onChange={handleChange} onChange={handleChange}
className={styles.modalInput} className={styles.modalInput}
placeholder="Ej: CONTABILIDAD-01" placeholder="Ej: TECNICA10"
autoComplete="off"
/> />
<label>Dirección IP (Requerido)</label> <label>Dirección IP (Requerido)</label>
@@ -59,7 +66,8 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
value={nuevoEquipo.ip} value={nuevoEquipo.ip}
onChange={handleChange} onChange={handleChange}
className={styles.modalInput} className={styles.modalInput}
placeholder="Ej: 192.168.1.50" placeholder="Ej: 192.168.10.50"
autoComplete="off"
/> />
<label>Sector</label> <label>Sector</label>
@@ -75,31 +83,35 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
))} ))}
</select> </select>
{/* --- 3. Usar las funciones memorizadas --- */}
<label>Motherboard (Opcional)</label> <label>Motherboard (Opcional)</label>
<AutocompleteInput <AutocompleteInput
mode="static"
name="motherboard" name="motherboard"
value={nuevoEquipo.motherboard} value={nuevoEquipo.motherboard}
onChange={handleChange} onChange={handleChange}
className={styles.modalInput} className={styles.modalInput}
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json())} fetchSuggestions={fetchMotherboardSuggestions}
/> />
<label>CPU (Opcional)</label> <label>CPU (Opcional)</label>
<AutocompleteInput <AutocompleteInput
mode="static"
name="cpu" name="cpu"
value={nuevoEquipo.cpu} value={nuevoEquipo.cpu}
onChange={handleChange} onChange={handleChange}
className={styles.modalInput} className={styles.modalInput}
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json())} fetchSuggestions={fetchCpuSuggestions}
/> />
<label>Sistema Operativo (Opcional)</label> <label>Sistema Operativo (Opcional)</label>
<AutocompleteInput <AutocompleteInput
mode="static"
name="os" name="os"
value={nuevoEquipo.os} value={nuevoEquipo.os}
onChange={handleChange} onChange={handleChange}
className={styles.modalInput} className={styles.modalInput}
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json())} fetchSuggestions={fetchOsSuggestions}
/> />
<div className={styles.modalActions}> <div className={styles.modalActions}>

View File

@@ -1,40 +1,100 @@
// frontend/src/components/ModalAnadirRam.tsx // frontend/src/components/ModalAnadirRam.tsx
import React, { useState } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import styles from './SimpleTable.module.css'; import styles from './SimpleTable.module.css';
import AutocompleteInput from './AutocompleteInput';
import { memoriaRamService } from '../services/apiService';
import type { MemoriaRam } from '../types/interfaces';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => void; onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number, partNumber?: string }) => void;
} }
const ModalAnadirRam: React.FC<Props> = ({ onClose, onSave }) => { const ModalAnadirRam: React.FC<Props> = ({ onClose, onSave }) => {
const [ram, setRam] = useState({ slot: '', tamano: '', fabricante: '', velocidad: '' }); const [ram, setRam] = useState({
slot: '',
tamano: '',
fabricante: '',
velocidad: '',
partNumber: ''
});
const [allRamModules, setAllRamModules] = useState<MemoriaRam[]>([]);
useEffect(() => {
memoriaRamService.getAll()
.then(setAllRamModules)
.catch(err => console.error("No se pudieron cargar los módulos de RAM", err));
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setRam(prev => ({ ...prev, [e.target.name]: e.target.value })); const { name, value } = e.target;
setRam(prev => ({ ...prev, [name]: value }));
}; };
const fetchRamSuggestions = useCallback(async () => {
return allRamModules.map(r =>
`${r.fabricante || 'Desconocido'} | ${r.tamano}GB | ${r.velocidad ? r.velocidad + 'MHz' : 'N/A'}`
);
}, [allRamModules]);
useEffect(() => {
const selectedSuggestion = ram.partNumber;
const match = allRamModules.find(s =>
`${s.fabricante || 'Desconocido'} | ${s.tamano}GB | ${s.velocidad ? s.velocidad + 'MHz' : 'N/A'}` === selectedSuggestion
);
if (match) {
setRam(prev => ({
...prev,
fabricante: match.fabricante || '',
tamano: match.tamano.toString(),
velocidad: match.velocidad?.toString() || '',
partNumber: match.partNumber || ''
}));
}
}, [ram.partNumber, allRamModules]);
const handleSave = () => { const handleSave = () => {
onSave({ onSave({
slot: ram.slot, slot: ram.slot,
tamano: parseInt(ram.tamano, 10), tamano: parseInt(ram.tamano, 10),
fabricante: ram.fabricante || undefined, fabricante: ram.fabricante || undefined,
velocidad: ram.velocidad ? parseInt(ram.velocidad, 10) : undefined, velocidad: ram.velocidad ? parseInt(ram.velocidad, 10) : undefined,
partNumber: ram.partNumber || undefined,
}); });
}; };
return ( return (
<div className={styles.modalOverlay}> <div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
<div className={styles.modal}> <div className={styles.modal}>
<h3>Añadir Módulo de RAM</h3> <h3>Añadir Módulo de RAM</h3>
<label>Slot (Requerido)</label> <label>Slot (Requerido)</label>
<input type="text" name="slot" value={ram.slot} onChange={handleChange} className={styles.modalInput} placeholder="Ej: DIMM0" /> <input type="text" name="slot" value={ram.slot} onChange={handleChange} className={styles.modalInput} placeholder="Ej: DIMM0" />
<label>Buscar Módulo Existente (Opcional)</label>
<AutocompleteInput
mode="static"
name="partNumber"
value={ram.partNumber}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={fetchRamSuggestions}
placeholder="Clic para ver todos o escribe para filtrar"
/>
<label>Tamaño (GB) (Requerido)</label> <label>Tamaño (GB) (Requerido)</label>
<input type="number" name="tamano" value={ram.tamano} onChange={handleChange} className={styles.modalInput} placeholder="Ej: 8" /> <input type="number" name="tamano" value={ram.tamano} onChange={handleChange} className={styles.modalInput} placeholder="Ej: 8" />
<label>Fabricante (Opcional)</label>
<label>Fabricante</label>
<input type="text" name="fabricante" value={ram.fabricante} onChange={handleChange} className={styles.modalInput} /> <input type="text" name="fabricante" value={ram.fabricante} onChange={handleChange} className={styles.modalInput} />
<label>Velocidad (MHz) (Opcional)</label>
<label>Velocidad (MHz)</label>
<input type="number" name="velocidad" value={ram.velocidad} onChange={handleChange} className={styles.modalInput} /> <input type="number" name="velocidad" value={ram.velocidad} onChange={handleChange} className={styles.modalInput} />
<div className={styles.modalActions}> <div className={styles.modalActions}>
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!ram.slot || !ram.tamano}>Guardar</button> <button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!ram.slot || !ram.tamano}>Guardar</button>
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button> <button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>

View File

@@ -1,30 +1,34 @@
import React, { useState } from 'react'; // frontend/src/components/ModalAnadirUsuario.tsx
import React, { useState, useCallback } from 'react';
import styles from './SimpleTable.module.css'; import styles from './SimpleTable.module.css';
import AutocompleteInput from './AutocompleteInput'; import AutocompleteInput from './AutocompleteInput';
import { usuarioService } from '../services/apiService';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
onSave: (usuario: { username: string }) => void; onSave: (usuario: { username: string }) => void;
} }
const BASE_URL = '/api';
const ModalAnadirUsuario: React.FC<Props> = ({ onClose, onSave }) => { const ModalAnadirUsuario: React.FC<Props> = ({ onClose, onSave }) => {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const fetchUserSuggestions = async (query: string): Promise<string[]> => { const fetchUserSuggestions = useCallback(async (query: string): Promise<string[]> => {
if (!query) return []; if (!query) return [];
const response = await fetch(`${BASE_URL}/usuarios/buscar/${query}`); try {
if (!response.ok) return []; return await usuarioService.search(query);
return response.json(); } catch (error) {
}; console.error("Error buscando usuarios", error);
return [];
}
}, []);
return ( return (
<div className={styles.modalOverlay}> <div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
<div className={styles.modal}> <div className={styles.modal}>
<h3>Añadir Usuario Manualmente</h3> <h3>Añadir Usuario Manualmente</h3>
<label>Nombre de Usuario</label> <label>Nombre de Usuario</label>
<AutocompleteInput <AutocompleteInput
mode="dynamic"
name="username" name="username"
value={username} value={username}
onChange={e => setUsername(e.target.value)} onChange={e => setUsername(e.target.value)}

View File

@@ -1,17 +1,18 @@
// frontend/src/components/ModalDetallesEquipo.tsx // frontend/src/components/ModalDetallesEquipo.tsx
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import type { Equipo, HistorialEquipo, Sector } from '../types/interfaces'; import type { Equipo, HistorialEquipo, Sector } from '../types/interfaces';
import { Tooltip } from 'react-tooltip'; import { Tooltip } from 'react-tooltip';
import styles from './SimpleTable.module.css'; import styles from './SimpleTable.module.css';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import AutocompleteInput from './AutocompleteInput'; import AutocompleteInput from './AutocompleteInput';
import { equipoService } from '../services/apiService';
// Interfaces actualizadas para las props
interface ModalDetallesEquipoProps { interface ModalDetallesEquipoProps {
equipo: Equipo; equipo: Equipo;
isOnline: boolean; isOnline: boolean;
historial: HistorialEquipo[]; historial: HistorialEquipo[];
sectores: Sector[]; sectores: Sector[];
isChildModalOpen: boolean;
onClose: () => void; onClose: () => void;
onDelete: (id: number) => Promise<boolean>; onDelete: (id: number) => Promise<boolean>;
onRemoveAssociation: (type: 'disco' | 'ram' | 'usuario', id: any) => void; onRemoveAssociation: (type: 'disco' | 'ram' | 'usuario', id: any) => void;
@@ -19,10 +20,9 @@ interface ModalDetallesEquipoProps {
onAddComponent: (type: 'disco' | 'ram' | 'usuario') => void; onAddComponent: (type: 'disco' | 'ram' | 'usuario') => void;
} }
const BASE_URL = '/api';
const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
equipo, isOnline, historial, sectores, onClose, onDelete, onRemoveAssociation, onEdit, onAddComponent equipo, isOnline, historial, sectores, isChildModalOpen,
onClose, onDelete, onRemoveAssociation, onEdit, onAddComponent
}) => { }) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editableEquipo, setEditableEquipo] = useState({ ...equipo }); const [editableEquipo, setEditableEquipo] = useState({ ...equipo });
@@ -75,19 +75,20 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
setIsEditing(false); setIsEditing(false);
}; };
const handleEditClick = () => {
setEditableEquipo({ ...equipo });
setIsEditing(true);
};
const handleWolClick = async () => { const handleWolClick = async () => {
// La validación ahora es redundante por el 'disabled', pero la dejamos como buena práctica
if (!equipo.mac || !equipo.ip) { if (!equipo.mac || !equipo.ip) {
toast.error("Este equipo no tiene MAC o IP para encenderlo."); toast.error("Este equipo no tiene MAC o IP para encenderlo.");
return; return;
} }
const toastId = toast.loading('Enviando paquete WOL...'); const toastId = toast.loading('Enviando paquete WOL...');
try { try {
const response = await fetch(`${BASE_URL}/equipos/wake-on-lan`, { await equipoService.wakeOnLan(equipo.mac, equipo.ip);
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 }); toast.success('Solicitud de encendido enviada.', { id: toastId });
} catch (error) { } catch (error) {
toast.error('Error al enviar la solicitud.', { id: toastId }); toast.error('Error al enviar la solicitud.', { id: toastId });
@@ -109,9 +110,14 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
const isSaveDisabled = !editableEquipo.hostname || !editableEquipo.ip || !isMacValid; const isSaveDisabled = !editableEquipo.hostname || !editableEquipo.ip || !isMacValid;
const fetchOsSuggestions = useCallback(() => equipoService.getDistinctValues('os'), []);
const fetchMotherboardSuggestions = useCallback(() => equipoService.getDistinctValues('motherboard'), []);
const fetchCpuSuggestions = useCallback(() => equipoService.getDistinctValues('cpu'), []);
return ( return (
<div className={styles.modalLarge}> <div className={styles.modalLarge}>
<button onClick={onClose} className={styles.closeButton}>×</button> <button onClick={onClose} className={styles.closeButton} disabled={isChildModalOpen}>×</button>
<div className={styles.modalLargeContent}> <div className={styles.modalLargeContent}>
<div className={styles.modalLargeHeader}> <div className={styles.modalLargeHeader}>
<h2>Detalles del equipo: <strong>{equipo.hostname}</strong></h2> <h2>Detalles del equipo: <strong>{equipo.hostname}</strong></h2>
@@ -123,26 +129,30 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
<button onClick={handleCancel} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button> <button onClick={handleCancel} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</> </>
) : ( ) : (
<button onClick={() => setIsEditing(true)} className={`${styles.btn} ${styles.btnPrimary}`}> Editar</button> <button onClick={handleEditClick} className={`${styles.btn} ${styles.btnPrimary}`}> Editar</button>
)} )}
</div> </div>
)} )}
</div> </div>
<div className={styles.modalBodyColumns}> <div className={styles.modalBodyColumns}>
{/* COLUMNA PRINCIPAL */}
<div className={styles.mainColumn}> <div className={styles.mainColumn}>
{/* SECCIÓN DE DATOS PRINCIPALES */}
<div className={styles.section}> <div className={styles.section}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 className={styles.sectionTitle} style={{ border: 'none', padding: 0 }}>🔗 Datos Principales</h3> <h3 className={styles.sectionTitle} style={{ border: 'none', padding: 0 }}>🔗 Datos Principales</h3>
{equipo.origen === 'manual' && (<div style={{ display: 'flex', gap: '5px' }}><button onClick={() => onAddComponent('disco')} className={styles.tableButton}>+ Disco</button><button onClick={() => onAddComponent('ram')} className={styles.tableButton}>+ RAM</button><button onClick={() => onAddComponent('usuario')} className={styles.tableButton}>+ Usuario</button></div>)} {equipo.origen === 'manual' && (
<div style={{ display: 'flex', gap: '5px' }}>
<button onClick={() => onAddComponent('disco')} className={styles.tableButtonMas}>Agregar Disco</button>
<button onClick={() => onAddComponent('ram')} className={styles.tableButtonMas}>Agregar RAM</button>
<button onClick={() => onAddComponent('usuario')} className={styles.tableButtonMas}>Agregar Usuario</button>
</div>
)}
</div> </div>
<div className={styles.componentsGrid}> <div className={styles.componentsGrid}>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Hostname:</strong>{isEditing ? <input type="text" name="hostname" value={editableEquipo.hostname} onChange={handleChange} className={styles.modalInput} /> : <span className={styles.detailValue}>{equipo.hostname}</span>}</div> <div className={styles.detailItem}><strong className={styles.detailLabel}>Hostname:</strong>{isEditing ? <input type="text" name="hostname" value={editableEquipo.hostname} onChange={handleChange} className={styles.modalInput} autoComplete="off" /> : <span className={styles.detailValue}>{equipo.hostname}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>IP:</strong>{isEditing ? <input type="text" name="ip" value={editableEquipo.ip} onChange={handleChange} className={styles.modalInput} /> : <span className={styles.detailValue}>{equipo.ip}</span>}</div> <div className={styles.detailItem}><strong className={styles.detailLabel}>IP:</strong>{isEditing ? <input type="text" name="ip" value={editableEquipo.ip} onChange={handleChange} className={styles.modalInput} autoComplete="off" /> : <span className={styles.detailValue}>{equipo.ip}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>MAC Address:</strong>{isEditing ? (<div><input type="text" name="mac" value={editableEquipo.mac || ''} onChange={handleChange} onBlur={handleMacBlur} className={`${styles.modalInput} ${!isMacValid ? styles.inputError : ''}`} placeholder="FC:AA:14:92:12:99" />{!isMacValid && <small className={styles.errorMessage}>Formato inválido.</small>}</div>) : (<span className={styles.detailValue}>{equipo.mac || 'N/A'}</span>)}</div> <div className={styles.detailItem}><strong className={styles.detailLabel}>MAC Address:</strong>{isEditing ? (<div><input type="text" name="mac" value={editableEquipo.mac || ''} onChange={handleChange} onBlur={handleMacBlur} className={`${styles.modalInput} ${!isMacValid ? styles.inputError : ''}`} placeholder="FC:AA:14:92:12:99" />{!isMacValid && <small className={styles.errorMessage}>Formato inválido.</small>}</div>) : (<span className={styles.detailValue}>{equipo.mac || 'N/A'}</span>)}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Sistema Operativo:</strong>{isEditing ? <AutocompleteInput name="os" value={editableEquipo.os} onChange={handleChange} className={styles.modalInput} fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json())} /> : <span className={styles.detailValue}>{equipo.os || 'N/A'}</span>}</div> <div className={styles.detailItem}><strong className={styles.detailLabel}>Sistema Operativo:</strong>{isEditing ? <AutocompleteInput mode="static" name="os" value={editableEquipo.os} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchOsSuggestions} /> : <span className={styles.detailValue}>{equipo.os || 'N/A'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Sector:</strong>{isEditing ? <select name="sector_id" value={editableEquipo.sector_id || ''} onChange={handleChange} className={styles.modalInput}><option value="">- Sin Asignar -</option>{sectores.map(s => <option key={s.id} value={s.id}>{s.nombre}</option>)}</select> : <span className={styles.detailValue}>{equipo.sector?.nombre || 'No asignado'}</span>}</div> <div className={styles.detailItem}><strong className={styles.detailLabel}>Sector:</strong>{isEditing ? <select name="sector_id" value={editableEquipo.sector_id || ''} onChange={handleChange} className={styles.modalInput}><option value="">- Sin Asignar -</option>{sectores.map(s => <option key={s.id} value={s.id}>{s.nombre}</option>)}</select> : <span className={styles.detailValue}>{equipo.sector?.nombre || 'No asignado'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Creación:</strong><span className={styles.detailValue}>{formatDate(equipo.created_at)}</span></div> <div className={styles.detailItem}><strong className={styles.detailLabel}>Creación:</strong><span className={styles.detailValue}>{formatDate(equipo.created_at)}</span></div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Última Actualización:</strong><span className={styles.detailValue}>{formatDate(equipo.updated_at)}</span></div> <div className={styles.detailItem}><strong className={styles.detailLabel}>Última Actualización:</strong><span className={styles.detailValue}>{formatDate(equipo.updated_at)}</span></div>
@@ -150,36 +160,78 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
</div> </div>
</div> </div>
{/* SECCIÓN DE COMPONENTES */}
<div className={styles.section}> <div className={styles.section}>
<h3 className={styles.sectionTitle}>💻 Componentes</h3> <h3 className={styles.sectionTitle}>💻 Componentes</h3>
<div className={styles.detailsGrid}> <div className={styles.detailsGrid}>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Motherboard:</strong>{isEditing ? <AutocompleteInput name="motherboard" value={editableEquipo.motherboard} onChange={handleChange} className={styles.modalInput} fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json())} /> : <span className={styles.detailValue}>{equipo.motherboard || 'N/A'}</span>}</div> <div className={styles.detailItem}><strong className={styles.detailLabel}>Motherboard:</strong>{isEditing ? <AutocompleteInput mode="static" name="motherboard" value={editableEquipo.motherboard} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchMotherboardSuggestions} /> : <span className={styles.detailValue}>{equipo.motherboard || 'N/A'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>CPU:</strong>{isEditing ? <AutocompleteInput name="cpu" value={editableEquipo.cpu} onChange={handleChange} className={styles.modalInput} fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json())} /> : <span className={styles.detailValue}>{equipo.cpu || 'N/A'}</span>}</div> <div className={styles.detailItem}><strong className={styles.detailLabel}>CPU:</strong>{isEditing ? <AutocompleteInput mode="static" name="cpu" value={editableEquipo.cpu} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchCpuSuggestions} /> : <span className={styles.detailValue}>{equipo.cpu || 'N/A'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>RAM Instalada:</strong><span className={styles.detailValue}>{equipo.ram_installed} GB</span></div> <div className={styles.detailItem}><strong className={styles.detailLabel}>RAM Instalada:</strong><span className={styles.detailValue}>{equipo.ram_installed} GB</span></div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Arquitectura:</strong><span className={styles.detailValue}>{equipo.architecture || 'N/A'}</span></div> <div className={styles.detailItem}>
<strong className={styles.detailLabel}>Arquitectura:</strong>
{isEditing ? (
<select
name="architecture"
value={editableEquipo.architecture || ''}
onChange={handleChange}
className={styles.modalInput}
>
<option value="">- Seleccionar -</option>
<option value="64 bits">64 bits</option>
<option value="32 bits">32 bits</option>
</select>
) : (
<span className={styles.detailValue}>{equipo.architecture || 'N/A'}</span>
)}
</div>
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Discos:</strong><span className={styles.detailValue}>{equipo.discos?.length > 0 ? equipo.discos.map(d => (<div key={d.equipoDiscoId} className={styles.componentItem}><div><span title={`Origen: ${d.origen}`}>{d.origen === 'manual' ? '⌨️' : '⚙️'}</span>{` ${d.mediatype} ${d.size}GB`}</div>{d.origen === 'manual' && (<button onClick={() => onRemoveAssociation('disco', d.equipoDiscoId)} className={styles.deleteUserButton} title="Eliminar este disco">🗑</button>)}</div>)) : 'N/A'}</span></div> <div className={styles.detailItemFull}><strong className={styles.detailLabel}>Discos:</strong><span className={styles.detailValue}>{equipo.discos?.length > 0 ? equipo.discos.map(d => (<div key={d.equipoDiscoId} className={styles.componentItem}><div><span title={`Origen: ${d.origen}`}>{d.origen === 'manual' ? '⌨️' : '⚙️'}</span>{` ${d.mediatype} ${d.size}GB`}</div>{d.origen === 'manual' && (<button onClick={() => onRemoveAssociation('disco', d.equipoDiscoId)} className={styles.deleteUserButton} title="Eliminar este disco">🗑</button>)}</div>)) : 'N/A'}</span></div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Slots RAM:</strong><span className={styles.detailValue}>{equipo.ram_slots || 'N/A'}</span></div> <div className={styles.detailItem}>
<strong className={styles.detailLabel}>Total Slots RAM:</strong>
{isEditing ? (
<input
type="number"
name="ram_slots"
value={editableEquipo.ram_slots || ''}
onChange={handleChange}
className={styles.modalInput}
placeholder="Ej: 4"
/>
) : (
<span className={styles.detailValue}>{equipo.ram_slots || 'N/A'}</span>
)}
</div>
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Módulos RAM:</strong><span className={styles.detailValue}>{equipo.memoriasRam?.length > 0 ? equipo.memoriasRam.map(m => (<div key={m.equipoMemoriaRamId} className={styles.componentItem}><div><span title={`Origen: ${m.origen}`}>{m.origen === 'manual' ? '⌨️' : '⚙️'}</span>{` Slot ${m.slot}: ${m.partNumber || 'N/P'} ${m.tamano}GB ${m.velocidad || ''}MHz`}</div>{m.origen === 'manual' && (<button onClick={() => onRemoveAssociation('ram', m.equipoMemoriaRamId)} className={styles.deleteUserButton} title="Eliminar este módulo">🗑</button>)}</div>)) : 'N/A'}</span></div> <div className={styles.detailItemFull}><strong className={styles.detailLabel}>Módulos RAM:</strong><span className={styles.detailValue}>{equipo.memoriasRam?.length > 0 ? equipo.memoriasRam.map(m => (<div key={m.equipoMemoriaRamId} className={styles.componentItem}><div><span title={`Origen: ${m.origen}`}>{m.origen === 'manual' ? '⌨️' : '⚙️'}</span>{` Slot ${m.slot}: ${m.partNumber || 'N/P'} ${m.tamano}GB ${m.velocidad || ''}MHz`}</div>{m.origen === 'manual' && (<button onClick={() => onRemoveAssociation('ram', m.equipoMemoriaRamId)} className={styles.deleteUserButton} title="Eliminar este módulo">🗑</button>)}</div>)) : 'N/A'}</span></div>
</div> </div>
</div> </div>
</div> </div>
{/* COLUMNA LATERAL */}
<div className={styles.sidebarColumn}> <div className={styles.sidebarColumn}>
{/* SECCIÓN DE ACCIONES */}
<div className={styles.section}> <div className={styles.section}>
<h3 className={styles.sectionTitle}> Acciones y Estado</h3> <h3 className={styles.sectionTitle}> Acciones y Estado</h3>
<div className={styles.actionsGrid}> <div className={styles.actionsGrid}>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Estado:</strong><div className={styles.statusIndicator}><div className={`${styles.statusDot} ${isOnline ? styles.statusOnline : styles.statusOffline}`} /><span>{isOnline ? 'En línea' : 'Sin conexión'}</span></div></div> <div className={styles.detailItem}><strong className={styles.detailLabel}>Estado:</strong><div className={styles.statusIndicator}><div className={`${styles.statusDot} ${isOnline ? styles.statusOnline : styles.statusOffline}`} /><span>{isOnline ? 'En línea' : 'Sin conexión'}</span></div></div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Wake On Lan:</strong><button onClick={handleWolClick} className={styles.powerButton} data-tooltip-id="modal-power-tooltip"><img src="/img/power.png" alt="Encender equipo" className={styles.powerIcon} />Encender (WOL)</button><Tooltip id="modal-power-tooltip" place="top">Encender equipo remotamente</Tooltip></div>
<div className={styles.detailItem}>
<strong className={styles.detailLabel}>Wake On Lan:</strong>
<button
onClick={handleWolClick}
className={styles.powerButton}
data-tooltip-id="modal-power-tooltip"
disabled={!equipo.mac}
>
<img src="./power.png" alt="Encender equipo" className={styles.powerIcon} />
Encender (WOL)
</button>
<Tooltip id="modal-power-tooltip" place="top">
{equipo.mac ? 'Encender equipo remotamente' : 'Se requiere una dirección MAC para esta acción'}
</Tooltip>
</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Eliminar Equipo:</strong><button onClick={handleDeleteClick} className={styles.deleteButton} disabled={equipo.origen !== 'manual'} style={{ cursor: equipo.origen !== 'manual' ? 'not-allowed' : 'pointer' }} data-tooltip-id="modal-delete-tooltip">🗑 Eliminar</button><Tooltip id="modal-delete-tooltip" place="top">{equipo.origen === 'manual' ? 'Eliminar equipo permanentemente' : 'No se puede eliminar un equipo cargado automáticamente'}</Tooltip></div> <div className={styles.detailItem}><strong className={styles.detailLabel}>Eliminar Equipo:</strong><button onClick={handleDeleteClick} className={styles.deleteButton} disabled={equipo.origen !== 'manual'} style={{ cursor: equipo.origen !== 'manual' ? 'not-allowed' : 'pointer' }} data-tooltip-id="modal-delete-tooltip">🗑 Eliminar</button><Tooltip id="modal-delete-tooltip" place="top">{equipo.origen === 'manual' ? 'Eliminar equipo permanentemente' : 'No se puede eliminar un equipo cargado automáticamente'}</Tooltip></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* SECCIÓN DE HISTORIAL (FUERA DE LAS COLUMNAS) */}
<div className={`${styles.section} ${styles.historySectionFullWidth}`}> <div className={`${styles.section} ${styles.historySectionFullWidth}`}>
<h3 className={styles.sectionTitle}>📜 Historial de cambios</h3> <h3 className={styles.sectionTitle}>📜 Historial de cambios</h3>
<div className={styles.historyContainer}> <div className={styles.historyContainer}>
@@ -189,7 +241,6 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );

View File

@@ -92,6 +92,20 @@
border-color: #adb5bd; border-color: #adb5bd;
} }
.tableButtonMas {
padding: 0.375rem 0.75rem;
border-radius: 4px;
border: 1px solid #007bff;
background-color: #007bff;
color: #ffffff;
cursor: pointer;
transition: all 0.2s ease;
}
.tableButtonMas:hover {
background-color: #0056b3;
border-color: #0056b3;
}
.deleteUserButton { .deleteUserButton {
background: none; background: none;
border: none; border: none;
@@ -488,3 +502,21 @@
.sectorName { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sectorName { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sectorNameAssigned { color: #212529; font-style: normal; } .sectorNameAssigned { color: #212529; font-style: normal; }
.sectorNameUnassigned { color: #6c757d; font-style: italic; } .sectorNameUnassigned { color: #6c757d; font-style: italic; }
/* Estilo para el overlay de un modal anidado */
.modalOverlay--nested {
/* z-index superior al del botón de cierre del modal principal (1004) */
z-index: 1005;
}
/* También nos aseguramos de que el contenido del modal anidado tenga un z-index superior */
.modalOverlay--nested .modal {
z-index: 1006;
}
/* Estilo para deshabilitar el botón de cierre del modal principal */
.closeButton:disabled {
cursor: not-allowed;
opacity: 0.5;
background-color: #6c757d; /* Gris para indicar inactividad */
}

View File

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

View File

@@ -0,0 +1,120 @@
// frontend/src/services/apiService.ts
import type { Equipo, Sector, HistorialEquipo, Usuario, MemoriaRam } from '../types/interfaces';
const BASE_URL = '/api';
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Error en la respuesta del servidor' }));
throw new Error(errorData.message || 'Ocurrió un error desconocido');
}
if (response.status === 204) {
return null as T;
}
return response.json();
}
// --- Servicio para la gestión de Sectores ---
export const sectorService = {
getAll: () => request<Sector[]>(`${BASE_URL}/sectores`),
create: (nombre: string) => request<Sector>(`${BASE_URL}/sectores`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre }),
}),
update: (id: number, nombre: string) => request<void>(`${BASE_URL}/sectores/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre }),
}),
delete: (id: number) => request<void>(`${BASE_URL}/sectores/${id}`, { method: 'DELETE' }),
};
// --- Servicio para la gestión de Equipos ---
export const equipoService = {
getAll: () => request<Equipo[]>(`${BASE_URL}/equipos`),
getHistory: (hostname: string) => request<{ historial: HistorialEquipo[] }>(`${BASE_URL}/equipos/${hostname}/historial`),
ping: (ip: string) => request<{ isAlive: boolean }>(`${BASE_URL}/equipos/ping`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip }),
}),
wakeOnLan: (mac: string, ip: string) => request<void>(`${BASE_URL}/equipos/wake-on-lan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mac, ip }),
}),
updateSector: (equipoId: number, sectorId: number) => request<void>(`${BASE_URL}/equipos/${equipoId}/sector/${sectorId}`, { method: 'PATCH' }),
deleteManual: (id: number) => request<void>(`${BASE_URL}/equipos/${id}`, { method: 'DELETE' }),
createManual: (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => request<Equipo>(`${BASE_URL}/equipos/manual`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nuevoEquipo),
}),
updateManual: (id: number, equipoEditado: any) =>
request<Equipo>(`${BASE_URL}/equipos/manual/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(equipoEditado),
}),
removeDiscoAssociation: (id: number) => request<void>(`${BASE_URL}/equipos/asociacion/disco/${id}`, { method: 'DELETE' }),
removeRamAssociation: (id: number) => request<void>(`${BASE_URL}/equipos/asociacion/ram/${id}`, { method: 'DELETE' }),
removeUserAssociation: (equipoId: number, usuarioId: number) => request<void>(`${BASE_URL}/equipos/asociacion/usuario/${equipoId}/${usuarioId}`, { method: 'DELETE' }),
addDisco: (equipoId: number, data: any) => request<any>(`${BASE_URL}/equipos/manual/${equipoId}/disco`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}),
addRam: (equipoId: number, data: any) => request<any>(`${BASE_URL}/equipos/manual/${equipoId}/ram`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}),
addUsuario: (equipoId: number, data: any) => request<any>(`${BASE_URL}/equipos/manual/${equipoId}/usuario`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}),
getDistinctValues: (field: string) => request<string[]>(`${BASE_URL}/equipos/distinct/${field}`),
};
// --- Servicio para Usuarios ---
export const usuarioService = {
updatePassword: (id: number, password: string) => request<Usuario>(`${BASE_URL}/usuarios/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
}),
removeUserFromEquipo: (hostname: string, username: string) => request<void>(`${BASE_URL}/equipos/${hostname}/usuarios/${username}`, { method: 'DELETE' }),
search: (term: string) => request<string[]>(`${BASE_URL}/usuarios/buscar/${term}`),
};
// --- Servicio para RAM ---
export const memoriaRamService = {
getAll: () => request<MemoriaRam[]>(`${BASE_URL}/memoriasram`),
search: (term: string) => request<MemoriaRam[]>(`${BASE_URL}/memoriasram/buscar/${term}`),
};
// --- Servicio para Administración ---
export const adminService = {
getComponentValues: (type: string) => request<any[]>(`${BASE_URL}/admin/componentes/${type}`),
unifyComponentValues: (type: string, valorAntiguo: string, valorNuevo: string) => request<any>(`${BASE_URL}/admin/componentes/${type}/unificar`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ valorAntiguo, valorNuevo }),
}),
deleteRamComponent: (ramGroup: { fabricante?: string, tamano: number, velocidad?: number }) => request<void>(`${BASE_URL}/admin/componentes/ram`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ramGroup),
}),
deleteTextComponent: (type: string, value: string) => request<void>(`${BASE_URL}/admin/componentes/${type}/${encodeURIComponent(value)}`, { method: 'DELETE' }),
};

View File

@@ -4,4 +4,16 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
// --- AÑADIR ESTA SECCIÓN COMPLETA ---
server: {
proxy: {
// Cualquier petición que empiece con '/api' será redirigida.
'/api': {
// Redirige al servidor de backend que corre en local.
target: 'http://localhost:5198',
// Necesario para evitar problemas de CORS y de origen.
changeOrigin: true,
},
},
},
}) })