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:
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
@@ -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) => (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -118,7 +98,7 @@ const GestionSectores = () => {
|
|||||||
<td className={styles.td}>
|
<td className={styles.td}>
|
||||||
<div style={{ display: 'flex', gap: '10px' }}>
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
<button onClick={() => handleOpenEditModal(sector)} className={styles.tableButton}>✏️ Editar</button>
|
<button onClick={() => handleOpenEditModal(sector)} className={styles.tableButton}>✏️ Editar</button>
|
||||||
<button onClick={() => handleDelete(sector.id)} className={styles.deleteUserButton} style={{fontSize: '1em', padding: '0.375rem 0.75rem', border: '1px solid #dc3545', borderRadius: '4px'}}>
|
<button onClick={() => handleDelete(sector.id)} className={styles.deleteUserButton} style={{ fontSize: '1em', padding: '0.375rem 0.75rem', border: '1px solid #dc3545', borderRadius: '4px' }}>
|
||||||
🗑️ Eliminar
|
🗑️ Eliminar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
120
frontend/src/services/apiService.ts
Normal file
120
frontend/src/services/apiService.ts
Normal 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' }),
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user