feat: Implementación de gestión manual y panel de administración
Se introduce una refactorización masiva y se añaden nuevas funcionalidades críticas para la gestión del inventario, incluyendo un panel de administración para la limpieza de datos y un sistema completo para la gestión manual de equipos.
### Nuevas Funcionalidades
* **Panel de Administración:** Se crea una nueva vista de "Administración" para la gestión de datos maestros. Permite unificar valores inconsistentes (ej: "W10" -> "Windows 10 Pro") y eliminar registros maestros no utilizados (ej: Módulos de RAM) para mantener la base de datos limpia.
* **Gestión de Sectores (CRUD):** Se añade una vista dedicada para crear, editar y eliminar sectores de la organización.
* **Diferenciación Manual vs. Automático:** Se introduce una columna `origen` en la base de datos para distinguir entre los datos recopilados automáticamente por el script y los introducidos manualmente por el usuario. La UI ahora refleja visualmente este origen.
* **CRUD de Equipos Manuales:** Se implementa la capacidad de crear, editar y eliminar equipos de origen "manual" a través de la interfaz de usuario. Se protege la eliminación de equipos automáticos.
* **Gestión de Componentes Manuales:** Se permite añadir y eliminar componentes (Discos, RAM, Usuarios) a los equipos de origen "manual".
### Mejoras de UI/UX
* **Refactorización de Estilos:** Se migran todos los estilos en línea del componente `SimpleTable` a un archivo CSS Module (`SimpleTable.module.css`), mejorando la mantenibilidad y el rendimiento.
* **Notificaciones de Usuario:** Se integra `react-hot-toast` para proporcionar feedback visual inmediato (carga, éxito, error) en todas las operaciones asíncronas, reemplazando los `alert`.
* **Componentización:** Se extraen todos los modales (`ModalDetallesEquipo`, `ModalAnadirEquipo`, etc.) a sus propios componentes, limpiando y simplificando drásticamente el componente `SimpleTable`.
* **Paginación en Tabla Principal:** Se implementa paginación completa en la tabla de equipos, con controles para navegar, ir a una página específica y cambiar el número de items por página. Se añade un indicador de carga inicial.
* **Navegación Mejorada:** Se reemplaza la navegación por botones con un componente `Navbar` estilizado y dedicado, mejorando la estructura visual y de código.
* **Autocompletado de Datos:** Se introduce un componente `AutocompleteInput` reutilizable para guiar al usuario a usar datos consistentes al rellenar campos como OS, CPU y Motherboard. Se implementa búsqueda dinámica para la asociación de usuarios.
* **Validación de MAC Address:** Se añade validación de formato en tiempo real y auto-formateo para el campo de MAC Address, reduciendo errores humanos.
* **Consistencia de Iconos:** Se unifica el icono de eliminación a (🗑️) en toda la aplicación para una experiencia de usuario más coherente.
### Mejoras en el Backend / API
* **Seguridad de Credenciales:** Las credenciales SSH para la función Wake On Lan se mueven del código fuente a `appsettings.json`.
* **Nuevo `AdminController`:** Se crea un controlador dedicado para las tareas administrativas, con endpoints para obtener valores únicos de componentes y para ejecutar la lógica de unificación y eliminación.
* **Endpoints de Gestión Manual:** Se añaden rutas específicas (`/manual/...` y `/asociacion/...`) para la manipulación de datos de origen manual, separando la lógica de la gestión automática.
* **Protección de Datos Automáticos:** Los endpoints `DELETE` y `PUT` ahora validan el campo `origen` para prevenir la modificación o eliminación no deseada de datos generados automáticamente.
* **Correcciones y Refinamiento:** Se soluciona el mapeo incorrecto de fechas (`created_at`, `updated_at`), se corrigen errores de compilación y se refinan las consultas SQL para incluir los nuevos campos.
This commit is contained in:
192
backend/Controllers/AdminController.cs
Normal file
192
backend/Controllers/AdminController.cs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
// backend/Controllers/AdminController.cs
|
||||||
|
using Dapper;
|
||||||
|
using Inventario.API.Data;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Inventario.API.Controllers
|
||||||
|
{
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class AdminController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly DapperContext _context;
|
||||||
|
|
||||||
|
public AdminController(DapperContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTO para devolver los valores y su conteo
|
||||||
|
public class ComponenteValorDto
|
||||||
|
{
|
||||||
|
public string Valor { get; set; } = "";
|
||||||
|
public int Conteo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("componentes/{tipo}")]
|
||||||
|
public async Task<IActionResult> GetComponenteValores(string tipo)
|
||||||
|
{
|
||||||
|
var allowedTypes = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "os", "Os" },
|
||||||
|
{ "cpu", "Cpu" },
|
||||||
|
{ "motherboard", "Motherboard" },
|
||||||
|
{ "architecture", "Architecture" }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName))
|
||||||
|
{
|
||||||
|
return BadRequest("Tipo de componente no válido.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = $@"
|
||||||
|
SELECT {columnName} AS Valor, COUNT(*) AS Conteo
|
||||||
|
FROM dbo.equipos
|
||||||
|
WHERE {columnName} IS NOT NULL AND {columnName} != ''
|
||||||
|
GROUP BY {columnName}
|
||||||
|
ORDER BY Conteo DESC, Valor ASC;";
|
||||||
|
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
var valores = await connection.QueryAsync<ComponenteValorDto>(query);
|
||||||
|
return Ok(valores);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTO para la petición de unificación
|
||||||
|
public class UnificarComponenteDto
|
||||||
|
{
|
||||||
|
public required string ValorNuevo { get; set; }
|
||||||
|
public required string ValorAntiguo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("componentes/{tipo}/unificar")]
|
||||||
|
public async Task<IActionResult> UnificarComponenteValores(string tipo, [FromBody] UnificarComponenteDto dto)
|
||||||
|
{
|
||||||
|
var allowedTypes = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "os", "Os" },
|
||||||
|
{ "cpu", "Cpu" },
|
||||||
|
{ "motherboard", "Motherboard" },
|
||||||
|
{ "architecture", "Architecture" }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName))
|
||||||
|
{
|
||||||
|
return BadRequest("Tipo de componente no válido.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.ValorAntiguo == dto.ValorNuevo)
|
||||||
|
{
|
||||||
|
return BadRequest("El valor antiguo y el nuevo no pueden ser iguales.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = $@"
|
||||||
|
UPDATE dbo.equipos
|
||||||
|
SET {columnName} = @ValorNuevo
|
||||||
|
WHERE {columnName} = @ValorAntiguo;";
|
||||||
|
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
var filasAfectadas = await connection.ExecuteAsync(query, new { dto.ValorNuevo, dto.ValorAntiguo });
|
||||||
|
return Ok(new { message = $"Se unificaron {filasAfectadas} registros.", filasAfectadas });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTO para devolver los valores de RAM y su conteo
|
||||||
|
public class RamMaestraDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? Part_number { get; set; }
|
||||||
|
public string? Fabricante { get; set; }
|
||||||
|
public int Tamano { get; set; }
|
||||||
|
public int? Velocidad { get; set; }
|
||||||
|
public int Conteo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("componentes/ram")]
|
||||||
|
public async Task<IActionResult> GetComponentesRam()
|
||||||
|
{
|
||||||
|
var query = @"
|
||||||
|
SELECT
|
||||||
|
mr.Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad,
|
||||||
|
COUNT(emr.memoria_ram_id) as Conteo
|
||||||
|
FROM
|
||||||
|
dbo.memorias_ram mr
|
||||||
|
LEFT JOIN
|
||||||
|
dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id
|
||||||
|
GROUP BY
|
||||||
|
mr.Id, mr.part_number, mr.Fabricante, mr.Tamano, mr.Velocidad
|
||||||
|
ORDER BY
|
||||||
|
Conteo DESC, mr.Fabricante, mr.Tamano;";
|
||||||
|
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
var valores = await connection.QueryAsync<RamMaestraDto>(query);
|
||||||
|
return Ok(valores);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("componentes/ram/{id}")]
|
||||||
|
public async Task<IActionResult> BorrarComponenteRam(int id)
|
||||||
|
{
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
// 1. Verificación de seguridad: Asegurarse de que el módulo no esté en uso.
|
||||||
|
var usageQuery = "SELECT COUNT(*) FROM dbo.equipos_memorias_ram WHERE memoria_ram_id = @Id;";
|
||||||
|
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Id = id });
|
||||||
|
|
||||||
|
if (usageCount > 0)
|
||||||
|
{
|
||||||
|
return Conflict($"Este módulo de RAM está en uso por {usageCount} equipo(s) y no puede ser eliminado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Si no está en uso, proceder con la eliminación.
|
||||||
|
var deleteQuery = "DELETE FROM dbo.memorias_ram WHERE Id = @Id;";
|
||||||
|
var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { Id = id });
|
||||||
|
|
||||||
|
if (filasAfectadas == 0)
|
||||||
|
{
|
||||||
|
return NotFound("Módulo de RAM no encontrado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("componentes/{tipo}/{valor}")]
|
||||||
|
public async Task<IActionResult> BorrarComponenteTexto(string tipo, string valor)
|
||||||
|
{
|
||||||
|
var allowedTypes = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "os", "Os" },
|
||||||
|
{ "cpu", "Cpu" },
|
||||||
|
{ "motherboard", "Motherboard" },
|
||||||
|
{ "architecture", "Architecture" }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName))
|
||||||
|
{
|
||||||
|
return BadRequest("Tipo de componente no válido.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
// 1. Verificación de seguridad: Asegurarse de que el valor no esté en uso.
|
||||||
|
var usageQuery = $"SELECT COUNT(*) FROM dbo.equipos WHERE {columnName} = @Valor;";
|
||||||
|
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Valor = valor });
|
||||||
|
|
||||||
|
if (usageCount > 0)
|
||||||
|
{
|
||||||
|
return Conflict($"Este valor está en uso por {usageCount} equipo(s) y no puede ser eliminado. Intente unificarlo en su lugar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Net.NetworkInformation;
|
using System.Net.NetworkInformation;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Renci.SshNet;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace Inventario.API.Controllers
|
namespace Inventario.API.Controllers
|
||||||
{
|
{
|
||||||
@@ -15,24 +17,27 @@ namespace Inventario.API.Controllers
|
|||||||
public class EquiposController : ControllerBase
|
public class EquiposController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly DapperContext _context;
|
private readonly DapperContext _context;
|
||||||
|
private readonly IConfiguration _configuration; // 1. Añadimos el campo para la configuración
|
||||||
|
|
||||||
public EquiposController(DapperContext context)
|
// 2. Modificamos el constructor para inyectar IConfiguration
|
||||||
|
public EquiposController(DapperContext context, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_configuration = configuration; // Asignamos la configuración inyectada
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- MÉTODOS CRUD BÁSICOS (Ya implementados) ---
|
// --- MÉTODOS CRUD BÁSICOS ---
|
||||||
// GET /api/equipos
|
// GET /api/equipos
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Consultar()
|
public async Task<IActionResult> Consultar()
|
||||||
{
|
{
|
||||||
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.Id, e.Hostname, e.Ip, e.Mac, e.Motherboard, e.Cpu, e.Ram_installed, e.Ram_slots, e.Os, e.Architecture, e.created_at, e.updated_at, e.Origen,
|
||||||
s.Id as Id, s.Nombre,
|
s.Id as Id, s.Nombre,
|
||||||
u.Id as Id, u.Username, u.Password,
|
u.Id as Id, u.Username, u.Password, ue.Origen as Origen,
|
||||||
d.Id as Id, d.Mediatype, d.Size,
|
d.Id as Id, d.Mediatype, d.Size, ed.Origen as Origen, ed.Id as EquipoDiscoId,
|
||||||
mr.Id as Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad, emr.Slot
|
mr.Id as Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad, emr.Slot, emr.Origen as Origen, emr.Id as EquipoMemoriaRamId
|
||||||
FROM dbo.equipos e
|
FROM dbo.equipos e
|
||||||
LEFT JOIN dbo.sectores s ON e.sector_id = s.id
|
LEFT JOIN dbo.sectores s ON e.sector_id = s.id
|
||||||
LEFT JOIN dbo.usuarios_equipos ue ON e.id = ue.equipo_id
|
LEFT JOIN dbo.usuarios_equipos ue ON e.id = ue.equipo_id
|
||||||
@@ -46,7 +51,8 @@ namespace Inventario.API.Controllers
|
|||||||
{
|
{
|
||||||
var equipoDict = new Dictionary<int, Equipo>();
|
var equipoDict = new Dictionary<int, Equipo>();
|
||||||
|
|
||||||
await connection.QueryAsync<Equipo, Sector, Usuario, Disco, MemoriaRamDetalle, Equipo>(
|
// CAMBIO: Se actualizan los tipos en la función de mapeo de Dapper
|
||||||
|
await connection.QueryAsync<Equipo, Sector, UsuarioEquipoDetalle, DiscoDetalle, MemoriaRamEquipoDetalle, Equipo>(
|
||||||
query, (equipo, sector, usuario, disco, memoria) =>
|
query, (equipo, sector, usuario, disco, memoria) =>
|
||||||
{
|
{
|
||||||
if (!equipoDict.TryGetValue(equipo.Id, out var equipoActual))
|
if (!equipoDict.TryGetValue(equipo.Id, out var equipoActual))
|
||||||
@@ -55,6 +61,7 @@ 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.Id == disco.Id))
|
||||||
@@ -64,7 +71,7 @@ namespace Inventario.API.Controllers
|
|||||||
|
|
||||||
return equipoActual;
|
return equipoActual;
|
||||||
},
|
},
|
||||||
splitOn: "Id,Id,Id,Id" // Dapper divide en cada 'Id'
|
splitOn: "Id,Id,Id,Id"
|
||||||
);
|
);
|
||||||
return Ok(equipoDict.Values.OrderBy(e => e.Sector?.Nombre).ThenBy(e => e.Hostname));
|
return Ok(equipoDict.Values.OrderBy(e => e.Sector?.Nombre).ThenBy(e => e.Hostname));
|
||||||
}
|
}
|
||||||
@@ -77,7 +84,7 @@ namespace Inventario.API.Controllers
|
|||||||
var query = @"SELECT
|
var query = @"SELECT
|
||||||
e.*,
|
e.*,
|
||||||
s.Id as SectorId, s.Nombre as SectorNombre,
|
s.Id as SectorId, s.Nombre as SectorNombre,
|
||||||
u.Id as UsuarioId, u.Username, u.Password
|
u.Id as UsuarioId, u.Username, u.Password, ue.Origen as Origen
|
||||||
FROM dbo.equipos e
|
FROM dbo.equipos e
|
||||||
LEFT JOIN dbo.sectores s ON e.sector_id = s.id
|
LEFT JOIN dbo.sectores s ON e.sector_id = s.id
|
||||||
LEFT JOIN dbo.usuarios_equipos ue ON e.id = ue.equipo_id
|
LEFT JOIN dbo.usuarios_equipos ue ON e.id = ue.equipo_id
|
||||||
@@ -87,7 +94,8 @@ namespace Inventario.API.Controllers
|
|||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
var equipoDict = new Dictionary<int, Equipo>();
|
var equipoDict = new Dictionary<int, Equipo>();
|
||||||
var equipo = (await connection.QueryAsync<Equipo, Sector, Usuario, Equipo>(
|
|
||||||
|
var equipo = (await connection.QueryAsync<Equipo, Sector, UsuarioEquipoDetalle, Equipo>(
|
||||||
query, (e, sector, usuario) =>
|
query, (e, sector, usuario) =>
|
||||||
{
|
{
|
||||||
if (!equipoDict.TryGetValue(e.Id, out var equipoActual))
|
if (!equipoDict.TryGetValue(e.Id, out var equipoActual))
|
||||||
@@ -107,6 +115,12 @@ namespace Inventario.API.Controllers
|
|||||||
|
|
||||||
if (equipo == null) return NotFound("Equipo no encontrado.");
|
if (equipo == null) return NotFound("Equipo no encontrado.");
|
||||||
|
|
||||||
|
var discosQuery = "SELECT d.*, ed.Origen, ed.Id as EquipoDiscoId FROM dbo.discos d JOIN dbo.equipos_discos ed ON d.Id = ed.disco_id WHERE ed.equipo_id = @Id";
|
||||||
|
equipo.Discos = (await connection.QueryAsync<DiscoDetalle>(discosQuery, new { equipo.Id })).ToList();
|
||||||
|
|
||||||
|
var ramQuery = "SELECT mr.*, emr.Slot, emr.Origen, emr.Id as EquipoMemoriaRamId FROM dbo.memorias_ram mr JOIN dbo.equipos_memorias_ram emr ON mr.Id = emr.memoria_ram_id WHERE emr.equipo_id = @Id";
|
||||||
|
equipo.MemoriasRam = (await connection.QueryAsync<MemoriaRamEquipoDetalle>(ramQuery, new { equipo.Id })).ToList();
|
||||||
|
|
||||||
return Ok(equipo);
|
return Ok(equipo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,8 +138,8 @@ namespace Inventario.API.Controllers
|
|||||||
if (equipoExistente == null)
|
if (equipoExistente == null)
|
||||||
{
|
{
|
||||||
// Crear
|
// Crear
|
||||||
var insertQuery = @"INSERT INTO dbo.equipos (Hostname, Ip, Mac, Motherboard, Cpu, Ram_installed, Ram_slots, Os, Architecture)
|
var insertQuery = @"INSERT INTO dbo.equipos (Hostname, Ip, Mac, Motherboard, Cpu, Ram_installed, Ram_slots, Os, Architecture, Origen)
|
||||||
VALUES (@Hostname, @Ip, @Mac, @Motherboard, @Cpu, @Ram_installed, @Ram_slots, @Os, @Architecture);
|
VALUES (@Hostname, @Ip, @Mac, @Motherboard, @Cpu, @Ram_installed, @Ram_slots, @Os, @Architecture, 'automatica');
|
||||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||||
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoData);
|
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoData);
|
||||||
equipoData.Id = nuevoId;
|
equipoData.Id = nuevoId;
|
||||||
@@ -177,12 +191,16 @@ namespace Inventario.API.Controllers
|
|||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
public async Task<IActionResult> Borrar(int id)
|
public async Task<IActionResult> Borrar(int id)
|
||||||
{
|
{
|
||||||
// La base de datos está configurada con ON DELETE CASCADE, por lo que las relaciones se borrarán automáticamente.
|
var query = "DELETE FROM dbo.equipos WHERE Id = @Id AND Origen = 'manual';";
|
||||||
var query = "DELETE FROM dbo.equipos WHERE Id = @Id;";
|
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
|
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
|
||||||
if (filasAfectadas == 0) return NotFound("Equipo no encontrado.");
|
if (filasAfectadas == 0)
|
||||||
|
{
|
||||||
|
// Puede que no se haya borrado porque no existe o porque es automático.
|
||||||
|
// Damos un mensaje de error genérico pero informativo.
|
||||||
|
return NotFound("Equipo no encontrado o no se puede eliminar porque fue generado automáticamente.");
|
||||||
|
}
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,8 +240,8 @@ namespace Inventario.API.Controllers
|
|||||||
public async Task<IActionResult> AsociarUsuario(string hostname, [FromBody] AsociacionUsuarioDto dto)
|
public async Task<IActionResult> AsociarUsuario(string hostname, [FromBody] AsociacionUsuarioDto dto)
|
||||||
{
|
{
|
||||||
var query = @"
|
var query = @"
|
||||||
INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id)
|
INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen)
|
||||||
SELECT e.id, u.id
|
SELECT e.id, u.id, 'automatica'
|
||||||
FROM dbo.equipos e, dbo.usuarios u
|
FROM dbo.equipos e, dbo.usuarios u
|
||||||
WHERE e.Hostname = @Hostname AND u.Username = @Username;";
|
WHERE e.Hostname = @Hostname AND u.Username = @Username;";
|
||||||
|
|
||||||
@@ -283,14 +301,7 @@ namespace Inventario.API.Controllers
|
|||||||
JOIN dbo.discos d ON ed.disco_id = d.id
|
JOIN dbo.discos d ON ed.disco_id = d.id
|
||||||
WHERE ed.equipo_id = @EquipoId;";
|
WHERE ed.equipo_id = @EquipoId;";
|
||||||
|
|
||||||
// Creamos una clase anónima temporal para mapear el resultado del JOIN
|
var discosEnDb = (await connection.QueryAsync<DiscoAsociado>(discosActualesQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
|
||||||
var discosEnDb = (await connection.QueryAsync(discosActualesQuery, new { EquipoId = equipo.Id }, transaction)).Select(d => new
|
|
||||||
{
|
|
||||||
Id = (int)d.Id,
|
|
||||||
Mediatype = (string)d.Mediatype,
|
|
||||||
Size = (int)d.Size,
|
|
||||||
EquipoDiscoId = (int)d.EquipoDiscoId
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
// 3. AGRUPAR Y CONTAR DISCOS (del cliente y de la BD)
|
// 3. AGRUPAR Y CONTAR DISCOS (del cliente y de la BD)
|
||||||
// Crea un diccionario estilo: {"SSD_256": 2, "HDD_1024": 1}
|
// Crea un diccionario estilo: {"SSD_256": 2, "HDD_1024": 1}
|
||||||
@@ -344,9 +355,9 @@ namespace Inventario.API.Controllers
|
|||||||
{
|
{
|
||||||
// Este es un disco nuevo que hay que asociar.
|
// Este es un disco nuevo que hay que asociar.
|
||||||
var disco = await connection.QuerySingleOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;", discoCliente, transaction);
|
var disco = await connection.QuerySingleOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;", discoCliente, transaction);
|
||||||
if (disco == null) continue; // Si el disco no existe en la tabla maestra, lo ignoramos
|
if (disco == null) continue;
|
||||||
|
|
||||||
await connection.ExecuteAsync("INSERT INTO dbo.equipos_discos (equipo_id, disco_id) VALUES (@EquipoId, @DiscoId);", new { EquipoId = equipo.Id, DiscoId = disco.Id }, transaction);
|
await connection.ExecuteAsync("INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'automatica');", new { EquipoId = equipo.Id, DiscoId = disco.Id }, transaction);
|
||||||
|
|
||||||
// Registrar para el historial
|
// Registrar para el historial
|
||||||
var nombreDisco = $"Disco {disco.Mediatype} {disco.Size}GB";
|
var nombreDisco = $"Disco {disco.Mediatype} {disco.Size}GB";
|
||||||
@@ -381,81 +392,85 @@ namespace Inventario.API.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{hostname}/ram")]
|
[HttpPost("{hostname}/ram")]
|
||||||
public async Task<IActionResult> AsociarRam(string hostname, [FromBody] List<MemoriaRamDetalle> memoriasDesdeCliente)
|
public async Task<IActionResult> AsociarRam(string hostname, [FromBody] List<MemoriaRamEquipoDetalle> memoriasDesdeCliente)
|
||||||
{
|
{
|
||||||
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();
|
||||||
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>(equipoQuery, new { Hostname = hostname });
|
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>(equipoQuery, new { Hostname = hostname });
|
||||||
|
if (equipo == null) return NotFound("Equipo no encontrado.");
|
||||||
if (equipo == null)
|
|
||||||
{
|
|
||||||
return NotFound("Equipo no encontrado.");
|
|
||||||
}
|
|
||||||
|
|
||||||
using var transaction = connection.BeginTransaction();
|
using var transaction = connection.BeginTransaction();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. OBTENER ASOCIACIONES DE RAM ACTUALES
|
|
||||||
var ramActualQuery = @"
|
var ramActualQuery = @"
|
||||||
SELECT emr.Id as EquipoMemoriaRamId, emr.Slot, mr.Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad
|
SELECT emr.Id as EquipoMemoriaRamId, emr.Slot, mr.Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad
|
||||||
FROM dbo.equipos_memorias_ram emr
|
FROM dbo.equipos_memorias_ram emr
|
||||||
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
|
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
|
||||||
WHERE emr.equipo_id = @EquipoId;";
|
WHERE emr.equipo_id = @EquipoId;";
|
||||||
|
|
||||||
var ramEnDb = (await connection.QueryAsync<dynamic>(ramActualQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
|
var ramEnDb = (await connection.QueryAsync<dynamic>(ramActualQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
|
||||||
|
|
||||||
// 2. CREAR "HUELLAS DIGITALES" ÚNICAS PARA COMPARAR
|
Func<dynamic, string> crearHuella = ram => $"{ram.Slot}_{ram.PartNumber ?? ""}_{ram.Tamano}_{ram.Velocidad ?? 0}";
|
||||||
// Una huella única para cada módulo en un slot. Ej: "DIMM0_Kingston_8_3200"
|
|
||||||
Func<dynamic, string> crearHuella = ram =>
|
|
||||||
$"{ram.Slot}_{ram.PartNumber ?? ""}_{ram.Tamano}_{ram.Velocidad ?? 0}";
|
|
||||||
|
|
||||||
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));
|
||||||
|
|
||||||
// 3. CALCULAR Y EJECUTAR ELIMINACIONES
|
var cambios = new Dictionary<string, (string anterior, string nuevo)>();
|
||||||
var asociacionesAEliminar = ramEnDb
|
Func<dynamic, string> formatRamDetails = ram =>
|
||||||
.Where(ramDb => !huellasCliente.Contains(crearHuella(ramDb)))
|
{
|
||||||
.Select(ramDb => (int)ramDb.EquipoMemoriaRamId)
|
var parts = new List<string?> { ram.Fabricante, $"{ram.Tamano}GB", ram.PartNumber, ram.Velocidad?.ToString() + "MHz" };
|
||||||
.ToList();
|
return string.Join(" ", parts.Where(p => !string.IsNullOrEmpty(p)));
|
||||||
|
};
|
||||||
|
|
||||||
|
var modulosEliminados = ramEnDb.Where(ramDb => !huellasCliente.Contains(crearHuella(ramDb))).ToList();
|
||||||
|
foreach (var modulo in modulosEliminados)
|
||||||
|
{
|
||||||
|
var campo = $"RAM Slot {modulo.Slot}";
|
||||||
|
cambios[campo] = (formatRamDetails(modulo), "Vacio");
|
||||||
|
}
|
||||||
|
|
||||||
|
var modulosInsertados = memoriasDesdeCliente.Where(ramCliente => !huellasDb.Contains(crearHuella(ramCliente))).ToList();
|
||||||
|
foreach (var modulo in modulosInsertados)
|
||||||
|
{
|
||||||
|
var campo = $"RAM Slot {modulo.Slot}";
|
||||||
|
var valorNuevo = formatRamDetails(modulo);
|
||||||
|
if (cambios.ContainsKey(campo))
|
||||||
|
{
|
||||||
|
cambios[campo] = (cambios[campo].anterior, valorNuevo);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cambios[campo] = ("Vacio", valorNuevo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var asociacionesAEliminar = modulosEliminados.Select(ramDb => (int)ramDb.EquipoMemoriaRamId).ToList();
|
||||||
if (asociacionesAEliminar.Any())
|
if (asociacionesAEliminar.Any())
|
||||||
{
|
{
|
||||||
await connection.ExecuteAsync("DELETE FROM dbo.equipos_memorias_ram WHERE Id IN @Ids;", new { Ids = asociacionesAEliminar }, transaction);
|
await connection.ExecuteAsync("DELETE FROM dbo.equipos_memorias_ram WHERE Id IN @Ids;", new { Ids = asociacionesAEliminar }, transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. CALCULAR Y EJECUTAR INSERCIONES
|
foreach (var memInfo in modulosInsertados)
|
||||||
var memoriasAInsertar = memoriasDesdeCliente.Where(ramCliente => !huellasDb.Contains(crearHuella(ramCliente))).ToList();
|
|
||||||
|
|
||||||
foreach (var memInfo in memoriasAInsertar)
|
|
||||||
{
|
{
|
||||||
// Buscar o crear el módulo de RAM en la tabla maestra 'memorias_ram'
|
var findRamQuery = @"SELECT * FROM dbo.memorias_ram WHERE (part_number = @PartNumber OR (part_number IS NULL AND @PartNumber IS NULL)) AND tamano = @Tamano AND (velocidad = @Velocidad OR (velocidad IS NULL AND @Velocidad IS NULL));";
|
||||||
var findRamQuery = @"SELECT * FROM dbo.memorias_ram WHERE
|
|
||||||
(part_number = @PartNumber OR (part_number IS NULL AND @PartNumber IS NULL)) AND
|
|
||||||
tamano = @Tamano AND (velocidad = @Velocidad OR (velocidad IS NULL AND @Velocidad IS NULL));";
|
|
||||||
|
|
||||||
var memoriaMaestra = await connection.QuerySingleOrDefaultAsync<MemoriaRam>(findRamQuery, memInfo, transaction);
|
var memoriaMaestra = await connection.QuerySingleOrDefaultAsync<MemoriaRam>(findRamQuery, memInfo, transaction);
|
||||||
|
|
||||||
int memoriaMaestraId;
|
int memoriaMaestraId;
|
||||||
if (memoriaMaestra == null)
|
if (memoriaMaestra == null)
|
||||||
{
|
{
|
||||||
var insertRamQuery = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad)
|
var insertRamQuery = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad) VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad); SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||||
VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad);
|
|
||||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
|
||||||
memoriaMaestraId = await connection.ExecuteScalarAsync<int>(insertRamQuery, memInfo, transaction);
|
memoriaMaestraId = await connection.ExecuteScalarAsync<int>(insertRamQuery, memInfo, transaction);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
memoriaMaestraId = memoriaMaestra.Id;
|
memoriaMaestraId = memoriaMaestra.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear la asociación en la tabla intermedia
|
// Crear la asociación en la tabla intermedia
|
||||||
var insertAsociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot) VALUES (@EquipoId, @MemoriaRamId, @Slot);";
|
var insertAsociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot, origen) VALUES (@EquipoId, @MemoriaRamId, @Slot, 'automatica');";
|
||||||
await connection.ExecuteAsync(insertAsociacionQuery, new { EquipoId = equipo.Id, MemoriaRamId = memoriaMaestraId, memInfo.Slot }, transaction);
|
await connection.ExecuteAsync(insertAsociacionQuery, new { EquipoId = equipo.Id, MemoriaRamId = memoriaMaestraId, memInfo.Slot }, transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
// (Opcional, pero recomendado) Registrar cambios en el historial.
|
if (cambios.Count > 0)
|
||||||
// La lógica exacta para el historial de RAM puede ser compleja y la omitimos por ahora para centrarnos en la sincronización.
|
{
|
||||||
|
await HistorialHelper.RegistrarCambios(_context, equipo.Id, cambios);
|
||||||
|
}
|
||||||
|
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
return Ok(new { message = "Módulos de RAM sincronizados correctamente." });
|
return Ok(new { message = "Módulos de RAM sincronizados correctamente." });
|
||||||
@@ -478,32 +493,398 @@ namespace Inventario.API.Controllers
|
|||||||
{
|
{
|
||||||
using (var ping = new Ping())
|
using (var ping = new Ping())
|
||||||
{
|
{
|
||||||
var reply = await ping.SendPingAsync(request.Ip, 2000); // Timeout de 2 segundos
|
var reply = await ping.SendPingAsync(request.Ip, 2000);
|
||||||
return Ok(new { isAlive = reply.Status == IPStatus.Success, latency = reply.RoundtripTime });
|
bool isAlive = reply.Status == IPStatus.Success;
|
||||||
|
if (!isAlive)
|
||||||
|
{
|
||||||
|
reply = await ping.SendPingAsync(request.Ip, 2000);
|
||||||
|
isAlive = reply.Status == IPStatus.Success;
|
||||||
|
}
|
||||||
|
return Ok(new { isAlive, latency = isAlive ? reply.RoundtripTime : (long?)null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (PingException ex)
|
catch (PingException ex)
|
||||||
{
|
{
|
||||||
// Maneja errores comunes como "Host desconocido"
|
Console.WriteLine($"Error de Ping para {request.Ip}: {ex.Message}");
|
||||||
return Ok(new { isAlive = false, error = ex.Message });
|
return Ok(new { isAlive = false, error = "Host no alcanzable o desconocido." });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return StatusCode(500, $"Error interno al hacer ping: {ex.Message}");
|
Console.WriteLine($"Error interno al hacer ping a {request.Ip}: {ex.Message}");
|
||||||
|
return StatusCode(500, "Error interno del servidor al realizar el ping.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WOL (Wake-On-LAN) es más complejo porque requiere ejecutar comandos de sistema operativo.
|
|
||||||
// Lo dejamos pendiente para no añadir complejidad de configuración de SSH por ahora.
|
|
||||||
[HttpPost("wake-on-lan")]
|
[HttpPost("wake-on-lan")]
|
||||||
public IActionResult EnviarWol([FromBody] WolRequestDto request)
|
public IActionResult EnviarWol([FromBody] WolRequestDto request)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Recibida solicitud WOL para MAC: {request.Mac}");
|
var mac = request.Mac;
|
||||||
return Ok(new { message = "Solicitud WOL recibida. La ejecución del comando está pendiente de implementación." });
|
var ip = request.Ip;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(mac) || !Regex.IsMatch(mac, "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"))
|
||||||
|
{
|
||||||
|
return BadRequest("Formato de dirección MAC inválido.");
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(ip) || !Regex.IsMatch(ip, @"^(\d{1,3}\.){3}\d{1,3}$"))
|
||||||
|
{
|
||||||
|
return BadRequest("Formato de dirección IP inválido.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var octetos = ip.Split('.');
|
||||||
|
if (octetos.Length != 4)
|
||||||
|
{
|
||||||
|
return BadRequest("Formato de dirección IP incorrecto.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var vlanNumber = octetos[2];
|
||||||
|
var interfaceName = $"vlan{vlanNumber}";
|
||||||
|
|
||||||
|
// 3. Leemos los valores desde la configuración en lugar de hardcodearlos
|
||||||
|
var sshHost = _configuration.GetValue<string>("SshSettings:Host");
|
||||||
|
var sshPort = _configuration.GetValue<int>("SshSettings:Port");
|
||||||
|
var sshUser = _configuration.GetValue<string>("SshSettings:User");
|
||||||
|
var sshPass = _configuration.GetValue<string>("SshSettings:Password");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(sshHost) || string.IsNullOrEmpty(sshUser) || string.IsNullOrEmpty(sshPass))
|
||||||
|
{
|
||||||
|
Console.WriteLine("Error: La configuración SSH no está completa en appsettings.json.");
|
||||||
|
return StatusCode(500, "La configuración del servidor SSH está incompleta.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var client = new SshClient(sshHost, sshPort, sshUser, sshPass))
|
||||||
|
{
|
||||||
|
client.Connect();
|
||||||
|
if (client.IsConnected)
|
||||||
|
{
|
||||||
|
var command = $"/usr/sbin/etherwake -b -i {interfaceName} {mac}";
|
||||||
|
var sshCommand = client.CreateCommand(command);
|
||||||
|
sshCommand.Execute();
|
||||||
|
|
||||||
|
Console.WriteLine($"Comando WOL ejecutado: {sshCommand.CommandText}");
|
||||||
|
|
||||||
|
client.Disconnect();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("Error: No se pudo conectar al servidor SSH.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error al ejecutar comando WOL: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("manual")]
|
||||||
|
public async Task<IActionResult> CrearEquipoManual([FromBody] CrearEquipoManualDto equipoDto)
|
||||||
|
{
|
||||||
|
var findQuery = "SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname;";
|
||||||
|
var insertQuery = @"
|
||||||
|
INSERT INTO dbo.equipos (Hostname, Ip, Motherboard, Cpu, Os, Sector_id, Origen, Ram_installed, Architecture)
|
||||||
|
VALUES (@Hostname, @Ip, @Motherboard, @Cpu, @Os, @Sector_id, 'manual', 0, '');
|
||||||
|
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||||
|
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
var existente = await connection.QuerySingleOrDefaultAsync<int?>(findQuery, new { equipoDto.Hostname });
|
||||||
|
if (existente.HasValue)
|
||||||
|
{
|
||||||
|
return Conflict($"El hostname '{equipoDto.Hostname}' ya existe.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoDto);
|
||||||
|
|
||||||
|
// Devolvemos el objeto completo para que el frontend pueda actualizar su estado
|
||||||
|
var nuevoEquipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = nuevoId });
|
||||||
|
|
||||||
|
if (nuevoEquipo == null)
|
||||||
|
{
|
||||||
|
return StatusCode(500, "No se pudo recuperar el equipo después de crearlo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreatedAtAction(nameof(ConsultarDetalle), new { hostname = nuevoEquipo.Hostname }, nuevoEquipo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ENDPOINTS PARA BORRADO MANUAL DE ASOCIACIONES ---
|
||||||
|
|
||||||
|
[HttpDelete("asociacion/disco/{equipoDiscoId}")]
|
||||||
|
public async Task<IActionResult> BorrarAsociacionDisco(int equipoDiscoId)
|
||||||
|
{
|
||||||
|
var query = "DELETE FROM dbo.equipos_discos WHERE Id = @EquipoDiscoId AND Origen = 'manual';";
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoDiscoId = equipoDiscoId });
|
||||||
|
if (filasAfectadas == 0)
|
||||||
|
{
|
||||||
|
return NotFound("Asociación de disco no encontrada o no se puede eliminar porque es automática.");
|
||||||
|
}
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("asociacion/ram/{equipoMemoriaRamId}")]
|
||||||
|
public async Task<IActionResult> BorrarAsociacionRam(int equipoMemoriaRamId)
|
||||||
|
{
|
||||||
|
var query = "DELETE FROM dbo.equipos_memorias_ram WHERE Id = @EquipoMemoriaRamId AND Origen = 'manual';";
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoMemoriaRamId = equipoMemoriaRamId });
|
||||||
|
if (filasAfectadas == 0)
|
||||||
|
{
|
||||||
|
return NotFound("Asociación de RAM no encontrada o no se puede eliminar porque es automática.");
|
||||||
|
}
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("asociacion/usuario/{equipoId}/{usuarioId}")]
|
||||||
|
public async Task<IActionResult> BorrarAsociacionUsuario(int equipoId, int usuarioId)
|
||||||
|
{
|
||||||
|
var query = "DELETE FROM dbo.usuarios_equipos WHERE equipo_id = @EquipoId AND usuario_id = @UsuarioId AND Origen = 'manual';";
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoId = equipoId, UsuarioId = usuarioId });
|
||||||
|
if (filasAfectadas == 0)
|
||||||
|
{
|
||||||
|
return NotFound("Asociación de usuario no encontrada o no se puede eliminar porque es automática.");
|
||||||
|
}
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("manual/{id}")]
|
||||||
|
public async Task<IActionResult> ActualizarEquipoManual(int id, [FromBody] EditarEquipoManualDto equipoDto)
|
||||||
|
{
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
// 1. Verificar que el equipo existe y es manual
|
||||||
|
var equipoActual = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = id });
|
||||||
|
if (equipoActual == null)
|
||||||
|
{
|
||||||
|
return NotFound("El equipo no existe.");
|
||||||
|
}
|
||||||
|
if (equipoActual.Origen != "manual")
|
||||||
|
{
|
||||||
|
return Forbid("No se puede modificar un equipo generado automáticamente.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. (Opcional pero recomendado) Verificar que el nuevo hostname no exista ya en otro equipo
|
||||||
|
if (equipoActual.Hostname != equipoDto.Hostname)
|
||||||
|
{
|
||||||
|
var hostExistente = await connection.QuerySingleOrDefaultAsync<int?>("SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname AND Id != @Id", new { equipoDto.Hostname, Id = id });
|
||||||
|
if (hostExistente.HasValue)
|
||||||
|
{
|
||||||
|
return Conflict($"El hostname '{equipoDto.Hostname}' ya está en uso por otro equipo.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Construir y ejecutar la consulta de actualización
|
||||||
|
var updateQuery = @"UPDATE dbo.equipos SET
|
||||||
|
Hostname = @Hostname,
|
||||||
|
Ip = @Ip,
|
||||||
|
Mac = @Mac,
|
||||||
|
Motherboard = @Motherboard,
|
||||||
|
Cpu = @Cpu,
|
||||||
|
Os = @Os,
|
||||||
|
Sector_id = @Sector_id,
|
||||||
|
updated_at = GETDATE()
|
||||||
|
WHERE Id = @Id AND Origen = 'manual';";
|
||||||
|
|
||||||
|
var filasAfectadas = await connection.ExecuteAsync(updateQuery, new
|
||||||
|
{
|
||||||
|
equipoDto.Hostname,
|
||||||
|
equipoDto.Ip,
|
||||||
|
mac = equipoDto.Mac,
|
||||||
|
equipoDto.Motherboard,
|
||||||
|
equipoDto.Cpu,
|
||||||
|
equipoDto.Os,
|
||||||
|
equipoDto.Sector_id,
|
||||||
|
Id = id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filasAfectadas == 0)
|
||||||
|
{
|
||||||
|
// Esto no debería pasar si las primeras verificaciones pasaron, pero es una salvaguarda
|
||||||
|
return StatusCode(500, "No se pudo actualizar el equipo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent(); // Éxito en la actualización
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("manual/{equipoId}/disco")]
|
||||||
|
public async Task<IActionResult> AsociarDiscoManual(int equipoId, [FromBody] AsociarDiscoManualDto dto)
|
||||||
|
{
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
|
||||||
|
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
|
||||||
|
|
||||||
|
// Buscar o crear el disco maestro
|
||||||
|
var discoMaestro = await connection.QuerySingleOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size", dto);
|
||||||
|
int discoId;
|
||||||
|
if (discoMaestro == null)
|
||||||
|
{
|
||||||
|
discoId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.discos (Mediatype, Size) VALUES (@Mediatype, @Size); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
discoId = discoMaestro.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear la asociación manual
|
||||||
|
var asociacionQuery = "INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'manual'); SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||||
|
var nuevaAsociacionId = await connection.ExecuteScalarAsync<int>(asociacionQuery, new { EquipoId = equipoId, DiscoId = discoId });
|
||||||
|
|
||||||
|
return Ok(new { message = "Disco asociado manualmente.", equipoDiscoId = nuevaAsociacionId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("manual/{equipoId}/ram")]
|
||||||
|
public async Task<IActionResult> AsociarRamManual(int equipoId, [FromBody] AsociarRamManualDto dto)
|
||||||
|
{
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
|
||||||
|
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
|
||||||
|
|
||||||
|
// Lógica similar a la de discos para buscar/crear el módulo maestro
|
||||||
|
int ramId;
|
||||||
|
var ramMaestra = await connection.QuerySingleOrDefaultAsync<MemoriaRam>("SELECT * FROM dbo.memorias_ram WHERE Tamano = @Tamano AND Fabricante = @Fabricante AND Velocidad = @Velocidad", dto);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ramId = ramMaestra.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear la asociación manual
|
||||||
|
var asociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot, origen) VALUES (@EquipoId, @RamId, @Slot, 'manual'); SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||||
|
var nuevaAsociacionId = await connection.ExecuteScalarAsync<int>(asociacionQuery, new { EquipoId = equipoId, RamId = ramId, dto.Slot });
|
||||||
|
|
||||||
|
return Ok(new { message = "RAM asociada manualmente.", equipoMemoriaRamId = nuevaAsociacionId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("manual/{equipoId}/usuario")]
|
||||||
|
public async Task<IActionResult> AsociarUsuarioManual(int equipoId, [FromBody] AsociarUsuarioManualDto dto)
|
||||||
|
{
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
|
||||||
|
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
|
||||||
|
|
||||||
|
// Buscar o crear el usuario maestro
|
||||||
|
int usuarioId;
|
||||||
|
var usuario = await connection.QuerySingleOrDefaultAsync<Usuario>("SELECT * FROM dbo.usuarios WHERE Username = @Username", dto);
|
||||||
|
if (usuario == null)
|
||||||
|
{
|
||||||
|
usuarioId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.usuarios (Username) VALUES (@Username); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
usuarioId = usuario.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear la asociación manual
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var asociacionQuery = "INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen) VALUES (@EquipoId, @UsuarioId, 'manual');";
|
||||||
|
await connection.ExecuteAsync(asociacionQuery, new { EquipoId = equipoId, UsuarioId = usuarioId });
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (ex.Number == 2627)
|
||||||
|
{
|
||||||
|
return Conflict("El usuario ya está asociado a este equipo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { message = "Usuario asociado manualmente." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("distinct/{fieldName}")]
|
||||||
|
public async Task<IActionResult> GetDistinctFieldValues(string fieldName)
|
||||||
|
{
|
||||||
|
// 1. Lista blanca de campos permitidos para evitar inyección SQL y exposición de datos.
|
||||||
|
var allowedFields = new List<string> { "os", "cpu", "motherboard", "architecture" };
|
||||||
|
|
||||||
|
if (!allowedFields.Contains(fieldName.ToLower()))
|
||||||
|
{
|
||||||
|
return BadRequest("El campo especificado no es válido o no está permitido.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Construir la consulta de forma segura
|
||||||
|
var query = $"SELECT DISTINCT {fieldName} FROM dbo.equipos WHERE {fieldName} IS NOT NULL AND {fieldName} != '' ORDER BY {fieldName};";
|
||||||
|
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
var values = await connection.QueryAsync<string>(query);
|
||||||
|
return Ok(values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTOs locales para las peticiones
|
||||||
|
public class PingRequestDto { public string? Ip { get; set; } }
|
||||||
|
|
||||||
|
public class WolRequestDto
|
||||||
|
{
|
||||||
|
public string? Mac { get; set; }
|
||||||
|
public string? Ip { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiscoAsociado
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Mediatype { get; set; } = "";
|
||||||
|
public int Size { get; set; }
|
||||||
|
public int EquipoDiscoId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CrearEquipoManualDto
|
||||||
|
{
|
||||||
|
public required string Hostname { get; set; }
|
||||||
|
public required string Ip { get; set; }
|
||||||
|
public string? Motherboard { get; set; }
|
||||||
|
public string? Cpu { get; set; }
|
||||||
|
public string? Os { get; set; }
|
||||||
|
public int? Sector_id { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EditarEquipoManualDto
|
||||||
|
{
|
||||||
|
public required string Hostname { get; set; }
|
||||||
|
public required string Ip { get; set; }
|
||||||
|
public string? Mac { get; set; }
|
||||||
|
public string? Motherboard { get; set; }
|
||||||
|
public string? Cpu { get; set; }
|
||||||
|
public string? Os { get; set; }
|
||||||
|
public int? Sector_id { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AsociarDiscoManualDto
|
||||||
|
{
|
||||||
|
public required string Mediatype { get; set; }
|
||||||
|
public int Size { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AsociarRamManualDto
|
||||||
|
{
|
||||||
|
public required string Slot { get; set; }
|
||||||
|
public int Tamano { get; set; }
|
||||||
|
public string? Fabricante { get; set; }
|
||||||
|
public int? Velocidad { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AsociarUsuarioManualDto
|
||||||
|
{
|
||||||
|
public required string Username { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTOs locales para las peticiones
|
|
||||||
public class PingRequestDto { public string? Ip { get; set; } }
|
|
||||||
public class WolRequestDto { public string? Mac { get; set; } }
|
|
||||||
}
|
}
|
||||||
@@ -119,5 +119,18 @@ namespace Inventario.API.Controllers
|
|||||||
return NoContent(); // Respuesta HTTP 204 No Content
|
return NoContent(); // Respuesta HTTP 204 No Content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- GET /api/usuarios/buscar/{termino} ---
|
||||||
|
[HttpGet("buscar/{termino}")]
|
||||||
|
public async Task<IActionResult> BuscarUsuarios(string termino)
|
||||||
|
{
|
||||||
|
// Usamos LIKE para una búsqueda flexible. El '%' son comodines.
|
||||||
|
var query = "SELECT Username FROM dbo.usuarios WHERE Username LIKE @SearchTerm ORDER BY Username;";
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
var usuarios = await connection.QueryAsync<string>(query, new { SearchTerm = $"%{termino}%" });
|
||||||
|
return Ok(usuarios);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
||||||
|
<PackageReference Include="SSH.NET" Version="2025.0.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// backend/Models/Equipo.cs
|
||||||
namespace Inventario.API.Models
|
namespace Inventario.API.Models
|
||||||
{
|
{
|
||||||
public class Equipo
|
public class Equipo
|
||||||
@@ -5,22 +6,42 @@ namespace Inventario.API.Models
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Hostname { get; set; } = string.Empty;
|
public string Hostname { get; set; } = string.Empty;
|
||||||
public string Ip { get; set; } = string.Empty;
|
public string Ip { get; set; } = string.Empty;
|
||||||
public string? Mac { get; set; } // Mac puede ser nulo, así que usamos string?
|
public string? Mac { get; set; }
|
||||||
public string Motherboard { get; set; } = string.Empty;
|
public string Motherboard { get; set; } = string.Empty;
|
||||||
public string Cpu { get; set; } = string.Empty;
|
public string Cpu { get; set; } = string.Empty;
|
||||||
public int Ram_installed { get; set; }
|
public int Ram_installed { get; set; }
|
||||||
public int? Ram_slots { get; set; } // Puede ser nulo
|
public int? Ram_slots { get; set; }
|
||||||
public string Os { get; set; } = string.Empty;
|
public string Os { get; set; } = string.Empty;
|
||||||
public string Architecture { get; set; } = string.Empty;
|
public string Architecture { get; set; } = string.Empty;
|
||||||
public DateTime Created_at { get; set; }
|
public DateTime Created_at { get; set; }
|
||||||
public DateTime Updated_at { get; set; }
|
public DateTime Updated_at { get; set; }
|
||||||
public int? Sector_id { get; set; } // Puede ser nulo
|
public int? Sector_id { get; set; }
|
||||||
|
public string Origen { get; set; } = "automatica";
|
||||||
|
|
||||||
// Propiedades de navegación (no mapeadas directamente a la BD)
|
// Propiedades de navegación actualizadas
|
||||||
public Sector? Sector { get; set; }
|
public Sector? Sector { get; set; }
|
||||||
public List<Usuario> Usuarios { get; set; } = new();
|
public List<UsuarioEquipoDetalle> Usuarios { get; set; } = new(); // Tipo actualizado
|
||||||
public List<Disco> Discos { get; set; } = new();
|
public List<DiscoDetalle> Discos { get; set; } = new(); // Tipo actualizado
|
||||||
public List<MemoriaRamDetalle> MemoriasRam { get; set; } = new();
|
public List<MemoriaRamEquipoDetalle> MemoriasRam { get; set; } = new(); // Tipo actualizado
|
||||||
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 string Origen { get; set; } = "manual";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nuevo modelo para memorias RAM con su origen y slot
|
||||||
|
public class MemoriaRamEquipoDetalle : MemoriaRam
|
||||||
|
{
|
||||||
|
public string Slot { get; set; } = string.Empty;
|
||||||
|
public string Origen { get; set; } = "manual";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nuevo modelo para usuarios con su origen
|
||||||
|
public class UsuarioEquipoDetalle : Usuario
|
||||||
|
{
|
||||||
|
public string Origen { get; set; } = "manual";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Este modelo representa la tabla memorias_ram
|
// backend/Models/MemoriaRam.cs
|
||||||
namespace Inventario.API.Models
|
namespace Inventario.API.Models
|
||||||
{
|
{
|
||||||
public class MemoriaRam
|
public class MemoriaRam
|
||||||
@@ -9,10 +9,4 @@ namespace Inventario.API.Models
|
|||||||
public int Tamano { get; set; }
|
public int Tamano { get; set; }
|
||||||
public int? Velocidad { get; set; }
|
public int? Velocidad { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Este es un modelo combinado para devolver la información completa al frontend
|
|
||||||
public class MemoriaRamDetalle : MemoriaRam
|
|
||||||
{
|
|
||||||
public string Slot { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -4,5 +4,11 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"SshSettings": {
|
||||||
|
"Host": "192.168.10.1",
|
||||||
|
"Port": 22110,
|
||||||
|
"User": "root",
|
||||||
|
"Password": "PTP.847eld23"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,5 +8,11 @@
|
|||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Server=TECNICA3;Database=InventarioDB;User Id=apiequipos;Password=@Apiequipos513@;TrustServerCertificate=True"
|
"DefaultConnection": "Server=TECNICA3;Database=InventarioDB;User Id=apiequipos;Password=@Apiequipos513@;TrustServerCertificate=True"
|
||||||
|
},
|
||||||
|
"SshSettings": {
|
||||||
|
"Host": "192.168.10.1",
|
||||||
|
"Port": 22110,
|
||||||
|
"User": "root",
|
||||||
|
"Password": "PTP.847eld23"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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+3fbc9abf584ee687cb8cd7cea9ab20b716bdd897")]
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+99d98cc588b3922b6aa3ab9045fcee9cb31de1f3")]
|
||||||
[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")]
|
||||||
|
|||||||
@@ -72,6 +72,10 @@
|
|||||||
"target": "Package",
|
"target": "Package",
|
||||||
"version": "[9.0.9, )"
|
"version": "[9.0.9, )"
|
||||||
},
|
},
|
||||||
|
"SSH.NET": {
|
||||||
|
"target": "Package",
|
||||||
|
"version": "[2025.0.0, )"
|
||||||
|
},
|
||||||
"Swashbuckle.AspNetCore": {
|
"Swashbuckle.AspNetCore": {
|
||||||
"target": "Package",
|
"target": "Package",
|
||||||
"version": "[9.0.6, )"
|
"version": "[9.0.6, )"
|
||||||
|
|||||||
@@ -39,6 +39,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"BouncyCastle.Cryptography/2.5.1": {
|
||||||
|
"type": "package",
|
||||||
|
"compile": {
|
||||||
|
"lib/net6.0/BouncyCastle.Cryptography.dll": {
|
||||||
|
"related": ".xml"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net6.0/BouncyCastle.Cryptography.dll": {
|
||||||
|
"related": ".xml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Dapper/2.1.66": {
|
"Dapper/2.1.66": {
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"compile": {
|
"compile": {
|
||||||
@@ -1019,6 +1032,23 @@
|
|||||||
"buildTransitive/Mono.TextTemplating.targets": {}
|
"buildTransitive/Mono.TextTemplating.targets": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SSH.NET/2025.0.0": {
|
||||||
|
"type": "package",
|
||||||
|
"dependencies": {
|
||||||
|
"BouncyCastle.Cryptography": "2.5.1",
|
||||||
|
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||||
|
},
|
||||||
|
"compile": {
|
||||||
|
"lib/net9.0/Renci.SshNet.dll": {
|
||||||
|
"related": ".xml"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net9.0/Renci.SshNet.dll": {
|
||||||
|
"related": ".xml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Swashbuckle.AspNetCore/9.0.6": {
|
"Swashbuckle.AspNetCore/9.0.6": {
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1531,6 +1561,26 @@
|
|||||||
"lib/netstandard2.0/Azure.Identity.xml"
|
"lib/netstandard2.0/Azure.Identity.xml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"BouncyCastle.Cryptography/2.5.1": {
|
||||||
|
"sha512": "zy8TMeTP+1FH2NrLaNZtdRbBdq7u5MI+NFZQOBSM69u5RFkciinwzV2eveY6Kjf5MzgsYvvl6kTStsj3JrXqkg==",
|
||||||
|
"type": "package",
|
||||||
|
"path": "bouncycastle.cryptography/2.5.1",
|
||||||
|
"files": [
|
||||||
|
".nupkg.metadata",
|
||||||
|
".signature.p7s",
|
||||||
|
"LICENSE.md",
|
||||||
|
"README.md",
|
||||||
|
"bouncycastle.cryptography.2.5.1.nupkg.sha512",
|
||||||
|
"bouncycastle.cryptography.nuspec",
|
||||||
|
"lib/net461/BouncyCastle.Cryptography.dll",
|
||||||
|
"lib/net461/BouncyCastle.Cryptography.xml",
|
||||||
|
"lib/net6.0/BouncyCastle.Cryptography.dll",
|
||||||
|
"lib/net6.0/BouncyCastle.Cryptography.xml",
|
||||||
|
"lib/netstandard2.0/BouncyCastle.Cryptography.dll",
|
||||||
|
"lib/netstandard2.0/BouncyCastle.Cryptography.xml",
|
||||||
|
"packageIcon.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
"Dapper/2.1.66": {
|
"Dapper/2.1.66": {
|
||||||
"sha512": "/q77jUgDOS+bzkmk3Vy9SiWMaetTw+NOoPAV0xPBsGVAyljd5S6P+4RUW7R3ZUGGr9lDRyPKgAMj2UAOwvqZYw==",
|
"sha512": "/q77jUgDOS+bzkmk3Vy9SiWMaetTw+NOoPAV0xPBsGVAyljd5S6P+4RUW7R3ZUGGr9lDRyPKgAMj2UAOwvqZYw==",
|
||||||
"type": "package",
|
"type": "package",
|
||||||
@@ -3607,6 +3657,29 @@
|
|||||||
"readme.md"
|
"readme.md"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"SSH.NET/2025.0.0": {
|
||||||
|
"sha512": "AKYbB+q2zFkNQbBFx5gXdv+Wje0baBtADQ35WnMKi4bg1ka74wTQtWoPd+fOWcydohdfsD0nfT8ErMOAPxtSfA==",
|
||||||
|
"type": "package",
|
||||||
|
"path": "ssh.net/2025.0.0",
|
||||||
|
"files": [
|
||||||
|
".nupkg.metadata",
|
||||||
|
".signature.p7s",
|
||||||
|
"README.md",
|
||||||
|
"SS-NET-icon-h500.png",
|
||||||
|
"lib/net462/Renci.SshNet.dll",
|
||||||
|
"lib/net462/Renci.SshNet.xml",
|
||||||
|
"lib/net8.0/Renci.SshNet.dll",
|
||||||
|
"lib/net8.0/Renci.SshNet.xml",
|
||||||
|
"lib/net9.0/Renci.SshNet.dll",
|
||||||
|
"lib/net9.0/Renci.SshNet.xml",
|
||||||
|
"lib/netstandard2.0/Renci.SshNet.dll",
|
||||||
|
"lib/netstandard2.0/Renci.SshNet.xml",
|
||||||
|
"lib/netstandard2.1/Renci.SshNet.dll",
|
||||||
|
"lib/netstandard2.1/Renci.SshNet.xml",
|
||||||
|
"ssh.net.2025.0.0.nupkg.sha512",
|
||||||
|
"ssh.net.nuspec"
|
||||||
|
]
|
||||||
|
},
|
||||||
"Swashbuckle.AspNetCore/9.0.6": {
|
"Swashbuckle.AspNetCore/9.0.6": {
|
||||||
"sha512": "q/UfEAgrk6qQyjHXgsW9ILw0YZLfmPtWUY4wYijliX6supozC+TkzU0G6FTnn/dPYxnChjM8g8lHjWHF6VKy+A==",
|
"sha512": "q/UfEAgrk6qQyjHXgsW9ILw0YZLfmPtWUY4wYijliX6supozC+TkzU0G6FTnn/dPYxnChjM8g8lHjWHF6VKy+A==",
|
||||||
"type": "package",
|
"type": "package",
|
||||||
@@ -4348,6 +4421,7 @@
|
|||||||
"Microsoft.Data.SqlClient >= 6.1.1",
|
"Microsoft.Data.SqlClient >= 6.1.1",
|
||||||
"Microsoft.EntityFrameworkCore.Design >= 9.0.9",
|
"Microsoft.EntityFrameworkCore.Design >= 9.0.9",
|
||||||
"Microsoft.EntityFrameworkCore.SqlServer >= 9.0.9",
|
"Microsoft.EntityFrameworkCore.SqlServer >= 9.0.9",
|
||||||
|
"SSH.NET >= 2025.0.0",
|
||||||
"Swashbuckle.AspNetCore >= 9.0.6"
|
"Swashbuckle.AspNetCore >= 9.0.6"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -4423,6 +4497,10 @@
|
|||||||
"target": "Package",
|
"target": "Package",
|
||||||
"version": "[9.0.9, )"
|
"version": "[9.0.9, )"
|
||||||
},
|
},
|
||||||
|
"SSH.NET": {
|
||||||
|
"target": "Package",
|
||||||
|
"version": "[2025.0.0, )"
|
||||||
|
},
|
||||||
"Swashbuckle.AspNetCore": {
|
"Swashbuckle.AspNetCore": {
|
||||||
"target": "Package",
|
"target": "Package",
|
||||||
"version": "[9.0.6, )"
|
"version": "[9.0.6, )"
|
||||||
|
|||||||
28
frontend/package-lock.json
generated
28
frontend/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-tooltip": "^5.29.1"
|
"react-tooltip": "^5.29.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2045,7 +2046,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
@@ -2499,6 +2499,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/goober": {
|
||||||
|
"version": "2.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
|
||||||
|
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"csstype": "^3.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graphemer": {
|
"node_modules/graphemer": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||||
@@ -2988,6 +2997,23 @@
|
|||||||
"react": "^19.2.0"
|
"react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hot-toast": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.1.3",
|
||||||
|
"goober": "^2.1.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16",
|
||||||
|
"react-dom": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-tooltip": "^5.29.1"
|
"react-tooltip": "^5.29.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,47 @@
|
|||||||
main {
|
main {
|
||||||
padding: 2rem;
|
padding: 0rem 2rem;
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Estilos para la nueva Barra de Navegación */
|
||||||
|
.navbar {
|
||||||
|
background-color: #343a40; /* Un color oscuro para el fondo */
|
||||||
|
padding: 0 2rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #adb5bd; /* Color de texto gris claro */
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out;
|
||||||
|
border-bottom: 3px solid transparent; /* Borde inferior para el indicador activo */
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
color: #ffffff; /* Texto blanco al pasar el ratón */
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link-active {
|
||||||
|
color: #ffffff;
|
||||||
|
border-bottom: 3px solid #007bff; /* Indicador azul para la vista activa */
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import SimpleTable from "./components/SimpleTable";
|
import SimpleTable from "./components/SimpleTable";
|
||||||
|
import GestionSectores from "./components/GestionSectores";
|
||||||
|
import GestionComponentes from './components/GestionComponentes';
|
||||||
|
import Navbar from './components/Navbar';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
|
export type View = 'equipos' | 'sectores' | 'admin';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [currentView, setCurrentView] = useState<View>('equipos');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<>
|
||||||
<SimpleTable />
|
<Navbar currentView={currentView} setCurrentView={setCurrentView} />
|
||||||
</main>
|
|
||||||
|
<main>
|
||||||
|
{currentView === 'equipos' && <SimpleTable />}
|
||||||
|
{currentView === 'sectores' && <GestionSectores />}
|
||||||
|
{currentView === 'admin' && <GestionComponentes />}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
66
frontend/src/components/AutocompleteInput.tsx
Normal file
66
frontend/src/components/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface AutocompleteInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
name: string;
|
||||||
|
placeholder?: string;
|
||||||
|
// CAMBIO: La función ahora recibe el término de búsqueda
|
||||||
|
fetchSuggestions: (query: string) => Promise<string[]>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
name,
|
||||||
|
placeholder,
|
||||||
|
fetchSuggestions,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
|
const dataListId = `suggestions-for-${name}`;
|
||||||
|
|
||||||
|
// CAMBIO: Lógica de "debouncing" para buscar mientras se escribe
|
||||||
|
useEffect(() => {
|
||||||
|
// No buscar si el input está vacío o es muy corto
|
||||||
|
if (value.length < 2) {
|
||||||
|
setSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configura un temporizador para esperar 300ms después de la última pulsación
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
fetchSuggestions(value)
|
||||||
|
.then(setSuggestions)
|
||||||
|
.catch(err => console.error(`Error fetching suggestions for ${name}:`, err));
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// Limpia el temporizador si el usuario sigue escribiendo
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [value, fetchSuggestions, name]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={className}
|
||||||
|
list={dataListId}
|
||||||
|
autoComplete="off" // Importante para que no interfiera el autocompletado del navegador
|
||||||
|
/>
|
||||||
|
<datalist id={dataListId}>
|
||||||
|
{suggestions.map((suggestion, index) => (
|
||||||
|
<option key={index} value={suggestion} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AutocompleteInput;
|
||||||
217
frontend/src/components/GestionComponentes.tsx
Normal file
217
frontend/src/components/GestionComponentes.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import styles from './SimpleTable.module.css';
|
||||||
|
|
||||||
|
const BASE_URL = 'http://localhost:5198/api';
|
||||||
|
|
||||||
|
// Interfaces para los diferentes tipos de datos
|
||||||
|
interface TextValue {
|
||||||
|
valor: string;
|
||||||
|
conteo: number;
|
||||||
|
}
|
||||||
|
interface RamValue {
|
||||||
|
id: number;
|
||||||
|
fabricante?: string;
|
||||||
|
tamano: number;
|
||||||
|
velocidad?: number;
|
||||||
|
partNumber?: string;
|
||||||
|
conteo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GestionComponentes = () => {
|
||||||
|
const [componentType, setComponentType] = useState('os');
|
||||||
|
const [valores, setValores] = useState<(TextValue | RamValue)[]>([]); // Estado que acepta ambos tipos
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [valorAntiguo, setValorAntiguo] = useState('');
|
||||||
|
const [valorNuevo, setValorNuevo] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const endpoint = componentType === 'ram' ? `${BASE_URL}/admin/componentes/ram` : `${BASE_URL}/admin/componentes/${componentType}`;
|
||||||
|
|
||||||
|
fetch(endpoint)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setValores(data);
|
||||||
|
})
|
||||||
|
.catch(_err => {
|
||||||
|
toast.error(`No se pudieron cargar los datos de ${componentType}.`);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [componentType]);
|
||||||
|
|
||||||
|
const handleOpenModal = (valor: string) => {
|
||||||
|
setValorAntiguo(valor);
|
||||||
|
setValorNuevo(valor);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnificar = async () => {
|
||||||
|
const toastId = toast.loading('Unificando valores...');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/admin/componentes/${componentType}/unificar`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ valorAntiguo, valorNuevo }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message || 'La unificación falló.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refrescar la lista para ver el resultado
|
||||||
|
const refreshedData = await (await fetch(`${BASE_URL}/admin/componentes/${componentType}`)).json();
|
||||||
|
setValores(refreshedData);
|
||||||
|
|
||||||
|
toast.success('Valores unificados correctamente.', { id: toastId });
|
||||||
|
setIsModalOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRam = async (ramId: number) => {
|
||||||
|
if (!window.confirm("¿Estás seguro de eliminar este módulo de RAM de la base de datos maestra? Esta acción es irreversible.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastId = toast.loading('Eliminando módulo...');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/admin/componentes/ram/${ramId}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message || 'No se pudo eliminar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setValores(prev => prev.filter(v => (v as RamValue).id !== ramId));
|
||||||
|
toast.success("Módulo de RAM eliminado.", { id: toastId });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTexto = async (valor: string) => {
|
||||||
|
if (!window.confirm(`Este valor ya no está en uso. ¿Quieres intentar eliminarlo de la base de datos maestra? (Si no existe una tabla maestra, esta acción solo confirmará que no hay usos)`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastId = toast.loading('Eliminando valor...');
|
||||||
|
try {
|
||||||
|
// La API necesita el valor codificado para manejar caracteres especiales como '/'
|
||||||
|
const encodedValue = encodeURIComponent(valor);
|
||||||
|
const response = await fetch(`${BASE_URL}/admin/componentes/${componentType}/${encodedValue}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message || 'No se pudo eliminar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setValores(prev => prev.filter(v => (v as TextValue).valor !== valor));
|
||||||
|
toast.success("Valor eliminado/confirmado como no existente.", { id: toastId });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderValor = (item: TextValue | RamValue) => {
|
||||||
|
if (componentType === 'ram') {
|
||||||
|
const ram = item as RamValue;
|
||||||
|
return `${ram.fabricante || ''} ${ram.tamano}GB ${ram.velocidad ? ram.velocidad + 'MHz' : ''} (${ram.partNumber || 'N/P'})`;
|
||||||
|
}
|
||||||
|
return (item as TextValue).valor;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Gestión de Componentes Maestros</h2>
|
||||||
|
<p>Unifica valores inconsistentes y elimina registros no utilizados.</p>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<label><strong>Selecciona un tipo de componente:</strong></label>
|
||||||
|
<select value={componentType} onChange={e => setComponentType(e.target.value)} className={styles.sectorSelect} style={{marginLeft: '10px'}}>
|
||||||
|
<option value="os">Sistema Operativo</option>
|
||||||
|
<option value="cpu">CPU</option>
|
||||||
|
<option value="motherboard">Motherboard</option>
|
||||||
|
<option value="architecture">Arquitectura</option>
|
||||||
|
<option value="ram">Memorias RAM</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p>Cargando...</p>
|
||||||
|
) : (
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={styles.th}>Valor Registrado</th>
|
||||||
|
<th className={styles.th} style={{width: '150px'}}>Nº de Equipos</th>
|
||||||
|
<th className={styles.th} style={{width: '200px'}}>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{valores.map((item) => (
|
||||||
|
<tr key={componentType === 'ram' ? (item as RamValue).id : (item as TextValue).valor} className={styles.tr}>
|
||||||
|
<td className={styles.td}>{renderValor(item)}</td>
|
||||||
|
<td className={styles.td}>{item.conteo}</td>
|
||||||
|
<td className={styles.td}>
|
||||||
|
<div style={{display: 'flex', gap: '5px'}}>
|
||||||
|
{componentType === 'ram' ? (
|
||||||
|
// Lógica solo para RAM (no tiene sentido "unificar" un objeto complejo)
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteRam((item as RamValue).id)}
|
||||||
|
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}}
|
||||||
|
disabled={item.conteo > 0}
|
||||||
|
title={item.conteo > 0 ? `En uso por ${item.conteo} equipo(s)` : 'Eliminar este módulo maestro'}
|
||||||
|
>
|
||||||
|
🗑️ Eliminar
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
// Lógica para todos los demás tipos de componentes (texto)
|
||||||
|
<>
|
||||||
|
<button onClick={() => handleOpenModal((item as TextValue).valor)} className={styles.tableButton}>
|
||||||
|
✏️ Unificar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteTexto((item as TextValue).valor)}
|
||||||
|
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}}
|
||||||
|
disabled={item.conteo > 0}
|
||||||
|
title={item.conteo > 0 ? `En uso por ${item.conteo} equipo(s). Unifique primero.` : 'Eliminar este valor'}
|
||||||
|
>
|
||||||
|
🗑️ Eliminar
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className={styles.modalOverlay}>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
<h3>Unificar Valor</h3>
|
||||||
|
<p>Se reemplazarán todas las instancias de:</p>
|
||||||
|
<strong style={{ display: 'block', marginBottom: '1rem', background: '#e9ecef', padding: '8px', borderRadius: '4px' }}>{valorAntiguo}</strong>
|
||||||
|
<label>Por el nuevo valor:</label>
|
||||||
|
<input type="text" value={valorNuevo} onChange={e => setValorNuevo(e.target.value)} className={styles.modalInput} />
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<button onClick={handleUnificar} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!valorNuevo.trim() || valorNuevo === valorAntiguo}>Unificar</button>
|
||||||
|
<button onClick={() => setIsModalOpen(false)} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GestionComponentes;
|
||||||
142
frontend/src/components/GestionSectores.tsx
Normal file
142
frontend/src/components/GestionSectores.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import type { Sector } from '../types/interfaces';
|
||||||
|
import styles from './SimpleTable.module.css';
|
||||||
|
import ModalSector from './ModalSector';
|
||||||
|
|
||||||
|
const BASE_URL = 'http://localhost:5198/api';
|
||||||
|
|
||||||
|
const GestionSectores = () => {
|
||||||
|
const [sectores, setSectores] = useState<Sector[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingSector, setEditingSector] = useState<Sector | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${BASE_URL}/sectores`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then((data: Sector[]) => {
|
||||||
|
setSectores(data);
|
||||||
|
setIsLoading(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
toast.error("No se pudieron cargar los sectores.");
|
||||||
|
console.error(err);
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenCreateModal = () => {
|
||||||
|
setEditingSector(null); // Poner en modo 'crear'
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditModal = (sector: Sector) => {
|
||||||
|
setEditingSector(sector); // Poner en modo 'editar' con los datos del sector
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (id: number | null, nombre: string) => {
|
||||||
|
const isEditing = id !== null;
|
||||||
|
const url = isEditing ? `${BASE_URL}/sectores/${id}` : `${BASE_URL}/sectores`;
|
||||||
|
const method = isEditing ? 'PUT' : 'POST';
|
||||||
|
const toastId = toast.loading(isEditing ? 'Actualizando...' : 'Creando...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ nombre }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'La operación falló.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
// Actualizar el sector en la lista local
|
||||||
|
setSectores(prev => prev.map(s => s.id === id ? { ...s, nombre } : s));
|
||||||
|
toast.success('Sector actualizado.', { id: toastId });
|
||||||
|
} else {
|
||||||
|
// Añadir el nuevo sector a la lista local
|
||||||
|
const nuevoSector = await response.json();
|
||||||
|
setSectores(prev => [...prev, nuevoSector]);
|
||||||
|
toast.success('Sector creado.', { id: toastId });
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsModalOpen(false); // Cerrar el modal
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!window.confirm("¿Estás seguro de eliminar este sector? Los equipos asociados quedarán sin sector.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastId = toast.loading('Eliminando...');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/sectores/${id}`, { method: 'DELETE' });
|
||||||
|
if (response.status === 409) {
|
||||||
|
throw new Error("No se puede eliminar. Hay equipos asignados a este sector.");
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("El sector no se pudo eliminar.");
|
||||||
|
}
|
||||||
|
setSectores(prev => prev.filter(s => s.id !== id));
|
||||||
|
toast.success("Sector eliminado.", { id: toastId });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Cargando sectores...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||||
|
<h2>Gestión de Sectores</h2>
|
||||||
|
<button onClick={handleOpenCreateModal} className={`${styles.btn} ${styles.btnPrimary}`}>
|
||||||
|
+ Añadir Sector
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={styles.th}>Nombre del Sector</th>
|
||||||
|
<th className={styles.th} style={{ width: '200px' }}>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sectores.map(sector => (
|
||||||
|
<tr key={sector.id} className={styles.tr}>
|
||||||
|
<td className={styles.td}>{sector.nombre}</td>
|
||||||
|
<td className={styles.td}>
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
<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'}}>
|
||||||
|
🗑️ Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{isModalOpen && (
|
||||||
|
<ModalSector
|
||||||
|
sector={editingSector}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GestionSectores;
|
||||||
46
frontend/src/components/ModalAnadirDisco.tsx
Normal file
46
frontend/src/components/ModalAnadirDisco.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// frontend/src/components/ModalAnadirDisco.tsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import styles from './SimpleTable.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (disco: { mediatype: string, size: number }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalAnadirDisco: React.FC<Props> = ({ onClose, onSave }) => {
|
||||||
|
const [mediatype, setMediatype] = useState('SSD');
|
||||||
|
const [size, setSize] = useState('');
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (size && parseInt(size, 10) > 0) {
|
||||||
|
onSave({ mediatype, size: parseInt(size, 10) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.modalOverlay}>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
<h3>Añadir Disco Manualmente</h3>
|
||||||
|
<label>Tipo de Disco</label>
|
||||||
|
<select value={mediatype} onChange={e => setMediatype(e.target.value)} className={styles.modalInput}>
|
||||||
|
<option value="SSD">SSD</option>
|
||||||
|
<option value="HDD">HDD</option>
|
||||||
|
</select>
|
||||||
|
<label>Tamaño (GB)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={size}
|
||||||
|
onChange={e => setSize(e.target.value)}
|
||||||
|
className={styles.modalInput}
|
||||||
|
placeholder="Ej: 500"
|
||||||
|
/>
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!size}>Guardar</button>
|
||||||
|
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalAnadirDisco;
|
||||||
125
frontend/src/components/ModalAnadirEquipo.tsx
Normal file
125
frontend/src/components/ModalAnadirEquipo.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// frontend/src/components/ModalAnadirEquipo.tsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import type { Sector, Equipo } from '../types/interfaces';
|
||||||
|
import AutocompleteInput from './AutocompleteInput';
|
||||||
|
import styles from './SimpleTable.module.css';
|
||||||
|
|
||||||
|
interface ModalAnadirEquipoProps {
|
||||||
|
sectores: Sector[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_URL = 'http://localhost:5198/api';
|
||||||
|
|
||||||
|
const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose, onSave }) => {
|
||||||
|
const [nuevoEquipo, setNuevoEquipo] = useState({
|
||||||
|
hostname: '',
|
||||||
|
ip: '',
|
||||||
|
motherboard: '',
|
||||||
|
cpu: '',
|
||||||
|
os: '',
|
||||||
|
sector_id: undefined as number | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setNuevoEquipo(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: name === 'sector_id' ? (value ? parseInt(value, 10) : undefined) : value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveClick = () => {
|
||||||
|
// La UI pasará un objeto compatible con el DTO del backend
|
||||||
|
onSave(nuevoEquipo as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormValid = nuevoEquipo.hostname.trim() !== '' && nuevoEquipo.ip.trim() !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.modalOverlay}>
|
||||||
|
<div className={styles.modal} style={{ minWidth: '500px' }}>
|
||||||
|
<h3>Añadir Nuevo Equipo Manualmente</h3>
|
||||||
|
|
||||||
|
<label>Hostname (Requerido)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="hostname"
|
||||||
|
value={nuevoEquipo.hostname}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={styles.modalInput}
|
||||||
|
placeholder="Ej: CONTABILIDAD-01"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>Dirección IP (Requerido)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="ip"
|
||||||
|
value={nuevoEquipo.ip}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={styles.modalInput}
|
||||||
|
placeholder="Ej: 192.168.1.50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>Sector</label>
|
||||||
|
<select
|
||||||
|
name="sector_id"
|
||||||
|
value={nuevoEquipo.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>
|
||||||
|
|
||||||
|
<label>Motherboard (Opcional)</label>
|
||||||
|
<AutocompleteInput
|
||||||
|
name="motherboard"
|
||||||
|
value={nuevoEquipo.motherboard}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={styles.modalInput}
|
||||||
|
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json())}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>CPU (Opcional)</label>
|
||||||
|
<AutocompleteInput
|
||||||
|
name="cpu"
|
||||||
|
value={nuevoEquipo.cpu}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={styles.modalInput}
|
||||||
|
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json())}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>Sistema Operativo (Opcional)</label>
|
||||||
|
<AutocompleteInput
|
||||||
|
name="os"
|
||||||
|
value={nuevoEquipo.os}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={styles.modalInput}
|
||||||
|
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json())}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<button
|
||||||
|
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||||
|
onClick={handleSaveClick}
|
||||||
|
disabled={!isFormValid}
|
||||||
|
>
|
||||||
|
Guardar Equipo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.btn} ${styles.btnSecondary}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalAnadirEquipo;
|
||||||
47
frontend/src/components/ModalAnadirRam.tsx
Normal file
47
frontend/src/components/ModalAnadirRam.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// frontend/src/components/ModalAnadirRam.tsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import styles from './SimpleTable.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalAnadirRam: React.FC<Props> = ({ onClose, onSave }) => {
|
||||||
|
const [ram, setRam] = useState({ slot: '', tamano: '', fabricante: '', velocidad: '' });
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setRam(prev => ({ ...prev, [e.target.name]: e.target.value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave({
|
||||||
|
slot: ram.slot,
|
||||||
|
tamano: parseInt(ram.tamano, 10),
|
||||||
|
fabricante: ram.fabricante || undefined,
|
||||||
|
velocidad: ram.velocidad ? parseInt(ram.velocidad, 10) : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.modalOverlay}>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
<h3>Añadir Módulo de RAM</h3>
|
||||||
|
<label>Slot (Requerido)</label>
|
||||||
|
<input type="text" name="slot" value={ram.slot} onChange={handleChange} className={styles.modalInput} placeholder="Ej: DIMM0" />
|
||||||
|
<label>Tamaño (GB) (Requerido)</label>
|
||||||
|
<input type="number" name="tamano" value={ram.tamano} onChange={handleChange} className={styles.modalInput} placeholder="Ej: 8" />
|
||||||
|
<label>Fabricante (Opcional)</label>
|
||||||
|
<input type="text" name="fabricante" value={ram.fabricante} onChange={handleChange} className={styles.modalInput} />
|
||||||
|
<label>Velocidad (MHz) (Opcional)</label>
|
||||||
|
<input type="number" name="velocidad" value={ram.velocidad} onChange={handleChange} className={styles.modalInput} />
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalAnadirRam;
|
||||||
44
frontend/src/components/ModalAnadirUsuario.tsx
Normal file
44
frontend/src/components/ModalAnadirUsuario.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import styles from './SimpleTable.module.css';
|
||||||
|
import AutocompleteInput from './AutocompleteInput';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (usuario: { username: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_URL = 'http://localhost:5198/api';
|
||||||
|
|
||||||
|
const ModalAnadirUsuario: React.FC<Props> = ({ onClose, onSave }) => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
|
||||||
|
const fetchUserSuggestions = async (query: string): Promise<string[]> => {
|
||||||
|
if (!query) return [];
|
||||||
|
const response = await fetch(`${BASE_URL}/usuarios/buscar/${query}`);
|
||||||
|
if (!response.ok) return [];
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.modalOverlay}>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
<h3>Añadir Usuario Manualmente</h3>
|
||||||
|
<label>Nombre de Usuario</label>
|
||||||
|
<AutocompleteInput
|
||||||
|
name="username"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
className={styles.modalInput}
|
||||||
|
fetchSuggestions={fetchUserSuggestions}
|
||||||
|
placeholder="Escribe para buscar o crear un nuevo usuario"
|
||||||
|
/>
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<button onClick={() => onSave({ username })} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!username.trim()}>Guardar</button>
|
||||||
|
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalAnadirUsuario;
|
||||||
65
frontend/src/components/ModalCambiarClave.tsx
Normal file
65
frontend/src/components/ModalCambiarClave.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// frontend/src/components/ModalCambiarClave.tsx
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import type { Usuario } from '../types/interfaces';
|
||||||
|
import styles from './SimpleTable.module.css';
|
||||||
|
|
||||||
|
interface ModalCambiarClaveProps {
|
||||||
|
usuario: Usuario; // El componente padre asegura que esto no sea nulo
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (password: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalCambiarClave: React.FC<ModalCambiarClaveProps> = ({ usuario, onClose, onSave }) => {
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Enfocar el input cuando el modal se abre
|
||||||
|
setTimeout(() => passwordInputRef.current?.focus(), 100);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveClick = () => {
|
||||||
|
if (newPassword.trim()) {
|
||||||
|
onSave(newPassword);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.modalOverlay}>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
<h3>
|
||||||
|
Cambiar contraseña para {usuario.username}
|
||||||
|
</h3>
|
||||||
|
<label>
|
||||||
|
Nueva contraseña:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className={styles.modalInput}
|
||||||
|
placeholder="Ingrese la nueva contraseña"
|
||||||
|
ref={passwordInputRef}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSaveClick()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<button
|
||||||
|
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||||
|
onClick={handleSaveClick}
|
||||||
|
disabled={!newPassword.trim()}
|
||||||
|
>
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.btn} ${styles.btnSecondary}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalCambiarClave;
|
||||||
198
frontend/src/components/ModalDetallesEquipo.tsx
Normal file
198
frontend/src/components/ModalDetallesEquipo.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// frontend/src/components/ModalDetallesEquipo.tsx
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import type { Equipo, HistorialEquipo, Sector } from '../types/interfaces';
|
||||||
|
import { Tooltip } from 'react-tooltip';
|
||||||
|
import styles from './SimpleTable.module.css';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import AutocompleteInput from './AutocompleteInput';
|
||||||
|
|
||||||
|
// Interfaces actualizadas para las props
|
||||||
|
interface ModalDetallesEquipoProps {
|
||||||
|
equipo: Equipo;
|
||||||
|
isOnline: boolean;
|
||||||
|
historial: HistorialEquipo[];
|
||||||
|
sectores: Sector[];
|
||||||
|
onClose: () => void;
|
||||||
|
onDelete: (id: number) => Promise<boolean>;
|
||||||
|
onRemoveAssociation: (type: 'disco' | 'ram' | 'usuario', id: any) => void;
|
||||||
|
onEdit: (id: number, equipoEditado: any) => Promise<boolean>;
|
||||||
|
onAddComponent: (type: 'disco' | 'ram' | 'usuario') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_URL = 'http://localhost:5198/api';
|
||||||
|
|
||||||
|
const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
|
||||||
|
equipo, isOnline, historial, sectores, onClose, onDelete, onRemoveAssociation, onEdit, onAddComponent
|
||||||
|
}) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editableEquipo, setEditableEquipo] = useState({ ...equipo });
|
||||||
|
const [isMacValid, setIsMacValid] = useState(true);
|
||||||
|
const macRegex = /^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$/;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editableEquipo.mac && editableEquipo.mac.length > 0) {
|
||||||
|
setIsMacValid(macRegex.test(editableEquipo.mac));
|
||||||
|
} else {
|
||||||
|
setIsMacValid(true);
|
||||||
|
}
|
||||||
|
}, [editableEquipo.mac]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditableEquipo({ ...equipo });
|
||||||
|
}, [equipo]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setEditableEquipo(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: name === 'sector_id' ? (value ? parseInt(value, 10) : null) : value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMacBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
let value = e.target.value;
|
||||||
|
let cleaned = value.replace(/[^0-9A-Fa-f]/gi, '').toUpperCase().substring(0, 12);
|
||||||
|
if (cleaned.length === 12) {
|
||||||
|
value = cleaned.match(/.{1,2}/g)?.join(':') || '';
|
||||||
|
} else {
|
||||||
|
value = cleaned;
|
||||||
|
}
|
||||||
|
setEditableEquipo(prev => ({ ...prev, mac: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!isMacValid) {
|
||||||
|
toast.error("El formato de la MAC Address es incorrecto.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const success = await onEdit(equipo.id, editableEquipo);
|
||||||
|
if (success) setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditableEquipo({ ...equipo });
|
||||||
|
setIsMacValid(true);
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWolClick = async () => {
|
||||||
|
if (!equipo.mac || !equipo.ip) {
|
||||||
|
toast.error("Este equipo no tiene MAC o IP para encenderlo.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toastId = toast.loading('Enviando paquete WOL...');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/equipos/wake-on-lan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mac: equipo.mac, ip: equipo.ip })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("La respuesta del servidor no fue exitosa.");
|
||||||
|
toast.success('Solicitud de encendido enviada.', { id: toastId });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Error al enviar la solicitud.', { id: toastId });
|
||||||
|
console.error('Error al enviar la solicitud WOL:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = async () => {
|
||||||
|
const success = await onDelete(equipo.id);
|
||||||
|
if (success) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string | undefined | null) => {
|
||||||
|
if (!dateString || dateString.startsWith('0001-01-01')) return 'No registrado';
|
||||||
|
return new Date(dateString).toLocaleString('es-ES', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSaveDisabled = !editableEquipo.hostname || !editableEquipo.ip || !isMacValid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.modalLarge}>
|
||||||
|
<button onClick={onClose} className={styles.closeButton}>×</button>
|
||||||
|
<div className={styles.modalLargeContent}>
|
||||||
|
<div className={styles.modalLargeHeader}>
|
||||||
|
<h2>Detalles del equipo: <strong>{equipo.hostname}</strong></h2>
|
||||||
|
{equipo.origen === 'manual' && (
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={isSaveDisabled}>Guardar Cambios</button>
|
||||||
|
<button onClick={handleCancel} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setIsEditing(true)} className={`${styles.btn} ${styles.btnPrimary}`}>✏️ Editar</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.modalBodyColumns}>
|
||||||
|
{/* COLUMNA PRINCIPAL */}
|
||||||
|
<div className={styles.mainColumn}>
|
||||||
|
{/* SECCIÓN DE DATOS PRINCIPALES */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<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>)}
|
||||||
|
</div>
|
||||||
|
<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}>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}>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}>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}>Última Actualización:</strong><span className={styles.detailValue}>{formatDate(equipo.updated_at)}</span></div>
|
||||||
|
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Usuarios:</strong><span className={styles.detailValue}>{equipo.usuarios?.length > 0 ? equipo.usuarios.map(u => (<div key={u.id} className={styles.componentItem}><div><span title={`Origen: ${u.origen}`}>{u.origen === 'manual' ? '⌨️' : '⚙️'}</span>{` ${u.username}`}</div>{u.origen === 'manual' && (<button onClick={() => onRemoveAssociation('usuario', { equipoId: equipo.id, usuarioId: u.id })} className={styles.deleteUserButton} title="Quitar este usuario">🗑️</button>)}</div>)) : 'N/A'}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SECCIÓN DE COMPONENTES */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>💻 Componentes</h3>
|
||||||
|
<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}>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}>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.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.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>
|
||||||
|
|
||||||
|
{/* COLUMNA LATERAL */}
|
||||||
|
<div className={styles.sidebarColumn}>
|
||||||
|
{/* SECCIÓN DE ACCIONES */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>⚡ Acciones y Estado</h3>
|
||||||
|
<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}>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}>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>
|
||||||
|
|
||||||
|
{/* SECCIÓN DE HISTORIAL (FUERA DE LAS COLUMNAS) */}
|
||||||
|
<div className={`${styles.section} ${styles.historySectionFullWidth}`}>
|
||||||
|
<h3 className={styles.sectionTitle}>📜 Historial de cambios</h3>
|
||||||
|
<div className={styles.historyContainer}>
|
||||||
|
<table className={styles.historyTable}>
|
||||||
|
<thead><tr><th className={styles.historyTh}>Fecha</th><th className={styles.historyTh}>Campo</th><th className={styles.historyTh}>Valor anterior</th><th className={styles.historyTh}>Valor nuevo</th></tr></thead>
|
||||||
|
<tbody>{historial.sort((a, b) => new Date(b.fecha_cambio).getTime() - new Date(a.fecha_cambio).getTime()).map((cambio, index) => (<tr key={index} className={styles.historyTr}><td className={styles.historyTd}>{formatDate(cambio.fecha_cambio)}</td><td className={styles.historyTd}>{cambio.campo_modificado}</td><td className={styles.historyTd}>{cambio.valor_anterior}</td><td className={styles.historyTd}>{cambio.valor_nuevo}</td></tr>))}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalDetallesEquipo;
|
||||||
57
frontend/src/components/ModalEditarSector.tsx
Normal file
57
frontend/src/components/ModalEditarSector.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// frontend/src/components/ModalEditarSector.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import type { Equipo, Sector } from '../types/interfaces';
|
||||||
|
import styles from './SimpleTable.module.css';
|
||||||
|
|
||||||
|
interface ModalEditarSectorProps {
|
||||||
|
modalData: Equipo; // El componente padre asegura que esto no sea nulo
|
||||||
|
setModalData: (data: Equipo) => void;
|
||||||
|
sectores: Sector[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalEditarSector: React.FC<ModalEditarSectorProps> = ({ modalData, setModalData, sectores, onClose, onSave }) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.modalOverlay}>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
<h3>Editar Sector para {modalData.hostname}</h3>
|
||||||
|
<label>
|
||||||
|
Sector:
|
||||||
|
<select
|
||||||
|
className={styles.modalInput}
|
||||||
|
value={modalData.sector?.id || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selectedId = e.target.value;
|
||||||
|
const nuevoSector = selectedId === "" ? undefined : sectores.find(s => s.id === Number(selectedId));
|
||||||
|
setModalData({ ...modalData, sector: nuevoSector });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Asignar</option>
|
||||||
|
{sectores.map(sector => (
|
||||||
|
<option key={sector.id} value={sector.id}>{sector.nombre}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<button
|
||||||
|
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!modalData.sector}
|
||||||
|
>
|
||||||
|
Guardar cambios
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.btn} ${styles.btnSecondary}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalEditarSector;
|
||||||
59
frontend/src/components/ModalSector.tsx
Normal file
59
frontend/src/components/ModalSector.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import type { Sector } from '../types/interfaces';
|
||||||
|
import styles from './SimpleTable.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Si 'sector' es nulo, es para crear. Si tiene datos, es para editar.
|
||||||
|
sector: Sector | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (id: number | null, nombre: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalSector: React.FC<Props> = ({ sector, onClose, onSave }) => {
|
||||||
|
const [nombre, setNombre] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const isEditing = sector !== null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Si estamos editando, rellenamos el campo con el nombre actual
|
||||||
|
if (isEditing) {
|
||||||
|
setNombre(sector.nombre);
|
||||||
|
}
|
||||||
|
// Enfocar el input al abrir el modal
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 100);
|
||||||
|
}, [sector, isEditing]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (nombre.trim()) {
|
||||||
|
onSave(isEditing ? sector.id : null, nombre.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.modalOverlay}>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
<h3>{isEditing ? 'Editar Sector' : 'Añadir Nuevo Sector'}</h3>
|
||||||
|
<label>Nombre del Sector</label>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={nombre}
|
||||||
|
onChange={e => setNombre(e.target.value)}
|
||||||
|
className={styles.modalInput}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleSave()}
|
||||||
|
/>
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!nombre.trim()}>
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalSector;
|
||||||
41
frontend/src/components/Navbar.tsx
Normal file
41
frontend/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// frontend/src/components/Navbar.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import type { View } from '../App'; // Importaremos el tipo desde App.tsx
|
||||||
|
import '../App.css'; // Usaremos los estilos globales que acabamos de crear
|
||||||
|
|
||||||
|
interface NavbarProps {
|
||||||
|
currentView: View;
|
||||||
|
setCurrentView: (view: View) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
|
||||||
|
return (
|
||||||
|
<header className="navbar">
|
||||||
|
<div className="app-title">
|
||||||
|
Inventario IT
|
||||||
|
</div>
|
||||||
|
<nav className="nav-links">
|
||||||
|
<button
|
||||||
|
className={`nav-link ${currentView === 'equipos' ? 'nav-link-active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('equipos')}
|
||||||
|
>
|
||||||
|
Equipos
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`nav-link ${currentView === 'sectores' ? 'nav-link-active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('sectores')}
|
||||||
|
>
|
||||||
|
Sectores
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`nav-link ${currentView === 'admin' ? 'nav-link-active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('admin')}
|
||||||
|
>
|
||||||
|
Administración
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
490
frontend/src/components/SimpleTable.module.css
Normal file
490
frontend/src/components/SimpleTable.module.css
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
/* Estilos para el contenedor principal y controles */
|
||||||
|
.controlsContainer {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput, .sectorSelect {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Estilos de la tabla */
|
||||||
|
.table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1200px; /* Ancho mínimo para forzar el scroll horizontal si es necesario */
|
||||||
|
}
|
||||||
|
|
||||||
|
.th {
|
||||||
|
color: #212529;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: sticky;
|
||||||
|
top: 0; /* Mantiene la posición sticky en la parte superior del viewport */
|
||||||
|
z-index: 2;
|
||||||
|
background-color: #f8f9fa; /* Es crucial tener un fondo sólido */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortIndicator {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-size: 1.2em;
|
||||||
|
display: inline-block;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
color: #007bff;
|
||||||
|
min-width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip{
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tr {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tr:hover {
|
||||||
|
background-color: #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
color: #495057;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Estilos de botones dentro de la tabla */
|
||||||
|
.hostnameButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #007bff;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
padding: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableButton {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #212529;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.tableButton:hover {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border-color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteUserButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0 5px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.3s ease, color 0.3s ease;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.deleteUserButton:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: #a4202e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Estilo para el botón de scroll-to-top */
|
||||||
|
.scrollToTop {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 60px;
|
||||||
|
right: 20px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||||
|
font-size: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.3s, transform 0.3s;
|
||||||
|
z-index: 1002;
|
||||||
|
}
|
||||||
|
.scrollToTop:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Estilos genéricos para modales */
|
||||||
|
.modalOverlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 1000;
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 90%;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
font-family: 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h3 {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: #2d3436;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalInput {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-top: 4px; /* Separado del label */
|
||||||
|
margin-bottom: 4px; /* Espacio antes del siguiente elemento */
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
justify-content: flex-end; /* Alinea los botones a la derecha por defecto */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Estilos de botones para modales */
|
||||||
|
.btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPrimary {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btnPrimary:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
.btnPrimary:disabled {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSecondary {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btnSecondary:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== NUEVOS ESTILOS PARA EL MODAL DE DETALLES ===== */
|
||||||
|
|
||||||
|
.modalLarge {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: #f8f9fa; /* Un fondo ligeramente gris para el modal */
|
||||||
|
z-index: 1003;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalLargeContent {
|
||||||
|
max-width: 1400px; /* Ancho máximo del contenido */
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto; /* Centrar el contenido */
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalLargeHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.05rem;
|
||||||
|
padding-bottom: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalLargeHeader h2 {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #343a40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
background: black;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1004;
|
||||||
|
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||||
|
transition: transform 0.2s, background-color 0.2s;
|
||||||
|
position: fixed;
|
||||||
|
right: 30px;
|
||||||
|
top: 30px;
|
||||||
|
}
|
||||||
|
.closeButton:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalBodyColumns {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainColumn {
|
||||||
|
flex: 3;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarColumn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
color: #2d3436;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CAMBIO: Se aplica el mismo estilo de grid a componentsGrid para que se vea igual que detailsGrid */
|
||||||
|
.componentsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailItem, .detailItemFull {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailLabel {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailValue {
|
||||||
|
color: #495057;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.componentItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.powerButton, .deleteButton {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.powerButton:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
background-color: #e7f1ff;
|
||||||
|
color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.powerIcon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteButton {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
.deleteButton:hover {
|
||||||
|
border-color: #dc3545;
|
||||||
|
background-color: #fbebee;
|
||||||
|
color: #a4202e;
|
||||||
|
}
|
||||||
|
.deleteButton:disabled {
|
||||||
|
color: #6c757d;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border-color: #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyContainer {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyTh {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyTd {
|
||||||
|
padding: 12px;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyTr:last-child .historyTd {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CAMBIO: Nueva clase para dar espacio a la sección de historial */
|
||||||
|
.historySectionFullWidth {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusIndicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusOnline {
|
||||||
|
background-color: #28a745;
|
||||||
|
box-shadow: 0 0 8px #28a74580;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusOffline {
|
||||||
|
background-color: #dc3545;
|
||||||
|
box-shadow: 0 0 8px #dc354580;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputError {
|
||||||
|
border-color: #dc3545;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMessage {
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clases para la sección de usuarios y claves - No se usan en el nuevo modal pero se mantienen por si acaso */
|
||||||
|
.userList { min-width: 240px; }
|
||||||
|
.userItem { display: flex; align-items: center; justify-content: space-between; margin: 4px 0; padding: 6px; background-color: #f8f9fa; border-radius: 4px; position: relative; }
|
||||||
|
.userInfo { color: #495057; }
|
||||||
|
.userActions { display: flex; gap: 4px; align-items: center; }
|
||||||
|
.sectorContainer { display: flex; justify-content: space-between; align-items: center; width: 100%; gap: 0.5rem; }
|
||||||
|
.sectorName { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.sectorNameAssigned { color: #212529; font-style: normal; }
|
||||||
|
.sectorNameUnassigned { color: #6c757d; font-style: italic; }
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,30 @@
|
|||||||
|
// frontend/src/main.tsx
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import './index.css' // Importaremos un CSS base aquí
|
import './index.css'
|
||||||
|
import { Toaster } from 'react-hot-toast' // Importamos el Toaster
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
<Toaster
|
||||||
|
position="bottom-right" // Posición de las notificaciones
|
||||||
|
toastOptions={{
|
||||||
|
// Estilos por defecto para las notificaciones
|
||||||
|
success: {
|
||||||
|
style: {
|
||||||
|
background: '#28a745',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
style: {
|
||||||
|
background: '#dc3545',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
@@ -13,6 +13,11 @@ export interface Usuario {
|
|||||||
password?: string; // Es opcional ya que no siempre lo enviaremos
|
password?: string; // Es opcional ya que no siempre lo enviaremos
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAMBIO: Añadimos el origen y el ID de la asociación
|
||||||
|
export interface UsuarioEquipoDetalle extends Usuario {
|
||||||
|
origen: 'manual' | 'automatica';
|
||||||
|
}
|
||||||
|
|
||||||
// Corresponde al modelo 'Disco'
|
// Corresponde al modelo 'Disco'
|
||||||
export interface Disco {
|
export interface Disco {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -20,18 +25,26 @@ export interface Disco {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Corresponde al modelo 'MemoriaRam'
|
// CAMBIO: Añadimos el origen y el ID de la asociación
|
||||||
export interface MemoriaRam {
|
export interface DiscoDetalle extends Disco {
|
||||||
id: number;
|
equipoDiscoId: number; // El ID de la tabla equipos_discos
|
||||||
partNumber?: string;
|
origen: 'manual' | 'automatica';
|
||||||
fabricante?: string;
|
|
||||||
tamano: number;
|
|
||||||
velocidad?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interfaz combinada para mostrar los detalles de la RAM en la tabla de equipos
|
// Corresponde al modelo 'MemoriaRam'
|
||||||
export interface MemoriaRamDetalle extends MemoriaRam {
|
export interface MemoriaRam {
|
||||||
slot: string;
|
id: number;
|
||||||
|
partNumber?: string;
|
||||||
|
fabricante?: string;
|
||||||
|
tamano: number;
|
||||||
|
velocidad?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CCorresponde al modelo 'MemoriaRamEquipoDetalle'
|
||||||
|
export interface MemoriaRamEquipoDetalle extends MemoriaRam {
|
||||||
|
equipoMemoriaRamId: number; // El ID de la tabla equipos_memorias_ram
|
||||||
|
slot: string;
|
||||||
|
origen: 'manual' | 'automatica';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Corresponde al modelo 'HistorialEquipo'
|
// Corresponde al modelo 'HistorialEquipo'
|
||||||
@@ -59,11 +72,12 @@ export interface Equipo {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
sector_id?: number;
|
sector_id?: number;
|
||||||
|
origen: 'manual' | 'automatica'; // Campo de origen para el equipo
|
||||||
|
|
||||||
// Propiedades de navegación que vienen de las relaciones (JOINs)
|
// Propiedades de navegación que vienen de las relaciones (JOINs)
|
||||||
sector?: Sector;
|
sector?: Sector;
|
||||||
usuarios: Usuario[];
|
usuarios: UsuarioEquipoDetalle[];
|
||||||
discos: Disco[];
|
discos: DiscoDetalle[];
|
||||||
memoriasRam: MemoriaRamDetalle[];
|
memoriasRam: MemoriaRamEquipoDetalle[];
|
||||||
historial: HistorialEquipo[];
|
historial: HistorialEquipo[];
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user