diff --git a/Backend/GestionIntegral.Api/Controllers/Anomalia/AlertasController.cs b/Backend/GestionIntegral.Api/Controllers/Anomalia/AlertasController.cs new file mode 100644 index 0000000..e279003 --- /dev/null +++ b/Backend/GestionIntegral.Api/Controllers/Anomalia/AlertasController.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using System.Collections.Generic; +using System.Threading.Tasks; +using GestionIntegral.Api.Dtos.Anomalia; +using GestionIntegral.Api.Services.Anomalia; + +namespace GestionIntegral.Api.Controllers.Anomalia +{ + [Route("api/alertas")] + [ApiController] + [Authorize] + public class AlertasController : ControllerBase + { + private readonly IAlertaService _alertaService; + + public AlertasController(IAlertaService alertaService) + { + _alertaService = alertaService; + } + + // GET: api/alertas + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetAlertasNoLeidas() + { + var alertas = await _alertaService.ObtenerAlertasNoLeidasAsync(); + return Ok(alertas); + } + + // POST: api/alertas/{idAlerta}/marcar-leida + [HttpPost("{idAlerta:int}/marcar-leida")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task MarcarComoLeida(int idAlerta) + { + var (exito, error) = await _alertaService.MarcarComoLeidaAsync(idAlerta); + if (!exito) + { + return NotFound(new { message = error }); + } + return NoContent(); + } + + // POST: api/alertas/marcar-grupo-leido + [HttpPost("marcar-grupo-leido")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task MarcarGrupoLeido([FromBody] MarcarGrupoLeidoRequestDto request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + var (exito, error) = await _alertaService.MarcarGrupoComoLeidoAsync(request.TipoAlerta, request.IdEntidad); + if (!exito) + { + return BadRequest(new { message = error }); + } + return NoContent(); + } + } + + // DTO para el cuerpo del request de marcar grupo + public class MarcarGrupoLeidoRequestDto + { + [System.ComponentModel.DataAnnotations.Required] + public string TipoAlerta { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Required] + public int IdEntidad { get; set; } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Controllers/Distribucion/CanillasController.cs b/Backend/GestionIntegral.Api/Controllers/Distribucion/CanillasController.cs index bae03d9..7bf7342 100644 --- a/Backend/GestionIntegral.Api/Controllers/Distribucion/CanillasController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Distribucion/CanillasController.cs @@ -50,10 +50,19 @@ namespace GestionIntegral.Api.Controllers.Distribucion public async Task GetAllCanillas([FromQuery] string? nomApe, [FromQuery] int? legajo, [FromQuery] bool? esAccionista, [FromQuery] bool? soloActivos = true) { if (!TienePermiso(PermisoVer)) return Forbid(); - var canillitas = await _canillaService.ObtenerTodosAsync(nomApe, legajo, soloActivos, esAccionista); // <<-- Pasa el parámetro + var canillitas = await _canillaService.ObtenerTodosAsync(nomApe, legajo, soloActivos, esAccionista); return Ok(canillitas); } + [HttpGet("dropdown")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetAllDropdownCanillas([FromQuery] bool? esAccionista, [FromQuery] bool? soloActivos = true) + { + var canillitas = await _canillaService.ObtenerTodosDropdownAsync(esAccionista, soloActivos); + return Ok(canillitas); + } + + // GET: api/canillas/{id} [HttpGet("{id:int}", Name = "GetCanillaById")] [ProducesResponseType(typeof(CanillaDto), StatusCodes.Status200OK)] diff --git a/Backend/GestionIntegral.Api/Controllers/Distribucion/OtrosDestinosController.cs b/Backend/GestionIntegral.Api/Controllers/Distribucion/OtrosDestinosController.cs index 2c97f7d..88f69ed 100644 --- a/Backend/GestionIntegral.Api/Controllers/Distribucion/OtrosDestinosController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Distribucion/OtrosDestinosController.cs @@ -64,6 +64,23 @@ namespace GestionIntegral.Api.Controllers.Distribucion } } + [HttpGet("dropdown")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task GetAllOtrosDestinosDropdown() + { + try + { + var destinos = await _otroDestinoService.ObtenerTodosDropdownAsync(); + return Ok(destinos); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error al obtener Otros Destinos para dropdown."); + return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al obtener la lista de destinos."); + } + } + // GET: api/otrosdestinos/{id} [HttpGet("{id:int}", Name = "GetOtroDestinoById")] [ProducesResponseType(typeof(OtroDestinoDto), StatusCodes.Status200OK)] diff --git a/Backend/GestionIntegral.Api/Controllers/Impresion/EstadosBobinaController.cs b/Backend/GestionIntegral.Api/Controllers/Impresion/EstadosBobinaController.cs index 186b6aa..be60957 100644 --- a/Backend/GestionIntegral.Api/Controllers/Impresion/EstadosBobinaController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Impresion/EstadosBobinaController.cs @@ -67,6 +67,23 @@ namespace GestionIntegral.Api.Controllers.Impresion } } + // GET: api/estadosbobina/dropdown + [HttpGet("dropdown")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetAllDropdownEstadosBobina() + { + try + { + var estados = await _estadoBobinaService.ObtenerTodosDropdownAsync(); + return Ok(estados); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error al obtener todos los Estados de Bobina."); + return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al obtener los estados de bobina."); + } + } + // GET: api/estadosbobina/{id} [HttpGet("{id:int}", Name = "GetEstadoBobinaById")] [ProducesResponseType(typeof(EstadoBobinaDto), StatusCodes.Status200OK)] diff --git a/Backend/GestionIntegral.Api/Controllers/Impresion/TiposBobinaController.cs b/Backend/GestionIntegral.Api/Controllers/Impresion/TiposBobinaController.cs index 67670e2..dc8cd25 100644 --- a/Backend/GestionIntegral.Api/Controllers/Impresion/TiposBobinaController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Impresion/TiposBobinaController.cs @@ -62,6 +62,25 @@ namespace GestionIntegral.Api.Controllers.Impresion } } + // GET: api/tiposbobina/dropdown + [HttpGet("dropdown")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task GetAllTiposBobina() + { + try + { + var tiposBobina = await _tipoBobinaService.ObtenerTodosDropdownAsync(); + return Ok(tiposBobina); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error al obtener todos los Tipos de Bobina."); + return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al obtener los tipos de bobina."); + } + } + // GET: api/tiposbobina/{id} // Permiso: IB006 (Ver Tipos Bobinas) [HttpGet("{id:int}", Name = "GetTipoBobinaById")] diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/CanillaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/CanillaRepository.cs index baddbaa..ca8bebb 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/CanillaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/CanillaRepository.cs @@ -1,4 +1,5 @@ using Dapper; +using GestionIntegral.Api.Dtos.Distribucion; using GestionIntegral.Api.Models.Distribucion; using Microsoft.Extensions.Logging; using System; // Para Exception @@ -25,7 +26,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion string? nomApeFilter, int? legajoFilter, bool? esAccionista, - bool? soloActivos) // <<-- Parámetro aquí + bool? soloActivos) { using var connection = _connectionFactory.CreateConnection(); var sqlBuilder = new System.Text.StringBuilder(@" @@ -73,6 +74,37 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion return result; } + public async Task> GetAllDropdownAsync(bool? esAccionista, bool? soloActivos) + { + using var connection = _connectionFactory.CreateConnection(); + var sqlBuilder = new System.Text.StringBuilder(@" + SELECT c.Id_Canilla AS IdCanilla, c.Legajo, c.NomApe + FROM dbo.dist_dtCanillas c + WHERE 1=1 "); + + var parameters = new DynamicParameters(); + + if (soloActivos.HasValue) + { + sqlBuilder.Append(" AND c.Baja = @BajaStatus "); + parameters.Add("BajaStatus", !soloActivos.Value); // Si soloActivos es true, Baja debe ser false + } + + if (esAccionista.HasValue) + { + sqlBuilder.Append(" AND c.Accionista = @EsAccionista "); + parameters.Add("EsAccionista", esAccionista.Value); // true para accionistas, false para no accionistas (canillitas) + } + + sqlBuilder.Append(" ORDER BY c.NomApe;"); + + var result = await connection.QueryAsync( + sqlBuilder.ToString(), + parameters + ); + return result; + } + public async Task<(Canilla? Canilla, string? NombreZona, string? NombreEmpresa)> GetByIdAsync(int id) { const string sql = @" diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/ICanillaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/ICanillaRepository.cs index ef68eb5..caeea69 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/ICanillaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/ICanillaRepository.cs @@ -2,12 +2,14 @@ using GestionIntegral.Api.Models.Distribucion; using System.Collections.Generic; using System.Threading.Tasks; using System.Data; +using GestionIntegral.Api.Dtos.Distribucion; namespace GestionIntegral.Api.Data.Repositories.Distribucion { public interface ICanillaRepository { Task> GetAllAsync(string? nomApeFilter, int? legajoFilter, bool? soloActivos, bool? esAccionista); + Task> GetAllDropdownAsync(bool? esAccionista, bool? soloActivos); Task<(Canilla? Canilla, string? NombreZona, string? NombreEmpresa)> GetByIdAsync(int id); Task GetByIdSimpleAsync(int id); // Para obtener solo la entidad Canilla Task CreateAsync(Canilla nuevoCanilla, int idUsuario, IDbTransaction transaction); diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/IOtroDestinoRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/IOtroDestinoRepository.cs index f3fe5ed..8165b77 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/IOtroDestinoRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/IOtroDestinoRepository.cs @@ -2,12 +2,14 @@ using GestionIntegral.Api.Models.Distribucion; using System.Collections.Generic; using System.Threading.Tasks; using System.Data; +using GestionIntegral.Api.Dtos.Distribucion; namespace GestionIntegral.Api.Data.Repositories.Distribucion { public interface IOtroDestinoRepository { Task> GetAllAsync(string? nombreFilter); + Task> GetAllDropdownAsync(); Task GetByIdAsync(int id); Task CreateAsync(OtroDestino nuevoDestino, int idUsuario, IDbTransaction transaction); Task UpdateAsync(OtroDestino destinoAActualizar, int idUsuario, IDbTransaction transaction); diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/OtroDestinoRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/OtroDestinoRepository.cs index 6b107ad..52aa265 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/OtroDestinoRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/OtroDestinoRepository.cs @@ -1,4 +1,5 @@ using Dapper; +using GestionIntegral.Api.Dtos.Distribucion; using GestionIntegral.Api.Models.Distribucion; using Microsoft.Extensions.Logging; using System.Collections.Generic; @@ -44,6 +45,21 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion } } + public async Task> GetAllDropdownAsync() + { + const string sql = "SELECT Id_Destino AS IdDestino, Nombre FROM dbo.dist_dtOtrosDestinos ORDER BY Nombre;"; + try + { + using var connection = _connectionFactory.CreateConnection(); + return await connection.QueryAsync(sql); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error al obtener Otros Destinos para dropdown."); + return Enumerable.Empty(); + } + } + public async Task GetByIdAsync(int id) { const string sql = "SELECT Id_Destino AS IdDestino, Nombre, Obs FROM dbo.dist_dtOtrosDestinos WHERE Id_Destino = @Id"; diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Impresion/EstadoBobinaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Impresion/EstadoBobinaRepository.cs index bc8bfd7..55ac6c6 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Impresion/EstadoBobinaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Impresion/EstadoBobinaRepository.cs @@ -1,5 +1,6 @@ using Dapper; using GestionIntegral.Api.Data.Repositories.Impresion; +using GestionIntegral.Api.Dtos.Impresion; using GestionIntegral.Api.Models.Impresion; using Microsoft.Extensions.Logging; using System.Collections.Generic; @@ -45,6 +46,25 @@ namespace GestionIntegral.Api.Data.Repositories.Impresion } } + public async Task> GetAllDropdownAsync() + { + var sqlBuilder = new StringBuilder("SELECT Id_EstadoBobina AS IdEstadoBobina, Denominacion FROM dbo.bob_dtEstadosBobinas WHERE 1=1"); + var parameters = new DynamicParameters(); + + sqlBuilder.Append(" ORDER BY Denominacion;"); + + try + { + using var connection = _connectionFactory.CreateConnection(); + return await connection.QueryAsync(sqlBuilder.ToString(), parameters); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error al obtener todos los Estados de Bobina."); + return Enumerable.Empty(); + } + } + public async Task GetByIdAsync(int id) { const string sql = "SELECT Id_EstadoBobina AS IdEstadoBobina, Denominacion, Obs FROM dbo.bob_dtEstadosBobinas WHERE Id_EstadoBobina = @Id"; diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Impresion/IEstadoBobinaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Impresion/IEstadoBobinaRepository.cs index 0d61be6..b56db12 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Impresion/IEstadoBobinaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Impresion/IEstadoBobinaRepository.cs @@ -2,12 +2,14 @@ using GestionIntegral.Api.Models.Impresion; using System.Collections.Generic; using System.Threading.Tasks; using System.Data; +using GestionIntegral.Api.Dtos.Impresion; namespace GestionIntegral.Api.Data.Repositories.Impresion { public interface IEstadoBobinaRepository { Task> GetAllAsync(string? denominacionFilter); + Task> GetAllDropdownAsync(); Task GetByIdAsync(int id); Task CreateAsync(EstadoBobina nuevoEstadoBobina, int idUsuario, IDbTransaction transaction); Task UpdateAsync(EstadoBobina estadoBobinaAActualizar, int idUsuario, IDbTransaction transaction); diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Impresion/ITipoBobinaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Impresion/ITipoBobinaRepository.cs index 60a39bc..536ebb1 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Impresion/ITipoBobinaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Impresion/ITipoBobinaRepository.cs @@ -8,6 +8,7 @@ namespace GestionIntegral.Api.Data.Repositories.Impresion public interface ITipoBobinaRepository { Task> GetAllAsync(string? denominacionFilter); + Task> GetAllDropdownAsync(); Task GetByIdAsync(int id); Task CreateAsync(TipoBobina nuevoTipoBobina, int idUsuario, IDbTransaction transaction); Task UpdateAsync(TipoBobina tipoBobinaAActualizar, int idUsuario, IDbTransaction transaction); diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Impresion/TipoBobinaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Impresion/TipoBobinaRepository.cs index a04e458..4bd2d4d 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Impresion/TipoBobinaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Impresion/TipoBobinaRepository.cs @@ -44,6 +44,24 @@ namespace GestionIntegral.Api.Data.Repositories.Impresion } } + public async Task> GetAllDropdownAsync() + { + var sqlBuilder = new StringBuilder("SELECT Id_TipoBobina AS IdTipoBobina, Denominacion FROM dbo.bob_dtBobinas WHERE 1=1"); + + sqlBuilder.Append(" ORDER BY Denominacion;"); + + try + { + using var connection = _connectionFactory.CreateConnection(); + return await connection.QueryAsync(sqlBuilder.ToString()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error al obtener todos los Tipos de Bobina."); + return Enumerable.Empty(); + } + } + public async Task GetByIdAsync(int id) { const string sql = "SELECT Id_TipoBobina AS IdTipoBobina, Denominacion FROM dbo.bob_dtBobinas WHERE Id_TipoBobina = @Id"; diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Anomalia/AlertaGenericaDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Anomalia/AlertaGenericaDto.cs new file mode 100644 index 0000000..a29e680 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Anomalia/AlertaGenericaDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace GestionIntegral.Api.Dtos.Anomalia +{ + public class AlertaGenericaDto + { + public int IdAlerta { get; set; } + public DateTime FechaDeteccion { get; set; } + public string TipoAlerta { get; set; } = string.Empty; + public string Entidad { get; set; } = string.Empty; + public int IdEntidad { get; set; } + public string Mensaje { get; set; } = string.Empty; + public DateTime FechaAnomalia { get; set; } + public bool Leida { get; set; } + + // Propiedades que pueden ser nulas porque no aplican a todos los tipos de alerta + public int? CantidadEnviada { get; set; } + public int? CantidadDevuelta { get; set; } + public decimal? PorcentajeDevolucion { get; set; } + + // Podríamos añadir más propiedades opcionales en el futuro + // public string? NombreEntidad { get; set; } // Por ejemplo, el nombre del canillita + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Distribucion/CanillaDropdownDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Distribucion/CanillaDropdownDto.cs new file mode 100644 index 0000000..c931e8b --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Distribucion/CanillaDropdownDto.cs @@ -0,0 +1,9 @@ +namespace GestionIntegral.Api.Dtos.Distribucion +{ + public class CanillaDropdownDto + { + public int IdCanilla { get; set; } + public int? Legajo { get; set; } + public string NomApe { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Distribucion/OtroDestinoDropdownDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Distribucion/OtroDestinoDropdownDto.cs new file mode 100644 index 0000000..6032b51 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Distribucion/OtroDestinoDropdownDto.cs @@ -0,0 +1,8 @@ +namespace GestionIntegral.Api.Dtos.Distribucion +{ + public class OtroDestinoDropdownDto + { + public int IdDestino { get; set; } + public string Nombre { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Distribucion/PublicacioDropdownDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Distribucion/PublicacionDropdownDto.cs similarity index 64% rename from Backend/GestionIntegral.Api/Models/Dtos/Distribucion/PublicacioDropdownDto.cs rename to Backend/GestionIntegral.Api/Models/Dtos/Distribucion/PublicacionDropdownDto.cs index 258209f..6ac6ec1 100644 --- a/Backend/GestionIntegral.Api/Models/Dtos/Distribucion/PublicacioDropdownDto.cs +++ b/Backend/GestionIntegral.Api/Models/Dtos/Distribucion/PublicacionDropdownDto.cs @@ -4,6 +4,7 @@ namespace GestionIntegral.Api.Dtos.Distribucion { public int IdPublicacion { get; set; } public string Nombre { get; set; } = string.Empty; - public bool Habilitada { get; set; } // Simplificamos a bool, el backend manejará el default si es null + public string NombreEmpresa { get; set; } = string.Empty; + public bool Habilitada { get; set; } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Impresion/EstadoBobinaDropdownDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Impresion/EstadoBobinaDropdownDto.cs new file mode 100644 index 0000000..eea3a7d --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Impresion/EstadoBobinaDropdownDto.cs @@ -0,0 +1,8 @@ +namespace GestionIntegral.Api.Dtos.Impresion +{ + public class EstadoBobinaDropdownDto + { + public int IdEstadoBobina { get; set; } + public string Denominacion { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Program.cs b/Backend/GestionIntegral.Api/Program.cs index 80175c0..432202b 100644 --- a/Backend/GestionIntegral.Api/Program.cs +++ b/Backend/GestionIntegral.Api/Program.cs @@ -17,6 +17,7 @@ using GestionIntegral.Api.Data.Repositories.Reportes; using GestionIntegral.Api.Services.Reportes; using GestionIntegral.Api.Services.Pdf; using Microsoft.Extensions.Diagnostics.HealthChecks; +using GestionIntegral.Api.Services.Anomalia; var builder = WebApplication.CreateBuilder(args); @@ -96,6 +97,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); // QuestPDF builder.Services.AddScoped(); +// Servicio de Alertas +builder.Services.AddScoped(); // --- SERVICIO DE HEALTH CHECKS --- // Añadimos una comprobación específica para SQL Server. diff --git a/Backend/GestionIntegral.Api/Services/Anomalia/AlertaService.cs b/Backend/GestionIntegral.Api/Services/Anomalia/AlertaService.cs new file mode 100644 index 0000000..2267c2b --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Anomalia/AlertaService.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using GestionIntegral.Api.Data; +using GestionIntegral.Api.Dtos.Anomalia; +using Microsoft.Extensions.Logging; + +namespace GestionIntegral.Api.Services.Anomalia +{ + public class AlertaService : IAlertaService + { + private readonly DbConnectionFactory _dbConnectionFactory; + private readonly ILogger _logger; + + public AlertaService(DbConnectionFactory dbConnectionFactory, ILogger logger) + { + _dbConnectionFactory = dbConnectionFactory; + _logger = logger; + } + + public async Task> ObtenerAlertasNoLeidasAsync() + { + // Apunta a la nueva tabla genérica 'Sistema_Alertas' + var query = "SELECT * FROM Sistema_Alertas WHERE Leida = 0 ORDER BY FechaDeteccion DESC"; + try + { + using (var connection = _dbConnectionFactory.CreateConnection()) + { + var alertas = await connection.QueryAsync(query); + return alertas ?? Enumerable.Empty(); + } + } + catch (System.Exception ex) + { + _logger.LogError(ex, "Error al obtener las alertas no leídas desde Sistema_Alertas."); + return Enumerable.Empty(); + } + } + + public async Task<(bool Exito, string? Error)> MarcarComoLeidaAsync(int idAlerta) + { + var query = "UPDATE Sistema_Alertas SET Leida = 1 WHERE IdAlerta = @IdAlerta"; + try + { + using (var connection = _dbConnectionFactory.CreateConnection()) + { + var result = await connection.ExecuteAsync(query, new { IdAlerta = idAlerta }); + if (result > 0) + { + return (true, null); + } + return (false, "La alerta no fue encontrada o ya estaba marcada."); + } + } + catch (System.Exception ex) + { + _logger.LogError(ex, "Error al marcar la alerta {IdAlerta} como leída.", idAlerta); + return (false, "Error interno del servidor."); + } + } + + public async Task<(bool Exito, string? Error)> MarcarGrupoComoLeidoAsync(string tipoAlerta, int idEntidad) + { + var query = "UPDATE Sistema_Alertas SET Leida = 1 WHERE TipoAlerta = @TipoAlerta AND IdEntidad = @IdEntidad AND Leida = 0"; + try + { + using (var connection = _dbConnectionFactory.CreateConnection()) + { + var result = await connection.ExecuteAsync(query, new { TipoAlerta = tipoAlerta, IdEntidad = idEntidad }); + // No es un error si no se actualizan filas (puede que no hubiera ninguna para ese grupo) + _logger.LogInformation("Marcadas como leídas {Count} alertas para Tipo: {Tipo}, EntidadID: {IdEntidad}", result, tipoAlerta, idEntidad); + return (true, null); + } + } + catch (System.Exception ex) + { + _logger.LogError(ex, "Error al marcar grupo de alertas como leídas. Tipo: {Tipo}, EntidadID: {IdEntidad}", tipoAlerta, idEntidad); + return (false, "Error interno del servidor."); + } + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Anomalia/IAlertaService.cs b/Backend/GestionIntegral.Api/Services/Anomalia/IAlertaService.cs new file mode 100644 index 0000000..80ca410 --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Anomalia/IAlertaService.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using GestionIntegral.Api.Dtos.Anomalia; + +namespace GestionIntegral.Api.Services.Anomalia +{ + public interface IAlertaService + { + /// + /// Obtiene todas las alertas que no han sido marcadas como leídas. + /// + /// Una colección de DTOs de alertas genéricas. + Task> ObtenerAlertasNoLeidasAsync(); + + /// + /// Marca una alerta específica como leída. + /// + /// El ID de la alerta a marcar. + /// Una tupla indicando si la operación fue exitosa y un mensaje de error si falló. + Task<(bool Exito, string? Error)> MarcarComoLeidaAsync(int idAlerta); + + /// + /// Marca como leídas todas las alertas de un mismo tipo y para una misma entidad. + /// (Ej: todas las alertas de "DevolucionAnomala" para el Canillita ID 45). + /// + /// El tipo de alerta a marcar (ej. "DevolucionAnomala"). + /// El ID de la entidad afectada (ej. el IdCanilla). + /// Una tupla indicando si la operación fue exitosa y un mensaje de error si falló. + Task<(bool Exito, string? Error)> MarcarGrupoComoLeidoAsync(string tipoAlerta, int idEntidad); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Distribucion/CanillaService.cs b/Backend/GestionIntegral.Api/Services/Distribucion/CanillaService.cs index 7631def..e19d1fd 100644 --- a/Backend/GestionIntegral.Api/Services/Distribucion/CanillaService.cs +++ b/Backend/GestionIntegral.Api/Services/Distribucion/CanillaService.cs @@ -33,7 +33,6 @@ namespace GestionIntegral.Api.Services.Distribucion _logger = logger; } - // CORREGIDO: MapToDto ahora acepta una tupla con tipos anulables private CanillaDto? MapToDto((Canilla? Canilla, string? NombreZona, string? NombreEmpresa) data) { if (data.Canilla == null) return null; @@ -62,10 +61,14 @@ namespace GestionIntegral.Api.Services.Distribucion return data.Select(MapToDto).Where(dto => dto != null).Select(dto => dto!); } + public async Task> ObtenerTodosDropdownAsync(bool? esAccionista, bool? soloActivos) + { + return await _canillaRepository.GetAllDropdownAsync(esAccionista, soloActivos) ?? Enumerable.Empty(); + } + public async Task ObtenerPorIdAsync(int id) { var data = await _canillaRepository.GetByIdAsync(id); - // MapToDto ahora devuelve CanillaDto? así que esto es correcto return MapToDto(data); } @@ -89,7 +92,6 @@ namespace GestionIntegral.Api.Services.Distribucion } } - // CORREGIDO: Usar directamente el valor booleano if (createDto.Accionista == true && createDto.Empresa != 0) { return (null, "Un canillita accionista no debe tener una empresa asignada (Empresa debe ser 0)."); @@ -287,6 +289,6 @@ namespace GestionIntegral.Api.Services.Distribucion FechaMod = h.Historial.FechaMod, TipoMod = h.Historial.TipoMod }).ToList(); - } + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Distribucion/ICanillaService.cs b/Backend/GestionIntegral.Api/Services/Distribucion/ICanillaService.cs index e23b396..0037a23 100644 --- a/Backend/GestionIntegral.Api/Services/Distribucion/ICanillaService.cs +++ b/Backend/GestionIntegral.Api/Services/Distribucion/ICanillaService.cs @@ -8,6 +8,7 @@ namespace GestionIntegral.Api.Services.Distribucion public interface ICanillaService { Task> ObtenerTodosAsync(string? nomApeFilter, int? legajoFilter, bool? esAccionista, bool? soloActivos); + Task> ObtenerTodosDropdownAsync(bool? esAccionista, bool? soloActivos); Task ObtenerPorIdAsync(int id); Task<(CanillaDto? Canilla, string? Error)> CrearAsync(CreateCanillaDto createDto, int idUsuario); Task<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateCanillaDto updateDto, int idUsuario); diff --git a/Backend/GestionIntegral.Api/Services/Distribucion/IOtroDestinoService.cs b/Backend/GestionIntegral.Api/Services/Distribucion/IOtroDestinoService.cs index 19f9a14..3840777 100644 --- a/Backend/GestionIntegral.Api/Services/Distribucion/IOtroDestinoService.cs +++ b/Backend/GestionIntegral.Api/Services/Distribucion/IOtroDestinoService.cs @@ -8,6 +8,7 @@ namespace GestionIntegral.Api.Services.Distribucion public interface IOtroDestinoService { Task> ObtenerTodosAsync(string? nombreFilter); + Task> ObtenerTodosDropdownAsync(); Task ObtenerPorIdAsync(int id); Task<(OtroDestinoDto? Destino, string? Error)> CrearAsync(CreateOtroDestinoDto createDto, int idUsuario); Task<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateOtroDestinoDto updateDto, int idUsuario); diff --git a/Backend/GestionIntegral.Api/Services/Distribucion/OtroDestinoService.cs b/Backend/GestionIntegral.Api/Services/Distribucion/OtroDestinoService.cs index f603363..2a4d7db 100644 --- a/Backend/GestionIntegral.Api/Services/Distribucion/OtroDestinoService.cs +++ b/Backend/GestionIntegral.Api/Services/Distribucion/OtroDestinoService.cs @@ -37,6 +37,11 @@ namespace GestionIntegral.Api.Services.Distribucion return destinos.Select(MapToDto); } + public async Task> ObtenerTodosDropdownAsync() + { + return await _otroDestinoRepository.GetAllDropdownAsync(); + } + public async Task ObtenerPorIdAsync(int id) { var destino = await _otroDestinoRepository.GetByIdAsync(id); diff --git a/Backend/GestionIntegral.Api/Services/Distribucion/PublicacionService.cs b/Backend/GestionIntegral.Api/Services/Distribucion/PublicacionService.cs index 9c16f7a..e5e6877 100644 --- a/Backend/GestionIntegral.Api/Services/Distribucion/PublicacionService.cs +++ b/Backend/GestionIntegral.Api/Services/Distribucion/PublicacionService.cs @@ -86,6 +86,7 @@ namespace GestionIntegral.Api.Services.Distribucion { IdPublicacion = d.Publicacion!.IdPublicacion, // Usar ! si estás seguro que no es null después del Where Nombre = d.Publicacion!.Nombre, + NombreEmpresa = d.NombreEmpresa ?? "Empresa Desconocida", Habilitada = d.Publicacion!.Habilitada ?? true // Si necesitas filtrar por esto }) .OrderBy(p => p.Nombre) diff --git a/Backend/GestionIntegral.Api/Services/Impresion/EstadoBobinaService.cs b/Backend/GestionIntegral.Api/Services/Impresion/EstadoBobinaService.cs index 5b4fe60..2327595 100644 --- a/Backend/GestionIntegral.Api/Services/Impresion/EstadoBobinaService.cs +++ b/Backend/GestionIntegral.Api/Services/Impresion/EstadoBobinaService.cs @@ -37,6 +37,12 @@ namespace GestionIntegral.Api.Services.Impresion return estadosBobina.Select(MapToDto); } + public async Task> ObtenerTodosDropdownAsync() + { + var estadosBobina = await _estadoBobinaRepository.GetAllDropdownAsync(); + return estadosBobina; + } + public async Task ObtenerPorIdAsync(int id) { var estadoBobina = await _estadoBobinaRepository.GetByIdAsync(id); diff --git a/Backend/GestionIntegral.Api/Services/Impresion/IEstadoBobinaService.cs b/Backend/GestionIntegral.Api/Services/Impresion/IEstadoBobinaService.cs index b6cbfa8..926e1c0 100644 --- a/Backend/GestionIntegral.Api/Services/Impresion/IEstadoBobinaService.cs +++ b/Backend/GestionIntegral.Api/Services/Impresion/IEstadoBobinaService.cs @@ -8,6 +8,7 @@ namespace GestionIntegral.Api.Services.Impresion public interface IEstadoBobinaService { Task> ObtenerTodosAsync(string? denominacionFilter); + Task> ObtenerTodosDropdownAsync(); Task ObtenerPorIdAsync(int id); Task<(EstadoBobinaDto? EstadoBobina, string? Error)> CrearAsync(CreateEstadoBobinaDto createDto, int idUsuario); Task<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateEstadoBobinaDto updateDto, int idUsuario); diff --git a/Backend/GestionIntegral.Api/Services/Impresion/ITipoBobinaService.cs b/Backend/GestionIntegral.Api/Services/Impresion/ITipoBobinaService.cs index 3ba901d..9e6b909 100644 --- a/Backend/GestionIntegral.Api/Services/Impresion/ITipoBobinaService.cs +++ b/Backend/GestionIntegral.Api/Services/Impresion/ITipoBobinaService.cs @@ -8,6 +8,7 @@ namespace GestionIntegral.Api.Services.Impresion public interface ITipoBobinaService { Task> ObtenerTodosAsync(string? denominacionFilter); + Task> ObtenerTodosDropdownAsync(); Task ObtenerPorIdAsync(int id); Task<(TipoBobinaDto? TipoBobina, string? Error)> CrearAsync(CreateTipoBobinaDto createDto, int idUsuario); Task<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateTipoBobinaDto updateDto, int idUsuario); diff --git a/Backend/GestionIntegral.Api/Services/Impresion/TipoBobinaService.cs b/Backend/GestionIntegral.Api/Services/Impresion/TipoBobinaService.cs index 0cd9bb5..fa6aebd 100644 --- a/Backend/GestionIntegral.Api/Services/Impresion/TipoBobinaService.cs +++ b/Backend/GestionIntegral.Api/Services/Impresion/TipoBobinaService.cs @@ -36,6 +36,12 @@ namespace GestionIntegral.Api.Services.Impresion return tiposBobina.Select(MapToDto); } + public async Task> ObtenerTodosDropdownAsync() + { + var tiposBobina = await _tipoBobinaRepository.GetAllDropdownAsync(); + return tiposBobina.Select(MapToDto); + } + public async Task ObtenerPorIdAsync(int id) { var tipoBobina = await _tipoBobinaRepository.GetByIdAsync(id); diff --git a/Frontend/src/components/Modals/Usuarios/PermisosChecklist.tsx b/Frontend/src/components/Modals/Usuarios/PermisosChecklist.tsx index 31232ad..0097caf 100644 --- a/Frontend/src/components/Modals/Usuarios/PermisosChecklist.tsx +++ b/Frontend/src/components/Modals/Usuarios/PermisosChecklist.tsx @@ -44,6 +44,7 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => { if (moduloLower.includes("impresión tiradas") || moduloLower.includes("impresión bobinas") || // Cubre "Impresión Bobinas" y "Tipos Bobinas" moduloLower.includes("impresión plantas") || + moduloLower.includes("estados bobinas") || moduloLower.includes("tipos bobinas")) { // Añadido explícitamente return "Impresión"; } diff --git a/Frontend/src/components/Modals/Usuarios/UsuarioFormModal.tsx b/Frontend/src/components/Modals/Usuarios/UsuarioFormModal.tsx index a42340d..a32406a 100644 --- a/Frontend/src/components/Modals/Usuarios/UsuarioFormModal.tsx +++ b/Frontend/src/components/Modals/Usuarios/UsuarioFormModal.tsx @@ -228,15 +228,12 @@ const UsuarioFormModal: React.FC = ({ - {/* Fila 5 (Checkboxes) */} - - setSupAdmin(e.target.checked)} disabled={loading}/>} label="Super Administrador" /> - + setDebeCambiarClave(e.target.checked)} disabled={loading}/>} label="Debe Cambiar Clave" /> - {/* Fin contenedor principal de campos */} + {errorMessage && {errorMessage}} diff --git a/Frontend/src/contexts/AuthContext.tsx b/Frontend/src/contexts/AuthContext.tsx index 6dabe5b..472eba0 100644 --- a/Frontend/src/contexts/AuthContext.tsx +++ b/Frontend/src/contexts/AuthContext.tsx @@ -1,8 +1,7 @@ -import React, { createContext, useState, useContext, type ReactNode, useEffect } from 'react'; -import type { LoginResponseDto } from '../models/dtos/Usuarios/LoginResponseDto'; +import React, { createContext, useState, useContext, type ReactNode, useEffect, useCallback } from 'react'; import { jwtDecode } from 'jwt-decode'; +import { getAlertas, marcarAlertaLeida, marcarGrupoComoLeido, type AlertaGenericaDto } from '../services/Anomalia/alertaService'; -// Interfaz para los datos del usuario que guardaremos en el contexto export interface UserContextData { userId: number; username: string; @@ -11,33 +10,37 @@ export interface UserContextData { debeCambiarClave: boolean; perfil: string; idPerfil: number; - permissions: string[]; // Guardamos los codAcc + permissions: string[]; } -// Interfaz para el payload decodificado del JWT interface DecodedJwtPayload { - sub: string; // User ID (viene como string) - name: string; // Username - given_name?: string; // Nombre (estándar, pero verifica tu token) - family_name?: string; // Apellido (estándar, pero verifica tu token) - role: string | string[]; // Puede ser uno o varios roles + sub: string; + name: string; + given_name?: string; + family_name?: string; + role: string | string[]; perfil: string; - idPerfil: string; // (viene como string) - debeCambiarClave: string; // (viene como string "True" o "False") - permission?: string | string[]; // Nuestros claims de permiso (codAcc) - [key: string]: any; // Para otros claims + idPerfil: string; + debeCambiarClave: string; + permission?: string | string[]; + [key: string]: any; } interface AuthContextType { isAuthenticated: boolean; - user: UserContextData | null; // Usar el tipo extendido + user: UserContextData | null; token: string | null; isLoading: boolean; + alertas: AlertaGenericaDto[]; showForcedPasswordChangeModal: boolean; isPasswordChangeForced: boolean; + + marcarAlertaComoLeida: (idAlerta: number) => Promise; + marcarGrupoDeAlertasLeido: (tipoAlerta: string, idEntidad: number) => Promise; + setShowForcedPasswordChangeModal: (show: boolean) => void; passwordChangeCompleted: () => void; - login: (apiLoginResponse: LoginResponseDto) => void; // Recibe el DTO de la API + login: (apiLoginResponse: any) => void; // DTO no definido aquí, usamos any logout: () => void; } @@ -50,24 +53,57 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const [isLoading, setIsLoading] = useState(true); const [showForcedPasswordChangeModal, setShowForcedPasswordChangeModal] = useState(false); const [isPasswordChangeForced, setIsPasswordChangeForced] = useState(false); + const [alertas, setAlertas] = useState([]); - const processTokenAndSetUser = (jwtToken: string) => { + const fetchAlertas = useCallback(async (currentUser: UserContextData | null) => { + if (currentUser && (currentUser.esSuperAdmin || currentUser.permissions.includes('AL001'))) { + try { + const data = await getAlertas(); + setAlertas(data || []); + } catch (error) { + console.error("Error al obtener alertas en AuthContext:", error); + setAlertas([]); + } + } else { + setAlertas([]); + } + }, []); + + const marcarAlertaComoLeida = async (idAlerta: number) => { + try { + await marcarAlertaLeida(idAlerta); + await fetchAlertas(user); // Refresca el estado global + } catch (error) { + console.error("Error al marcar alerta como leída:", error); + } + }; + + const marcarGrupoDeAlertasLeido = async (tipoAlerta: string, idEntidad: number) => { + try { + await marcarGrupoComoLeido({ tipoAlerta, idEntidad }); + await fetchAlertas(user); // Refresca el estado global + } catch (error) { + console.error(`Error al marcar grupo ${tipoAlerta}/${idEntidad} como leído:`, error); + } + }; + + const logout = useCallback(() => { + localStorage.removeItem('authToken'); + setToken(null); + setUser(null); + setIsAuthenticated(false); + setShowForcedPasswordChangeModal(false); + setIsPasswordChangeForced(false); + setAlertas([]); + }, []); + + const processTokenAndSetUser = useCallback((jwtToken: string) => { try { const decodedToken = jwtDecode(jwtToken); - - // Verificar expiración (opcional, pero buena práctica aquí también) const currentTime = Date.now() / 1000; if (decodedToken.exp && decodedToken.exp < currentTime) { - console.warn("Token expirado al procesar."); - logout(); // Llama a logout que limpia todo - return; + logout(); return; } - - let permissions: string[] = []; - if (decodedToken.permission) { - permissions = Array.isArray(decodedToken.permission) ? decodedToken.permission : [decodedToken.permission]; - } - const userForContext: UserContextData = { userId: parseInt(decodedToken.sub, 10), username: decodedToken.name, @@ -75,27 +111,23 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => esSuperAdmin: decodedToken.role === "SuperAdmin" || (Array.isArray(decodedToken.role) && decodedToken.role.includes("SuperAdmin")), debeCambiarClave: decodedToken.debeCambiarClave?.toLowerCase() === 'true', idPerfil: decodedToken.idPerfil ? parseInt(decodedToken.idPerfil, 10) : 0, - permissions: permissions, - perfil: decodedToken.perfil || 'Usuario' // Asignar un valor por defecto si no existe + permissions: Array.isArray(decodedToken.permission) ? decodedToken.permission : (decodedToken.permission ? [decodedToken.permission] : []), + perfil: decodedToken.perfil || 'Usuario' }; - setToken(jwtToken); setUser(userForContext); setIsAuthenticated(true); localStorage.setItem('authToken', jwtToken); - localStorage.setItem('authUser', JSON.stringify(userForContext)); // Guardar el usuario procesado - // Lógica para el modal de cambio de clave if (userForContext.debeCambiarClave) { setShowForcedPasswordChangeModal(true); setIsPasswordChangeForced(true); } - } catch (error) { - console.error("Error al decodificar o procesar token:", error); - logout(); // Limpiar estado si el token es inválido + console.error("Error al decodificar token:", error); + logout(); } - }; + }, [logout]); useEffect(() => { setIsLoading(true); @@ -104,20 +136,18 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => processTokenAndSetUser(storedToken); } setIsLoading(false); - }, []); + }, [processTokenAndSetUser]); - const login = (apiLoginResponse: LoginResponseDto) => { - processTokenAndSetUser(apiLoginResponse.token); // Procesar el token recibido - }; + useEffect(() => { + if (user && isAuthenticated) { + fetchAlertas(user); + const intervalId = setInterval(() => fetchAlertas(user), 300000); // Refresca cada 5 mins + return () => clearInterval(intervalId); + } + }, [user, isAuthenticated, fetchAlertas]); - const logout = () => { - localStorage.removeItem('authToken'); - localStorage.removeItem('authUser'); - setToken(null); - setUser(null); - setIsAuthenticated(false); - setShowForcedPasswordChangeModal(false); - setIsPasswordChangeForced(false); + const login = (apiLoginResponse: any) => { + processTokenAndSetUser(apiLoginResponse.token); }; const passwordChangeCompleted = () => { @@ -138,6 +168,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => diff --git a/Frontend/src/layouts/MainLayout.tsx b/Frontend/src/layouts/MainLayout.tsx index f99c916..a182c08 100644 --- a/Frontend/src/layouts/MainLayout.tsx +++ b/Frontend/src/layouts/MainLayout.tsx @@ -1,14 +1,13 @@ -// src/layouts/MainLayout.tsx -import React, { type ReactNode, useState, useEffect, useMemo } // << AÑADIR useMemo - from 'react'; +import React, { type ReactNode, useState, useEffect, useMemo } from 'react'; import { Box, AppBar, Toolbar, Typography, Tabs, Tab, Paper, IconButton, Menu, MenuItem, ListItemIcon, ListItemText, Divider, - Button + Button, Badge } from '@mui/material'; import AccountCircle from '@mui/icons-material/AccountCircle'; import LockResetIcon from '@mui/icons-material/LockReset'; import LogoutIcon from '@mui/icons-material/Logout'; +import NotificationsIcon from '@mui/icons-material/Notifications'; import { useAuth } from '../contexts/AuthContext'; import ChangePasswordModal from '../components/Modals/Usuarios/ChangePasswordModal'; import { useNavigate, useLocation } from 'react-router-dom'; @@ -18,6 +17,16 @@ interface MainLayoutProps { children: ReactNode; } +// --- Helper para dar nombres legibles a los tipos de alerta --- +const getTipoAlertaLabel = (tipoAlerta: string): string => { + switch (tipoAlerta) { + case 'DevolucionAnomala': return 'Devoluciones Anómalas'; + case 'ComportamientoSistema': return 'Anomalías del Sistema'; + case 'FaltaDeDatos': return 'Falta de Datos'; + default: return tipoAlerta; + } +}; + // Definición original de módulos const allAppModules = [ { label: 'Inicio', path: '/', requiredPermission: null }, // Inicio siempre visible @@ -31,22 +40,36 @@ const allAppModules = [ ]; const MainLayout: React.FC = ({ children }) => { + // Obtenemos todo lo necesario del AuthContext, INCLUYENDO LAS ALERTAS const { - user, // user ya está disponible aquí - logout, - isAuthenticated, - isPasswordChangeForced, - showForcedPasswordChangeModal, - setShowForcedPasswordChangeModal, - passwordChangeCompleted + user, logout, isAuthenticated, isPasswordChangeForced, + showForcedPasswordChangeModal, setShowForcedPasswordChangeModal, + passwordChangeCompleted, + alertas } = useAuth(); - - const { tienePermiso, isSuperAdmin } = usePermissions(); // <<--- OBTENER HOOK DE PERMISOS + + // El resto de los hooks locales no cambian + const { tienePermiso, isSuperAdmin } = usePermissions(); const navigate = useNavigate(); const location = useLocation(); - const [selectedTab, setSelectedTab] = useState(false); const [anchorElUserMenu, setAnchorElUserMenu] = useState(null); + const [anchorElAlertasMenu, setAnchorElAlertasMenu] = useState(null); + + // --- Agrupación de alertas para el menú --- + const gruposDeAlertas = useMemo(() => { + if (!alertas || !Array.isArray(alertas)) return []; + + const groups = alertas.reduce((acc, alerta) => { + const label = getTipoAlertaLabel(alerta.tipoAlerta); + acc[label] = (acc[label] || 0) + 1; + return acc; + }, {} as Record); + + return Object.entries(groups); // Devuelve [['Devoluciones Anómalas', 5], ...] + }, [alertas]); + + const numAlertas = alertas.length; const accessibleModules = useMemo(() => { if (!isAuthenticated) return []; @@ -92,6 +115,17 @@ const MainLayout: React.FC = ({ children }) => { setAnchorElUserMenu(null); }; + // Handlers para el nuevo menú de alertas + const handleOpenAlertasMenu = (event: React.MouseEvent) => { + setAnchorElAlertasMenu(event.currentTarget); + }; + + const handleCloseAlertasMenu = () => { + setAnchorElAlertasMenu(null); + }; + + const handleNavigateToAlertas = () => { navigate('/anomalias/alertas'); handleCloseAlertasMenu(); }; + const handleChangePasswordClick = () => { setShowForcedPasswordChangeModal(true); handleCloseUserMenu(); @@ -133,7 +167,6 @@ const MainLayout: React.FC = ({ children }) => { ); } - // Si no hay módulos accesibles después del login (y no es el cambio de clave forzado) // Esto podría pasar si un usuario no tiene permiso para NINGUNA sección, ni siquiera Inicio. // Deberías redirigir a login o mostrar un mensaje de "Sin acceso". @@ -162,6 +195,37 @@ const MainLayout: React.FC = ({ children }) => { )} {isAuthenticated && ( <> + + + + + + + setAnchorElAlertasMenu(null)} + > + + + + + + {gruposDeAlertas.map(([label, count]) => ( + + + {label} + + ))} + + {numAlertas > 0 && } + + + Ver Todas las Alertas + + + { + switch (tipoAlerta) { + case 'DevolucionAnomala': return 'Devoluciones Anómalas'; + case 'ConsumoBobinaExcesivo': return 'Consumo de Bobinas Anómalo'; + case 'ComportamientoSistema': return 'Anomalías Generales del Sistema'; + case 'FaltaDeDatos': return 'Falta de Registros Críticos'; + default: return tipoAlerta; + } +}; + +const AlertasPage: React.FC = () => { + const { alertas, marcarAlertaComoLeida, marcarGrupoDeAlertasLeido, isLoading } = useAuth(); + + const gruposPorTipo = useMemo(() => { + if (!Array.isArray(alertas)) return []; + return alertas.reduce((acc, alerta) => { + (acc[alerta.tipoAlerta] = acc[alerta.tipoAlerta] || []).push(alerta); + return acc; + }, {} as Record); + }, [alertas]); + + const getColumnsForType = (tipoAlerta: string): GridColDef[] => { + const baseColumns: GridColDef[] = [ + { field: 'fechaAnomalia', headerName: 'Fecha Evento', width: 150, valueFormatter: (value) => new Date(value as string).toLocaleDateString('es-AR') }, + { field: 'mensaje', headerName: 'Descripción', flex: 1 } + ]; + + // Columnas específicas para 'DevolucionAnomala' + if (tipoAlerta === 'DevolucionAnomala') { + baseColumns.push( + { field: 'cantidadEnviada', headerName: 'Llevados', width: 120 }, + { field: 'cantidadDevuelta', headerName: 'Devueltos', width: 120 }, + { field: 'porcentajeDevolucion', headerName: '% Dev.', width: 120, valueFormatter: (value) => `${Number(value).toFixed(2)}%` } + ); + } + + baseColumns.push({ + field: 'actions', + headerName: 'Acciones', + width: 150, + sortable: false, + renderCell: (params) => ( + + ), + }); + + return baseColumns; + }; + + return ( + + Centro de Alertas del Sistema + + {Object.entries(gruposPorTipo).map(([tipoAlerta, alertasDelGrupo]) => { + const gruposPorEntidad = alertasDelGrupo.reduce((acc, alerta) => { + (acc[alerta.idEntidad] = acc[alerta.idEntidad] || []).push(alerta); + return acc; + }, {} as Record); + + return ( + + }> + {getTipoAlertaLabel(tipoAlerta)} ({alertasDelGrupo.length}) + + + {Object.entries(gruposPorEntidad).map(([idEntidad, alertasDeEntidad]) => { + const primeraAlerta = alertasDeEntidad[0]; + // Para obtener un nombre de canillita legible en el título del grupo + const nombreEntidad = primeraAlerta.entidad === 'Canillita' + ? primeraAlerta.mensaje.match(/'([^']+)'/)?.[1] || `ID ${idEntidad}` + : `ID ${idEntidad}`; + + const tituloGrupo = primeraAlerta.entidad === 'Sistema' + ? 'Alertas Generales del Sistema' + : `${primeraAlerta.entidad}: ${nombreEntidad}`; + + const rows = alertasDeEntidad.map(a => ({ ...a, id: a.idAlerta })); + + return ( + + + {tituloGrupo} ({alertasDeEntidad.length} alertas) + + + + + + + ); + })} + + + ); + })} + + {alertas.length === 0 && !isLoading && ( + No hay alertas pendientes. + )} + + ); +}; + +export default AlertasPage; \ No newline at end of file diff --git a/Frontend/src/pages/Distribucion/GestionarControlDevolucionesPage.tsx b/Frontend/src/pages/Distribucion/GestionarControlDevolucionesPage.tsx index 4be5e07..4c20f65 100644 --- a/Frontend/src/pages/Distribucion/GestionarControlDevolucionesPage.tsx +++ b/Frontend/src/pages/Distribucion/GestionarControlDevolucionesPage.tsx @@ -16,13 +16,14 @@ import empresaService from '../../services/Distribucion/empresaService'; import type { ControlDevolucionesDto } from '../../models/dtos/Distribucion/ControlDevolucionesDto'; import type { CreateControlDevolucionesDto } from '../../models/dtos/Distribucion/CreateControlDevolucionesDto'; import type { UpdateControlDevolucionesDto } from '../../models/dtos/Distribucion/UpdateControlDevolucionesDto'; -import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto'; +import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto'; import ControlDevolucionesFormModal from '../../components/Modals/Distribucion/ControlDevolucionesFormModal'; import { usePermissions } from '../../hooks/usePermissions'; import axios from 'axios'; const GestionarControlDevolucionesPage: React.FC = () => { + // ... (estados sin cambios) ... const [controles, setControles] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -32,8 +33,8 @@ const GestionarControlDevolucionesPage: React.FC = () => { const [filtroFechaHasta, setFiltroFechaHasta] = useState(new Date().toISOString().split('T')[0]); const [filtroIdEmpresa, setFiltroIdEmpresa] = useState(''); - const [empresas, setEmpresas] = useState([]); - const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(false); + const [empresas, setEmpresas] = useState([]); + const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(true); // << CAMBIO: Iniciar en true const [modalOpen, setModalOpen] = useState(false); const [editingControl, setEditingControl] = useState(null); @@ -47,42 +48,58 @@ const GestionarControlDevolucionesPage: React.FC = () => { const puedeVer = isSuperAdmin || tienePermiso("CD001"); const puedeCrear = isSuperAdmin || tienePermiso("CD002"); const puedeModificar = isSuperAdmin || tienePermiso("CD003"); - const puedeEliminar = isSuperAdmin || tienePermiso("CD003"); + // << CAMBIO: Permiso de eliminar debe ser diferente + const puedeEliminar = isSuperAdmin || tienePermiso("CD004"); // Asumiendo que CD004 es para eliminar - // CORREGIDO: Función para formatear la fecha + // ... (formatDate sin cambios) ... const formatDate = (dateString?: string | null): string => { if (!dateString) return '-'; - // Asumimos que dateString viene del backend como "YYYY-MM-DD" o "YYYY-MM-DDTHH:mm:ss..." - const datePart = dateString.split('T')[0]; // Tomar solo la parte YYYY-MM-DD + const datePart = dateString.split('T')[0]; const parts = datePart.split('-'); if (parts.length === 3) { - // parts[0] = YYYY, parts[1] = MM, parts[2] = DD - return `${parts[2]}/${parts[1]}/${parts[0]}`; // Formato DD/MM/YYYY + return `${parts[2]}/${parts[1]}/${parts[0]}`; } - return datePart; // Fallback si el formato no es el esperado + return datePart; }; - const fetchFiltersDropdownData = useCallback(async () => { + // << CAMBIO: Guardián de permisos para la carga de filtros + if (!puedeVer) { + setError("No tiene permiso para ver esta sección."); + setLoading(false); // Detiene el spinner principal + setLoadingFiltersDropdown(false); // Detiene el spinner de filtros + return; + } + setLoadingFiltersDropdown(true); try { - const empresasData = await empresaService.getAllEmpresas(); + const empresasData = await empresaService.getEmpresasDropdown(); setEmpresas(empresasData); } catch (err) { console.error("Error cargando empresas para filtro:", err); + // El error principal se manejará en cargarControles si también falla setError("Error al cargar opciones de filtro."); } finally { setLoadingFiltersDropdown(false); } - }, []); + }, [puedeVer]); // << CAMBIO: Añadir `puedeVer` como dependencia - useEffect(() => { fetchFiltersDropdownData(); }, [fetchFiltersDropdownData]); + useEffect(() => { + fetchFiltersDropdownData(); + }, [fetchFiltersDropdownData]); const cargarControles = useCallback(async () => { + // El guardián aquí ya estaba y es correcto. if (!puedeVer) { - setError("No tiene permiso para ver esta sección."); setLoading(false); return; + // Si ya se estableció el error en el fetch de filtros, no lo sobrescribimos. + if (!error) setError("No tiene permiso para ver esta sección."); + setLoading(false); + return; } - setLoading(true); setError(null); setApiErrorMessage(null); + + setLoading(true); + setError(null); + setApiErrorMessage(null); try { const params = { fechaDesde: filtroFechaDesde || null, @@ -92,19 +109,27 @@ const GestionarControlDevolucionesPage: React.FC = () => { const data = await controlDevolucionesService.getAllControlesDevoluciones(params); setControles(data); } catch (err) { - console.error(err); setError('Error al cargar los controles de devoluciones.'); - } finally { setLoading(false); } - }, [puedeVer, filtroFechaDesde, filtroFechaHasta, filtroIdEmpresa]); + console.error(err); + setError('Error al cargar los controles de devoluciones.'); + } finally { + setLoading(false); + } + }, [puedeVer, filtroFechaDesde, filtroFechaHasta, filtroIdEmpresa, error]); // << CAMBIO: Añadido `error` a dependencias - useEffect(() => { cargarControles(); }, [cargarControles]); + useEffect(() => { + // Solo cargar controles si los filtros se han cargado (o intentado cargar) + if (!loadingFiltersDropdown) { + cargarControles(); + } + }, [cargarControles, loadingFiltersDropdown]); // << CAMBIO: Depende de la carga de filtros + // ... (resto de los handlers sin cambios) ... const handleOpenModal = (item?: ControlDevolucionesDto) => { setEditingControl(item || null); setApiErrorMessage(null); setModalOpen(true); }; const handleCloseModal = () => { setModalOpen(false); setEditingControl(null); }; - const handleSubmitModal = async (data: CreateControlDevolucionesDto | UpdateControlDevolucionesDto, idControl?: number) => { setApiErrorMessage(null); try { @@ -119,7 +144,6 @@ const GestionarControlDevolucionesPage: React.FC = () => { setApiErrorMessage(message); throw err; } }; - const handleDelete = async (idControl: number) => { if (window.confirm(`¿Seguro de eliminar este control de devoluciones (ID: ${idControl})?`)) { setApiErrorMessage(null); @@ -133,26 +157,35 @@ const GestionarControlDevolucionesPage: React.FC = () => { } handleMenuClose(); }; - const handleMenuOpen = (event: React.MouseEvent, item: ControlDevolucionesDto) => { setAnchorEl(event.currentTarget); setSelectedRow(item); }; const handleMenuClose = () => { setAnchorEl(null); setSelectedRow(null); }; - const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage); const handleChangeRowsPerPage = (event: React.ChangeEvent) => { - setRowsPerPage(parseInt(event.target.value, 25)); setPage(0); + setRowsPerPage(parseInt(event.target.value, 10)); setPage(0); }; - // displayData ahora usará la 'controles' directamente, el formato se aplica en el renderizado const displayData = controles.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); - - if (!loading && !puedeVer && !loadingFiltersDropdown) return {error || "Acceso denegado."}; + + // Si no tiene permiso, muestra solo la alerta y nada más. + if (!puedeVer) { + return ( + + + {error || "No tiene permiso para acceder a esta sección."} + + + ); + } return ( Control de Devoluciones a Empresa + + {/* El resto del JSX se renderizará solo si 'puedeVer' es true */} + Filtros @@ -169,12 +202,12 @@ const GestionarControlDevolucionesPage: React.FC = () => { {puedeCrear && ()} - {loading && } + {(loading || loadingFiltersDropdown) && } {error && !loading && {error}} {apiErrorMessage && {apiErrorMessage}} - {!loading && !error && puedeVer && ( - {/* Ajusta maxHeight según sea necesario */} + {!loading && !loadingFiltersDropdown && !error && ( + {/* Ajusta maxHeight */} FechaEmpresa @@ -186,7 +219,7 @@ const GestionarControlDevolucionesPage: React.FC = () => { {displayData.length === 0 ? ( - No se encontraron controles. + No se encontraron controles con los filtros aplicados. ) : ( displayData.map((c) => ( @@ -217,7 +250,10 @@ const GestionarControlDevolucionesPage: React.FC = () => { {puedeModificar && selectedRow && ( { handleOpenModal(selectedRow); handleMenuClose(); }}> Modificar)} {puedeEliminar && selectedRow && ( - handleDelete(selectedRow.idControl)}> Eliminar)} + { if (selectedRow) handleDelete(selectedRow.idControl) }}> + Eliminar + + )} { const [loadingTicketPdf, setLoadingTicketPdf] = useState(false); const [publicaciones, setPublicaciones] = useState([]); - const [destinatariosDropdown, setDestinatariosDropdown] = useState([]); - const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(false); + const [destinatariosDropdown, setDestinatariosDropdown] = useState([]); + const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(false) const [modalOpen, setModalOpen] = useState(false); const [editingMovimiento, setEditingMovimiento] = useState(null); @@ -81,29 +81,40 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { }; useEffect(() => { - const fetchPublicaciones = async () => { + const fetchDropdownData = async () => { + if (!puedeVer) { + setError("No tiene permiso para ver esta sección."); + setLoading(false); // Detiene el spinner principal + setLoadingFiltersDropdown(false); // Detiene el spinner de los filtros + return; + } + setLoadingFiltersDropdown(true); + setError(null); try { const pubsData = await publicacionService.getPublicacionesForDropdown(true); setPublicaciones(pubsData); + // La carga de destinatarios se hará en el otro useEffect } catch (err) { - console.error("Error cargando publicaciones para filtro:",err); + console.error("Error cargando publicaciones para filtro:", err); setError("Error al cargar publicaciones."); } finally { - // No setLoadingFiltersDropdown(false) acá, esperar a la otra carga + // La carga finaliza cuando se cargan los destinatarios también. } }; - fetchPublicaciones(); - }, []); + fetchDropdownData(); + }, [puedeVer]); // << CAMBIO: Añadir `puedeVer` como dependencia const fetchDestinatariosParaDropdown = useCallback(async () => { + if (!puedeVer) { return; } + setLoadingFiltersDropdown(true); setFiltroIdCanillitaSeleccionado(''); setDestinatariosDropdown([]); setError(null); try { const esAccionistaFilter = filtroTipoDestinatario === 'accionistas'; - const data = await canillaService.getAllCanillas(undefined, undefined, true, esAccionistaFilter); + const data = await canillaService.getAllDropdownCanillas(true, esAccionistaFilter); setDestinatariosDropdown(data); } catch (err) { console.error("Error cargando destinatarios para filtro:", err); @@ -111,21 +122,23 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { } finally { setLoadingFiltersDropdown(false); } - }, [filtroTipoDestinatario]); + }, [filtroTipoDestinatario, puedeVer]); useEffect(() => { fetchDestinatariosParaDropdown(); }, [fetchDestinatariosParaDropdown]); - const cargarMovimientos = useCallback(async () => { - if (!puedeVer) { setError("No tiene permiso para ver esta sección."); setLoading(false); return; } + if (!puedeVer) { + setError("No tiene permiso para ver esta sección."); + setLoading(false); + return; + } if (!filtroFecha || !filtroIdCanillitaSeleccionado) { if (loading) setLoading(false); setMovimientos([]); return; } - setLoading(true); setError(null); setApiErrorMessage(null); try { const params = { @@ -148,6 +161,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { } }, [puedeVer, filtroFecha, filtroIdPublicacion, filtroIdCanillitaSeleccionado]); + useEffect(() => { if (filtroFecha && filtroIdCanillitaSeleccionado) { cargarMovimientos(); @@ -156,8 +170,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { if (loading) setLoading(false); } }, [cargarMovimientos, filtroFecha, filtroIdCanillitaSeleccionado]); - - + const handleOpenModal = (item?: EntradaSalidaCanillaDto) => { if (!puedeCrear && !item) { setApiErrorMessage("No tiene permiso para registrar nuevos movimientos."); @@ -195,7 +208,6 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { } handleMenuClose(); }; - const handleMenuOpen = (event: React.MouseEvent, item: EntradaSalidaCanillaDto) => { event.currentTarget.setAttribute('data-rowid', item.idParte.toString()); setAnchorEl(event.currentTarget); @@ -258,17 +270,15 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { await entradaSalidaCanillaService.liquidarMovimientos(liquidarDto); setOpenLiquidarDialog(false); const primerIdParteLiquidado = Array.from(selectedIdsParaLiquidar)[0]; - // Necesitamos encontrar el movimiento en la lista ANTES de recargar const movimientoParaTicket = movimientos.find(m => m.idParte === primerIdParteLiquidado); - await cargarMovimientos(); // Recargar la lista para reflejar el estado liquidado + await cargarMovimientos(); - // Usar la fecha del movimiento original para el ticket if (movimientoParaTicket && !movimientoParaTicket.canillaEsAccionista) { console.log("Liquidación exitosa, generando ticket para canillita NO accionista:", movimientoParaTicket.idCanilla); await handleImprimirTicketLiquidacion( movimientoParaTicket.idCanilla, - movimientoParaTicket.fecha, // Usar la fecha del movimiento + movimientoParaTicket.fecha, false ); } else if (movimientoParaTicket && movimientoParaTicket.canillaEsAccionista) { @@ -328,7 +338,6 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { } finally { setLoadingTicketPdf(false); } }, []); - const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage); const handleChangeRowsPerPage = (event: React.ChangeEvent) => { setRowsPerPage(parseInt(event.target.value, 10)); setPage(0); @@ -339,8 +348,14 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { displayData.filter(m => !m.liquidado).reduce((sum, item) => sum + item.montoARendir, 0) , [displayData]); - if (!loading && !puedeVer && !loadingFiltersDropdown && movimientos.length === 0 && !filtroFecha && !filtroIdCanillitaSeleccionado ) { - return {error || "Acceso denegado."}; + if (!puedeVer) { + return ( + + + {error || "No tiene permiso para acceder a esta sección."} + + + ); } const numSelectedToLiquidate = selectedIdsParaLiquidar.size; @@ -352,7 +367,6 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { Filtros - {/* ... (Filtros sin cambios) ... */} setFiltroFecha(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} diff --git a/Frontend/src/pages/Distribucion/GestionarEntradasSalidasDistPage.tsx b/Frontend/src/pages/Distribucion/GestionarEntradasSalidasDistPage.tsx index f309247..4329783 100644 --- a/Frontend/src/pages/Distribucion/GestionarEntradasSalidasDistPage.tsx +++ b/Frontend/src/pages/Distribucion/GestionarEntradasSalidasDistPage.tsx @@ -17,8 +17,8 @@ import distribuidorService from '../../services/Distribucion/distribuidorService import type { EntradaSalidaDistDto } from '../../models/dtos/Distribucion/EntradaSalidaDistDto'; import type { CreateEntradaSalidaDistDto } from '../../models/dtos/Distribucion/CreateEntradaSalidaDistDto'; import type { UpdateEntradaSalidaDistDto } from '../../models/dtos/Distribucion/UpdateEntradaSalidaDistDto'; -import type { PublicacionDto } from '../../models/dtos/Distribucion/PublicacionDto'; -import type { DistribuidorDto } from '../../models/dtos/Distribucion/DistribuidorDto'; +import type { PublicacionDropdownDto } from '../../models/dtos/Distribucion/PublicacionDropdownDto'; +import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto'; import EntradaSalidaDistFormModal from '../../components/Modals/Distribucion/EntradaSalidaDistFormModal'; import { usePermissions } from '../../hooks/usePermissions'; @@ -36,8 +36,8 @@ const GestionarEntradasSalidasDistPage: React.FC = () => { const [filtroIdDistribuidor, setFiltroIdDistribuidor] = useState(''); const [filtroTipoMov, setFiltroTipoMov] = useState<'Salida' | 'Entrada' | ''>(''); - const [publicaciones, setPublicaciones] = useState([]); - const [distribuidores, setDistribuidores] = useState([]); + const [publicaciones, setPublicaciones] = useState([]); + const [distribuidores, setDistribuidores] = useState([]); const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(false); const [modalOpen, setModalOpen] = useState(false); @@ -69,8 +69,8 @@ const GestionarEntradasSalidasDistPage: React.FC = () => { setLoadingFiltersDropdown(true); try { const [pubsData, distData] = await Promise.all([ - publicacionService.getAllPublicaciones(undefined, undefined, true), - distribuidorService.getAllDistribuidores() + publicacionService.getPublicacionesForDropdown(true), + distribuidorService.getAllDistribuidoresDropdown() ]); setPublicaciones(pubsData); setDistribuidores(distData); diff --git a/Frontend/src/pages/Distribucion/GestionarSalidasOtrosDestinosPage.tsx b/Frontend/src/pages/Distribucion/GestionarSalidasOtrosDestinosPage.tsx index 24ea500..6cdd05e 100644 --- a/Frontend/src/pages/Distribucion/GestionarSalidasOtrosDestinosPage.tsx +++ b/Frontend/src/pages/Distribucion/GestionarSalidasOtrosDestinosPage.tsx @@ -16,8 +16,8 @@ import otroDestinoService from '../../services/Distribucion/otroDestinoService'; import type { SalidaOtroDestinoDto } from '../../models/dtos/Distribucion/SalidaOtroDestinoDto'; import type { CreateSalidaOtroDestinoDto } from '../../models/dtos/Distribucion/CreateSalidaOtroDestinoDto'; import type { UpdateSalidaOtroDestinoDto } from '../../models/dtos/Distribucion/UpdateSalidaOtroDestinoDto'; -import type { PublicacionDto } from '../../models/dtos/Distribucion/PublicacionDto'; -import type { OtroDestinoDto } from '../../models/dtos/Distribucion/OtroDestinoDto'; +import type { PublicacionDropdownDto } from '../../models/dtos/Distribucion/PublicacionDropdownDto'; +import type { OtroDestinoDropdownDto } from '../../models/dtos/Distribucion/OtroDestinoDropdownDto'; import SalidaOtroDestinoFormModal from '../../components/Modals/Distribucion/SalidaOtroDestinoFormModal'; import { usePermissions } from '../../hooks/usePermissions'; @@ -34,8 +34,8 @@ const GestionarSalidasOtrosDestinosPage: React.FC = () => { const [filtroIdPublicacion, setFiltroIdPublicacion] = useState(''); const [filtroIdDestino, setFiltroIdDestino] = useState(''); - const [publicaciones, setPublicaciones] = useState([]); - const [otrosDestinos, setOtrosDestinos] = useState([]); + const [publicaciones, setPublicaciones] = useState([]); + const [otrosDestinos, setOtrosDestinos] = useState([]); const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(false); const [modalOpen, setModalOpen] = useState(false); @@ -68,8 +68,8 @@ const GestionarSalidasOtrosDestinosPage: React.FC = () => { setLoadingFiltersDropdown(true); try { const [pubsData, destinosData] = await Promise.all([ - publicacionService.getAllPublicaciones(undefined, undefined, true), - otroDestinoService.getAllOtrosDestinos() + publicacionService.getPublicacionesForDropdown(true), + otroDestinoService.getAllDropdownOtrosDestinos() ]); setPublicaciones(pubsData); setOtrosDestinos(destinosData); diff --git a/Frontend/src/pages/Impresion/GestionarEstadosBobinaPage.tsx b/Frontend/src/pages/Impresion/GestionarEstadosBobinaPage.tsx index 4ae668c..412a017 100644 --- a/Frontend/src/pages/Impresion/GestionarEstadosBobinaPage.tsx +++ b/Frontend/src/pages/Impresion/GestionarEstadosBobinaPage.tsx @@ -36,7 +36,6 @@ const GestionarEstadosBobinaPage: React.FC = () => { const { tienePermiso, isSuperAdmin } = usePermissions(); - // Permisos para Estados de Bobina (ej: IB010 a IB013) const puedeVer = isSuperAdmin || tienePermiso("IB010"); const puedeCrear = isSuperAdmin || tienePermiso("IB011"); const puedeModificar = isSuperAdmin || tienePermiso("IB012"); diff --git a/Frontend/src/pages/Impresion/GestionarStockBobinasPage.tsx b/Frontend/src/pages/Impresion/GestionarStockBobinasPage.tsx index c5aea02..55d1780 100644 --- a/Frontend/src/pages/Impresion/GestionarStockBobinasPage.tsx +++ b/Frontend/src/pages/Impresion/GestionarStockBobinasPage.tsx @@ -21,8 +21,8 @@ import type { CreateStockBobinaDto } from '../../models/dtos/Impresion/CreateSto import type { UpdateStockBobinaDto } from '../../models/dtos/Impresion/UpdateStockBobinaDto'; import type { CambiarEstadoBobinaDto } from '../../models/dtos/Impresion/CambiarEstadoBobinaDto'; import type { TipoBobinaDto } from '../../models/dtos/Impresion/TipoBobinaDto'; -import type { PlantaDto } from '../../models/dtos/Impresion/PlantaDto'; -import type { EstadoBobinaDto } from '../../models/dtos/Impresion/EstadoBobinaDto'; +import type { PlantaDropdownDto } from '../../models/dtos/Impresion/PlantaDropdownDto'; +import type { EstadoBobinaDropdownDto } from '../../models/dtos/Impresion/EstadoBobinaDropdownDto'; import StockBobinaIngresoFormModal from '../../components/Modals/Impresion/StockBobinaIngresoFormModal'; import StockBobinaEditFormModal from '../../components/Modals/Impresion/StockBobinaEditFormModal'; @@ -50,8 +50,8 @@ const GestionarStockBobinasPage: React.FC = () => { const [filtroFechaHasta, setFiltroFechaHasta] = useState(new Date().toISOString().split('T')[0]); const [tiposBobina, setTiposBobina] = useState([]); - const [plantas, setPlantas] = useState([]); - const [estadosBobina, setEstadosBobina] = useState([]); + const [plantas, setPlantas] = useState([]); + const [estadosBobina, setEstadosBobina] = useState([]); const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(false); const [ingresoModalOpen, setIngresoModalOpen] = useState(false); @@ -76,9 +76,9 @@ const GestionarStockBobinasPage: React.FC = () => { setLoadingFiltersDropdown(true); try { const [tiposData, plantasData, estadosData] = await Promise.all([ - tipoBobinaService.getAllTiposBobina(), - plantaService.getAllPlantas(), - estadoBobinaService.getAllEstadosBobina() + tipoBobinaService.getAllDropdownTiposBobina(), + plantaService.getPlantasForDropdown(), + estadoBobinaService.getAllDropdownEstadosBobina() ]); setTiposBobina(tiposData); setPlantas(plantasData); diff --git a/Frontend/src/pages/Impresion/GestionarTiradasPage.tsx b/Frontend/src/pages/Impresion/GestionarTiradasPage.tsx index 980caf8..98863cd 100644 --- a/Frontend/src/pages/Impresion/GestionarTiradasPage.tsx +++ b/Frontend/src/pages/Impresion/GestionarTiradasPage.tsx @@ -18,8 +18,8 @@ import plantaService from '../../services/Impresion/plantaService'; // Para filt import type { TiradaDto } from '../../models/dtos/Impresion/TiradaDto'; import type { CreateTiradaRequestDto } from '../../models/dtos/Impresion/CreateTiradaRequestDto'; -import type { PublicacionDto } from '../../models/dtos/Distribucion/PublicacionDto'; -import type { PlantaDto } from '../../models/dtos/Impresion/PlantaDto'; +import type { PublicacionDropdownDto } from '../../models/dtos/Distribucion/PublicacionDropdownDto'; +import type { PlantaDropdownDto } from '../../models/dtos/Impresion/PlantaDropdownDto'; import TiradaFormModal from '../../components/Modals/Impresion/TiradaFormModal'; import { usePermissions } from '../../hooks/usePermissions'; @@ -36,8 +36,8 @@ const GestionarTiradasPage: React.FC = () => { const [filtroIdPublicacion, setFiltroIdPublicacion] = useState(''); const [filtroIdPlanta, setFiltroIdPlanta] = useState(''); - const [publicaciones, setPublicaciones] = useState([]); - const [plantas, setPlantas] = useState([]); + const [publicaciones, setPublicaciones] = useState([]); + const [plantas, setPlantas] = useState([]); const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(false); const [modalOpen, setModalOpen] = useState(false); @@ -52,8 +52,8 @@ const GestionarTiradasPage: React.FC = () => { setLoadingFiltersDropdown(true); try { const [pubsData, plantasData] = await Promise.all([ - publicacionService.getAllPublicaciones(undefined, undefined, true), - plantaService.getAllPlantas() + publicacionService.getPublicacionesForDropdown(true), + plantaService.getPlantasForDropdown() ]); setPublicaciones(pubsData); setPlantas(plantasData); diff --git a/Frontend/src/pages/Usuarios/AsignarPermisosAPerfilPage.tsx b/Frontend/src/pages/Usuarios/AsignarPermisosAPerfilPage.tsx index 7dbf5df..81558b0 100644 --- a/Frontend/src/pages/Usuarios/AsignarPermisosAPerfilPage.tsx +++ b/Frontend/src/pages/Usuarios/AsignarPermisosAPerfilPage.tsx @@ -8,67 +8,68 @@ import SaveIcon from '@mui/icons-material/Save'; import perfilService from '../../services/Usuarios/perfilService'; import type { PermisoAsignadoDto } from '../../models/dtos/Usuarios/PermisoAsignadoDto'; import type { PerfilDto } from '../../models/dtos/Usuarios/PerfilDto'; -import { usePermissions as usePagePermissions } from '../../hooks/usePermissions'; // Renombrar para evitar conflicto +import { usePermissions as usePagePermissions } from '../../hooks/usePermissions'; import axios from 'axios'; import PermisosChecklist from '../../components/Modals/Usuarios/PermisosChecklist'; const SECCION_PERMISSIONS_PREFIX = "SS"; const getModuloFromSeccionCodAcc = (codAcc: string): string | null => { - if (codAcc === "SS001") return "Distribución"; - if (codAcc === "SS002") return "Contables"; - if (codAcc === "SS003") return "Impresión"; - if (codAcc === "SS004") return "Reportes"; - if (codAcc === "SS005") return "Radios"; - if (codAcc === "SS006") return "Usuarios"; - return null; + if (codAcc === "SS001") return "Distribución"; + if (codAcc === "SS002") return "Contables"; + if (codAcc === "SS003") return "Impresión"; + if (codAcc === "SS004") return "Reportes"; + if (codAcc === "SS005") return "Radios"; + if (codAcc === "SS006") return "Usuarios"; + return null; }; const getModuloConceptualDelPermiso = (permisoModulo: string): string => { - const moduloLower = permisoModulo.toLowerCase(); - if (moduloLower.includes("distribuidores") || - moduloLower.includes("canillas") || - moduloLower.includes("publicaciones distribución") || - moduloLower.includes("zonas distribuidores") || - moduloLower.includes("movimientos distribuidores") || - moduloLower.includes("empresas") || - moduloLower.includes("otros destinos") || - moduloLower.includes("ctrl. devoluciones") || - moduloLower.includes("movimientos canillas") || - moduloLower.includes("salidas otros destinos")) { - return "Distribución"; - } - if (moduloLower.includes("cuentas pagos") || - moduloLower.includes("cuentas notas") || - moduloLower.includes("cuentas tipos pagos")) { - return "Contables"; - } - if (moduloLower.includes("impresión tiradas") || - moduloLower.includes("impresión bobinas") || - moduloLower.includes("impresión plantas") || - moduloLower.includes("tipos bobinas")) { - return "Impresión"; - } - if (moduloLower.includes("radios")) { - return "Radios"; - } - if (moduloLower.includes("usuarios") || - moduloLower.includes("perfiles")) { - return "Usuarios"; - } - if (moduloLower.includes("reportes")) { - return "Reportes"; - } - if (moduloLower.includes("permisos")) { - return "Permisos (Definición)"; - } - return permisoModulo; + const moduloLower = permisoModulo.toLowerCase(); + if (moduloLower.includes("distribuidores") || + moduloLower.includes("canillas") || + moduloLower.includes("publicaciones distribución") || + moduloLower.includes("zonas distribuidores") || + moduloLower.includes("movimientos distribuidores") || + moduloLower.includes("empresas") || + moduloLower.includes("otros destinos") || + moduloLower.includes("ctrl. devoluciones") || + moduloLower.includes("movimientos canillas") || + moduloLower.includes("salidas otros destinos")) { + return "Distribución"; + } + if (moduloLower.includes("cuentas pagos") || + moduloLower.includes("cuentas notas") || + moduloLower.includes("cuentas tipos pagos")) { + return "Contables"; + } + if (moduloLower.includes("impresión tiradas") || + moduloLower.includes("impresión bobinas") || + moduloLower.includes("impresión plantas") || + moduloLower.includes("estados bobinas") || + moduloLower.includes("tipos bobinas")) { + return "Impresión"; + } + if (moduloLower.includes("radios")) { + return "Radios"; + } + if (moduloLower.includes("usuarios") || + moduloLower.includes("perfiles")) { + return "Usuarios"; + } + if (moduloLower.includes("reportes")) { + return "Reportes"; + } + if (moduloLower.includes("permisos")) { + return "Permisos (Definición)"; + } + return permisoModulo; }; const AsignarPermisosAPerfilPage: React.FC = () => { const { idPerfil } = useParams<{ idPerfil: string }>(); const navigate = useNavigate(); - const { tienePermiso: tienePermisoPagina, isSuperAdmin } = usePagePermissions(); // Renombrado + const { tienePermiso: tienePermisoPagina, isSuperAdmin } = usePagePermissions(); const puedeAsignar = isSuperAdmin || tienePermisoPagina("PU004"); @@ -124,76 +125,75 @@ const AsignarPermisosAPerfilPage: React.FC = () => { moduloConceptualAsociado?: string // Este es el módulo conceptual del padre SSxxx o del grupo del hijo ) => { setPermisosSeleccionados(prevSelected => { - const newSelected = new Set(prevSelected); - const permisoActual = permisosDisponibles.find(p => p.id === permisoId); - if (!permisoActual) return prevSelected; + const newSelected = new Set(prevSelected); + const permisoActual = permisosDisponibles.find(p => p.id === permisoId); + if (!permisoActual) return prevSelected; - const permisosDelModuloHijo = moduloConceptualAsociado - ? permisosDisponibles.filter(p => { - const mc = getModuloConceptualDelPermiso(p.modulo); // Usar la función helper - return mc === moduloConceptualAsociado && !p.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX); - }) - : []; + const permisosDelModuloHijo = moduloConceptualAsociado + ? permisosDisponibles.filter(p => { + const mc = getModuloConceptualDelPermiso(p.modulo); // Usar la función helper + return mc === moduloConceptualAsociado && !p.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX); + }) + : []; - if (esPermisoSeccionClick && moduloConceptualAsociado) { - const idPermisoSeccion = permisoActual.id; - const estabaSeccionSeleccionada = prevSelected.has(idPermisoSeccion); - const todosHijosEstabanSeleccionados = permisosDelModuloHijo.length > 0 && permisosDelModuloHijo.every(p => prevSelected.has(p.id)); - const ningunHijoEstabaSeleccionado = permisosDelModuloHijo.every(p => !prevSelected.has(p.id)); + if (esPermisoSeccionClick && moduloConceptualAsociado) { + const idPermisoSeccion = permisoActual.id; + const estabaSeccionSeleccionada = prevSelected.has(idPermisoSeccion); + const todosHijosEstabanSeleccionados = permisosDelModuloHijo.length > 0 && permisosDelModuloHijo.every(p => prevSelected.has(p.id)); + const ningunHijoEstabaSeleccionado = permisosDelModuloHijo.every(p => !prevSelected.has(p.id)); - if (!estabaSeccionSeleccionada) { // Estaba Off, pasa a "Solo Sección" (Indeterminate si hay hijos) - newSelected.add(idPermisoSeccion); - // NO se marcan los hijos - } else if (estabaSeccionSeleccionada && (ningunHijoEstabaSeleccionado || !todosHijosEstabanSeleccionados) && permisosDelModuloHijo.length > 0 ) { - // Estaba "Solo Sección" o "Parcial Hijos", pasa a "Sección + Todos los Hijos" - newSelected.add(idPermisoSeccion); // Asegurar - permisosDelModuloHijo.forEach(p => newSelected.add(p.id)); - } else { // Estaba "Sección + Todos los Hijos" (o no había hijos), pasa a Off - newSelected.delete(idPermisoSeccion); - permisosDelModuloHijo.forEach(p => newSelected.delete(p.id)); - } - - } else if (!esPermisoSeccionClick && moduloConceptualAsociado) { // Clic en un permiso hijo - if (asignadoViaCheckboxHijo) { - newSelected.add(permisoId); - const permisoSeccionPadre = permisosDisponibles.find( - ps => ps.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX) && getModuloFromSeccionCodAcc(ps.codAcc) === moduloConceptualAsociado - ); - if (permisoSeccionPadre && !newSelected.has(permisoSeccionPadre.id)) { - newSelected.add(permisoSeccionPadre.id); // Marcar padre si no estaba - } - } else { // Desmarcando un hijo - newSelected.delete(permisoId); - const permisoSeccionPadre = permisosDisponibles.find( - ps => ps.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX) && getModuloFromSeccionCodAcc(ps.codAcc) === moduloConceptualAsociado - ); - if (permisoSeccionPadre) { - const algunOtroHijoSeleccionado = permisosDelModuloHijo.some(p => p.id !== permisoId && newSelected.has(p.id)); - if (!algunOtroHijoSeleccionado && newSelected.has(permisoSeccionPadre.id)) { - // Si era el último hijo y el padre estaba marcado, NO desmarcamos el padre automáticamente. - // El estado indeterminate se encargará visualmente. - // Si quisiéramos que se desmarque el padre, aquí iría: newSelected.delete(permisoSeccionPadre.id); - } - } - } - } else { // Permiso sin módulo conceptual asociado (ej: "Permisos (Definición)") - if (asignadoViaCheckboxHijo) { - newSelected.add(permisoId); - } else { - newSelected.delete(permisoId); - } + if (!estabaSeccionSeleccionada) { // Estaba Off, pasa a "Solo Sección" (Indeterminate si hay hijos) + newSelected.add(idPermisoSeccion); + // NO se marcan los hijos + } else if (estabaSeccionSeleccionada && (ningunHijoEstabaSeleccionado || !todosHijosEstabanSeleccionados) && permisosDelModuloHijo.length > 0) { + // Estaba "Solo Sección" o "Parcial Hijos", pasa a "Sección + Todos los Hijos" + newSelected.add(idPermisoSeccion); // Asegurar + permisosDelModuloHijo.forEach(p => newSelected.add(p.id)); + } else { // Estaba "Sección + Todos los Hijos" (o no había hijos), pasa a Off + newSelected.delete(idPermisoSeccion); + permisosDelModuloHijo.forEach(p => newSelected.delete(p.id)); } - if (successMessage) setSuccessMessage(null); - if (error) setError(null); - return newSelected; + } else if (!esPermisoSeccionClick && moduloConceptualAsociado) { // Clic en un permiso hijo + if (asignadoViaCheckboxHijo) { + newSelected.add(permisoId); + const permisoSeccionPadre = permisosDisponibles.find( + ps => ps.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX) && getModuloFromSeccionCodAcc(ps.codAcc) === moduloConceptualAsociado + ); + if (permisoSeccionPadre && !newSelected.has(permisoSeccionPadre.id)) { + newSelected.add(permisoSeccionPadre.id); // Marcar padre si no estaba + } + } else { // Desmarcando un hijo + newSelected.delete(permisoId); + const permisoSeccionPadre = permisosDisponibles.find( + ps => ps.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX) && getModuloFromSeccionCodAcc(ps.codAcc) === moduloConceptualAsociado + ); + if (permisoSeccionPadre) { + const algunOtroHijoSeleccionado = permisosDelModuloHijo.some(p => p.id !== permisoId && newSelected.has(p.id)); + if (!algunOtroHijoSeleccionado && newSelected.has(permisoSeccionPadre.id)) { + // Si era el último hijo y el padre estaba marcado, NO desmarcamos el padre automáticamente. + // El estado indeterminate se encargará visualmente. + // Si quisiéramos que se desmarque el padre, aquí iría: newSelected.delete(permisoSeccionPadre.id); + } + } + } + } else { // Permiso sin módulo conceptual asociado (ej: "Permisos (Definición)") + if (asignadoViaCheckboxHijo) { + newSelected.add(permisoId); + } else { + newSelected.delete(permisoId); + } + } + + if (successMessage) setSuccessMessage(null); + if (error) setError(null); + return newSelected; }); }, [permisosDisponibles, successMessage, error]); const handleGuardarCambios = async () => { - // ... (sin cambios) ... if (!puedeAsignar || !perfil) return; setSaving(true); setError(null); setSuccessMessage(null); try { @@ -214,54 +214,54 @@ const AsignarPermisosAPerfilPage: React.FC = () => { }; if (loading) { - return ; - } + return ; + } - if (error && !perfil) { - return {error}; - } - if (!puedeAsignar) { - return Acceso denegado.; - } - if (!perfil && !loading) { - return Perfil no encontrado o error al cargar.; - } + if (error && !perfil) { + return {error}; + } + if (!puedeAsignar) { + return Acceso denegado.; + } + if (!perfil && !loading) { + return Perfil no encontrado o error al cargar.; + } - return ( - - - - Asignar Permisos al Perfil: {perfil?.nombrePerfil || 'Cargando...'} - - - ID Perfil: {perfil?.id} - + return ( + + + + Asignar Permisos al Perfil: {perfil?.nombrePerfil || 'Cargando...'} + + + ID Perfil: {perfil?.id} + - {error && !successMessage && {error}} - {successMessage && {successMessage}} + {error && !successMessage && {error}} + {successMessage && {successMessage}} - - - - - - + + + + - ); + + + ); }; export default AsignarPermisosAPerfilPage; \ No newline at end of file diff --git a/Frontend/src/pages/Usuarios/UsuariosIndexPage.tsx b/Frontend/src/pages/Usuarios/UsuariosIndexPage.tsx index b155ca0..c823efe 100644 --- a/Frontend/src/pages/Usuarios/UsuariosIndexPage.tsx +++ b/Frontend/src/pages/Usuarios/UsuariosIndexPage.tsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { Box, Tabs, Tab, Paper, Typography } from '@mui/material'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import { usePermissions } from '../../hooks/usePermissions'; const usuariosSubModules = [ { label: 'Perfiles', path: 'perfiles' }, @@ -13,37 +14,54 @@ const UsuariosIndexPage: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); const [selectedSubTab, setSelectedSubTab] = useState(false); + const { isSuperAdmin } = usePermissions(); + // --- Filtrar solo lo que puede ver este usuario --- + const availableSubModules = useMemo( + () => + usuariosSubModules.filter(sub => { + // Estos dos ítems solo para superadmins + if ( + (sub.path === 'permisos' || sub.path === 'auditoria-usuarios') + && !isSuperAdmin + ) { + return false; + } + return true; + }), + [isSuperAdmin] + ); + + // --- Ajustar la pestaña activa según la ruta --- useEffect(() => { - const currentBasePath = '/usuarios'; - const subPath = location.pathname.startsWith(currentBasePath + '/') - ? location.pathname.substring(currentBasePath.length + 1).split('/')[0] // Tomar solo la primera parte de la subruta - : (location.pathname === currentBasePath ? usuariosSubModules[0]?.path : undefined); - - const activeTabIndex = usuariosSubModules.findIndex( - (subModule) => subModule.path === subPath - ); - - if (activeTabIndex !== -1) { - setSelectedSubTab(activeTabIndex); - } else { - if (location.pathname === currentBasePath && usuariosSubModules.length > 0) { - navigate(usuariosSubModules[0].path, { replace: true }); - setSelectedSubTab(0); - } else { - setSelectedSubTab(false); - } + const base = '/usuarios'; + let subPath: string | undefined; + if (location.pathname.startsWith(base + '/')) { + subPath = location.pathname.slice(base.length + 1).split('/')[0]; + } else if (location.pathname === base) { + subPath = availableSubModules[0]?.path; } - }, [location.pathname, navigate]); + const idx = availableSubModules.findIndex(m => m.path === subPath); + if (idx !== -1) { + setSelectedSubTab(idx); + } else if (location.pathname === base && availableSubModules.length) { + navigate(availableSubModules[0].path, { replace: true }); + setSelectedSubTab(0); + } else { + setSelectedSubTab(false); + } + }, [location.pathname, navigate, availableSubModules]); - const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => { + const handleSubTabChange = (_: any, newValue: number) => { setSelectedSubTab(newValue); - navigate(usuariosSubModules[newValue].path); + navigate(availableSubModules[newValue].path); }; return ( - Módulo de Usuarios y Seguridad + + Módulo de Usuarios y Seguridad + { scrollButtons="auto" aria-label="sub-módulos de usuarios" > - {usuariosSubModules.map((subModule) => ( - + {availableSubModules.map(sub => ( + ))} @@ -66,4 +84,4 @@ const UsuariosIndexPage: React.FC = () => { ); }; -export default UsuariosIndexPage; \ No newline at end of file +export default UsuariosIndexPage; diff --git a/Frontend/src/routes/AppRoutes.tsx b/Frontend/src/routes/AppRoutes.tsx index 095966e..403f1bf 100644 --- a/Frontend/src/routes/AppRoutes.tsx +++ b/Frontend/src/routes/AppRoutes.tsx @@ -76,6 +76,9 @@ import GestionarNovedadesCanillaPage from '../pages/Distribucion/GestionarNoveda import ReporteNovedadesCanillasPage from '../pages/Reportes/ReporteNovedadesCanillasPage'; import ReporteListadoDistMensualPage from '../pages/Reportes/ReporteListadoDistMensualPage'; +// Anonalías +import AlertasPage from '../pages/Anomalia/AlertasPage'; + // Auditorias import GestionarAuditoriaUsuariosPage from '../pages/Usuarios/Auditoria/GestionarAuditoriaUsuariosPage'; import AuditoriaGeneralPage from '../pages/Auditoria/AuditoriaGeneralPage'; @@ -130,6 +133,19 @@ const AppRoutes = () => { {/* Rutas hijas que se renderizarán en el Outlet de MainLayoutWrapper */} } /> {/* Para la ruta exacta "/" */} + {/* Módulo de Anomalías */} + + + + } + > + } /> + } /> + + {/* Módulo de Distribución (anidado) */} => { + try { + const response = await apiClient.get('/alertas'); + return response.data || []; + } catch (error) { + console.error("Error en getAlertas:", error); + return []; + } +}; + +/** + * Marca una única alerta como leída. + */ +export const marcarAlertaLeida = async (idAlerta: number): Promise => { + await apiClient.post(`/alertas/${idAlerta}/marcar-leida`); +}; + +/** + * Marca un grupo completo de alertas como leídas. + */ +export const marcarGrupoComoLeido = async (request: MarcarGrupoLeidoRequestDto): Promise => { + await apiClient.post('/alertas/marcar-grupo-leido', request); +}; \ No newline at end of file diff --git a/Frontend/src/services/Distribucion/canillaService.ts b/Frontend/src/services/Distribucion/canillaService.ts index 08af15d..5817250 100644 --- a/Frontend/src/services/Distribucion/canillaService.ts +++ b/Frontend/src/services/Distribucion/canillaService.ts @@ -3,6 +3,7 @@ import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto'; import type { CreateCanillaDto } from '../../models/dtos/Distribucion/CreateCanillaDto'; import type { UpdateCanillaDto } from '../../models/dtos/Distribucion/UpdateCanillaDto'; import type { ToggleBajaCanillaDto } from '../../models/dtos/Distribucion/ToggleBajaCanillaDto'; +import type { CanillaDropdownDto } from '../../models/dtos/Distribucion/CanillaDropdownDto'; const getAllCanillas = async ( @@ -15,12 +16,24 @@ const getAllCanillas = async ( if (nomApeFilter) params.nomApe = nomApeFilter; if (legajoFilter !== undefined && legajoFilter !== null) params.legajo = legajoFilter; if (soloActivos !== undefined) params.soloActivos = soloActivos; - if (esAccionistaFilter !== undefined) params.esAccionista = esAccionistaFilter; // <<-- ¡CLAVE! Verifica esto. + if (esAccionistaFilter !== undefined) params.esAccionista = esAccionistaFilter; const response = await apiClient.get('/canillas', { params }); return response.data; }; +const getAllDropdownCanillas = async ( + soloActivos?: boolean, + esAccionistaFilter?: boolean // Asegúrate que esté aquí +): Promise => { + const params: Record = {}; + if (soloActivos !== undefined) params.soloActivos = soloActivos; + if (esAccionistaFilter !== undefined) params.esAccionista = esAccionistaFilter; + + const response = await apiClient.get('/canillas/dropdown', { params }); + return response.data; +}; + const getCanillaById = async (id: number): Promise => { const response = await apiClient.get(`/canillas/${id}`); return response.data; @@ -43,6 +56,7 @@ const toggleBajaCanilla = async (id: number, data: ToggleBajaCanillaDto): Promis const canillaService = { getAllCanillas, + getAllDropdownCanillas, getCanillaById, createCanilla, updateCanilla, diff --git a/Frontend/src/services/Distribucion/otroDestinoService.ts b/Frontend/src/services/Distribucion/otroDestinoService.ts index 63eea66..d543534 100644 --- a/Frontend/src/services/Distribucion/otroDestinoService.ts +++ b/Frontend/src/services/Distribucion/otroDestinoService.ts @@ -2,6 +2,7 @@ import apiClient from '../apiClient'; import type { OtroDestinoDto } from '../../models/dtos/Distribucion/OtroDestinoDto'; import type { CreateOtroDestinoDto } from '../../models/dtos/Distribucion/CreateOtroDestinoDto'; import type { UpdateOtroDestinoDto } from '../../models/dtos/Distribucion/UpdateOtroDestinoDto'; +import type { OtroDestinoDropdownDto } from '../../models/dtos/Distribucion/OtroDestinoDropdownDto'; const getAllOtrosDestinos = async (nombreFilter?: string): Promise => { const params: Record = {}; @@ -12,6 +13,12 @@ const getAllOtrosDestinos = async (nombreFilter?: string): Promise => { + // Llama a GET /api/otrosdestinos/dropdown + const response = await apiClient.get('/otrosdestinos/dropdown'); + return response.data; +}; + const getOtroDestinoById = async (id: number): Promise => { // Llama a GET /api/otrosdestinos/{id} const response = await apiClient.get(`/otrosdestinos/${id}`); @@ -36,6 +43,7 @@ const deleteOtroDestino = async (id: number): Promise => { const otroDestinoService = { getAllOtrosDestinos, + getAllDropdownOtrosDestinos, getOtroDestinoById, createOtroDestino, updateOtroDestino, diff --git a/Frontend/src/services/Impresion/estadoBobinaService.ts b/Frontend/src/services/Impresion/estadoBobinaService.ts index 715718a..5afcafc 100644 --- a/Frontend/src/services/Impresion/estadoBobinaService.ts +++ b/Frontend/src/services/Impresion/estadoBobinaService.ts @@ -2,6 +2,7 @@ import apiClient from '../apiClient'; import type { EstadoBobinaDto } from '../../models/dtos/Impresion/EstadoBobinaDto'; import type { CreateEstadoBobinaDto } from '../../models/dtos/Impresion/CreateEstadoBobinaDto'; import type { UpdateEstadoBobinaDto } from '../../models/dtos/Impresion/UpdateEstadoBobinaDto'; +import type { EstadoBobinaDropdownDto } from '../../models/dtos/Impresion/EstadoBobinaDropdownDto'; const getAllEstadosBobina = async (denominacionFilter?: string): Promise => { const params: Record = {}; @@ -11,6 +12,11 @@ const getAllEstadosBobina = async (denominacionFilter?: string): Promise => { + const response = await apiClient.get('/estadosbobina/dropdown'); + return response.data; +}; + const getEstadoBobinaById = async (id: number): Promise => { const response = await apiClient.get(`/estadosbobina/${id}`); return response.data; @@ -31,6 +37,7 @@ const deleteEstadoBobina = async (id: number): Promise => { const estadoBobinaService = { getAllEstadosBobina, + getAllDropdownEstadosBobina, getEstadoBobinaById, createEstadoBobina, updateEstadoBobina, diff --git a/Frontend/src/services/Impresion/tipoBobinaService.ts b/Frontend/src/services/Impresion/tipoBobinaService.ts index 00c4655..a1b6d86 100644 --- a/Frontend/src/services/Impresion/tipoBobinaService.ts +++ b/Frontend/src/services/Impresion/tipoBobinaService.ts @@ -12,6 +12,12 @@ const getAllTiposBobina = async (denominacionFilter?: string): Promise => { + // Llama a GET /api/tiposbobina/dropdown + const response = await apiClient.get('/tiposbobina/dropdown'); + return response.data; +}; + const getTipoBobinaById = async (id: number): Promise => { // Llama a GET /api/tiposbobina/{id} const response = await apiClient.get(`/tiposbobina/${id}`); @@ -36,6 +42,7 @@ const deleteTipoBobina = async (id: number): Promise => { const tipoBobinaService = { getAllTiposBobina, + getAllDropdownTiposBobina, getTipoBobinaById, createTipoBobina, updateTipoBobina, diff --git a/ProyectoIA_Gestion/detect.py b/ProyectoIA_Gestion/detect.py new file mode 100644 index 0000000..56079d9 --- /dev/null +++ b/ProyectoIA_Gestion/detect.py @@ -0,0 +1,134 @@ +import pandas as pd +import joblib +import os +import pyodbc +from datetime import datetime, timedelta +import sys + +def insertar_alerta_en_db(cursor, tipo_alerta, id_entidad, entidad, mensaje, fecha_anomalia, cant_enviada=None, cant_devuelta=None, porc_devolucion=None): + """Función centralizada para insertar en la nueva tabla Sistema_Alertas.""" + insert_query = """ + INSERT INTO Sistema_Alertas + (TipoAlerta, IdEntidad, Entidad, Mensaje, FechaAnomalia, CantidadEnviada, CantidadDevuelta, PorcentajeDevolucion, Leida) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) + """ + try: + # Asegurarse de que los valores numéricos opcionales sean None si no se proporcionan + p_dev = float(porc_devolucion) if porc_devolucion is not None else None + c_env = int(cant_enviada) if cant_enviada is not None else None + c_dev = int(cant_devuelta) if cant_devuelta is not None else None + + cursor.execute(insert_query, tipo_alerta, id_entidad, entidad, mensaje, fecha_anomalia, c_env, c_dev, p_dev) + print(f"INFO: Alerta '{tipo_alerta}' para '{entidad}' ID {id_entidad} registrada.") + except Exception as e: + print(f"ERROR: No se pudo insertar la alerta para '{entidad}' ID {id_entidad}. Error: {e}") + +print("--- INICIANDO SCRIPT DE DETECCIÓN COMPLETO ---") + +# --- 1. Configuración --- +DB_SERVER = 'TECNICA3' +DB_DATABASE = 'SistemaGestion' +CONNECTION_STRING = f'DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={DB_SERVER};DATABASE={DB_DATABASE};Trusted_Connection=yes;TrustServerCertificate=yes;' +MODEL_INDIVIDUAL_FILE = 'modelo_anomalias.joblib' +MODEL_SISTEMA_FILE = 'modelo_sistema_anomalias.joblib' + +# --- 2. Determinar Fecha --- +if len(sys.argv) > 1: + target_date = datetime.strptime(sys.argv[1], '%Y-%m-%d') +else: + target_date = datetime.now() - timedelta(days=1) +print(f"--- FECHA DE ANÁLISIS: {target_date.date()} ---") + +try: + cnxn = pyodbc.connect(CONNECTION_STRING) + cursor = cnxn.cursor() +except Exception as e: + print(f"CRITICAL: No se pudo conectar a la base de datos. Error: {e}") + exit() + +# --- 3. DETECCIÓN INDIVIDUAL (CANILLITAS) --- +print("\n--- FASE 1: Detección de Anomalías Individuales (Canillitas) ---") +if not os.path.exists(MODEL_INDIVIDUAL_FILE): + print(f"ADVERTENCIA: Modelo individual '{MODEL_INDIVIDUAL_FILE}' no encontrado.") +else: + model_individual = joblib.load(MODEL_INDIVIDUAL_FILE) + query_individual = f""" + SELECT esc.Id_Canilla AS id_canilla, esc.Fecha AS fecha, esc.CantSalida AS cantidad_enviada, esc.CantEntrada AS cantidad_devuelta, c.NomApe AS nombre_canilla + FROM dist_EntradasSalidasCanillas esc + JOIN dist_dtCanillas c ON esc.Id_Canilla = c.Id_Canilla + WHERE CAST(Fecha AS DATE) = '{target_date.strftime('%Y-%m-%d')}' AND CantSalida > 0 + """ + df_new = pd.read_sql(query_individual, cnxn) + + if not df_new.empty: + df_new['porcentaje_devolucion'] = (df_new['cantidad_devuelta'] / df_new['cantidad_enviada']).fillna(0) * 100 + df_new['dia_semana'] = pd.to_datetime(df_new['fecha']).dt.dayofweek + features = ['id_canilla', 'porcentaje_devolucion', 'dia_semana'] + X_new = df_new[features] + df_new['anomalia'] = model_individual.predict(X_new) + anomalias_detectadas = df_new[df_new['anomalia'] == -1] + + if not anomalias_detectadas.empty: + for index, row in anomalias_detectadas.iterrows(): + mensaje = f"Devolución del {row['porcentaje_devolucion']:.2f}% para '{row['nombre_canilla']}'." + insertar_alerta_en_db(cursor, + tipo_alerta='DevolucionAnomala', + id_entidad=row['id_canilla'], + entidad='Canillita', + mensaje=mensaje, + fecha_anomalia=row['fecha'].date(), + cant_enviada=row['cantidad_enviada'], + cant_devuelta=row['cantidad_devuelta'], + porc_devolucion=row['porcentaje_devolucion']) + else: + print("INFO: No se encontraron anomalías individuales significativas.") + else: + print("INFO: No hay datos de canillitas para analizar en la fecha seleccionada.") + +# --- 4. DETECCIÓN DE SISTEMA --- +print("\n--- FASE 2: Detección de Anomalías de Sistema ---") +if not os.path.exists(MODEL_SISTEMA_FILE): + print(f"ADVERTENCIA: Modelo de sistema '{MODEL_SISTEMA_FILE}' no encontrado.") +else: + model_sistema = joblib.load(MODEL_SISTEMA_FILE) + query_agregada = f""" + SELECT CAST(Fecha AS DATE) AS fecha_dia, DATEPART(weekday, Fecha) as dia_semana, + COUNT(DISTINCT Id_Canilla) as total_canillitas_activos, + SUM(CantSalida) as total_salidas, SUM(CantEntrada) as total_devoluciones + FROM dist_EntradasSalidasCanillas + WHERE CAST(Fecha AS DATE) = '{target_date.strftime('%Y-%m-%d')}' AND CantSalida > 0 + GROUP BY CAST(Fecha AS DATE), DATEPART(weekday, Fecha) + """ + df_system = pd.read_sql(query_agregada, cnxn) + + if not df_system.empty and df_system['total_salidas'].iloc[0] > 0: + df_system['ratio_devolucion'] = (df_system['total_devoluciones'] / df_system['total_salidas']).fillna(0) + df_system['salidas_por_canillita'] = (df_system['total_salidas'] / df_system['total_canillitas_activos']).fillna(0) + features_system = ['dia_semana', 'total_salidas', 'ratio_devolucion', 'salidas_por_canillita'] + X_system = df_system[features_system] + df_system['anomalia_sistema'] = model_sistema.predict(X_system) + + if df_system['anomalia_sistema'].iloc[0] == -1: + ratio_hoy = df_system['ratio_devolucion'].iloc[0] * 100 + mensaje = f"El ratio de devolución global fue del {ratio_hoy:.2f}%, un valor atípico para este día de la semana." + insertar_alerta_en_db(cursor, + tipo_alerta='ComportamientoSistema', + id_entidad=0, + entidad='Sistema', + mensaje=mensaje, + fecha_anomalia=target_date.date()) + else: + print("INFO: El comportamiento agregado del sistema fue normal.") + else: + mensaje = f"ALERTA GRAVE: No se registraron movimientos de salida para ningún canillita en la fecha {target_date.date()}." + insertar_alerta_en_db(cursor, + tipo_alerta='FaltaDeDatos', + id_entidad=0, + entidad='Sistema', + mensaje=mensaje, + fecha_anomalia=target_date.date()) + +# --- 5. Finalización --- +cnxn.commit() +cnxn.close() +print("\n--- DETECCIÓN COMPLETA ---") \ No newline at end of file diff --git a/ProyectoIA_Gestion/modelo_anomalias.joblib b/ProyectoIA_Gestion/modelo_anomalias.joblib new file mode 100644 index 0000000..9d2c54e Binary files /dev/null and b/ProyectoIA_Gestion/modelo_anomalias.joblib differ diff --git a/ProyectoIA_Gestion/modelo_sistema_anomalias.joblib b/ProyectoIA_Gestion/modelo_sistema_anomalias.joblib new file mode 100644 index 0000000..a968e96 Binary files /dev/null and b/ProyectoIA_Gestion/modelo_sistema_anomalias.joblib differ diff --git a/ProyectoIA_Gestion/train.py b/ProyectoIA_Gestion/train.py new file mode 100644 index 0000000..e503f31 --- /dev/null +++ b/ProyectoIA_Gestion/train.py @@ -0,0 +1,65 @@ +import pandas as pd +from sklearn.ensemble import IsolationForest +import joblib +import os +import pyodbc +from datetime import datetime, timedelta + +print("--- INICIANDO SCRIPT DE ENTRENAMIENTO (CONEXIÓN BD) ---") + +# --- 1. Configuración de Conexión y Parámetros --- +DB_SERVER = 'TECNICA3' # O el nombre de tu instancia, ej: '.\SQLEXPRESS' +DB_DATABASE = 'SistemaGestion' # El nombre de tu base de datos +# Para autenticación de Windows, usa la siguiente línea: +CONNECTION_STRING = f'DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={DB_SERVER};DATABASE={DB_DATABASE};Trusted_Connection=yes;TrustServerCertificate=yes;' + +MODEL_FILE = 'modelo_anomalias.joblib' +CONTAMINATION_RATE = 0.001 # Tasa de contaminación del 0.013% (ajustable según tus necesidades) + +# --- 2. Carga de Datos desde SQL Server --- +try: + print(f"Conectando a la base de datos '{DB_DATABASE}' en '{DB_SERVER}'...") + cnxn = pyodbc.connect(CONNECTION_STRING) + + # Tomamos el último año de datos para el entrenamiento + fecha_limite = datetime.now() - timedelta(days=365) + + query = f""" + SELECT + Id_Canilla AS id_canilla, + Fecha AS fecha, + CantSalida AS cantidad_enviada, + CantEntrada AS cantidad_devuelta + FROM + dist_EntradasSalidasCanillas + WHERE + Fecha >= '{fecha_limite.strftime('%Y-%m-%d')}' + """ + print("Ejecutando consulta para obtener datos de entrenamiento...") + df = pd.read_sql(query, cnxn) + cnxn.close() + +except Exception as e: + print(f"Error al conectar o consultar la base de datos: {e}") + exit() + +if df.empty: + print("No se encontraron datos de entrenamiento en el último año. Saliendo.") + exit() + +# --- 3. Preparación de Datos (sin cambios) --- +print(f"Preparando {len(df)} registros para el entrenamiento...") +df['porcentaje_devolucion'] = (df['cantidad_devuelta'] / (df['cantidad_enviada'] + 0.001)) * 100 +df.fillna(0, inplace=True) +df['porcentaje_devolucion'] = df['porcentaje_devolucion'].clip(0, 100) +df['dia_semana'] = df['fecha'].dt.dayofweek +features = ['id_canilla', 'porcentaje_devolucion', 'dia_semana'] +X = df[features] + +# --- 4. Entrenamiento y Guardado (sin cambios) --- +print(f"Entrenando el modelo con tasa de contaminación de {CONTAMINATION_RATE}...") +model = IsolationForest(n_estimators=100, contamination=CONTAMINATION_RATE, random_state=42) +model.fit(X) +joblib.dump(model, MODEL_FILE) + +print(f"--- ENTRENAMIENTO COMPLETADO. Modelo guardado en '{MODEL_FILE}' ---") \ No newline at end of file diff --git a/ProyectoIA_Gestion/train_sistema.py b/ProyectoIA_Gestion/train_sistema.py new file mode 100644 index 0000000..cbade53 --- /dev/null +++ b/ProyectoIA_Gestion/train_sistema.py @@ -0,0 +1,71 @@ +import pandas as pd +from sklearn.ensemble import IsolationForest +import joblib +import os +import pyodbc +from datetime import datetime + +print("--- INICIANDO SCRIPT DE ENTRENAMIENTO DE SISTEMA ---") + +# --- 1. Configuración --- +DB_SERVER = 'TECNICA3' +DB_DATABASE = 'SistemaGestion' +CONNECTION_STRING = f'DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={DB_SERVER};DATABASE={DB_DATABASE};Trusted_Connection=yes;TrustServerCertificate=yes;' +MODEL_FILE = 'modelo_sistema_anomalias.joblib' +CONTAMINATION_RATE = 0.02 # Tasa de contaminación del 0.2% (ajustable según tus necesidades) + +# --- 2. Carga y Agregación de Datos desde SQL Server --- +try: + print("Conectando a la base de datos...") + cnxn = pyodbc.connect(CONNECTION_STRING) + + # Consulta para agregar los datos por día + query = """ + SELECT + CAST(Fecha AS DATE) AS fecha_dia, + DATEPART(weekday, Fecha) as dia_semana, -- 1=Domingo, 2=Lunes... + COUNT(DISTINCT Id_Canilla) as total_canillitas_activos, + SUM(CantSalida) as total_salidas, + SUM(CantEntrada) as total_devoluciones + FROM + dist_EntradasSalidasCanillas + WHERE CantSalida > 0 -- Solo considerar días con actividad de salida + GROUP BY + CAST(Fecha AS DATE), DATEPART(weekday, Fecha) + """ + print("Ejecutando consulta de agregación de datos históricos...") + df = pd.read_sql(query, cnxn) + cnxn.close() + +except Exception as e: + print(f"Error al conectar o consultar la base de datos: {e}") + exit() + +if df.empty: + print("No se encontraron datos históricos para entrenar el modelo de sistema. Saliendo.") + exit() + +# --- 3. Feature Engineering para el modelo de sistema --- +print(f"Preparando {len(df)} registros agregados para el entrenamiento...") +# El ratio de devolución es una característica muy potente +df['ratio_devolucion'] = (df['total_devoluciones'] / df['total_salidas']).fillna(0) +# Ratio de salidas por canillita activo +df['salidas_por_canillita'] = (df['total_salidas'] / df['total_canillitas_activos']).fillna(0) + +# Seleccionamos las características que el modelo usará +features = ['dia_semana', 'total_salidas', 'ratio_devolucion', 'salidas_por_canillita'] +X = df[features] + +# --- 4. Entrenamiento del Modelo --- +print(f"Entrenando el modelo IsolationForest de sistema con tasa de contaminación de {CONTAMINATION_RATE}...") +model = IsolationForest( + n_estimators=100, + contamination=CONTAMINATION_RATE, + random_state=42 +) +model.fit(X) + +# --- 5. Guardado del Modelo --- +joblib.dump(model, MODEL_FILE) +print("--- ENTRENAMIENTO DE SISTEMA COMPLETADO ---") +print(f"Modelo de sistema guardado exitosamente como '{MODEL_FILE}'") \ No newline at end of file