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

View File

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

View File

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